From 6fa5aff5fa8e721b38f03a6000a045c689024f16 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 28 Jul 2026 17:01:10 -0700 Subject: [PATCH 01/10] feat(pi): add update branch mode --- .../content/docs/en/workflows/blocks/pi.mdx | 90 +++-- apps/sim/blocks/blocks/pi.test.ts | 154 +++++--- apps/sim/blocks/blocks/pi.ts | 87 +++-- .../executor/handlers/pi/babysit-backend.ts | 4 +- apps/sim/executor/handlers/pi/backend.ts | 18 +- .../handlers/pi/cloud-backend.test.ts | 339 +++++++++++++++++- .../sim/executor/handlers/pi/cloud-backend.ts | 190 +++++++--- .../executor/handlers/pi/github-pr.test.ts | 80 +++++ apps/sim/executor/handlers/pi/github-pr.ts | 84 +++++ apps/sim/executor/handlers/pi/keys.test.ts | 14 + apps/sim/executor/handlers/pi/keys.ts | 11 +- .../executor/handlers/pi/pi-handler.test.ts | 180 +++++++++- apps/sim/executor/handlers/pi/pi-handler.ts | 57 ++- 13 files changed, 1121 insertions(+), 187 deletions(-) diff --git a/apps/docs/content/docs/en/workflows/blocks/pi.mdx b/apps/docs/content/docs/en/workflows/blocks/pi.mdx index 1f6f2ccb309..e320f0625e9 100644 --- a/apps/docs/content/docs/en/workflows/blocks/pi.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/pi.mdx @@ -1,6 +1,6 @@ --- title: Pi Coding Agent -description: The Pi Coding Agent creates and optionally babysits pull requests, reviews existing PRs, and edits repositories over SSH. +description: The Pi Coding Agent block runs an autonomous coding agent on a real repository — opening a pull request, updating a branch, reviewing a PR, or editing files over SSH. pageType: reference --- @@ -8,11 +8,12 @@ import { Callout } from 'fumadocs-ui/components/callout' import { BlockPreview } from '@/components/workflow-preview' import { FAQ } from '@/components/ui/faq' -The **Pi Coding Agent block** runs the [Pi](https://github.com/earendil-works/pi-mono) coding harness against a real repository. You give it a task and a model; it opens and optionally babysits a pull request, posts a review on an existing PR, or changes your files in place. Create PR and Local Dev can reuse your [skills](/agents/skills) and multi-turn [memory](#memory). Review Code loads neither. +The **Pi Coding Agent block** runs the [Pi](https://github.com/earendil-works/pi-mono) coding harness against a real repository. You give it a task and a model; it opens a pull request, updates an existing branch, posts a PR review, or changes your files in place. Create PR and Update Branch can optionally babysit the pull request on their branch. Create PR, Update Branch, and Local Dev can reuse your [skills](/agents/skills) and multi-turn [memory](#memory). Review Code deliberately loads neither. -It has three modes that decide *where* it runs and *how* its work lands: +It has four modes that decide *where* it runs and *how* its work lands: - **Create PR** — spins up an isolated sandbox, edits the repository, and opens a **pull request**. Its optional **Babysit Mode** requests bot reviews and works through trusted feedback and checks in bounded rounds. +- **Update Branch** — checks out an existing branch in an isolated sandbox, edits it, and pushes a new commit back to that same branch. Its optional **Babysit Mode** continues the one open same-repository PR attached to that branch. - **Review Code** — checks out an existing PR in a sandbox, analyzes it with bounded read-only access across the repository, and posts a **GitHub review** (summary + optional inline comments). - **Local Dev** — connects to your own machine over **SSH** and edits files there directly. @@ -26,14 +27,14 @@ Pick the mode with the **Mode** dropdown. The fields below it change to match. Create PR runs entirely inside a disposable sandbox, so it never touches your machine. It clones the repo, lets the agent work with full read/shell/edit/git, pushes a branch, and opens a PR you review and merge. -- Requires sandbox execution to be enabled (Create PR and Review Code only appear when it is). +- Requires sandbox execution to be enabled. - Requires **your own provider API key (BYOK)** — the model key is handed to the sandbox. - Needs a **GitHub token** with permission to clone, push, and open a PR (see [Setup](#setup-cloud-pr)). - The deliverable is a **pull request** — nothing is committed to your default branch directly. #### Babysit Mode -When enabled, Create PR forces the new PR to be ready for review, posts every required **Reviewer Mention** as its own issue comment, and then starts a second, strict sandbox against the newly created same-repository PR. It repeats this bounded host-controlled lifecycle: +When enabled, Create PR forces the new PR to be ready for review, while Update Branch first finds exactly one open same-repository PR whose head is the target branch. Both modes post every required **Reviewer Mention** as its own issue comment and then start a second, strict sandbox against that PR. They repeat this bounded host-controlled lifecycle: 1. Read every review thread and the complete check rollup for the pinned SHA. 2. Give Pi trusted block instructions plus clearly delimited, untrusted thread/check data and bounded diagnostics. @@ -49,10 +50,22 @@ Only complete threads are actionable. Every comment in a thread must come from a The continuation gives Pi no GitHub credential, GitHub tool, or Sim integration. GitHub API operations use block-configured coordinates on the host and are not filtered by workspace tool denylists. The model and optional search keys do enter the editing sandbox. The GitHub token enters only the clone and credentialed push commands. This is risk reduction, not credential isolation: the push still executes inside a previously agent-controlled root sandbox. - Requires at least one **Reviewer Mention**, such as `@greptile` or `@cursor review`. -- Needs **Contents, Pull requests, Issues, Actions, and commit-status/check read access** as described in [Create PR setup](#setup-cloud-pr). +- Needs **Contents, Pull requests, Issues, Actions, and commit-status/check read access** as described in [Create PR setup](#setup-cloud-pr) and [Update Branch setup](#setup-cloud-branch). - Does not support fork PRs, force-push/history rewriting, merge-conflict resolution, or `.github/` edits. A base conflict is reported but does not prevent review-fix pushes. - A round that posts replies but loses the pin before resolving can reply to the same unresolved thread again on a later run. Replies are intentionally safer than resolving stale feedback. +### Update Branch + +Update Branch uses the same disposable authoring sandbox as Create PR, but checks out a branch that already exists and pushes changes back to it without opening a new pull request. + +- Requires sandbox execution and **your own provider API key (BYOK)**. +- Needs a **GitHub token** with permission to clone and push. +- Never creates, rebases, merges, or force-pushes the branch. If another commit reaches the branch while Pi is working, the push fails rather than overwriting it. +- Pushing to a branch that already has an open PR updates that PR automatically. +- With Babysit Mode enabled, exactly one open same-repository PR must already use the target branch. No match fails before a sandbox starts; multiple matches fail as ambiguous. Fork, closed, moved, or mismatched PRs fail closed. +- A no-change authoring pass still babysits the existing PR. A rejected initial push stops the run before Babysit begins. +- The deliverable is the updated branch — read `branch`, `changedFiles`, and `diff`. + ### Review Code Review Code uses a disposable sandbox for the repository, but the Pi harness and model credential stay in Sim. It pins the PR base and head commits, gives the agent only bounded read/search tools, and validates inline coordinates against that exact local diff. Sim then submits one GitHub review with a summary body and optional inline line comments. @@ -84,11 +97,11 @@ The model that drives the agent. Defaults to `claude-sonnet-4-6`. The dropdown c ### API Key -Your key for the chosen provider. On hosted Sim it is optional for Local Dev and Review Code runs (a hosted key is used and metered to your workspace). **Create PR requires your own key** because its model client runs in the sandbox, including during its Babysit continuation. When the provider supports workspace BYOK, you can store the key in **Settings → BYOK** instead of entering it on the block. +Your key for the chosen provider. On hosted Sim it is optional for Local Dev and Review Code runs (a hosted key is used and metered to your workspace). **Create PR and Update Branch require your own key** because their model client runs in the sandbox, including during a Babysit continuation. When the provider supports workspace BYOK, you can store the key in **Settings → BYOK** instead of entering it on the block. ### Internet Search -Off by default. Pick a provider — **Exa**, **Serper**, **Parallel AI**, or **Firecrawl** — and the agent gains a single `web_search` tool that returns a handful of results, each with a title, URL, snippet, and (where the provider reports one) a publication date. It works the same way in all three modes, and it is the agent's only network access in Review Code. The tool accepts at most 20 calls **per block execution**, which bounds accidental tool loops. A Pi block inside a Loop or Parallel gets that allowance again on every iteration, so bound the iteration count too if you care about what a single workflow run can spend. +Off by default. Pick a provider — **Exa**, **Serper**, **Parallel AI**, or **Firecrawl** — and the agent gains a single `web_search` tool that returns a handful of results, each with a title, URL, snippet, and (where the provider reports one) a publication date. It works the same way in all four modes, and it is the agent's only network access in Review Code. The tool accepts at most 20 calls **per block execution**, which bounds accidental tool loops. A Pi block inside a Loop or Parallel gets that allowance again on every iteration, so bound the iteration count too if you care about what a single workflow run can spend. Search always uses **your own key** for the selected provider, entered in the block's **Search API Key** field. That field is the only source: there is no workspace BYOK fallback and Sim never supplies a hosted search key, so unlike the model key this field appears on every deployment. Leave it empty and the run fails with a setup error before any sandbox is created. Changing the provider in the editor clears the field, so re-enter the key that belongs to the provider you picked — a workflow you import, fork, or update through the API keeps whatever key was saved, so check it there. @@ -102,10 +115,10 @@ Results are third-party data. The agent is instructed to treat them as quoted ev Traffic goes both ways: the agent writes its own queries after reading the repository, so leave search on **None** in Review Code when the pull request comes from an untrusted fork of a private repo. Injected instructions in a diff could otherwise put repository text into a query sent to the provider. -### Repository (Create PR / Review Code) +### Repository (Create PR / Update Branch / Review Code) - **Repository Owner / Repository Name** — the GitHub repo (for example `your-org` / `your-repo`). -- **GitHub Token** — a personal access token used for GitHub access. Permissions differ by mode and by whether Create PR uses Babysit Mode; see [Create PR setup](#setup-cloud-pr) and [Review Code setup](#setup-cloud-code-review). +- **GitHub Token** — a personal access token used for GitHub access. Permissions differ by mode; see [Create PR setup](#setup-cloud-pr), [Update Branch setup](#setup-cloud-branch), or [Review Code setup](#setup-cloud-code-review). ### Create PR fields @@ -117,6 +130,11 @@ Traffic goes both ways: the agent writes its own queries after reading the repos - **Open as Draft PR** *(advanced)* — opens the PR as a draft. On by default and hidden when Babysit Mode is enabled, because those PRs are always ready for review. - **PR Title / PR Body** *(advanced)* — generated from the run when blank. +### Update Branch fields + +- **Target Branch** — the existing remote branch to update. The run fails if it does not exist, is protected against the token, or changes before Pi pushes. +- **Babysit Mode / Reviewer Mentions / Maximum Rounds** — the same controls as Create PR. When enabled, the target branch must have exactly one open same-repository PR. + ### Review Code fields - **Pull Request Number** — the PR to review (for example `42`). @@ -136,15 +154,15 @@ Traffic goes both ways: the agent writes its own queries after reading the repos Sim tools the agent can call while it works — search a knowledge base, send a Slack message, call any of the [integrations](/integrations). They run through Sim with your connected credentials, exactly like the [Agent block](/workflows/blocks/agent). MCP and custom tools aren't supported here yet (they appear greyed out). -### Skills (Create PR / Local Dev) +### Skills (Create PR / Update Branch / Local Dev) -[Agent skills](/agents/skills) the agent can use — reusable instruction packages like a coding standard or a review playbook. Create PR passes explicitly selected Sim skills to its Babysit continuation, while repository skills, Pi extensions, prompt templates, and project trust remain disabled there. +[Agent skills](/agents/skills) the agent can use — reusable instruction packages like a coding standard or a review playbook. They're shared with the Agent block. Create PR and Update Branch pass explicitly selected Sim skills to their Babysit continuation, while repository skills, Pi extensions, prompt templates, and project trust remain disabled there. ### Thinking Level For models with extended reasoning, how much the model thinks before acting. Higher is more thorough but slower and costs more tokens. Defaults to `medium`. -### Memory (Create PR / Local Dev) +### Memory (Create PR / Update Branch / Local Dev) Multi-turn memory keyed by a conversation ID, shared with the [Agent block](/workflows/blocks/agent): @@ -153,11 +171,11 @@ Multi-turn memory keyed by a conversation ID, shared with the [Agent block](/wor - **Sliding window (messages).** The most recent N messages. - **Sliding window (tokens).** Recent messages up to a token budget. -Reuse the same **Conversation ID** across runs to continue a thread. Each turn stores your task and the Create PR or Local Dev summary, which is folded into the next run's prompt. Review Code never loads or saves memory. Create PR's Babysit continuation starts with empty memory, and its review-derived report is not saved to memory. +Reuse the same **Conversation ID** across runs to continue a thread. Each turn stores your task and the initial authoring or Local Dev summary, which is folded into the next run's prompt. Review Code never loads or saves memory. A Babysit continuation starts with empty memory, and its review-derived report is not saved to memory. ### Context limits -For Create PR and Local Dev, memory is folded into the agent's first prompt, and two layers keep it within the model's context window: +For Create PR, Update Branch, and Local Dev, memory is folded into the agent's first prompt, and two layers keep it within the model's context window: - **Sim trims before the run.** The selected memory type bounds what's injected: **Conversation** is automatically capped to a fraction of the model's context window (for models in Sim's catalog), **Sliding window (messages)** keeps the last N messages, and **Sliding window (tokens)** keeps history up to an explicit token budget. - **Pi compacts during the run.** As the agent works (reading files, running commands), Pi automatically summarizes older turns to stay under the window — in all modes, on by default. You don't need to configure anything for context growth mid-run. @@ -171,16 +189,16 @@ The one case neither layer can rescue is a *first* prompt that already exceeds t | `` | The agent's final message / run summary | | `` | The files the agent changed | | `` | A unified diff of the changes | -| `` | URL of the opened pull request *(Create PR)* | -| `` | The branch pushed with the changes *(Create PR)* | +| `` | URL of the opened pull request, or the existing PR when Update Branch Babysit is enabled | +| `` | The branch pushed with the changes *(Create PR / Update Branch)* | | `` | URL of the submitted GitHub review *(Review Code)* | | `` | Number of inline review comments posted *(Review Code)* | -| `` | Number of fixing rounds that invoked Pi *(Create PR + Babysit Mode)* | -| `` | Whether no actionable or skipped unresolved threads remain *(Create PR + Babysit Mode)* | -| `` | Whether all required checks are passing with none pending or missing *(Create PR + Babysit Mode)* | -| `` | Number of threads this run resolved *(Create PR + Babysit Mode)* | -| `` | Number of one-commit fixing rounds pushed *(Create PR + Babysit Mode)* | -| `` | Why the Babysit continuation stopped, including partial-success outcomes *(Create PR + Babysit Mode)* | +| `` | Number of fixing rounds that invoked Pi *(authoring + Babysit Mode)* | +| `` | Whether no actionable or skipped unresolved threads remain *(authoring + Babysit Mode)* | +| `` | Whether all required checks are passing with none pending or missing *(authoring + Babysit Mode)* | +| `` | Number of threads this run resolved *(authoring + Babysit Mode)* | +| `` | Number of one-commit fixing rounds pushed *(authoring + Babysit Mode)* | +| `` | Why the Babysit continuation stopped, including partial-success outcomes *(authoring + Babysit Mode)* | | `` | The model that ran | | `` | Token usage, an object `{ input, output, total }` | | `` | Estimated cost of the run | @@ -192,7 +210,7 @@ The one case neither layer can rescue is a *first* prompt that already exceeds t Create PR runs in a sandbox image with the Pi CLI and git baked in. -1. **Enable sandbox execution.** On self-hosted Sim, set `E2B_ENABLED=true`, `E2B_API_KEY`, `E2B_PI_TEMPLATE_ID` (the Pi template id), and `NEXT_PUBLIC_E2B_ENABLED=true` (this reveals Create PR and Review Code in the UI). Build the template with `bun run apps/sim/scripts/build-pi-e2b-template.ts`. These modes stay hidden until `NEXT_PUBLIC_E2B_ENABLED` is set. +1. **Enable sandbox execution.** On self-hosted Sim, set `E2B_ENABLED=true`, `E2B_API_KEY`, `E2B_PI_TEMPLATE_ID` (the Pi template id), and `NEXT_PUBLIC_E2B_ENABLED=true` (this reveals Create PR, Update Branch, and Review Code in the UI). Build the template with `bun run apps/sim/scripts/build-pi-e2b-template.ts`. These modes stay hidden until `NEXT_PUBLIC_E2B_ENABLED` is set. The template requests **4 vCPU and 8 GB of RAM** (within the per-build maximum on every E2B plan). Sizing is fixed when the template is built — E2B has no per-sandbox override — so changing it means rebuilding the template, not restarting Sim. Sandboxes are billed per second against the resources they are allocated, not the ones they use. Both numbers live in `apps/sim/scripts/pi-sandbox-packages.ts` and are shared with the Daytona snapshot so the failover image cannot drift from the primary. @@ -202,7 +220,7 @@ Create PR runs in a sandbox image with the Pi CLI and git baked in. - *Fine-grained:* select the repo, then **Contents: Read and write** + **Pull requests: Read and write**. - *Classic:* the **`repo`** scope. For org repos, authorize the token for SSO. -When **Babysit Mode** is enabled, use an asynchronous schedule, webhook, or background execution where possible because the continuation can spend several minutes waiting for CI or review bots. The token must additionally read checks and Actions logs, reply to and resolve review threads, and post issue comments: +When **Babysit Mode** is enabled in either authoring mode, use an asynchronous schedule, webhook, or background execution where possible because the continuation can spend several minutes waiting for CI or review bots. The token must additionally read checks and Actions logs, reply to and resolve review threads, and post issue comments: - *Fine-grained:* add **Issues: Read and write**, **Actions: Read**, and **Commit statuses: Read** (plus check-suite read access if your organization exposes it separately). - *Classic:* the **`repo`** scope, SSO-authorized for organization repositories. A classic token or GitHub App installation may be required where a fine-grained token cannot access every check endpoint. @@ -217,6 +235,17 @@ Babysit enforces fixed bounds that are not configurable, and they behave differe - **Trimmed silently.** At most 30 review threads are shown to Pi per round. Extra actionable threads are carried to a later round, so `threadsClean` stays `false` until they are handled. - **Stops the run with `bounds_exceeded`.** More than 20 failing required checks in a round, or a cumulative change across the run exceeding 50 files or 200,000 diff bytes. +### Update Branch [#setup-cloud-branch] + +Enable sandbox execution and BYOK as for Create PR. The GitHub token needs permission to clone and push: + +- *Fine-grained:* select the repo, then grant **Contents: Read and write**. +- *Classic:* the **`repo`** scope. For org repos, authorize the token for SSO. + +The target branch must already exist. GitHub branch protection remains authoritative, and a branch that advances during the run rejects Pi's normal non-force push. + +With **Babysit Mode**, the token also needs the Pull requests, Issues, Actions, commit-status, and check permissions listed above. The branch must have exactly one open pull request in this same repository; Update Branch never creates one and does not support fork PRs. + ### Review Code [#setup-cloud-code-review] Enable sandbox execution as for Create PR. BYOK is optional because the model credential remains in Sim. The GitHub token needs enough access to clone the repo and submit a review — push permission is not required: @@ -233,20 +262,21 @@ Enable sandbox execution as for Create PR. BYOK is optional because the model cr ## Best Practices - **Scope the task.** A specific instruction ("fix the failing `auth` test and add a regression case") produces far better results than a vague one. -- **Match the mode to the deliverable.** Create PR for unattended changes, Review Code for feedback on an existing PR, and Local Dev for a repo you already have checked out. +- **Match the mode to the deliverable.** Create PR for a new reviewable change, Update Branch for continuing existing remote work, Review Code for feedback on a PR, and Local Dev for a repo you already have checked out. - **Use Babysit Mode for automated review follow-through.** Run it asynchronously and expect partial success; a pushed fix followed by `awaiting_checks` is normal. - **Prefer key auth and tear down tunnels.** A public SSH tunnel is a real attack surface — use a private key and stop the tunnel when you're done. -- **Reuse a Conversation ID for Create PR or Local Dev follow-ups.** It carries the prior task and outcome into the next run so the agent can build on its own work. +- **Reuse a Conversation ID for Create PR, Update Branch, or Local Dev follow-ups.** It carries the prior task and outcome into the next run so the agent can build on its own work. diff --git a/apps/sim/blocks/blocks/pi.test.ts b/apps/sim/blocks/blocks/pi.test.ts index e8aa4491e28..72091f05068 100644 --- a/apps/sim/blocks/blocks/pi.test.ts +++ b/apps/sim/blocks/blocks/pi.test.ts @@ -22,6 +22,7 @@ import { PI_SEARCH_PROVIDERS } from '@/executor/handlers/pi/keys' const searchProviderField = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'searchProvider') const searchApiKeyField = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'searchApiKey') +const targetBranchField = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'targetBranch') function searchKeyVisible(values: Record): boolean { return evaluateSubBlockCondition(searchApiKeyField?.condition, values) @@ -84,69 +85,73 @@ describe('Pi block search fields', () => { }) }) -describe('Pi Create PR Babysit surface', () => { - it('offers exactly Create PR, Review Code, and Local Dev as top-level modes', () => { +describe('Pi cloud authoring surface', () => { + it('offers Create PR, Update Branch, Review Code, and Local Dev as top-level modes', () => { const mode = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'mode') const options = typeof mode?.options === 'function' ? mode.options() : (mode?.options as Array<{ id: string }> | undefined) - expect(options?.map(({ id }) => id)).toEqual(['cloud', 'cloud_review', 'local']) + expect(options?.map(({ id }) => id)).toEqual(['cloud', 'cloud_branch', 'cloud_review', 'local']) }) - it('declares the toggle, required reviewer mentions, advanced rounds, and result outputs', () => { - const toggle = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'babysitMode') - const maxRounds = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'maxRounds') - const mentions = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'reviewMentions') - - expect(toggle).toMatchObject({ - type: 'switch', - defaultValue: false, - condition: { field: 'mode', value: 'cloud' }, - }) - expect(maxRounds).toMatchObject({ - type: 'short-input', - defaultValue: '3', - mode: 'advanced', - condition: { - field: 'mode', - value: 'cloud', - and: { field: 'babysitMode', value: [true, 'true'] }, - }, - }) - expect(mentions).toMatchObject({ - type: 'short-input', - defaultValue: '', - hideDividerBefore: true, - required: { - field: 'mode', - value: 'cloud', - and: { field: 'babysitMode', value: [true, 'true'] }, - }, - condition: { - field: 'mode', - value: 'cloud', - and: { field: 'babysitMode', value: [true, 'true'] }, - }, - }) - for (const output of [ - 'rounds', - 'threadsClean', - 'checksGreen', - 'threadsResolved', - 'commitsPushed', - 'stopReason', - ]) { - expect(PiBlock.outputs[output]).toMatchObject({ + it.each(['cloud', 'cloud_branch'])( + 'declares Babysit controls and outputs for %s', + (authoringMode) => { + const toggle = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'babysitMode') + const maxRounds = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'maxRounds') + const mentions = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'reviewMentions') + + expect(toggle).toMatchObject({ + type: 'switch', + defaultValue: false, + condition: { field: 'mode', value: ['cloud', 'cloud_branch'] }, + }) + expect(maxRounds).toMatchObject({ + type: 'short-input', + defaultValue: '3', + mode: 'advanced', condition: { field: 'mode', - value: 'cloud', + value: ['cloud', 'cloud_branch'], and: { field: 'babysitMode', value: [true, 'true'] }, }, }) + expect(mentions).toMatchObject({ + type: 'short-input', + defaultValue: '', + hideDividerBefore: true, + required: { + field: 'mode', + value: ['cloud', 'cloud_branch'], + and: { field: 'babysitMode', value: [true, 'true'] }, + }, + condition: { + field: 'mode', + value: ['cloud', 'cloud_branch'], + and: { field: 'babysitMode', value: [true, 'true'] }, + }, + }) + for (const output of [ + 'rounds', + 'threadsClean', + 'checksGreen', + 'threadsResolved', + 'commitsPushed', + 'stopReason', + ]) { + expect(PiBlock.outputs[output]).toMatchObject({ + condition: { + field: 'mode', + value: ['cloud', 'cloud_branch'], + and: { field: 'babysitMode', value: [true, 'true'] }, + }, + }) + } + expect(evaluateSubBlockCondition(toggle?.condition, { mode: authoringMode })).toBe(true) } - }) + ) it('requires a task and hides Draft PR while Babysit Mode is enabled', () => { const task = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'task') @@ -176,4 +181,55 @@ describe('Pi Create PR Babysit surface', () => { expect(evaluateSubBlockCondition(pullNumber?.condition, { mode: 'cloud' })).toBe(false) expect(evaluateSubBlockCondition(pullNumber?.condition, { mode: 'cloud_review' })).toBe(true) }) + + it('requires the target branch only in Update Branch mode', () => { + expect(targetBranchField?.type).toBe('short-input') + expect(targetBranchField?.required).toBe(true) + expect(evaluateSubBlockCondition(targetBranchField?.condition, { mode: 'cloud_branch' })).toBe( + true + ) + expect(evaluateSubBlockCondition(targetBranchField?.condition, { mode: 'cloud' })).toBe(false) + expect(evaluateSubBlockCondition(targetBranchField?.condition, { mode: 'cloud_review' })).toBe( + false + ) + expect(evaluateSubBlockCondition(targetBranchField?.condition, { mode: 'local' })).toBe(false) + }) + + it('declares the target branch input and branch output for cloud authoring modes', () => { + expect(PiBlock.inputs.targetBranch).toBeDefined() + expect( + evaluateSubBlockCondition(PiBlock.outputs.branch.condition, { mode: 'cloud_branch' }) + ).toBe(true) + expect(evaluateSubBlockCondition(PiBlock.outputs.branch.condition, { mode: 'cloud' })).toBe( + true + ) + expect( + evaluateSubBlockCondition(PiBlock.outputs.branch.condition, { mode: 'cloud_review' }) + ).toBe(false) + }) + + it('reuses skills and memory fields, including their dependent controls', () => { + for (const id of [ + 'skills', + 'memoryType', + 'conversationId', + 'slidingWindowSize', + 'slidingWindowTokens', + ]) { + const field = PiBlock.subBlocks.find((subBlock) => subBlock.id === id) + expect( + evaluateSubBlockCondition(field?.condition, { + mode: 'cloud_branch', + memoryType: id === 'slidingWindowTokens' ? 'sliding_window_tokens' : 'sliding_window', + }) + ).toBe(true) + } + }) + + it('hides Create PR and Review Code-specific fields', () => { + for (const id of ['baseBranch', 'branchName', 'draft', 'prTitle', 'prBody', 'pullNumber']) { + const field = PiBlock.subBlocks.find((subBlock) => subBlock.id === id) + expect(evaluateSubBlockCondition(field?.condition, { mode: 'cloud_branch' })).toBe(false) + } + }) }) diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts index d0351e30bfb..984c9959993 100644 --- a/apps/sim/blocks/blocks/pi.ts +++ b/apps/sim/blocks/blocks/pi.ts @@ -50,18 +50,29 @@ const CLOUD_REVIEW: { field: 'mode'; value: 'cloud_review' } = { field: 'mode', value: 'cloud_review', } -const CLOUD_ANY: { field: 'mode'; value: Array<'cloud' | 'cloud_review'> } = { +const CLOUD_BRANCH: { field: 'mode'; value: 'cloud_branch' } = { field: 'mode', - value: ['cloud', 'cloud_review'], + value: 'cloud_branch', +} +const CLOUD_ANY: { + field: 'mode' + value: Array<'cloud' | 'cloud_branch' | 'cloud_review'> +} = { + field: 'mode', + value: ['cloud', 'cloud_branch', 'cloud_review'], +} +const CLOUD_AUTHORING: { field: 'mode'; value: Array<'cloud' | 'cloud_branch'> } = { + field: 'mode', + value: ['cloud', 'cloud_branch'], } const BABYSIT_ENABLED_VALUES: Array = [true, 'true'] const CLOUD_WITH_BABYSIT: { field: 'mode' - value: 'cloud' + value: Array<'cloud' | 'cloud_branch'> and: { field: 'babysitMode'; value: Array } } = { field: 'mode', - value: 'cloud', + value: ['cloud', 'cloud_branch'], and: { field: 'babysitMode', value: BABYSIT_ENABLED_VALUES }, } function getCloudWithoutBabysitCondition(values?: Record): { @@ -80,9 +91,12 @@ function getCloudWithoutBabysitCondition(values?: Record): { } } const LOCAL: { field: 'mode'; value: 'local' } = { field: 'mode', value: 'local' } -const AUTHORING_MODES: { field: 'mode'; value: Array<'cloud' | 'local'> } = { +const AUTHORING_MODES: { + field: 'mode' + value: Array<'cloud' | 'cloud_branch' | 'local'> +} = { field: 'mode', - value: ['cloud', 'local'], + value: ['cloud', 'cloud_branch', 'local'], } const MEMORY_TYPES = ['conversation', 'sliding_window', 'sliding_window_tokens'] @@ -132,13 +146,14 @@ export const PiBlock: BlockConfig = { description: 'Run an autonomous coding agent on a repo', authMode: AuthMode.ApiKey, longDescription: - 'The Pi Coding Agent runs the Pi harness against a real repository. Create PR spins up an isolated sandbox, clones a GitHub repo, edits with native shell + git, and opens a pull request; Babysit Mode then keeps that pull request under watch, fixing trusted bot review threads and failing required checks in bounded rounds. Review Code checks out a pinned PR snapshot with read-only tools and posts a structured review with optional inline comments. Local Dev edits files on your own machine over SSH. Create PR and Local Dev can reuse skills and multi-turn memory; Review Code runs without either because PR contents are untrusted. Any mode can optionally get one web_search tool backed by your own Exa, Serper, Parallel AI, or Firecrawl key; the agent writes its own queries, so repository content may reach the provider, and results are untrusted third-party data.', + 'The Pi Coding Agent runs the Pi harness against a real repository. Create PR spins up an isolated sandbox, clones a GitHub repo, edits with native shell + git, and opens a pull request; Update Branch checks out an existing remote branch and pushes commits back without force-pushing. Babysit Mode then keeps the pull request under watch, fixing trusted bot review threads and failing required checks in bounded rounds. Review Code checks out a pinned PR snapshot with read-only tools and posts a structured review with optional inline comments. Local Dev edits files on your own machine over SSH. Create PR, Update Branch, and Local Dev can reuse skills and multi-turn memory; Review Code runs without either because PR contents are untrusted. Any mode can optionally get one web_search tool backed by your own Exa, Serper, Parallel AI, or Firecrawl key; the agent writes its own queries, so repository content may reach the provider, and results are untrusted third-party data.', bestPractices: ` - Use Create PR for hands-off changes against a GitHub repo where a reviewable PR is the deliverable. - - Enable Babysit Mode on Create PR when trusted review bots and required checks should be monitored and fixed in bounded rounds. + - Use Update Branch to continue work on an existing remote branch, including one previously created by Pi. + - Enable Babysit Mode on Create PR or Update Branch when trusted review bots and required checks should be monitored and fixed in bounded rounds. - Use Review Code to analyze an existing PR and leave summary + inline review comments. - Use Local Dev to edit a repo on your own machine; expose the machine on a public hostname/tunnel so Sim can reach it over SSH. - - Create PR requires your own provider API key for every model, including ones Sim hosts, because the model runs in the sandbox. Review Code and Local Dev keep the model key in Sim and can use either BYOK or a hosted key. + - Create PR and Update Branch require your own provider API key for every model, including ones Sim hosts, because the model runs in the sandbox. Review Code and Local Dev keep the model key in Sim and can use either BYOK or a hosted key. - Internet Search is off by default and always needs your own key for the selected provider, entered on the block. There is no workspace BYOK fallback and no hosted key. Leave it on None unless the task genuinely needs external information. `, category: 'blocks', @@ -150,7 +165,7 @@ export const PiBlock: BlockConfig = { id: 'mode', title: 'Mode', type: 'dropdown', - /** Create PR and Review Code require E2B and stay hidden when it is disabled. */ + /** Cloud modes require E2B and stay hidden when it is disabled. */ value: () => (isTruthy(getEnv('NEXT_PUBLIC_E2B_ENABLED')) ? 'cloud' : 'local'), options: () => { const options = [ @@ -167,6 +182,11 @@ export const PiBlock: BlockConfig = { id: 'cloud', description: 'Runs in an isolated sandbox, clones your repo, and opens a PR', }, + { + label: 'Update Branch', + id: 'cloud_branch', + description: 'Checks out an existing branch, makes changes, and pushes commits back', + }, { label: 'Review Code', id: 'cloud_review', @@ -211,7 +231,7 @@ export const PiBlock: BlockConfig = { defaultValue: 'none', options: SEARCH_PROVIDER_OPTIONS, tooltip: - 'Gives the agent a single web_search tool backed by the selected provider. Search always uses your own key for that provider, never a Sim-hosted one, because Create PR places the key inside the coding sandbox.', + 'Gives the agent a single web_search tool backed by the selected provider. Search always uses your own key for that provider, never a Sim-hosted one, because cloud authoring places the key inside the coding sandbox.', }, { id: 'searchApiKey', @@ -259,7 +279,7 @@ export const PiBlock: BlockConfig = { paramVisibility: 'user-only', placeholder: 'GitHub personal access token', tooltip: - 'Personal access token used for GitHub access. Create PR needs clone/push/PR permissions; with Babysit Mode it also needs check/Actions reads, thread writes, and issue comments. Review Code needs clone + review permissions.', + 'Personal access token used for GitHub access. Create PR needs clone/push/PR permissions; Update Branch needs clone/push permissions. With Babysit Mode, either also needs pull request, check/Actions read, thread write, and issue comment permissions. Review Code needs clone + review permissions.', required: true, condition: CLOUD_ANY, }, @@ -271,14 +291,24 @@ export const PiBlock: BlockConfig = { tooltip: 'The branch the pull request is opened against; the repo is cloned from it too.', condition: CLOUD, }, + { + id: 'targetBranch', + title: 'Target Branch', + type: 'short-input', + placeholder: 'e.g., feature/add-auth', + tooltip: + 'Existing remote branch to update. The run never force-pushes and fails if the branch does not exist or changes while Pi is running.', + required: true, + condition: CLOUD_BRANCH, + }, { id: 'babysitMode', title: 'Babysit Mode', type: 'switch', defaultValue: false, description: - 'Create the PR ready for review, request the configured bot reviews, and fix trusted feedback and required checks in bounded rounds.', - condition: CLOUD, + 'Use the branch PR, request the configured bot reviews, and fix trusted feedback and required checks in bounded rounds.', + condition: CLOUD_AUTHORING, }, { id: 'reviewMentions', @@ -287,7 +317,7 @@ export const PiBlock: BlockConfig = { defaultValue: '', placeholder: '@greptile, @cursor review', tooltip: - 'Required comma-separated issue comments. Each is posted after PR creation and again after every pushed Babysit fix.', + 'Required comma-separated issue comments. Each is posted when Babysit starts and again after every pushed fix.', hideDividerBefore: true, required: CLOUD_WITH_BABYSIT, condition: CLOUD_WITH_BABYSIT, @@ -503,12 +533,12 @@ export const PiBlock: BlockConfig = { mode: 'advanced', required: { field: 'mode', - value: ['cloud', 'local'], + value: ['cloud', 'cloud_branch', 'local'], and: { field: 'memoryType', value: MEMORY_TYPES }, }, condition: { field: 'mode', - value: ['cloud', 'local'], + value: ['cloud', 'cloud_branch', 'local'], and: { field: 'memoryType', value: MEMORY_TYPES }, }, dependsOn: ['memoryType'], @@ -521,7 +551,7 @@ export const PiBlock: BlockConfig = { mode: 'advanced', condition: { field: 'mode', - value: ['cloud', 'local'], + value: ['cloud', 'cloud_branch', 'local'], and: { field: 'memoryType', value: ['sliding_window'] }, }, dependsOn: ['memoryType'], @@ -534,7 +564,7 @@ export const PiBlock: BlockConfig = { mode: 'advanced', condition: { field: 'mode', - value: ['cloud', 'local'], + value: ['cloud', 'cloud_branch', 'local'], and: { field: 'memoryType', value: ['sliding_window_tokens'] }, }, dependsOn: ['memoryType'], @@ -546,26 +576,27 @@ export const PiBlock: BlockConfig = { inputs: { mode: { type: 'string', - description: 'Execution mode: Create PR, Review Code, or Local Dev', + description: 'Execution mode: Create PR, Update Branch, Review Code, or Local Dev', }, task: { type: 'string', description: 'Instruction for the coding agent' }, model: { type: 'string', description: 'AI model to use' }, - owner: { type: 'string', description: 'GitHub repository owner' }, - repo: { type: 'string', description: 'GitHub repository name' }, - githubToken: { type: 'string', description: 'GitHub token' }, + owner: { type: 'string', description: 'GitHub repository owner (cloud modes)' }, + repo: { type: 'string', description: 'GitHub repository name (cloud modes)' }, + githubToken: { type: 'string', description: 'GitHub token (cloud modes)' }, baseBranch: { type: 'string', description: 'Base branch for the PR (Create PR)' }, branchName: { type: 'string', description: 'Branch to create (Create PR)' }, + targetBranch: { type: 'string', description: 'Existing branch to update (Update Branch)' }, draft: { type: 'boolean', description: 'Open the PR as a draft (Create PR)' }, prTitle: { type: 'string', description: 'Pull request title (Create PR)' }, prBody: { type: 'string', description: 'Pull request body (Create PR)' }, babysitMode: { type: 'boolean', - description: 'Create the PR and babysit trusted bot reviews and required checks', + description: 'Babysit trusted bot reviews and required checks after authoring', }, pullNumber: { type: 'number', description: 'Pull request number (Review Code)' }, maxRounds: { type: 'number', - description: 'Maximum Create PR Babysit fixing rounds (1-10)', + description: 'Maximum Babysit fixing rounds (1-10)', }, reviewMentions: { type: 'string', @@ -605,13 +636,13 @@ export const PiBlock: BlockConfig = { diff: { type: 'string', description: 'Unified diff of the changes' }, prUrl: { type: 'string', - description: 'URL of the opened pull request', - condition: CLOUD, + description: 'URL of the created or babysat pull request', + condition: CLOUD_AUTHORING, }, branch: { type: 'string', description: 'Branch pushed with the changes', - condition: CLOUD, + condition: CLOUD_AUTHORING, }, reviewUrl: { type: 'string', diff --git a/apps/sim/executor/handlers/pi/babysit-backend.ts b/apps/sim/executor/handlers/pi/babysit-backend.ts index 2d608df70e3..1b3a91ebb87 100644 --- a/apps/sim/executor/handlers/pi/babysit-backend.ts +++ b/apps/sim/executor/handlers/pi/babysit-backend.ts @@ -753,7 +753,7 @@ export async function runBabysitPiWithOptions( if (signal.aborted) throw new Error('Pi run aborted') if (lifetime < MIN_BABYSIT_BUDGET_MS) { progress.notes.push( - 'Babysit had less than one minute of execution budget remaining after Create PR.' + 'Babysit had less than one minute of execution budget remaining after authoring.' ) return resultFor(totals, 'budget_exhausted', progress, false, false) } @@ -779,7 +779,7 @@ export async function runBabysitPiWithOptions( return resultFor(totals, 'startup_failure', progress, threadsAreClean(latestThreads), false) } if (params.reviewMentions.length === 0) { - progress.notes.push('No reviewer mentions were configured for Create PR Babysit Mode.') + progress.notes.push('No reviewer mentions were configured for Babysit Mode.') return resultFor( totals, 'startup_failure', diff --git a/apps/sim/executor/handlers/pi/backend.ts b/apps/sim/executor/handlers/pi/backend.ts index 29f5965a492..d6a67d6e617 100644 --- a/apps/sim/executor/handlers/pi/backend.ts +++ b/apps/sim/executor/handlers/pi/backend.ts @@ -114,6 +114,16 @@ export interface PiCloudBabysitOptions { executionId?: string } +/** Parameters for a cloud (E2B) Pi run that updates an existing branch. */ +export interface PiCloudBranchRunParams extends PiContextualRunParams { + mode: 'cloud_branch' + owner: string + repo: string + githubToken: string + targetBranch: string + babysit?: PiCloudBabysitOptions +} + /** Parameters for a cloud (E2B) Pi run that reviews an existing PR. */ export interface PiCloudReviewRunParams extends PiRunBaseParams { mode: 'cloud_review' @@ -124,7 +134,7 @@ export interface PiCloudReviewRunParams extends PiRunBaseParams { reviewEvent: 'COMMENT' | 'REQUEST_CHANGES' } -/** Internal parameters for babysitting the pull request just opened by Create PR. */ +/** Internal parameters for babysitting the pull request associated with an authoring branch. */ export interface PiBabysitContinuationParams extends PiContextualRunParams { owner: string repo: string @@ -136,7 +146,11 @@ export interface PiBabysitContinuationParams extends PiContextualRunParams { executionBudgetMs?: number } -export type PiRunParams = PiLocalRunParams | PiCloudRunParams | PiCloudReviewRunParams +export type PiRunParams = + | PiLocalRunParams + | PiCloudRunParams + | PiCloudBranchRunParams + | PiCloudReviewRunParams /** Progress callbacks and cancellation passed into a backend run. */ export interface PiRunContext { diff --git a/apps/sim/executor/handlers/pi/cloud-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-backend.test.ts index db14274d1cb..91799afd5c6 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.test.ts @@ -41,8 +41,8 @@ vi.mock('@/executor/handlers/pi/keys', () => ({ vi.mock('@/executor/handlers/pi/context', () => ({ buildPiPrompt: () => 'PROMPT' })) import { createTimeoutAbortController } from '@/lib/core/execution-limits' -import type { PiCloudRunParams } from '@/executor/handlers/pi/backend' -import { runCloudPi } from '@/executor/handlers/pi/cloud-backend' +import type { PiCloudBranchRunParams, PiCloudRunParams } from '@/executor/handlers/pi/backend' +import { runCloudBranchPi, runCloudPi } from '@/executor/handlers/pi/cloud-backend' function baseParams(overrides: Partial = {}): PiCloudRunParams { return { @@ -64,6 +64,50 @@ function baseParams(overrides: Partial = {}): PiCloudRunParams } } +function branchParams(overrides: Partial = {}): PiCloudBranchRunParams { + return { + mode: 'cloud_branch', + model: 'claude', + piModel: 'claude', + providerId: 'anthropic', + apiKey: 'sk-byok', + isBYOK: true, + task: 'continue it', + skills: [], + initialMessages: [], + owner: 'octo', + repo: 'demo', + githubToken: 'ghp_secret', + targetBranch: 'feature/existing', + ...overrides, + } +} + +function mockExistingBranchPullRequest(): void { + mockExecuteTool + .mockResolvedValueOnce({ + success: true, + output: { items: [{ number: 7 }], count: 1 }, + }) + .mockResolvedValueOnce({ + success: true, + output: { + title: 'Feature', + body: '', + html_url: 'https://github.com/octo/demo/pull/7', + state: 'open', + merged: false, + mergeable: true, + head: { + sha: 'a'.repeat(40), + ref: 'feature/existing', + repo_full_name: 'octo/demo', + }, + base: { sha: 'b'.repeat(40), ref: 'staging' }, + }, + }) +} + describe('runCloudPi', () => { beforeEach(() => { vi.clearAllMocks() @@ -154,6 +198,7 @@ describe('runCloudPi', () => { expect(pushCmd).toContain('core.hooksPath=/dev/null') expect(pushCmd).toContain('credential.helper=') expect(pushCmd).toContain('core.fsmonitor=') + expect(pushCmd).toContain('"HEAD:refs/heads/$BRANCH"') expect(pushOpts.envs.GITHUB_TOKEN).toBe('ghp_secret') expect(pushOpts.envs.ANTHROPIC_API_KEY).toBeUndefined() // The `-c` flags do not reach config-driven URL rewriting, which would send @@ -618,4 +663,294 @@ describe('runCloudPi', () => { expect(error.message).not.toContain('ghp_secret') expect(mockExecuteTool).not.toHaveBeenCalled() }) + + describe('Update Branch', () => { + it('checks out and non-force pushes the exact existing branch without opening a PR', async () => { + const result = await runCloudBranchPi(branchParams(), { onEvent: vi.fn() }) + + const [cloneCmd, cloneOpts] = mockRun.mock.calls[0] + expect(cloneCmd).toContain('git check-ref-format "refs/heads/$BRANCH"') + expect(cloneCmd).toContain('--single-branch --branch "$BRANCH"') + expect(cloneCmd).toContain('git symbolic-ref --quiet --short HEAD') + expect(cloneCmd).toContain('[ "$CURRENT_BRANCH" != "$BRANCH" ]') + expect(cloneCmd).not.toContain('checkout -b') + expect(cloneOpts.envs.BRANCH).toBe('feature/existing') + expect(cloneOpts.envs.GITHUB_TOKEN).toBe('ghp_secret') + + const [piCmd, piOpts] = mockRun.mock.calls[1] + expect(piCmd).toContain('pi -p') + expect(piOpts.envs.ANTHROPIC_API_KEY).toBe('sk-byok') + expect(piOpts.envs.GITHUB_TOKEN).toBeUndefined() + + const [prepareCmd, prepareOpts] = mockRun.mock.calls[2] + expect(prepareCmd).toContain('commit -F /workspace/pi-commit.txt') + expect(prepareOpts.envs.GITHUB_TOKEN).toBeUndefined() + + const [pushCmd, pushOpts] = mockRun.mock.calls[3] + expect(pushCmd).toContain('"HEAD:refs/heads/$BRANCH"') + expect(pushCmd).not.toContain('--force') + expect(pushOpts.envs.BRANCH).toBe('feature/existing') + expect(pushOpts.envs.GITHUB_TOKEN).toBe('ghp_secret') + + expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-commit.txt', 'Pi: continue it') + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(result).toEqual( + expect.objectContaining({ + branch: 'feature/existing', + changedFiles: ['src/x.ts'], + diff: 'diff content', + }) + ) + }) + + it('returns the target branch without pushing when there are no changes', async () => { + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + return Promise.resolve({ stdout: '__NO_CHANGES__=1', stderr: '', exitCode: 0 }) + }) + + const result = await runCloudBranchPi(branchParams(), { onEvent: vi.fn() }) + + expect(result.branch).toBe('feature/existing') + expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) + + it('discovers the exact existing PR, pushes first, and reuses the Babysit continuation', async () => { + mockExistingBranchPullRequest() + + const result = await runCloudBranchPi( + branchParams({ + skills: [{ name: 'style', content: 'Be concise.' }], + initialMessages: [{ role: 'user', content: 'authoring memory only' }], + babysit: { + maxRounds: 4, + reviewMentions: ['@greptile'], + executionId: 'execution-2', + }, + }), + { onEvent: vi.fn() } + ) + + expect(mockExecuteTool).toHaveBeenNthCalledWith( + 1, + 'github_list_prs_v2', + expect.objectContaining({ + head: 'octo:feature/existing', + state: 'open', + per_page: 2, + }), + { signal: undefined } + ) + expect(mockRunBabysit).toHaveBeenCalledTimes(1) + expect(mockRunBabysit.mock.calls[0][0]).toMatchObject({ + pullNumber: 7, + skills: [{ name: 'style', content: 'Be concise.' }], + initialMessages: [], + maxRounds: 4, + reviewMentions: ['@greptile'], + executionId: 'execution-2', + }) + const pushCall = mockRun.mock.calls.find(([command]: [string]) => command.includes('push')) + expect(pushCall).toBeDefined() + expect(mockRun.mock.invocationCallOrder.at(-1)).toBeLessThan( + mockRunBabysit.mock.invocationCallOrder[0] + ) + expect(result).toMatchObject({ + memoryText: 'done', + prUrl: 'https://github.com/octo/demo/pull/7', + branch: 'feature/existing', + changedFiles: ['src/x.ts', 'src/y.ts'], + diff: 'diff content\nbabysit diff', + rounds: 1, + threadsClean: true, + checksGreen: true, + commitsPushed: 1, + stopReason: 'clean', + }) + expect(result.totals.finalText).toBe( + 'Update Branch:\ndone\n\nBabysit:\nBabysit stopped: clean.' + ) + }) + + it('still babysits the existing PR when authoring makes no changes', async () => { + mockExistingBranchPullRequest() + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + return Promise.resolve({ stdout: '__NO_CHANGES__=1', stderr: '', exitCode: 0 }) + }) + + const result = await runCloudBranchPi( + branchParams({ babysit: { maxRounds: 3, reviewMentions: ['@greptile'] } }), + { onEvent: vi.fn() } + ) + + expect(mockRun.mock.calls.some(([command]: [string]) => command.includes('push'))).toBe(false) + expect(mockRunBabysit).toHaveBeenCalledWith( + expect.objectContaining({ pullNumber: 7 }), + expect.anything() + ) + expect(result).toMatchObject({ + prUrl: 'https://github.com/octo/demo/pull/7', + branch: 'feature/existing', + stopReason: 'clean', + }) + }) + + it('fails before starting a sandbox when no open PR uses the target branch', async () => { + mockExecuteTool.mockResolvedValueOnce({ + success: true, + output: { items: [], count: 0 }, + }) + + await expect( + runCloudBranchPi( + branchParams({ babysit: { maxRounds: 3, reviewMentions: ['@greptile'] } }), + { onEvent: vi.fn() } + ) + ).rejects.toThrow(/none was found/) + expect(mockWithPiSandbox).not.toHaveBeenCalled() + expect(mockRunBabysit).not.toHaveBeenCalled() + }) + + it('scrubs every sandbox credential from PR discovery errors', async () => { + mockExecuteTool.mockResolvedValueOnce({ + success: false, + error: 'denied ghp_secret sk-byok sk-search', + }) + + const error = (await runCloudBranchPi( + branchParams({ + search: { + provider: 'exa', + apiKey: 'sk-search', + keySource: 'block', + }, + babysit: { maxRounds: 3, reviewMentions: ['@greptile'] }, + }), + { onEvent: vi.fn() } + ).catch((caught) => caught)) as Error + + expect(error.message).toContain('Failed to find an open PR') + expect(error.message).not.toContain('ghp_secret') + expect(error.message).not.toContain('sk-byok') + expect(error.message).not.toContain('sk-search') + expect(mockWithPiSandbox).not.toHaveBeenCalled() + expect(mockRunBabysit).not.toHaveBeenCalled() + }) + + it('fails before Pi runs when the target branch cannot be cloned', async () => { + mockRun.mockResolvedValueOnce({ + stdout: '', + stderr: 'fatal: Remote branch feature/missing not found in upstream origin', + exitCode: 128, + }) + + await expect( + runCloudBranchPi(branchParams({ targetBranch: 'feature/missing' }), { onEvent: vi.fn() }) + ).rejects.toThrow(/Remote branch feature\/missing not found/) + expect(mockRun).toHaveBeenCalledTimes(1) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) + + it('rejects a tag-only target instead of creating a branch from it', async () => { + mockRun.mockResolvedValueOnce({ + stdout: '', + stderr: 'Target v1.0.0 is not an existing branch', + exitCode: 1, + }) + + await expect( + runCloudBranchPi(branchParams({ targetBranch: 'v1.0.0' }), { onEvent: vi.fn() }) + ).rejects.toThrow(/not an existing branch/) + expect(mockRun).toHaveBeenCalledTimes(1) + expect(mockRun.mock.calls[0][0]).toContain('git symbolic-ref --quiet --short HEAD') + expect(mockExecuteTool).not.toHaveBeenCalled() + }) + + it('surfaces a non-fast-forward rejection without retrying or force-pushing', async () => { + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + if (command.includes('push')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 }) + } + return Promise.resolve({ + stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1', + stderr: '', + exitCode: 0, + }) + }) + mockReadFile.mockResolvedValue( + '! [rejected] feature/existing -> feature/existing (non-fast-forward)' + ) + + await expect(runCloudBranchPi(branchParams(), { onEvent: vi.fn() })).rejects.toThrow( + /non-fast-forward/ + ) + const pushCalls = mockRun.mock.calls.filter(([cmd]: [string]) => cmd.includes('push')) + expect(pushCalls).toHaveLength(1) + expect(pushCalls[0][0]).not.toContain('--force') + expect(mockExecuteTool).not.toHaveBeenCalled() + }) + + it('does not start Babysit after the initial branch push is rejected', async () => { + mockExistingBranchPullRequest() + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + if (command.includes('push')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 }) + } + return Promise.resolve({ + stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1', + stderr: '', + exitCode: 0, + }) + }) + mockReadFile.mockResolvedValue( + '! [rejected] feature/existing -> feature/existing (non-fast-forward)' + ) + + await expect( + runCloudBranchPi( + branchParams({ babysit: { maxRounds: 3, reviewMentions: ['@greptile'] } }), + { onEvent: vi.fn() } + ) + ).rejects.toThrow(/non-fast-forward/) + expect(mockRunBabysit).not.toHaveBeenCalled() + }) + + it('aborts without reaching the Pi or push steps', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + runCloudBranchPi(branchParams(), { + onEvent: vi.fn(), + signal: controller.signal, + }) + ).rejects.toThrow(/aborted/) + expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('pi -p'))).toBe(false) + expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false) + }) + }) }) diff --git a/apps/sim/executor/handlers/pi/cloud-backend.ts b/apps/sim/executor/handlers/pi/cloud-backend.ts index ea592982247..0c2397c0686 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.ts @@ -1,9 +1,10 @@ /** - * Create PR backend: runs the Pi CLI inside an E2B sandbox against a cloned - * GitHub repo, then pushes a branch and opens a PR. Secrets are isolated per - * command (S2/KTD10): the GitHub token is present only for the clone and push - * commands (and stripped from the cloned remote), while the Pi loop runs with a - * BYOK model key only. The model key is never a Sim-owned hosted key (S1). + * Cloud authoring backend: runs the Pi CLI inside an E2B sandbox against a + * cloned GitHub repo, then either opens a PR from a new branch or updates an + * existing branch. Secrets are isolated per command (S2/KTD10): the GitHub token + * is present only for the clone and push commands (and stripped from the cloned + * remote), while the Pi loop runs with a BYOK model key only. The model key is + * never a Sim-owned hosted key (S1). * * Untrusted text (the assembled prompt, which folds in workspace-shared skills * and memory, and the commit message) is never placed on a shell command line. @@ -28,7 +29,13 @@ import { resolvePiSandboxLifetimeMs, } from '@/lib/execution/remote-sandbox/pi-lifetime' import { runBabysitPi } from '@/executor/handlers/pi/babysit-backend' -import type { PiBackendRun, PiCloudRunParams, PiRunResult } from '@/executor/handlers/pi/backend' +import type { + PiBackendRun, + PiCloudBranchRunParams, + PiCloudRunParams, + PiRunContext, + PiRunResult, +} from '@/executor/handlers/pi/backend' import { buildPiScript, CLONE_TIMEOUT_MS, @@ -55,6 +62,7 @@ import { type PiRunTotals, parseJsonLine, } from '@/executor/handlers/pi/events' +import { type BranchPullRequest, findOpenPrForBranch } from '@/executor/handlers/pi/github-pr' import { mapThinkingLevel, providerApiKeyEnvVar } from '@/executor/handlers/pi/keys' import { createScrubbedPiError, @@ -80,7 +88,7 @@ interface OpenedPullRequest { number?: number } -interface CreatePrPhaseResult extends PiRunResult { +interface AuthoringPhaseResult extends PiRunResult { pullNumber?: number } @@ -88,14 +96,15 @@ interface CreatePrPhaseResult extends PiRunResult { * Keeps git authentication out of the agent loop by reserving commit, push, and * PR creation for Sim's credential-scoped finalization step. */ -const CLOUD_GUIDANCE = +const CLOUD_GUIDANCE_PREFIX = 'You are running inside an automated sandbox. Make only the file changes needed to complete the task. ' + 'Do not run git commands (commit, push, branch, remote), do not configure git credentials or authenticate ' + - 'with GitHub, and do not open a pull request — after you finish, Sim automatically commits your changes, ' + - "pushes the branch, and opens the pull request. The project's package manager and test tooling may not be " + + 'with GitHub, and do not open a pull request — after you finish, Sim automatically commits your changes and ' +const CLOUD_GUIDANCE_SUFFIX = + "The project's package manager and test tooling may not be " + 'installed, so do not block on running the full build or test suite; focus on correct, minimal edits.' -const CLONE_SCRIPT = `set -e +const CREATE_PR_CLONE_SCRIPT = `set -e rm -rf ${REPO_DIR} git clone "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" ${REPO_DIR} cd ${REPO_DIR} @@ -107,6 +116,20 @@ git checkout -b "$BRANCH" git remote set-url origin "https://github.com/$REPO_OWNER/$REPO_NAME.git" ${GIT_CONFIG_DIGEST_LINE}` +const UPDATE_BRANCH_CLONE_SCRIPT = `set -e +rm -rf ${REPO_DIR} +git check-ref-format "refs/heads/$BRANCH" >/dev/null +git clone --no-tags --single-branch --branch "$BRANCH" "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" ${REPO_DIR} +cd ${REPO_DIR} +CURRENT_BRANCH=$(git symbolic-ref --quiet --short HEAD || true) +if [ "$CURRENT_BRANCH" != "$BRANCH" ]; then + echo "Target $BRANCH is not an existing branch" >&2 + exit 1 +fi +git rev-parse HEAD | sed "s/^/__BASE_SHA__=/" +git remote set-url origin "https://github.com/$REPO_OWNER/$REPO_NAME.git" +${GIT_CONFIG_DIGEST_LINE}` + function buildPrBody(task: string, finalText: string): string { const summary = finalText.trim() ? truncate(finalText.trim(), PR_SUMMARY_MAX) @@ -114,9 +137,22 @@ function buildPrBody(task: string, finalText: string): string { return `## Task\n\n${task}\n\n## Summary\n\n${summary}` } +type PiCloudAuthoringRunParams = PiCloudRunParams | PiCloudBranchRunParams + /** The commit message and PR title share one default, derived from the PR title or task. */ -function defaultTitle(params: PiCloudRunParams): string { - return params.prTitle?.trim() || truncate(`Pi: ${params.task}`, COMMIT_TITLE_MAX) +function defaultTitle(params: PiCloudAuthoringRunParams): string { + return ( + (params.mode === 'cloud' ? params.prTitle?.trim() : undefined) || + truncate(`Pi: ${params.task}`, COMMIT_TITLE_MAX) + ) +} + +function guidanceFor(params: PiCloudAuthoringRunParams): string { + const finalization = + params.mode === 'cloud' + ? 'pushes the branch, and opens the pull request. ' + : 'pushes them back to the configured branch without force-pushing. ' + return `${CLOUD_GUIDANCE_PREFIX}${finalization}${CLOUD_GUIDANCE_SUFFIX}` } async function openPullRequest( @@ -184,23 +220,27 @@ function mergePhaseDiffs(createDiff: string | undefined, babysitDiff: string | u : merged } -function combineCreateAndBabysit(created: CreatePrPhaseResult, babysit: PiRunResult): PiRunResult { - const createText = created.totals.finalText +function combineAuthoringAndBabysit( + label: 'Create PR' | 'Update Branch', + authored: AuthoringPhaseResult, + babysit: PiRunResult +): PiRunResult { + const authoringText = authored.totals.finalText return { totals: { - finalText: `Create PR:\n${createText}\n\nBabysit:\n${babysit.totals.finalText}`, - inputTokens: created.totals.inputTokens + babysit.totals.inputTokens, - outputTokens: created.totals.outputTokens + babysit.totals.outputTokens, - toolCalls: [...created.totals.toolCalls, ...babysit.totals.toolCalls], - ...(created.totals.errorMessage || babysit.totals.errorMessage - ? { errorMessage: created.totals.errorMessage ?? babysit.totals.errorMessage } + finalText: `${label}:\n${authoringText}\n\nBabysit:\n${babysit.totals.finalText}`, + inputTokens: authored.totals.inputTokens + babysit.totals.inputTokens, + outputTokens: authored.totals.outputTokens + babysit.totals.outputTokens, + toolCalls: [...authored.totals.toolCalls, ...babysit.totals.toolCalls], + ...(authored.totals.errorMessage || babysit.totals.errorMessage + ? { errorMessage: authored.totals.errorMessage ?? babysit.totals.errorMessage } : {}), }, - memoryText: createText, - changedFiles: mergeChangedFiles(created.changedFiles, babysit.changedFiles), - diff: mergePhaseDiffs(created.diff, babysit.diff), - ...(created.prUrl ? { prUrl: created.prUrl } : {}), - ...(created.branch ? { branch: created.branch } : {}), + memoryText: authoringText, + changedFiles: mergeChangedFiles(authored.changedFiles, babysit.changedFiles), + diff: mergePhaseDiffs(authored.diff, babysit.diff), + ...(authored.prUrl ? { prUrl: authored.prUrl } : {}), + ...(authored.branch ? { branch: authored.branch } : {}), ...(typeof babysit.rounds === 'number' ? { rounds: babysit.rounds } : {}), ...(typeof babysit.threadsClean === 'boolean' ? { threadsClean: babysit.threadsClean } : {}), ...(typeof babysit.checksGreen === 'boolean' ? { checksGreen: babysit.checksGreen } : {}), @@ -232,54 +272,77 @@ function skippedBabysitResult(reason: 'no_pr_created' | 'startup_failure'): PiRu } } -export const runCloudPi: PiBackendRun = async (params, context) => { +async function runCloudAuthoringPi( + params: PiCloudAuthoringRunParams, + context: PiRunContext +): Promise { const startedAt = Date.now() + const modeLabel = params.mode === 'cloud' ? 'Create PR' : 'Update Branch' if (!params.isBYOK) { throw new Error( - 'Create PR requires your own provider API key (BYOK). Set one in Settings > BYOK.' + `${modeLabel} requires your own provider API key (BYOK). Set one in Settings > BYOK.` ) } const keyEnvVar = providerApiKeyEnvVar(params.providerId) if (!keyEnvVar) { throw new Error( - `Provider "${params.providerId}" is not supported in Create PR. Use a key-based provider or run in Local Dev.` + `Provider "${params.providerId}" is not supported in ${modeLabel}. Use a key-based provider or run in Local Dev.` ) } // Every credential that reaches this run, scrubbed from agent-visible and GitHub-visible text. // The guarantee covers the paths the key travels by design; it deliberately does not extend to a - // key wired into `branchName`, which becomes a git ref and could not be substituted without + // key wired into a branch input, which becomes a git ref and could not be substituted without // failing the checkout outright. const secrets = [params.apiKey, params.githubToken, params.search?.apiKey ?? ''] - const branch = params.branchName?.trim() || `pi/${generateShortId(8)}` + const branch = + params.mode === 'cloud' + ? params.branchName?.trim() || `pi/${generateShortId(8)}` + : params.targetBranch const commitMessage = scrubPiSecrets(defaultTitle(params), secrets) const prompt = scrubPiSecrets( buildPiPrompt({ skills: params.skills, initialMessages: params.initialMessages, task: params.task, - guidance: CLOUD_GUIDANCE, + guidance: guidanceFor(params), }), secrets ) const totals = createPiTotals() const thinking = mapThinkingLevel(params.thinkingLevel) ?? 'medium' + let existingPullRequest: BranchPullRequest | undefined + if (params.mode === 'cloud_branch' && params.babysit) { + try { + existingPullRequest = await findOpenPrForBranch( + { + owner: params.owner, + repo: params.repo, + branch, + githubToken: params.githubToken, + }, + context.signal + ) + } catch (error) { + throw createScrubbedPiError(error, secrets, 'Pi cloud run failed') + } + } // Resolved once and shared: the sandbox is created with this lifetime and the // agent turn reserves its finalize budget against the same number. const lifetimeMs = resolvePiRunLifetimeMs(context.signal) const piTimeoutMs = resolvePiTimeoutMs(lifetimeMs) - const created = await withPiSandbox({ lifetimeMs }, async (runner) => { + const authored = await withPiSandbox({ lifetimeMs }, async (runner) => { try { const clone = await raceAbort( - runner.run(CLONE_SCRIPT, { + runner.run(params.mode === 'cloud' ? CREATE_PR_CLONE_SCRIPT : UPDATE_BRANCH_CLONE_SCRIPT, { envs: { GITHUB_TOKEN: params.githubToken, REPO_OWNER: params.owner, REPO_NAME: params.repo, - BASE_BRANCH: params.baseBranch?.trim() ?? '', + BASE_BRANCH: params.mode === 'cloud' ? (params.baseBranch?.trim() ?? '') : '', BRANCH: branch, }, timeoutMs: CLONE_TIMEOUT_MS, @@ -295,7 +358,10 @@ export const runCloudPi: PiBackendRun = async (params, context if (!baseSha) { throw new Error('Clone did not report a base commit') } - const detectedBase = extractMarkerValues(clone.stdout, '__DEFAULT_BRANCH__=')[0] + const detectedBase = + params.mode === 'cloud' + ? extractMarkerValues(clone.stdout, '__DEFAULT_BRANCH__=')[0] + : undefined // Deliver the prompt as a file (read back on Pi's stdin), not a CLI // arg/env, so its skill/memory content can't be parsed by the shell that @@ -396,7 +462,22 @@ export const runCloudPi: PiBackendRun = async (params, context owner: params.owner, repo: params.repo, }) - return { totals, changedFiles, diff } + return { + totals, + changedFiles, + diff, + ...(params.mode === 'cloud_branch' + ? { + branch, + ...(existingPullRequest + ? { + pullNumber: existingPullRequest.pullNumber, + prUrl: existingPullRequest.snapshot.htmlUrl, + } + : {}), + } + : {}), + } } // PUSH is the only command that carries the token, hardened against any @@ -425,6 +506,21 @@ export const runCloudPi: PiBackendRun = async (params, context throw new Error(`git push failed: ${truncate(scrubbed, PUSH_ERROR_MAX)}`) } + if (params.mode === 'cloud_branch') { + return { + totals, + changedFiles, + diff, + branch, + ...(existingPullRequest + ? { + pullNumber: existingPullRequest.pullNumber, + prUrl: existingPullRequest.snapshot.htmlUrl, + } + : {}), + } + } + const pullRequest = await openPullRequest(params, branch, detectedBase, totals, secrets) return { totals, @@ -446,12 +542,12 @@ export const runCloudPi: PiBackendRun = async (params, context } }) - if (!params.babysit) return created - if (!created.branch) { - return combineCreateAndBabysit(created, skippedBabysitResult('no_pr_created')) + if (!params.babysit) return authored + if (!authored.branch) { + return combineAuthoringAndBabysit(modeLabel, authored, skippedBabysitResult('no_pr_created')) } - if (!created.pullNumber) { - return combineCreateAndBabysit(created, skippedBabysitResult('startup_failure')) + if (!authored.pullNumber) { + return combineAuthoringAndBabysit(modeLabel, authored, skippedBabysitResult('startup_failure')) } // The run's own deadline when the platform set one, because Babysit spends this @@ -479,7 +575,7 @@ export const runCloudPi: PiBackendRun = async (params, context owner: params.owner, repo: params.repo, githubToken: params.githubToken, - pullNumber: created.pullNumber, + pullNumber: authored.pullNumber, maxRounds: params.babysit.maxRounds, reviewMentions: params.babysit.reviewMentions, ...(params.babysit.executionId ? { executionId: params.babysit.executionId } : {}), @@ -487,5 +583,11 @@ export const runCloudPi: PiBackendRun = async (params, context }, context ) - return combineCreateAndBabysit(created, babysit) + return combineAuthoringAndBabysit(modeLabel, authored, babysit) } + +export const runCloudPi: PiBackendRun = (params, context) => + runCloudAuthoringPi(params, context) + +export const runCloudBranchPi: PiBackendRun = (params, context) => + runCloudAuthoringPi(params, context) diff --git a/apps/sim/executor/handlers/pi/github-pr.test.ts b/apps/sim/executor/handlers/pi/github-pr.test.ts index d86619dd1a2..6d0892c9b31 100644 --- a/apps/sim/executor/handlers/pi/github-pr.test.ts +++ b/apps/sim/executor/handlers/pi/github-pr.test.ts @@ -10,6 +10,7 @@ vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) import { fetchOpenPrSnapshot, fetchPrSnapshot, + findOpenPrForBranch, validateRepositoryCoordinates, } from '@/executor/handlers/pi/github-pr' @@ -102,6 +103,85 @@ describe('fetchOpenPrSnapshot', () => { }) }) +describe('findOpenPrForBranch', () => { + const params = { + owner: 'octo', + repo: 'demo', + branch: 'feature/existing', + githubToken: 'ghp_secret', + } + + beforeEach(() => vi.clearAllMocks()) + + function list(items: unknown[]) { + return { success: true, output: { items, count: items.length } } + } + + it('returns the one exact open same-repository pull request', async () => { + mockExecuteTool.mockResolvedValueOnce(list([{ number: 7 }])).mockResolvedValueOnce({ + success: true, + output: snapshot({ + head: { sha: HEAD_SHA, ref: 'feature/existing', repo_full_name: 'octo/demo' }, + }), + }) + + await expect(findOpenPrForBranch(params)).resolves.toMatchObject({ + pullNumber: 7, + snapshot: { htmlUrl: 'https://github.com/octo/demo/pull/7' }, + }) + expect(mockExecuteTool).toHaveBeenNthCalledWith( + 1, + 'github_list_prs_v2', + expect.objectContaining({ + owner: 'octo', + repo: 'demo', + state: 'open', + head: 'octo:feature/existing', + per_page: 2, + apiKey: 'ghp_secret', + }), + { signal: undefined } + ) + }) + + it('fails when there is no matching pull request or the match is ambiguous', async () => { + mockExecuteTool.mockResolvedValueOnce(list([])) + await expect(findOpenPrForBranch(params)).rejects.toThrow(/none was found/) + + mockExecuteTool.mockResolvedValueOnce(list([{ number: 7 }, { number: 8 }])) + await expect(findOpenPrForBranch(params)).rejects.toThrow(/multiple open pull requests/) + }) + + it.each([ + [ + 'fork', + { + head: { sha: HEAD_SHA, ref: 'feature/existing', repo_full_name: 'someone/fork' }, + }, + ], + [ + 'moved head', + { + head: { sha: HEAD_SHA, ref: 'feature/moved', repo_full_name: 'octo/demo' }, + }, + ], + ])('fails closed for a %s', async (_label, overrides) => { + mockExecuteTool + .mockResolvedValueOnce(list([{ number: 7 }])) + .mockResolvedValueOnce({ success: true, output: snapshot(overrides) }) + + await expect(findOpenPrForBranch(params)).rejects.toThrow(/no longer points to/) + }) + + it('fails closed when the matching pull request closes during validation', async () => { + mockExecuteTool + .mockResolvedValueOnce(list([{ number: 7 }])) + .mockResolvedValueOnce({ success: true, output: snapshot({ state: 'closed' }) }) + + await expect(findOpenPrForBranch(params)).rejects.toThrow(/only open PRs/) + }) +}) + describe('validateRepositoryCoordinates', () => { it('accepts ordinary GitHub coordinates', () => { expect(() => validateRepositoryCoordinates(COORDINATES)).not.toThrow() diff --git a/apps/sim/executor/handlers/pi/github-pr.ts b/apps/sim/executor/handlers/pi/github-pr.ts index e23918b09a9..185b876e9ee 100644 --- a/apps/sim/executor/handlers/pi/github-pr.ts +++ b/apps/sim/executor/handlers/pi/github-pr.ts @@ -12,6 +12,7 @@ import { nullableBoolean, nullableString, requiredBoolean, + requiredNumber, requiredRecord, requiredTrimmedString, } from '@/tools/github/response-parsers' @@ -47,6 +48,11 @@ export interface PullRequestSnapshot { mergeable: boolean | null } +export interface BranchPullRequest { + pullNumber: number + snapshot: PullRequestSnapshot +} + function requiredSha(record: Record, field: string, context: string): string { const value = requiredTrimmedString(record, field, context) if (!COMMIT_SHA_PATTERN.test(value)) { @@ -118,6 +124,84 @@ export async function fetchOpenPrSnapshot( return snapshot } +/** + * Finds the single open, same-repository pull request whose head is exactly the + * requested branch. Update Branch uses this before starting a sandbox so + * Babysit never guesses which pull request to monitor. + */ +export async function findOpenPrForBranch( + params: Omit & { branch: string }, + signal?: AbortSignal +): Promise { + validateRepositoryCoordinates({ ...params, pullNumber: 1 }) + const result = await executeTool( + 'github_list_prs_v2', + { + owner: params.owner, + repo: params.repo, + state: 'open', + head: `${params.owner}:${params.branch}`, + per_page: 2, + page: 1, + apiKey: params.githubToken, + }, + { signal } + ) + if (!result.success) { + throw new Error( + `Failed to find an open PR for branch ${params.branch}: ${result.error ?? 'unknown error'}` + ) + } + + const output = result.output + if (!isRecord(output)) { + throw new Error('GitHub pull request list response.output must be an object') + } + const items = output.items + if (!Array.isArray(items)) { + throw new Error('GitHub pull request list response.output.items must be an array') + } + if (items.length === 0) { + throw new Error( + `Update Branch Babysit requires one open pull request for branch ${params.branch}, but none was found` + ) + } + if (items.length > 1) { + throw new Error( + `Update Branch Babysit found multiple open pull requests for branch ${params.branch}` + ) + } + + if (!isRecord(items[0])) { + throw new Error('GitHub pull request list response item must be an object') + } + const pullNumber = requiredNumber(items[0], 'number', 'GitHub pull request list response item') + if (pullNumber < 1) { + throw new Error('GitHub pull request list response item.number must be positive') + } + + const snapshot = await fetchOpenPrSnapshot( + { + owner: params.owner, + repo: params.repo, + pullNumber, + githubToken: params.githubToken, + }, + signal + ) + const expectedRepo = `${params.owner}/${params.repo}`.toLowerCase() + if ( + snapshot.headRef !== params.branch || + snapshot.headRepoFullName?.toLowerCase() !== expectedRepo + ) { + throw new Error( + `PR #${pullNumber} no longer points to ${params.owner}/${params.repo}:${params.branch}` + ) + } + + return { pullNumber, snapshot } +} + export function validateRepositoryCoordinates(params: PullRequestCoordinates): void { if ( !GITHUB_OWNER_PATTERN.test(params.owner) || diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts index 3a3ff94167b..45331062a3d 100644 --- a/apps/sim/executor/handlers/pi/keys.test.ts +++ b/apps/sim/executor/handlers/pi/keys.test.ts @@ -188,6 +188,20 @@ describe('resolvePiModelKey', () => { expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() }) + it('Update Branch rejects when no user key is available (never a hosted key)', async () => { + mockGetBYOKKey.mockResolvedValue(null) + + await expect( + resolvePiModelKey({ + providerId: 'anthropic', + model: 'claude', + mode: 'cloud_branch', + workspaceId: 'ws-1', + }) + ).rejects.toThrow(/Update Branch requires your own provider API key/) + expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() + }) + it('cloud_review mode preserves a direct user key as BYOK', async () => { const result = await resolvePiModelKey({ providerId: 'anthropic', diff --git a/apps/sim/executor/handlers/pi/keys.ts b/apps/sim/executor/handlers/pi/keys.ts index 3b216acf795..c456a89d26a 100644 --- a/apps/sim/executor/handlers/pi/keys.ts +++ b/apps/sim/executor/handlers/pi/keys.ts @@ -2,8 +2,8 @@ * Model, provider-key, and cost resolution shared by Pi backends. Local Dev * mirrors the Agent block — keys resolve through `getApiKeyWithBYOK`, so a * Sim-hosted key may be used and billed. Review Code has the same host-side key - * boundary. Create PR alone requires the user's own key (the - * block's API Key field, or a stored workspace BYOK key) because that mode runs + * boundary. Create PR and Update Branch require the user's own key (the + * block's API Key field, or a stored workspace BYOK key) because those modes run * the model client in an untrusted sandbox. Cost uses the billing multiplier and * is zeroed for BYOK / non-billable models. * @@ -28,7 +28,7 @@ interface PiKeyResolution { isBYOK: boolean } -type PiKeyMode = 'cloud' | 'cloud_review' | 'local' +type PiKeyMode = 'cloud' | 'cloud_branch' | 'cloud_review' | 'local' interface ResolvePiModelKeyParams { providerId: PiSupportedProvider @@ -47,6 +47,7 @@ export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promis } if (isPiByokOnlyMode(params.mode)) { + const modeLabel = params.mode === 'cloud' ? 'Create PR' : 'Update Branch' const workspaceBYOKProviderId = getPiWorkspaceBYOKProviderId(providerId) if (params.workspaceId && workspaceBYOKProviderId) { const byok = await getBYOKKey(params.workspaceId, workspaceBYOKProviderId) @@ -56,8 +57,8 @@ export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promis } throw new Error( workspaceBYOKProviderId - ? 'Create PR requires your own provider API key (BYOK). Enter it in the API Key field, or store one in Settings > BYOK.' - : 'Create PR requires your own provider API key (BYOK). Enter it in the API Key field.' + ? `${modeLabel} requires your own provider API key (BYOK). Enter it in the API Key field, or store one in Settings > BYOK.` + : `${modeLabel} requires your own provider API key (BYOK). Enter it in the API Key field.` ) } diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts index b3f30ca2582..080171b827a 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.test.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockRunLocal, mockRunCloud, + mockRunCloudBranch, mockRunCloudReview, mockResolveKey, mockResolveSkills, @@ -22,6 +23,7 @@ const { } = vi.hoisted(() => ({ mockRunLocal: vi.fn(), mockRunCloud: vi.fn(), + mockRunCloudBranch: vi.fn(), mockRunCloudReview: vi.fn(), mockResolveKey: vi.fn(), mockResolveSkills: vi.fn(), @@ -63,7 +65,10 @@ vi.mock('@/executor/handlers/pi/sim-tools', () => ({ buildSimToolSpecs: vi.fn().mockResolvedValue([]), })) vi.mock('@/executor/handlers/pi/local-backend', () => ({ runLocalPi: mockRunLocal })) -vi.mock('@/executor/handlers/pi/cloud-backend', () => ({ runCloudPi: mockRunCloud })) +vi.mock('@/executor/handlers/pi/cloud-backend', () => ({ + runCloudPi: mockRunCloud, + runCloudBranchPi: mockRunCloudBranch, +})) vi.mock('@/executor/handlers/pi/cloud-review-backend', () => ({ runCloudReviewPi: mockRunCloudReview, })) @@ -151,6 +156,12 @@ describe('PiBlockHandler', () => { changedFiles: ['a.ts'], diff: 'diff', }) + mockRunCloudBranch.mockResolvedValue({ + totals: { finalText: 'updated', inputTokens: 0, outputTokens: 0, toolCalls: [] }, + branch: 'feature/existing', + changedFiles: ['b.ts'], + diff: 'branch diff', + }) mockRunCloudReview.mockResolvedValue({ totals: { finalText: 'looks good', inputTokens: 0, outputTokens: 0, toolCalls: [] }, reviewUrl: 'https://github.com/o/r/pull/7#pullrequestreview-1', @@ -178,7 +189,7 @@ describe('PiBlockHandler', () => { it('rejects a mode outside the three the block offers', async () => { await expect( handler.execute(ctx(), block, { mode: 'babysit', task: 'x', model: 'claude' }) - ).rejects.toThrow(/Invalid Pi mode: babysit/) + ).rejects.toThrow(/Use Create PR or Update Branch with Babysit Mode enabled/) expect(mockResolveKey).not.toHaveBeenCalled() }) @@ -195,6 +206,7 @@ describe('PiBlockHandler', () => { const output = await handler.execute(ctx(), block, localInputs()) expect(mockRunLocal).toHaveBeenCalledTimes(1) expect(mockRunCloud).not.toHaveBeenCalled() + expect(mockRunCloudBranch).not.toHaveBeenCalled() expect(mockRunCloudReview).not.toHaveBeenCalled() const params = mockRunLocal.mock.calls[0][0] expect(params.mode).toBe('local') @@ -213,11 +225,47 @@ describe('PiBlockHandler', () => { githubToken: 'ghp', })) as Record expect(mockRunCloud).toHaveBeenCalledTimes(1) + expect(mockRunCloudBranch).not.toHaveBeenCalled() expect(mockRunCloudReview).not.toHaveBeenCalled() expect(output.prUrl).toBe('https://github.com/o/r/pull/1') expect(output.branch).toBe('pi/abc') }) + it('routes Update Branch to the cloud branch backend and surfaces branch output', async () => { + const output = (await handler.execute(ctx(), block, { + mode: 'cloud_branch', + task: 'continue it', + model: 'claude', + owner: 'o', + repo: 'r', + githubToken: 'ghp', + targetBranch: 'feature/existing', + skills: [{ skillId: 'skill-1' }], + memoryType: 'conversation', + conversationId: 'thread-1', + })) as Record + + expect(mockRunCloudBranch).toHaveBeenCalledTimes(1) + expect(mockRunCloud).not.toHaveBeenCalled() + expect(mockRunCloudReview).not.toHaveBeenCalled() + expect(mockRunCloudBranch.mock.calls[0][0]).toEqual( + expect.objectContaining({ + mode: 'cloud_branch', + owner: 'o', + repo: 'r', + githubToken: 'ghp', + targetBranch: 'feature/existing', + skills: [], + initialMessages: [], + }) + ) + expect(mockResolveSkills).toHaveBeenCalled() + expect(mockLoadMemory).toHaveBeenCalled() + expect(mockAppendMemory).toHaveBeenCalled() + expect(output.branch).toBe('feature/existing') + expect(output.content).toBe('updated') + }) + it('routes cloud_review mode and surfaces review output', async () => { const output = (await handler.execute(ctx(), block, { mode: 'cloud_review', @@ -232,6 +280,7 @@ describe('PiBlockHandler', () => { expect(mockRunCloudReview).toHaveBeenCalledTimes(1) expect(mockRunCloud).not.toHaveBeenCalled() + expect(mockRunCloudBranch).not.toHaveBeenCalled() const params = mockRunCloudReview.mock.calls[0][0] expect(params.mode).toBe('cloud_review') expect(params.pullNumber).toBe(7) @@ -312,29 +361,92 @@ describe('PiBlockHandler', () => { }) }) - it('requires reviewer mentions, defaults maxRounds to three, and rejects values above ten', async () => { - const inputs = { - mode: 'cloud', - task: 'build it', + it('passes the same Babysit configuration through Update Branch', async () => { + mockRunCloudBranch.mockResolvedValue({ + totals: { + finalText: 'Update Branch:\nupdated\n\nBabysit:\nclean', + inputTokens: 1, + outputTokens: 2, + toolCalls: [], + }, + memoryText: 'updated', + prUrl: 'https://github.com/o/r/pull/7', + branch: 'feature/existing', + rounds: 1, + threadsClean: true, + checksGreen: true, + threadsResolved: 1, + commitsPushed: 1, + stopReason: 'clean', + }) + + const output = (await handler.execute(ctx({ executionId: 'execution-2' }), block, { + mode: 'cloud_branch', + task: 'continue it', model: 'claude', owner: 'o', repo: 'r', githubToken: 'ghp', + targetBranch: 'feature/existing', babysitMode: true, - reviewMentions: '@greptile', - } - await handler.execute(ctx(), block, inputs) - expect(mockRunCloud.mock.calls[0][0].babysit.maxRounds).toBe(3) + maxRounds: '5', + reviewMentions: '@greptile, @cursor review', + memoryType: 'conversation', + conversationId: 'memory', + })) as Record - await expect(handler.execute(ctx(), block, { ...inputs, maxRounds: '11' })).rejects.toThrow( - /at most 10/ + expect(mockRunCloudBranch.mock.calls[0][0]).toMatchObject({ + mode: 'cloud_branch', + targetBranch: 'feature/existing', + babysit: { + maxRounds: 5, + reviewMentions: ['@greptile', '@cursor review'], + executionId: 'execution-2', + }, + }) + expect(mockAppendMemory).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'continue it', + 'updated' ) - await expect( - handler.execute(ctx(), block, { ...inputs, reviewMentions: ' , ' }) - ).rejects.toThrow(/requires at least one reviewer mention/) - expect(mockRunCloud).toHaveBeenCalledTimes(1) + expect(output).toMatchObject({ + prUrl: 'https://github.com/o/r/pull/7', + rounds: 1, + threadsClean: true, + checksGreen: true, + stopReason: 'clean', + }) }) + it.each(['cloud', 'cloud_branch'])( + 'requires reviewer mentions and bounds maxRounds in %s', + async (mode) => { + const inputs = { + mode, + task: 'build it', + model: 'claude', + owner: 'o', + repo: 'r', + githubToken: 'ghp', + ...(mode === 'cloud_branch' ? { targetBranch: 'feature/existing' } : {}), + babysitMode: true, + reviewMentions: '@greptile', + } + await handler.execute(ctx(), block, inputs) + const backend = mode === 'cloud' ? mockRunCloud : mockRunCloudBranch + expect(backend.mock.calls[0][0].babysit.maxRounds).toBe(3) + + await expect(handler.execute(ctx(), block, { ...inputs, maxRounds: '11' })).rejects.toThrow( + /at most 10/ + ) + await expect( + handler.execute(ctx(), block, { ...inputs, reviewMentions: ' , ' }) + ).rejects.toThrow(/requires at least one reviewer mention/) + expect(backend).toHaveBeenCalledTimes(1) + } + ) + it('ignores stale Babysit fields when the Create PR toggle is off', async () => { await handler.execute(ctx(), block, { mode: 'cloud', @@ -418,6 +530,20 @@ describe('PiBlockHandler', () => { ).rejects.toThrow(/Create PR requires/) }) + it('requires a target branch in Update Branch', async () => { + await expect( + handler.execute(ctx(), block, { + mode: 'cloud_branch', + task: 'x', + model: 'claude', + owner: 'o', + repo: 'r', + githubToken: 'ghp', + }) + ).rejects.toThrow(/Update Branch requires a target branch/) + expect(mockRunCloudBranch).not.toHaveBeenCalled() + }) + it('requires pullNumber in cloud_review mode', async () => { await expect( handler.execute(ctx(), block, { @@ -561,6 +687,28 @@ describe('PiBlockHandler', () => { }) }) + it('passes Update Branch the key without a host tool', async () => { + mockParseSearchProvider.mockReturnValue('exa') + + await handler.execute(ctx(), block, { + mode: 'cloud_branch', + task: 'continue it', + model: 'claude', + owner: 'o', + repo: 'r', + githubToken: 'ghp', + targetBranch: 'feature/existing', + searchProvider: 'exa', + }) + + expect(mockBuildSearchTool).not.toHaveBeenCalled() + expect(mockRunCloudBranch.mock.calls[0][0].search).toEqual({ + provider: 'exa', + apiKey: 'search-key', + keySource: 'byok', + }) + }) + it('checks the tool denylist before touching the key', async () => { mockParseSearchProvider.mockReturnValue('exa') mockAssertPermissionsAllowed.mockRejectedValue(new MockToolNotAllowedError('denied')) diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts index 018c27bb20a..f59bcd8bb6e 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -16,6 +16,7 @@ import { import { BlockType } from '@/executor/constants' import type { PiBackendRun, + PiCloudBranchRunParams, PiCloudReviewRunParams, PiCloudRunParams, PiLocalRunParams, @@ -23,7 +24,7 @@ import type { PiRunResult, PiSearchConfig, } from '@/executor/handlers/pi/backend' -import { runCloudPi } from '@/executor/handlers/pi/cloud-backend' +import { runCloudBranchPi, runCloudPi } from '@/executor/handlers/pi/cloud-backend' import { runCloudReviewPi } from '@/executor/handlers/pi/cloud-review-backend' import { appendPiMemory, @@ -89,7 +90,17 @@ function isSwitchEnabled(value: unknown, defaultValue = false): boolean { } function parsePiMode(value: unknown): PiRunParams['mode'] { - if (value === 'cloud' || value === 'cloud_review' || value === 'local') { + if (value === 'babysit') { + throw new Error( + 'Standalone Babysit mode was removed. Use Create PR or Update Branch with Babysit Mode enabled.' + ) + } + if ( + value === 'cloud' || + value === 'cloud_branch' || + value === 'cloud_review' || + value === 'local' + ) { return value } throw new Error(`Invalid Pi mode: ${String(value)}`) @@ -254,7 +265,8 @@ export class PiBlockHandler implements BlockHandler { const repo = asOptString(inputs.repo) const githubToken = asRawString(inputs.githubToken) if (!owner || !repo || !githubToken) { - throw new Error('Create PR requires repository owner, name, and a GitHub token') + const label = mode === 'cloud_branch' ? 'Update Branch' : 'Create PR' + throw new Error(`${label} requires repository owner, name, and a GitHub token`) } // A `switch` subblock reaches a handler as the string 'true' when its value came // through a variable reference, an API trigger payload, or a legacy serialized @@ -264,7 +276,8 @@ export class PiBlockHandler implements BlockHandler { const babysitMode = isSwitchEnabled(inputs.babysitMode) const reviewMentions = babysitMode ? parsePiReviewMentions(inputs.reviewMentions) : [] if (babysitMode && reviewMentions.length === 0) { - throw new Error('Create PR Babysit Mode requires at least one reviewer mention') + const label = mode === 'cloud_branch' ? 'Update Branch' : 'Create PR' + throw new Error(`${label} Babysit Mode requires at least one reviewer mention`) } const maxRounds = babysitMode ? (parseOptionalNumberInput(inputs.maxRounds, 'maxRounds', { @@ -273,6 +286,31 @@ export class PiBlockHandler implements BlockHandler { max: 10, }) ?? 3) : undefined + + if (mode === 'cloud_branch') { + const targetBranch = asOptString(inputs.targetBranch) + if (!targetBranch) { + throw new Error('Update Branch requires a target branch') + } + const params: PiCloudBranchRunParams = { + ...contextualBase, + mode: 'cloud_branch', + owner, + repo, + githubToken, + targetBranch, + ...(babysitMode + ? { + babysit: { + maxRounds: maxRounds ?? 3, + reviewMentions, + ...(ctx.executionId ? { executionId: ctx.executionId } : {}), + }, + } + : {}), + } + return this.runPi(ctx, block, runCloudBranchPi, params, memoryConfig) + } const params: PiCloudRunParams = { ...contextualBase, mode: 'cloud', @@ -306,8 +344,8 @@ export class PiBlockHandler implements BlockHandler { * * The host-side tool is built here rather than in a backend because it needs the * {@link ExecutionContext}, which backends never receive — they see only `{ onEvent, signal }`. - * Create PR gets no tool: it registers a sandbox extension instead, so a spec built here could - * never execute. + * Cloud authoring gets no host tool: it registers a sandbox extension instead, so a spec built + * here could never execute. */ private async resolveSearch( ctx: ExecutionContext, @@ -321,8 +359,9 @@ export class PiBlockHandler implements BlockHandler { // Authorization before credentials, which is the order `executeTool` itself uses and is // observable: reversed, a denied user's stored key is fetched and decrypted and they are told to - // add a key instead of being denied. The preflight is also the only denylist check Create PR - // gets, because its extension calls the provider directly and never reaches `executeTool`. + // add a key instead of being denied. The preflight is also the only denylist check cloud + // authoring gets, because its extension calls the provider directly and never reaches + // `executeTool`. try { await assertPermissionsAllowed({ userId: ctx.userId, @@ -345,7 +384,7 @@ export class PiBlockHandler implements BlockHandler { }) const credentials = { provider, apiKey } - return mode === 'cloud' + return mode === 'cloud' || mode === 'cloud_branch' ? credentials : { ...credentials, tool: buildPiSearchToolSpec(ctx, credentials, mode) } } From 49f79150fbd9e55d69bc0b6a625dd855bd33fd7a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 28 Jul 2026 19:03:25 -0700 Subject: [PATCH 02/10] feat(pi): manage pull requests in update mode --- .../content/docs/en/workflows/blocks/pi.mdx | 71 ++--- apps/sim/blocks/blocks/pi.test.ts | 20 +- apps/sim/blocks/blocks/pi.ts | 73 +++-- apps/sim/executor/handlers/pi/backend.ts | 8 +- .../handlers/pi/cloud-backend.test.ts | 214 ++++++++++--- .../sim/executor/handlers/pi/cloud-backend.ts | 281 ++++++++++++------ .../executor/handlers/pi/github-pr.test.ts | 54 +++- apps/sim/executor/handlers/pi/github-pr.ts | 123 ++++++-- apps/sim/executor/handlers/pi/keys.test.ts | 4 +- apps/sim/executor/handlers/pi/keys.ts | 4 +- .../executor/handlers/pi/pi-handler.test.ts | 38 ++- apps/sim/executor/handlers/pi/pi-handler.ts | 21 +- 12 files changed, 687 insertions(+), 224 deletions(-) diff --git a/apps/docs/content/docs/en/workflows/blocks/pi.mdx b/apps/docs/content/docs/en/workflows/blocks/pi.mdx index e320f0625e9..45a17d48fff 100644 --- a/apps/docs/content/docs/en/workflows/blocks/pi.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/pi.mdx @@ -1,6 +1,6 @@ --- title: Pi Coding Agent -description: The Pi Coding Agent block runs an autonomous coding agent on a real repository — opening a pull request, updating a branch, reviewing a PR, or editing files over SSH. +description: The Pi Coding Agent block runs an autonomous coding agent on a real repository — creating or updating a pull request, reviewing a PR, or editing files over SSH. pageType: reference --- @@ -8,12 +8,12 @@ import { Callout } from 'fumadocs-ui/components/callout' import { BlockPreview } from '@/components/workflow-preview' import { FAQ } from '@/components/ui/faq' -The **Pi Coding Agent block** runs the [Pi](https://github.com/earendil-works/pi-mono) coding harness against a real repository. You give it a task and a model; it opens a pull request, updates an existing branch, posts a PR review, or changes your files in place. Create PR and Update Branch can optionally babysit the pull request on their branch. Create PR, Update Branch, and Local Dev can reuse your [skills](/agents/skills) and multi-turn [memory](#memory). Review Code deliberately loads neither. +The **Pi Coding Agent block** runs the [Pi](https://github.com/earendil-works/pi-mono) coding harness against a real repository. You give it a task and a model; it creates or updates a pull request, posts a PR review, or changes your files in place. Create PR and Update PR can optionally babysit their pull request. Create PR, Update PR, and Local Dev can reuse your [skills](/agents/skills) and multi-turn [memory](#memory). Review Code deliberately loads neither. It has four modes that decide *where* it runs and *how* its work lands: - **Create PR** — spins up an isolated sandbox, edits the repository, and opens a **pull request**. Its optional **Babysit Mode** requests bot reviews and works through trusted feedback and checks in bounded rounds. -- **Update Branch** — checks out an existing branch in an isolated sandbox, edits it, and pushes a new commit back to that same branch. Its optional **Babysit Mode** continues the one open same-repository PR attached to that branch. +- **Update PR** — checks out an existing branch in an isolated sandbox, edits it, pushes a new commit back to that branch, then creates or updates its pull request. Its optional **Babysit Mode** continues that PR. - **Review Code** — checks out an existing PR in a sandbox, analyzes it with bounded read-only access across the repository, and posts a **GitHub review** (summary + optional inline comments). - **Local Dev** — connects to your own machine over **SSH** and edits files there directly. @@ -34,7 +34,7 @@ Create PR runs entirely inside a disposable sandbox, so it never touches your ma #### Babysit Mode -When enabled, Create PR forces the new PR to be ready for review, while Update Branch first finds exactly one open same-repository PR whose head is the target branch. Both modes post every required **Reviewer Mention** as its own issue comment and then start a second, strict sandbox against that PR. They repeat this bounded host-controlled lifecycle: +When enabled, both modes ensure the branch has a ready-for-review PR before posting every required **Reviewer Mention** as its own issue comment and starting a second, strict sandbox against that PR. Update PR uses the exact open same-repository PR whose head is the target branch, or creates it when missing. They repeat this bounded host-controlled lifecycle: 1. Read every review thread and the complete check rollup for the pinned SHA. 2. Give Pi trusted block instructions plus clearly delimited, untrusted thread/check data and bounded diagnostics. @@ -50,21 +50,21 @@ Only complete threads are actionable. Every comment in a thread must come from a The continuation gives Pi no GitHub credential, GitHub tool, or Sim integration. GitHub API operations use block-configured coordinates on the host and are not filtered by workspace tool denylists. The model and optional search keys do enter the editing sandbox. The GitHub token enters only the clone and credentialed push commands. This is risk reduction, not credential isolation: the push still executes inside a previously agent-controlled root sandbox. - Requires at least one **Reviewer Mention**, such as `@greptile` or `@cursor review`. -- Needs **Contents, Pull requests, Issues, Actions, and commit-status/check read access** as described in [Create PR setup](#setup-cloud-pr) and [Update Branch setup](#setup-cloud-branch). +- Needs **Contents, Pull requests, Issues, Actions, and commit-status/check read access** as described in [Create PR setup](#setup-cloud-pr) and [Update PR setup](#setup-cloud-branch). - Does not support fork PRs, force-push/history rewriting, merge-conflict resolution, or `.github/` edits. A base conflict is reported but does not prevent review-fix pushes. - A round that posts replies but loses the pin before resolving can reply to the same unresolved thread again on a later run. Replies are intentionally safer than resolving stale feedback. -### Update Branch +### Update PR -Update Branch uses the same disposable authoring sandbox as Create PR, but checks out a branch that already exists and pushes changes back to it without opening a new pull request. +Update PR uses the same disposable authoring sandbox and host-controlled PR operations as Create PR, but checks out a branch that already exists and pushes changes back to it. - Requires sandbox execution and **your own provider API key (BYOK)**. -- Needs a **GitHub token** with permission to clone and push. +- Needs the same **GitHub token permissions as Create PR**: permission to clone, push, and create or update a pull request. - Never creates, rebases, merges, or force-pushes the branch. If another commit reaches the branch while Pi is working, the push fails rather than overwriting it. -- Pushing to a branch that already has an open PR updates that PR automatically. -- With Babysit Mode enabled, exactly one open same-repository PR must already use the target branch. No match fails before a sandbox starts; multiple matches fail as ambiguous. Fork, closed, moved, or mismatched PRs fail closed. -- A no-change authoring pass still babysits the existing PR. A rejected initial push stops the run before Babysit begins. -- The deliverable is the updated branch — read `branch`, `changedFiles`, and `diff`. +- Finds the exact open same-repository PR whose head is the target branch. One match is updated; no match is created after authoring; multiple matches fail as ambiguous. Fork, closed, moved, or mismatched PRs fail closed. +- When explicitly set, **Base Branch**, **PR Title**, **PR Body**, and **PR State** update the existing PR. Blank metadata preserves it. A newly created PR uses generated metadata and the repository default base when those fields are blank. +- A no-change authoring pass still creates or updates the PR. A rejected initial push stops the run before any PR mutation or Babysit continuation. +- The deliverable is the updated branch and its PR — read `prUrl`, `branch`, `changedFiles`, and `diff`. ### Review Code @@ -97,7 +97,7 @@ The model that drives the agent. Defaults to `claude-sonnet-4-6`. The dropdown c ### API Key -Your key for the chosen provider. On hosted Sim it is optional for Local Dev and Review Code runs (a hosted key is used and metered to your workspace). **Create PR and Update Branch require your own key** because their model client runs in the sandbox, including during a Babysit continuation. When the provider supports workspace BYOK, you can store the key in **Settings → BYOK** instead of entering it on the block. +Your key for the chosen provider. On hosted Sim it is optional for Local Dev and Review Code runs (a hosted key is used and metered to your workspace). **Create PR and Update PR require your own key** because their model client runs in the sandbox, including during a Babysit continuation. When the provider supports workspace BYOK, you can store the key in **Settings → BYOK** instead of entering it on the block. ### Internet Search @@ -115,10 +115,10 @@ Results are third-party data. The agent is instructed to treat them as quoted ev Traffic goes both ways: the agent writes its own queries after reading the repository, so leave search on **None** in Review Code when the pull request comes from an untrusted fork of a private repo. Injected instructions in a diff could otherwise put repository text into a query sent to the provider. -### Repository (Create PR / Update Branch / Review Code) +### Repository (Create PR / Update PR / Review Code) - **Repository Owner / Repository Name** — the GitHub repo (for example `your-org` / `your-repo`). -- **GitHub Token** — a personal access token used for GitHub access. Permissions differ by mode; see [Create PR setup](#setup-cloud-pr), [Update Branch setup](#setup-cloud-branch), or [Review Code setup](#setup-cloud-code-review). +- **GitHub Token** — a personal access token used for GitHub access. Permissions differ by mode; see [Create PR setup](#setup-cloud-pr), [Update PR setup](#setup-cloud-branch), or [Review Code setup](#setup-cloud-code-review). ### Create PR fields @@ -130,10 +130,13 @@ Traffic goes both ways: the agent writes its own queries after reading the repos - **Open as Draft PR** *(advanced)* — opens the PR as a draft. On by default and hidden when Babysit Mode is enabled, because those PRs are always ready for review. - **PR Title / PR Body** *(advanced)* — generated from the run when blank. -### Update Branch fields +### Update PR fields - **Target Branch** — the existing remote branch to update. The run fails if it does not exist, is protected against the token, or changes before Pi pushes. -- **Babysit Mode / Reviewer Mentions / Maximum Rounds** — the same controls as Create PR. When enabled, the target branch must have exactly one open same-repository PR. +- **Base Branch** — changes an existing PR's base when set, or becomes a new PR's base. A new PR defaults to the repository's default branch when blank. +- **Babysit Mode / Reviewer Mentions / Maximum Rounds** — the same controls as Create PR. Babysit makes the PR ready for review and creates it first when the target branch has no open PR. +- **PR State** *(advanced)* — preserves an existing PR's draft state, converts it to draft, or marks it ready for review. A missing PR opens as a draft for **Leave unchanged** or **Draft**, and ready for **Ready for review**. Hidden during Babysit because Babysit always requires a ready PR. +- **PR Title / PR Body** *(advanced)* — update an existing PR only when set. For a missing PR, blank values are generated from the task and run summary. ### Review Code fields @@ -154,15 +157,15 @@ Traffic goes both ways: the agent writes its own queries after reading the repos Sim tools the agent can call while it works — search a knowledge base, send a Slack message, call any of the [integrations](/integrations). They run through Sim with your connected credentials, exactly like the [Agent block](/workflows/blocks/agent). MCP and custom tools aren't supported here yet (they appear greyed out). -### Skills (Create PR / Update Branch / Local Dev) +### Skills (Create PR / Update PR / Local Dev) -[Agent skills](/agents/skills) the agent can use — reusable instruction packages like a coding standard or a review playbook. They're shared with the Agent block. Create PR and Update Branch pass explicitly selected Sim skills to their Babysit continuation, while repository skills, Pi extensions, prompt templates, and project trust remain disabled there. +[Agent skills](/agents/skills) the agent can use — reusable instruction packages like a coding standard or a review playbook. They're shared with the Agent block. Create PR and Update PR pass explicitly selected Sim skills to their Babysit continuation, while repository skills, Pi extensions, prompt templates, and project trust remain disabled there. ### Thinking Level For models with extended reasoning, how much the model thinks before acting. Higher is more thorough but slower and costs more tokens. Defaults to `medium`. -### Memory (Create PR / Update Branch / Local Dev) +### Memory (Create PR / Update PR / Local Dev) Multi-turn memory keyed by a conversation ID, shared with the [Agent block](/workflows/blocks/agent): @@ -175,7 +178,7 @@ Reuse the same **Conversation ID** across runs to continue a thread. Each turn s ### Context limits -For Create PR, Update Branch, and Local Dev, memory is folded into the agent's first prompt, and two layers keep it within the model's context window: +For Create PR, Update PR, and Local Dev, memory is folded into the agent's first prompt, and two layers keep it within the model's context window: - **Sim trims before the run.** The selected memory type bounds what's injected: **Conversation** is automatically capped to a fraction of the model's context window (for models in Sim's catalog), **Sliding window (messages)** keeps the last N messages, and **Sliding window (tokens)** keeps history up to an explicit token budget. - **Pi compacts during the run.** As the agent works (reading files, running commands), Pi automatically summarizes older turns to stay under the window — in all modes, on by default. You don't need to configure anything for context growth mid-run. @@ -189,8 +192,8 @@ The one case neither layer can rescue is a *first* prompt that already exceeds t | `` | The agent's final message / run summary | | `` | The files the agent changed | | `` | A unified diff of the changes | -| `` | URL of the opened pull request, or the existing PR when Update Branch Babysit is enabled | -| `` | The branch pushed with the changes *(Create PR / Update Branch)* | +| `` | URL of the created or updated pull request *(Create PR / Update PR)* | +| `` | The branch pushed with the changes *(Create PR / Update PR)* | | `` | URL of the submitted GitHub review *(Review Code)* | | `` | Number of inline review comments posted *(Review Code)* | | `` | Number of fixing rounds that invoked Pi *(authoring + Babysit Mode)* | @@ -210,7 +213,7 @@ The one case neither layer can rescue is a *first* prompt that already exceeds t Create PR runs in a sandbox image with the Pi CLI and git baked in. -1. **Enable sandbox execution.** On self-hosted Sim, set `E2B_ENABLED=true`, `E2B_API_KEY`, `E2B_PI_TEMPLATE_ID` (the Pi template id), and `NEXT_PUBLIC_E2B_ENABLED=true` (this reveals Create PR, Update Branch, and Review Code in the UI). Build the template with `bun run apps/sim/scripts/build-pi-e2b-template.ts`. These modes stay hidden until `NEXT_PUBLIC_E2B_ENABLED` is set. +1. **Enable sandbox execution.** On self-hosted Sim, set `E2B_ENABLED=true`, `E2B_API_KEY`, `E2B_PI_TEMPLATE_ID` (the Pi template id), and `NEXT_PUBLIC_E2B_ENABLED=true` (this reveals Create PR, Update PR, and Review Code in the UI). Build the template with `bun run apps/sim/scripts/build-pi-e2b-template.ts`. These modes stay hidden until `NEXT_PUBLIC_E2B_ENABLED` is set. The template requests **4 vCPU and 8 GB of RAM** (within the per-build maximum on every E2B plan). Sizing is fixed when the template is built — E2B has no per-sandbox override — so changing it means rebuilding the template, not restarting Sim. Sandboxes are billed per second against the resources they are allocated, not the ones they use. Both numbers live in `apps/sim/scripts/pi-sandbox-packages.ts` and are shared with the Daytona snapshot so the failover image cannot drift from the primary. @@ -235,16 +238,16 @@ Babysit enforces fixed bounds that are not configurable, and they behave differe - **Trimmed silently.** At most 30 review threads are shown to Pi per round. Extra actionable threads are carried to a later round, so `threadsClean` stays `false` until they are handled. - **Stops the run with `bounds_exceeded`.** More than 20 failing required checks in a round, or a cumulative change across the run exceeding 50 files or 200,000 diff bytes. -### Update Branch [#setup-cloud-branch] +### Update PR [#setup-cloud-branch] -Enable sandbox execution and BYOK as for Create PR. The GitHub token needs permission to clone and push: +Enable sandbox execution and BYOK as for Create PR. Update PR also uses the same GitHub permissions because it always creates or updates a pull request after authoring: -- *Fine-grained:* select the repo, then grant **Contents: Read and write**. +- *Fine-grained:* select the repo, then grant **Contents: Read and write** + **Pull requests: Read and write**. - *Classic:* the **`repo`** scope. For org repos, authorize the token for SSO. The target branch must already exist. GitHub branch protection remains authoritative, and a branch that advances during the run rejects Pi's normal non-force push. -With **Babysit Mode**, the token also needs the Pull requests, Issues, Actions, commit-status, and check permissions listed above. The branch must have exactly one open pull request in this same repository; Update Branch never creates one and does not support fork PRs. +Update PR accepts one exact open same-repository PR for the target branch, creates one when none exists, and fails if the match is ambiguous. It does not support fork PRs. With **Babysit Mode**, the token also needs the Issues, Actions, commit-status, and check permissions listed above. ### Review Code [#setup-cloud-code-review] @@ -262,21 +265,21 @@ Enable sandbox execution as for Create PR. BYOK is optional because the model cr ## Best Practices - **Scope the task.** A specific instruction ("fix the failing `auth` test and add a regression case") produces far better results than a vague one. -- **Match the mode to the deliverable.** Create PR for a new reviewable change, Update Branch for continuing existing remote work, Review Code for feedback on a PR, and Local Dev for a repo you already have checked out. +- **Match the mode to the deliverable.** Create PR for a new reviewable branch, Update PR for continuing existing remote work and managing its PR, Review Code for feedback on a PR, and Local Dev for a repo you already have checked out. - **Use Babysit Mode for automated review follow-through.** Run it asynchronously and expect partial success; a pushed fix followed by `awaiting_checks` is normal. - **Prefer key auth and tear down tunnels.** A public SSH tunnel is a real attack surface — use a private key and stop the tunnel when you're done. -- **Reuse a Conversation ID for Create PR, Update Branch, or Local Dev follow-ups.** It carries the prior task and outcome into the next run so the agent can build on its own work. +- **Reuse a Conversation ID for Create PR, Update PR, or Local Dev follow-ups.** It carries the prior task and outcome into the next run so the agent can build on its own work. diff --git a/apps/sim/blocks/blocks/pi.test.ts b/apps/sim/blocks/blocks/pi.test.ts index 72091f05068..e85491fd416 100644 --- a/apps/sim/blocks/blocks/pi.test.ts +++ b/apps/sim/blocks/blocks/pi.test.ts @@ -86,7 +86,7 @@ describe('Pi block search fields', () => { }) describe('Pi cloud authoring surface', () => { - it('offers Create PR, Update Branch, Review Code, and Local Dev as top-level modes', () => { + it('offers Create PR, Update PR, Review Code, and Local Dev as top-level modes', () => { const mode = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'mode') const options = typeof mode?.options === 'function' @@ -182,7 +182,7 @@ describe('Pi cloud authoring surface', () => { expect(evaluateSubBlockCondition(pullNumber?.condition, { mode: 'cloud_review' })).toBe(true) }) - it('requires the target branch only in Update Branch mode', () => { + it('requires the target branch only in Update PR mode', () => { expect(targetBranchField?.type).toBe('short-input') expect(targetBranchField?.required).toBe(true) expect(evaluateSubBlockCondition(targetBranchField?.condition, { mode: 'cloud_branch' })).toBe( @@ -226,10 +226,22 @@ describe('Pi cloud authoring surface', () => { } }) - it('hides Create PR and Review Code-specific fields', () => { - for (const id of ['baseBranch', 'branchName', 'draft', 'prTitle', 'prBody', 'pullNumber']) { + it('shows PR metadata controls and hides Create PR and Review Code-only fields', () => { + for (const id of ['baseBranch', 'prTitle', 'prBody', 'prState']) { + const field = PiBlock.subBlocks.find((subBlock) => subBlock.id === id) + expect(evaluateSubBlockCondition(field?.condition, { mode: 'cloud_branch' })).toBe(true) + } + for (const id of ['branchName', 'draft', 'pullNumber']) { const field = PiBlock.subBlocks.find((subBlock) => subBlock.id === id) expect(evaluateSubBlockCondition(field?.condition, { mode: 'cloud_branch' })).toBe(false) } + const prState = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'prState') + expect( + evaluateSubBlockCondition(prState?.condition, { + mode: 'cloud_branch', + babysitMode: true, + }) + ).toBe(false) + expect(PiBlock.inputs.prState).toBeDefined() }) }) diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts index 984c9959993..d52b065f6c3 100644 --- a/apps/sim/blocks/blocks/pi.ts +++ b/apps/sim/blocks/blocks/pi.ts @@ -90,6 +90,21 @@ function getCloudWithoutBabysitCondition(values?: Record): { }, } } +function getCloudBranchWithoutBabysitCondition(values?: Record): { + field: 'mode' + value: 'cloud_branch' + and: { field: 'babysitMode'; value: true | 'true'; not: true } +} { + return { + field: 'mode', + value: 'cloud_branch', + and: { + field: 'babysitMode', + value: values?.babysitMode === 'true' ? 'true' : true, + not: true, + }, + } +} const LOCAL: { field: 'mode'; value: 'local' } = { field: 'mode', value: 'local' } const AUTHORING_MODES: { field: 'mode' @@ -146,14 +161,14 @@ export const PiBlock: BlockConfig = { description: 'Run an autonomous coding agent on a repo', authMode: AuthMode.ApiKey, longDescription: - 'The Pi Coding Agent runs the Pi harness against a real repository. Create PR spins up an isolated sandbox, clones a GitHub repo, edits with native shell + git, and opens a pull request; Update Branch checks out an existing remote branch and pushes commits back without force-pushing. Babysit Mode then keeps the pull request under watch, fixing trusted bot review threads and failing required checks in bounded rounds. Review Code checks out a pinned PR snapshot with read-only tools and posts a structured review with optional inline comments. Local Dev edits files on your own machine over SSH. Create PR, Update Branch, and Local Dev can reuse skills and multi-turn memory; Review Code runs without either because PR contents are untrusted. Any mode can optionally get one web_search tool backed by your own Exa, Serper, Parallel AI, or Firecrawl key; the agent writes its own queries, so repository content may reach the provider, and results are untrusted third-party data.', + 'The Pi Coding Agent runs the Pi harness against a real repository. Create PR spins up an isolated sandbox, clones a GitHub repo, edits with native shell + git, and opens a pull request; Update PR checks out an existing remote branch, pushes commits back without force-pushing, and creates or updates its pull request. Babysit Mode then keeps the pull request under watch, fixing trusted bot review threads and failing required checks in bounded rounds. Review Code checks out a pinned PR snapshot with read-only tools and posts a structured review with optional inline comments. Local Dev edits files on your own machine over SSH. Create PR, Update PR, and Local Dev can reuse skills and multi-turn memory; Review Code runs without either because PR contents are untrusted. Any mode can optionally get one web_search tool backed by your own Exa, Serper, Parallel AI, or Firecrawl key; the agent writes its own queries, so repository content may reach the provider, and results are untrusted third-party data.', bestPractices: ` - Use Create PR for hands-off changes against a GitHub repo where a reviewable PR is the deliverable. - - Use Update Branch to continue work on an existing remote branch, including one previously created by Pi. - - Enable Babysit Mode on Create PR or Update Branch when trusted review bots and required checks should be monitored and fixed in bounded rounds. + - Use Update PR to continue work on an existing remote branch and create or update its pull request. + - Enable Babysit Mode on Create PR or Update PR when trusted review bots and required checks should be monitored and fixed in bounded rounds. - Use Review Code to analyze an existing PR and leave summary + inline review comments. - Use Local Dev to edit a repo on your own machine; expose the machine on a public hostname/tunnel so Sim can reach it over SSH. - - Create PR and Update Branch require your own provider API key for every model, including ones Sim hosts, because the model runs in the sandbox. Review Code and Local Dev keep the model key in Sim and can use either BYOK or a hosted key. + - Create PR and Update PR require your own provider API key for every model, including ones Sim hosts, because the model runs in the sandbox. Review Code and Local Dev keep the model key in Sim and can use either BYOK or a hosted key. - Internet Search is off by default and always needs your own key for the selected provider, entered on the block. There is no workspace BYOK fallback and no hosted key. Leave it on None unless the task genuinely needs external information. `, category: 'blocks', @@ -183,9 +198,9 @@ export const PiBlock: BlockConfig = { description: 'Runs in an isolated sandbox, clones your repo, and opens a PR', }, { - label: 'Update Branch', + label: 'Update PR', id: 'cloud_branch', - description: 'Checks out an existing branch, makes changes, and pushes commits back', + description: 'Updates an existing branch and creates or updates its pull request', }, { label: 'Review Code', @@ -279,7 +294,7 @@ export const PiBlock: BlockConfig = { paramVisibility: 'user-only', placeholder: 'GitHub personal access token', tooltip: - 'Personal access token used for GitHub access. Create PR needs clone/push/PR permissions; Update Branch needs clone/push permissions. With Babysit Mode, either also needs pull request, check/Actions read, thread write, and issue comment permissions. Review Code needs clone + review permissions.', + 'Personal access token used for GitHub access. Create PR and Update PR both need clone, push, and pull request read/write permissions. With Babysit Mode, either also needs check/Actions reads, thread writes, and issue comments. Review Code needs clone + review permissions.', required: true, condition: CLOUD_ANY, }, @@ -288,8 +303,9 @@ export const PiBlock: BlockConfig = { title: 'Base Branch', type: 'short-input', placeholder: 'e.g., main (defaults to the repository default branch)', - tooltip: 'The branch the pull request is opened against; the repo is cloned from it too.', - condition: CLOUD, + tooltip: + 'Create PR clones this branch and opens against it. Update PR changes an existing pull request only when set, or uses it when creating a missing pull request.', + condition: CLOUD_AUTHORING, }, { id: 'targetBranch', @@ -307,7 +323,7 @@ export const PiBlock: BlockConfig = { type: 'switch', defaultValue: false, description: - 'Use the branch PR, request the configured bot reviews, and fix trusted feedback and required checks in bounded rounds.', + 'Create or update the branch PR, request the configured bot reviews, and fix trusted feedback and required checks in bounded rounds.', condition: CLOUD_AUTHORING, }, { @@ -338,21 +354,36 @@ export const PiBlock: BlockConfig = { mode: 'advanced', condition: getCloudWithoutBabysitCondition, }, + { + id: 'prState', + title: 'PR State', + type: 'dropdown', + defaultValue: 'preserve', + options: [ + { label: 'Leave unchanged', id: 'preserve' }, + { label: 'Draft', id: 'draft' }, + { label: 'Ready for review', id: 'ready' }, + ], + tooltip: + 'State for an existing pull request. When a pull request must be created, Leave unchanged uses the Create PR default and opens it as a draft.', + mode: 'advanced', + condition: getCloudBranchWithoutBabysitCondition, + }, { id: 'prTitle', title: 'PR Title', type: 'short-input', - placeholder: 'Generated from the run when blank', + placeholder: 'Generated for a new PR; preserves an existing PR when blank', mode: 'advanced', - condition: CLOUD, + condition: CLOUD_AUTHORING, }, { id: 'prBody', title: 'PR Body', type: 'long-input', - placeholder: 'Generated from the run when blank', + placeholder: 'Generated for a new PR; preserves an existing PR when blank', mode: 'advanced', - condition: CLOUD, + condition: CLOUD_AUTHORING, }, { id: 'pullNumber', @@ -576,19 +607,23 @@ export const PiBlock: BlockConfig = { inputs: { mode: { type: 'string', - description: 'Execution mode: Create PR, Update Branch, Review Code, or Local Dev', + description: 'Execution mode: Create PR, Update PR, Review Code, or Local Dev', }, task: { type: 'string', description: 'Instruction for the coding agent' }, model: { type: 'string', description: 'AI model to use' }, owner: { type: 'string', description: 'GitHub repository owner (cloud modes)' }, repo: { type: 'string', description: 'GitHub repository name (cloud modes)' }, githubToken: { type: 'string', description: 'GitHub token (cloud modes)' }, - baseBranch: { type: 'string', description: 'Base branch for the PR (Create PR)' }, + baseBranch: { type: 'string', description: 'Base branch for the pull request' }, branchName: { type: 'string', description: 'Branch to create (Create PR)' }, - targetBranch: { type: 'string', description: 'Existing branch to update (Update Branch)' }, + targetBranch: { type: 'string', description: 'Existing branch to update (Update PR)' }, draft: { type: 'boolean', description: 'Open the PR as a draft (Create PR)' }, - prTitle: { type: 'string', description: 'Pull request title (Create PR)' }, - prBody: { type: 'string', description: 'Pull request body (Create PR)' }, + prTitle: { type: 'string', description: 'Pull request title' }, + prBody: { type: 'string', description: 'Pull request body' }, + prState: { + type: 'string', + description: 'Existing pull request state: preserve, draft, or ready (Update PR)', + }, babysitMode: { type: 'boolean', description: 'Babysit trusted bot reviews and required checks after authoring', diff --git a/apps/sim/executor/handlers/pi/backend.ts b/apps/sim/executor/handlers/pi/backend.ts index d6a67d6e617..dbca3e2779b 100644 --- a/apps/sim/executor/handlers/pi/backend.ts +++ b/apps/sim/executor/handlers/pi/backend.ts @@ -114,13 +114,19 @@ export interface PiCloudBabysitOptions { executionId?: string } -/** Parameters for a cloud (E2B) Pi run that updates an existing branch. */ +export type PiPullRequestState = 'preserve' | 'draft' | 'ready' + +/** Parameters for a cloud (E2B) Pi run that updates an existing branch and its pull request. */ export interface PiCloudBranchRunParams extends PiContextualRunParams { mode: 'cloud_branch' owner: string repo: string githubToken: string targetBranch: string + baseBranch?: string + prTitle?: string + prBody?: string + prState: PiPullRequestState babysit?: PiCloudBabysitOptions } diff --git a/apps/sim/executor/handlers/pi/cloud-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-backend.test.ts index 91799afd5c6..030a862bd29 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.test.ts @@ -79,33 +79,38 @@ function branchParams(overrides: Partial = {}): PiCloudB repo: 'demo', githubToken: 'ghp_secret', targetBranch: 'feature/existing', + prState: 'preserve', ...overrides, } } +function existingPullRequestOutput() { + return { + success: true, + output: { + title: 'Feature', + body: '', + html_url: 'https://github.com/octo/demo/pull/7', + state: 'open', + merged: false, + mergeable: true, + head: { + sha: 'a'.repeat(40), + ref: 'feature/existing', + repo_full_name: 'octo/demo', + }, + base: { sha: 'b'.repeat(40), ref: 'staging' }, + }, + } +} + function mockExistingBranchPullRequest(): void { mockExecuteTool .mockResolvedValueOnce({ success: true, output: { items: [{ number: 7 }], count: 1 }, }) - .mockResolvedValueOnce({ - success: true, - output: { - title: 'Feature', - body: '', - html_url: 'https://github.com/octo/demo/pull/7', - state: 'open', - merged: false, - mergeable: true, - head: { - sha: 'a'.repeat(40), - ref: 'feature/existing', - repo_full_name: 'octo/demo', - }, - base: { sha: 'b'.repeat(40), ref: 'staging' }, - }, - }) + .mockResolvedValueOnce(existingPullRequestOutput()) } describe('runCloudPi', () => { @@ -116,10 +121,39 @@ describe('runCloudPi', () => { ) mockProviderEnvVar.mockReturnValue('ANTHROPIC_API_KEY') mockReadFile.mockResolvedValue('diff content') - mockExecuteTool.mockResolvedValue({ - success: true, - output: { metadata: { html_url: 'https://github.com/octo/demo/pull/1', number: 1 } }, + mockExecuteTool.mockImplementation((tool: string) => { + if (tool === 'github_list_prs_v2') { + return Promise.resolve({ success: true, output: { items: [], count: 0 } }) + } + if (tool === 'github_repo_info_v2') { + return Promise.resolve({ success: true, output: { default_branch: 'main' } }) + } + if (tool === 'github_pr_v2') { + return Promise.resolve(existingPullRequestOutput()) + } + if (tool === 'github_update_pr') { + return Promise.resolve({ success: true, output: {} }) + } + return Promise.resolve({ + success: true, + output: { metadata: { html_url: 'https://github.com/octo/demo/pull/1', number: 1 } }, + }) }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: { + repository: { + pullRequest: { id: 'PR_kwDOExample', isDraft: false }, + }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + ) mockRunBabysit.mockResolvedValue({ totals: { finalText: 'Babysit stopped: clean.', @@ -272,7 +306,8 @@ describe('runCloudPi', () => { base: 'main', draft: true, apiKey: 'ghp_secret', - }) + }), + { signal: undefined } ) expect(result.prUrl).toBe('https://github.com/octo/demo/pull/1') expect(result.branch).toBe('feature-x') @@ -299,7 +334,8 @@ describe('runCloudPi', () => { expect(mockRunBabysit).toHaveBeenCalledTimes(1) expect(mockExecuteTool).toHaveBeenCalledWith( 'github_create_pr', - expect.objectContaining({ draft: false }) + expect.objectContaining({ draft: false }), + { signal: undefined } ) expect(mockWithPiSandbox.mock.invocationCallOrder[0]).toBeLessThan( mockRunBabysit.mock.invocationCallOrder[0] @@ -664,8 +700,8 @@ describe('runCloudPi', () => { expect(mockExecuteTool).not.toHaveBeenCalled() }) - describe('Update Branch', () => { - it('checks out and non-force pushes the exact existing branch without opening a PR', async () => { + describe('Update PR', () => { + it('checks out and non-force pushes the exact existing branch, then creates its PR', async () => { const result = await runCloudBranchPi(branchParams(), { onEvent: vi.fn() }) const [cloneCmd, cloneOpts] = mockRun.mock.calls[0] @@ -693,9 +729,18 @@ describe('runCloudPi', () => { expect(pushOpts.envs.GITHUB_TOKEN).toBe('ghp_secret') expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-commit.txt', 'Pi: continue it') - expect(mockExecuteTool).not.toHaveBeenCalled() + expect(mockExecuteTool).toHaveBeenCalledWith( + 'github_create_pr', + expect.objectContaining({ + head: 'feature/existing', + base: 'main', + draft: true, + }), + { signal: undefined } + ) expect(result).toEqual( expect.objectContaining({ + prUrl: 'https://github.com/octo/demo/pull/1', branch: 'feature/existing', changedFiles: ['src/x.ts'], diff: 'diff content', @@ -703,7 +748,7 @@ describe('runCloudPi', () => { ) }) - it('returns the target branch without pushing when there are no changes', async () => { + it('creates the missing PR without pushing when there are no code changes', async () => { mockRun.mockImplementation((command: string) => { if (command.includes('git clone')) { return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) @@ -717,8 +762,85 @@ describe('runCloudPi', () => { const result = await runCloudBranchPi(branchParams(), { onEvent: vi.fn() }) expect(result.branch).toBe('feature/existing') + expect(result.prUrl).toBe('https://github.com/octo/demo/pull/1') expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false) - expect(mockExecuteTool).not.toHaveBeenCalled() + expect(mockExecuteTool).toHaveBeenCalledWith( + 'github_create_pr', + expect.objectContaining({ head: 'feature/existing', draft: true }), + { signal: undefined } + ) + }) + + it('updates explicit metadata and draft state on the exact existing PR', async () => { + mockExistingBranchPullRequest() + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + repository: { + pullRequest: { id: 'PR_kwDOExample', isDraft: false }, + }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + convertPullRequestToDraft: { + pullRequest: { id: 'PR_kwDOExample', isDraft: true }, + }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', mockFetch) + + const result = await runCloudBranchPi( + branchParams({ + baseBranch: 'release', + prTitle: 'New title', + prBody: 'New body', + prState: 'draft', + }), + { onEvent: vi.fn() } + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'github_update_pr', + { + owner: 'octo', + repo: 'demo', + pullNumber: 7, + title: 'New title', + body: 'New body', + base: 'release', + apiKey: 'ghp_secret', + }, + { signal: undefined } + ) + expect(mockFetch).toHaveBeenCalledTimes(2) + expect((mockFetch.mock.calls[1][1] as RequestInit).body).toContain( + 'convertPullRequestToDraft' + ) + expect(result.prUrl).toBe('https://github.com/octo/demo/pull/7') + }) + + it('preserves unspecified metadata and state on an existing PR', async () => { + mockExistingBranchPullRequest() + + const result = await runCloudBranchPi(branchParams(), { onEvent: vi.fn() }) + + expect( + mockExecuteTool.mock.calls.some(([tool]: [string]) => tool === 'github_update_pr') + ).toBe(false) + expect(vi.mocked(fetch)).not.toHaveBeenCalled() + expect(result.prUrl).toBe('https://github.com/octo/demo/pull/7') }) it('discovers the exact existing PR, pushes first, and reuses the Babysit continuation', async () => { @@ -773,9 +895,7 @@ describe('runCloudPi', () => { commitsPushed: 1, stopReason: 'clean', }) - expect(result.totals.finalText).toBe( - 'Update Branch:\ndone\n\nBabysit:\nBabysit stopped: clean.' - ) + expect(result.totals.finalText).toBe('Update PR:\ndone\n\nBabysit:\nBabysit stopped: clean.') }) it('still babysits the existing PR when authoring makes no changes', async () => { @@ -807,20 +927,30 @@ describe('runCloudPi', () => { }) }) - it('fails before starting a sandbox when no open PR uses the target branch', async () => { + it('creates a ready PR and then starts Babysit when the branch has no PR', async () => { mockExecuteTool.mockResolvedValueOnce({ success: true, output: { items: [], count: 0 }, }) - await expect( - runCloudBranchPi( - branchParams({ babysit: { maxRounds: 3, reviewMentions: ['@greptile'] } }), - { onEvent: vi.fn() } - ) - ).rejects.toThrow(/none was found/) - expect(mockWithPiSandbox).not.toHaveBeenCalled() - expect(mockRunBabysit).not.toHaveBeenCalled() + const result = await runCloudBranchPi( + branchParams({ babysit: { maxRounds: 3, reviewMentions: ['@greptile'] } }), + { onEvent: vi.fn() } + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'github_create_pr', + expect.objectContaining({ head: 'feature/existing', draft: false }), + { signal: undefined } + ) + expect(mockRunBabysit).toHaveBeenCalledWith( + expect.objectContaining({ pullNumber: 1 }), + expect.anything() + ) + expect(result).toMatchObject({ + prUrl: 'https://github.com/octo/demo/pull/1', + stopReason: 'clean', + }) }) it('scrubs every sandbox credential from PR discovery errors', async () => { @@ -860,7 +990,7 @@ describe('runCloudPi', () => { runCloudBranchPi(branchParams({ targetBranch: 'feature/missing' }), { onEvent: vi.fn() }) ).rejects.toThrow(/Remote branch feature\/missing not found/) expect(mockRun).toHaveBeenCalledTimes(1) - expect(mockExecuteTool).not.toHaveBeenCalled() + expect(mockExecuteTool).toHaveBeenCalledTimes(1) }) it('rejects a tag-only target instead of creating a branch from it', async () => { @@ -875,7 +1005,7 @@ describe('runCloudPi', () => { ).rejects.toThrow(/not an existing branch/) expect(mockRun).toHaveBeenCalledTimes(1) expect(mockRun.mock.calls[0][0]).toContain('git symbolic-ref --quiet --short HEAD') - expect(mockExecuteTool).not.toHaveBeenCalled() + expect(mockExecuteTool).toHaveBeenCalledTimes(1) }) it('surfaces a non-fast-forward rejection without retrying or force-pushing', async () => { @@ -905,7 +1035,7 @@ describe('runCloudPi', () => { const pushCalls = mockRun.mock.calls.filter(([cmd]: [string]) => cmd.includes('push')) expect(pushCalls).toHaveLength(1) expect(pushCalls[0][0]).not.toContain('--force') - expect(mockExecuteTool).not.toHaveBeenCalled() + expect(mockExecuteTool).toHaveBeenCalledTimes(1) }) it('does not start Babysit after the initial branch push is rejected', async () => { diff --git a/apps/sim/executor/handlers/pi/cloud-backend.ts b/apps/sim/executor/handlers/pi/cloud-backend.ts index 0c2397c0686..25fbbc03159 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.ts @@ -62,7 +62,12 @@ import { type PiRunTotals, parseJsonLine, } from '@/executor/handlers/pi/events' -import { type BranchPullRequest, findOpenPrForBranch } from '@/executor/handlers/pi/github-pr' +import { + type BranchPullRequest, + fetchOpenPrForBranch, + findOpenPrForBranch, + setPullRequestDraftState, +} from '@/executor/handlers/pi/github-pr' import { mapThinkingLevel, providerApiKeyEnvVar } from '@/executor/handlers/pi/keys' import { createScrubbedPiError, @@ -77,6 +82,7 @@ import { } from '@/executor/handlers/pi/search/extension-source' import { getPiProviderId } from '@/providers/pi-providers' import { executeTool } from '@/tools' +import { isRecord, requiredRecord, requiredTrimmedString } from '@/tools/github/response-parsers' const logger = createLogger('PiCloudBackend') @@ -141,49 +147,46 @@ type PiCloudAuthoringRunParams = PiCloudRunParams | PiCloudBranchRunParams /** The commit message and PR title share one default, derived from the PR title or task. */ function defaultTitle(params: PiCloudAuthoringRunParams): string { - return ( - (params.mode === 'cloud' ? params.prTitle?.trim() : undefined) || - truncate(`Pi: ${params.task}`, COMMIT_TITLE_MAX) - ) + return params.prTitle?.trim() || truncate(`Pi: ${params.task}`, COMMIT_TITLE_MAX) } function guidanceFor(params: PiCloudAuthoringRunParams): string { const finalization = params.mode === 'cloud' ? 'pushes the branch, and opens the pull request. ' - : 'pushes them back to the configured branch without force-pushing. ' + : 'pushes them back to the configured branch without force-pushing, then creates or updates its pull request. ' return `${CLOUD_GUIDANCE_PREFIX}${finalization}${CLOUD_GUIDANCE_SUFFIX}` } async function openPullRequest( - params: PiCloudRunParams, + params: PiCloudAuthoringRunParams, branch: string, - detectedBase: string | undefined, + base: string, + draft: boolean, totals: PiRunTotals, - secrets: readonly string[] + secrets: readonly string[], + signal?: AbortSignal ): Promise { - const base = params.baseBranch?.trim() || detectedBase - if (!base) { - throw new Error( - `Branch ${branch} pushed, but the base branch could not be determined — set "Base Branch" on the block and re-run.` - ) - } const title = scrubPiSecrets(defaultTitle(params), secrets) const body = scrubPiSecrets( params.prBody?.trim() || buildPrBody(params.task, totals.finalText), secrets ) - const result = await executeTool('github_create_pr', { - owner: params.owner, - repo: params.repo, - title, - head: branch, - base, - body, - draft: params.babysit ? false : params.draft, - apiKey: params.githubToken, - }) + const result = await executeTool( + 'github_create_pr', + { + owner: params.owner, + repo: params.repo, + title, + head: branch, + base, + body, + draft, + apiKey: params.githubToken, + }, + { signal } + ) if (!result.success) { throw new Error( @@ -191,15 +194,119 @@ async function openPullRequest( ) } - const output = result.output as { metadata?: { html_url?: string; number?: number } } | undefined + if (!isRecord(result.output)) { + throw new Error(`Branch ${branch} pushed but PR creation returned an invalid response`) + } + const metadata = requiredRecord(result.output, 'metadata', 'GitHub create pull request response') + const rawNumber = metadata.number + const number = + typeof rawNumber === 'number' && Number.isSafeInteger(rawNumber) && rawNumber > 0 + ? rawNumber + : undefined return { - ...(typeof output?.metadata?.html_url === 'string' ? { url: output.metadata.html_url } : {}), - ...(typeof output?.metadata?.number === 'number' && - Number.isSafeInteger(output.metadata.number) && - output.metadata.number > 0 - ? { number: output.metadata.number } - : {}), + url: requiredTrimmedString( + metadata, + 'html_url', + 'GitHub create pull request response.metadata' + ), + ...(number ? { number } : {}), + } +} + +async function repositoryDefaultBranch( + params: PiCloudAuthoringRunParams, + signal?: AbortSignal +): Promise { + const result = await executeTool( + 'github_repo_info_v2', + { + owner: params.owner, + repo: params.repo, + apiKey: params.githubToken, + }, + { signal } + ) + if (!result.success) { + throw new Error( + `Failed to determine the repository default branch: ${result.error ?? 'unknown error'}` + ) + } + if (!isRecord(result.output)) { + throw new Error('GitHub repository response must be an object') } + return requiredTrimmedString(result.output, 'default_branch', 'GitHub repository response') +} + +async function updatePullRequest( + params: PiCloudBranchRunParams, + pullRequest: BranchPullRequest, + secrets: readonly string[], + signal?: AbortSignal +): Promise { + const verified = await fetchOpenPrForBranch( + { + owner: params.owner, + repo: params.repo, + pullNumber: pullRequest.pullNumber, + branch: params.targetBranch, + githubToken: params.githubToken, + }, + signal + ) + const title = params.prTitle?.trim() ? scrubPiSecrets(params.prTitle.trim(), secrets) : undefined + const body = params.prBody?.trim() ? scrubPiSecrets(params.prBody.trim(), secrets) : undefined + const base = params.baseBranch?.trim() + if (title || body || base) { + const result = await executeTool( + 'github_update_pr', + { + owner: params.owner, + repo: params.repo, + pullNumber: pullRequest.pullNumber, + ...(title ? { title } : {}), + ...(body ? { body } : {}), + ...(base ? { base } : {}), + apiKey: params.githubToken, + }, + { signal } + ) + if (!result.success) { + throw new Error( + `Branch ${params.targetBranch} pushed but PR update failed: ${result.error ?? 'unknown error'}` + ) + } + } + + const requestedState = params.babysit ? 'ready' : params.prState + if (requestedState !== 'preserve') { + await setPullRequestDraftState( + { + owner: params.owner, + repo: params.repo, + pullNumber: pullRequest.pullNumber, + githubToken: params.githubToken, + state: requestedState, + }, + signal + ) + } + return { number: pullRequest.pullNumber, url: verified.snapshot.htmlUrl } +} + +async function ensureUpdatePullRequest( + params: PiCloudBranchRunParams, + branch: string, + existingPullRequest: BranchPullRequest | undefined, + totals: PiRunTotals, + secrets: readonly string[], + signal?: AbortSignal +): Promise { + if (existingPullRequest) { + return updatePullRequest(params, existingPullRequest, secrets, signal) + } + const base = params.baseBranch?.trim() || (await repositoryDefaultBranch(params, signal)) + const draft = params.babysit ? false : params.prState !== 'ready' + return openPullRequest(params, branch, base, draft, totals, secrets, signal) } function mergeChangedFiles( @@ -221,7 +328,7 @@ function mergePhaseDiffs(createDiff: string | undefined, babysitDiff: string | u } function combineAuthoringAndBabysit( - label: 'Create PR' | 'Update Branch', + label: 'Create PR' | 'Update PR', authored: AuthoringPhaseResult, babysit: PiRunResult ): PiRunResult { @@ -277,7 +384,7 @@ async function runCloudAuthoringPi( context: PiRunContext ): Promise { const startedAt = Date.now() - const modeLabel = params.mode === 'cloud' ? 'Create PR' : 'Update Branch' + const modeLabel = params.mode === 'cloud' ? 'Create PR' : 'Update PR' if (!params.isBYOK) { throw new Error( `${modeLabel} requires your own provider API key (BYOK). Set one in Settings > BYOK.` @@ -313,7 +420,7 @@ async function runCloudAuthoringPi( const totals = createPiTotals() const thinking = mapThinkingLevel(params.thinkingLevel) ?? 'medium' let existingPullRequest: BranchPullRequest | undefined - if (params.mode === 'cloud_branch' && params.babysit) { + if (params.mode === 'cloud_branch') { try { existingPullRequest = await findOpenPrForBranch( { @@ -462,66 +569,64 @@ async function runCloudAuthoringPi( owner: params.owner, repo: params.repo, }) - return { - totals, - changedFiles, - diff, - ...(params.mode === 'cloud_branch' - ? { - branch, - ...(existingPullRequest - ? { - pullNumber: existingPullRequest.pullNumber, - prUrl: existingPullRequest.snapshot.htmlUrl, - } - : {}), - } - : {}), + if (params.mode === 'cloud') { + return { totals, changedFiles, diff } + } + } else { + // PUSH is the only command that carries the token, hardened against any + // git-config program execution the agent may have planted. + const push = await raceAbort( + runner.run(PUSH_SCRIPT, { + envs: { + GITHUB_TOKEN: params.githubToken, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + REPO_OWNER: params.owner, + REPO_NAME: params.repo, + BRANCH: branch, + }, + timeoutMs: FINALIZE_TIMEOUT_MS, + }), + context.signal + ) + if (!push.stdout.includes('__PUSHED__=1')) { + let reason = push.stderr?.trim() + try { + const pushErr = (await runner.readFile(PUSH_ERR_PATH)).trim() + if (pushErr) reason = pushErr + } catch {} + const scrubbed = scrubGitSecrets(reason || 'unknown error', params.githubToken) + throw new Error(`git push failed: ${truncate(scrubbed, PUSH_ERROR_MAX)}`) } } - // PUSH is the only command that carries the token, hardened against any - // git-config program execution the agent may have planted. - const push = await raceAbort( - runner.run(PUSH_SCRIPT, { - envs: { - GITHUB_TOKEN: params.githubToken, - GIT_CONFIG_NOSYSTEM: '1', - GIT_CONFIG_GLOBAL: '/dev/null', - REPO_OWNER: params.owner, - REPO_NAME: params.repo, - BRANCH: branch, - }, - timeoutMs: FINALIZE_TIMEOUT_MS, - }), - context.signal - ) - if (!push.stdout.includes('__PUSHED__=1')) { - let reason = push.stderr?.trim() - try { - const pushErr = (await runner.readFile(PUSH_ERR_PATH)).trim() - if (pushErr) reason = pushErr - } catch {} - const scrubbed = scrubGitSecrets(reason || 'unknown error', params.githubToken) - throw new Error(`git push failed: ${truncate(scrubbed, PUSH_ERROR_MAX)}`) - } - + let pullRequest: OpenedPullRequest if (params.mode === 'cloud_branch') { - return { - totals, - changedFiles, - diff, + pullRequest = await ensureUpdatePullRequest( + params, branch, - ...(existingPullRequest - ? { - pullNumber: existingPullRequest.pullNumber, - prUrl: existingPullRequest.snapshot.htmlUrl, - } - : {}), + existingPullRequest, + totals, + secrets, + context.signal + ) + } else { + const base = params.baseBranch?.trim() || detectedBase + if (!base) { + throw new Error( + `Branch ${branch} pushed, but the base branch could not be determined — set "Base Branch" on the block and re-run.` + ) } + pullRequest = await openPullRequest( + params, + branch, + base, + params.babysit ? false : params.draft, + totals, + secrets, + context.signal + ) } - - const pullRequest = await openPullRequest(params, branch, detectedBase, totals, secrets) return { totals, changedFiles, diff --git a/apps/sim/executor/handlers/pi/github-pr.test.ts b/apps/sim/executor/handlers/pi/github-pr.test.ts index 6d0892c9b31..91fabd74d99 100644 --- a/apps/sim/executor/handlers/pi/github-pr.test.ts +++ b/apps/sim/executor/handlers/pi/github-pr.test.ts @@ -11,6 +11,7 @@ import { fetchOpenPrSnapshot, fetchPrSnapshot, findOpenPrForBranch, + setPullRequestDraftState, validateRepositoryCoordinates, } from '@/executor/handlers/pi/github-pr' @@ -144,9 +145,9 @@ describe('findOpenPrForBranch', () => { ) }) - it('fails when there is no matching pull request or the match is ambiguous', async () => { + it('returns no match so Update PR can create it, but fails when ambiguous', async () => { mockExecuteTool.mockResolvedValueOnce(list([])) - await expect(findOpenPrForBranch(params)).rejects.toThrow(/none was found/) + await expect(findOpenPrForBranch(params)).resolves.toBeUndefined() mockExecuteTool.mockResolvedValueOnce(list([{ number: 7 }, { number: 8 }])) await expect(findOpenPrForBranch(params)).rejects.toThrow(/multiple open pull requests/) @@ -182,6 +183,55 @@ describe('findOpenPrForBranch', () => { }) }) +describe('setPullRequestDraftState', () => { + beforeEach(() => { + vi.unstubAllGlobals() + }) + + function graphQlResponse(data: Record): Response { + return new Response(JSON.stringify({ data }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + + it('does nothing when the pull request already has the requested state', async () => { + const mockFetch = vi.fn().mockResolvedValue( + graphQlResponse({ + repository: { pullRequest: { id: 'PR_kwDOExample', isDraft: false } }, + }) + ) + vi.stubGlobal('fetch', mockFetch) + + await setPullRequestDraftState({ ...COORDINATES, state: 'ready' }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['draft', false, 'convertPullRequestToDraft'], + ['ready', true, 'markPullRequestReadyForReview'], + ] as const)('changes a pull request to %s', async (state, isDraft, mutation) => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + graphQlResponse({ + repository: { pullRequest: { id: 'PR_kwDOExample', isDraft } }, + }) + ) + .mockResolvedValueOnce(graphQlResponse({ [mutation]: { pullRequest: {} } })) + vi.stubGlobal('fetch', mockFetch) + + await setPullRequestDraftState({ ...COORDINATES, state }) + + expect(mockFetch).toHaveBeenCalledTimes(2) + const mutationRequest = mockFetch.mock.calls[1][1] as RequestInit + expect(mutationRequest.headers).toMatchObject({ Authorization: 'Bearer ghp_secret' }) + expect(mutationRequest.body).toContain(mutation) + expect(mutationRequest.body).toContain('PR_kwDOExample') + }) +}) + describe('validateRepositoryCoordinates', () => { it('accepts ordinary GitHub coordinates', () => { expect(() => validateRepositoryCoordinates(COORDINATES)).not.toThrow() diff --git a/apps/sim/executor/handlers/pi/github-pr.ts b/apps/sim/executor/handlers/pi/github-pr.ts index 185b876e9ee..c761452dfac 100644 --- a/apps/sim/executor/handlers/pi/github-pr.ts +++ b/apps/sim/executor/handlers/pi/github-pr.ts @@ -7,6 +7,7 @@ */ import { executeTool } from '@/tools' +import { GITHUB_GRAPHQL_URL, githubGraphQlHeaders, readGraphQlData } from '@/tools/github/graphql' import { isRecord, nullableBoolean, @@ -53,6 +54,8 @@ export interface BranchPullRequest { snapshot: PullRequestSnapshot } +export type PullRequestDraftState = 'draft' | 'ready' + function requiredSha(record: Record, field: string, context: string): string { const value = requiredTrimmedString(record, field, context) if (!COMMIT_SHA_PATTERN.test(value)) { @@ -124,15 +127,36 @@ export async function fetchOpenPrSnapshot( return snapshot } +/** + * Verifies that an open pull request still belongs to the requested branch in + * the requested repository. + */ +export async function fetchOpenPrForBranch( + params: PullRequestCoordinates & { branch: string }, + signal?: AbortSignal +): Promise { + const snapshot = await fetchOpenPrSnapshot(params, signal) + const expectedRepo = `${params.owner}/${params.repo}`.toLowerCase() + if ( + snapshot.headRef !== params.branch || + snapshot.headRepoFullName?.toLowerCase() !== expectedRepo + ) { + throw new Error( + `PR #${params.pullNumber} no longer points to ${params.owner}/${params.repo}:${params.branch}` + ) + } + return { pullNumber: params.pullNumber, snapshot } +} + /** * Finds the single open, same-repository pull request whose head is exactly the - * requested branch. Update Branch uses this before starting a sandbox so - * Babysit never guesses which pull request to monitor. + * requested branch. No match is valid because Update PR can create the missing + * pull request after it has safely pushed the branch. */ export async function findOpenPrForBranch( params: Omit & { branch: string }, signal?: AbortSignal -): Promise { +): Promise { validateRepositoryCoordinates({ ...params, pullNumber: 1 }) const result = await executeTool( 'github_list_prs_v2', @@ -162,14 +186,10 @@ export async function findOpenPrForBranch( throw new Error('GitHub pull request list response.output.items must be an array') } if (items.length === 0) { - throw new Error( - `Update Branch Babysit requires one open pull request for branch ${params.branch}, but none was found` - ) + return undefined } if (items.length > 1) { - throw new Error( - `Update Branch Babysit found multiple open pull requests for branch ${params.branch}` - ) + throw new Error(`Update PR found multiple open pull requests for branch ${params.branch}`) } if (!isRecord(items[0])) { @@ -180,26 +200,91 @@ export async function findOpenPrForBranch( throw new Error('GitHub pull request list response item.number must be positive') } - const snapshot = await fetchOpenPrSnapshot( + return fetchOpenPrForBranch( { owner: params.owner, repo: params.repo, pullNumber, githubToken: params.githubToken, + branch: params.branch, }, signal ) - const expectedRepo = `${params.owner}/${params.repo}`.toLowerCase() - if ( - snapshot.headRef !== params.branch || - snapshot.headRepoFullName?.toLowerCase() !== expectedRepo - ) { - throw new Error( - `PR #${pullNumber} no longer points to ${params.owner}/${params.repo}:${params.branch}` - ) +} + +/** + * Changes only the draft/ready state. GitHub's REST pull-request update API + * cannot perform this transition, so the host uses the corresponding GraphQL + * mutation while keeping the token out of Pi's environment. + */ +export async function setPullRequestDraftState( + params: PullRequestCoordinates & { state: PullRequestDraftState }, + signal?: AbortSignal +): Promise { + validateRepositoryCoordinates(params) + const variables = { + owner: params.owner, + repo: params.repo, + pullNumber: params.pullNumber, } + const queryResponse = await fetch(GITHUB_GRAPHQL_URL, { + method: 'POST', + headers: githubGraphQlHeaders(params.githubToken), + body: JSON.stringify({ + query: `query PiPullRequestDraftState($owner: String!, $repo: String!, $pullNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pullNumber) { + id + isDraft + } + } + }`, + variables, + }), + signal, + }) + const queryData = await readGraphQlData(queryResponse, 'GitHub pull request draft-state query') + const repository = requiredRecord( + queryData, + 'repository', + 'GitHub pull request draft-state query.data' + ) + const pullRequest = requiredRecord( + repository, + 'pullRequest', + 'GitHub pull request draft-state query.data.repository' + ) + const pullRequestId = requiredTrimmedString( + pullRequest, + 'id', + 'GitHub pull request draft-state query.data.repository.pullRequest' + ) + const isDraft = requiredBoolean( + pullRequest, + 'isDraft', + 'GitHub pull request draft-state query.data.repository.pullRequest' + ) + const shouldBeDraft = params.state === 'draft' + if (isDraft === shouldBeDraft) return - return { pullNumber, snapshot } + const mutationName = shouldBeDraft ? 'convertPullRequestToDraft' : 'markPullRequestReadyForReview' + const mutationResponse = await fetch(GITHUB_GRAPHQL_URL, { + method: 'POST', + headers: githubGraphQlHeaders(params.githubToken), + body: JSON.stringify({ + query: `mutation PiSetPullRequestDraftState($pullRequestId: ID!) { + ${mutationName}(input: { pullRequestId: $pullRequestId }) { + pullRequest { + id + isDraft + } + } + }`, + variables: { pullRequestId }, + }), + signal, + }) + await readGraphQlData(mutationResponse, 'GitHub pull request draft-state mutation') } export function validateRepositoryCoordinates(params: PullRequestCoordinates): void { diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts index 45331062a3d..2d33c2a30fa 100644 --- a/apps/sim/executor/handlers/pi/keys.test.ts +++ b/apps/sim/executor/handlers/pi/keys.test.ts @@ -188,7 +188,7 @@ describe('resolvePiModelKey', () => { expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() }) - it('Update Branch rejects when no user key is available (never a hosted key)', async () => { + it('Update PR rejects when no user key is available (never a hosted key)', async () => { mockGetBYOKKey.mockResolvedValue(null) await expect( @@ -198,7 +198,7 @@ describe('resolvePiModelKey', () => { mode: 'cloud_branch', workspaceId: 'ws-1', }) - ).rejects.toThrow(/Update Branch requires your own provider API key/) + ).rejects.toThrow(/Update PR requires your own provider API key/) expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() }) diff --git a/apps/sim/executor/handlers/pi/keys.ts b/apps/sim/executor/handlers/pi/keys.ts index c456a89d26a..8cba20437f9 100644 --- a/apps/sim/executor/handlers/pi/keys.ts +++ b/apps/sim/executor/handlers/pi/keys.ts @@ -2,7 +2,7 @@ * Model, provider-key, and cost resolution shared by Pi backends. Local Dev * mirrors the Agent block — keys resolve through `getApiKeyWithBYOK`, so a * Sim-hosted key may be used and billed. Review Code has the same host-side key - * boundary. Create PR and Update Branch require the user's own key (the + * boundary. Create PR and Update PR require the user's own key (the * block's API Key field, or a stored workspace BYOK key) because those modes run * the model client in an untrusted sandbox. Cost uses the billing multiplier and * is zeroed for BYOK / non-billable models. @@ -47,7 +47,7 @@ export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promis } if (isPiByokOnlyMode(params.mode)) { - const modeLabel = params.mode === 'cloud' ? 'Create PR' : 'Update Branch' + const modeLabel = params.mode === 'cloud' ? 'Create PR' : 'Update PR' const workspaceBYOKProviderId = getPiWorkspaceBYOKProviderId(providerId) if (params.workspaceId && workspaceBYOKProviderId) { const byok = await getBYOKKey(params.workspaceId, workspaceBYOKProviderId) diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts index 080171b827a..cc314d087fe 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.test.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts @@ -189,7 +189,7 @@ describe('PiBlockHandler', () => { it('rejects a mode outside the three the block offers', async () => { await expect( handler.execute(ctx(), block, { mode: 'babysit', task: 'x', model: 'claude' }) - ).rejects.toThrow(/Use Create PR or Update Branch with Babysit Mode enabled/) + ).rejects.toThrow(/Use Create PR or Update PR with Babysit Mode enabled/) expect(mockResolveKey).not.toHaveBeenCalled() }) @@ -231,7 +231,7 @@ describe('PiBlockHandler', () => { expect(output.branch).toBe('pi/abc') }) - it('routes Update Branch to the cloud branch backend and surfaces branch output', async () => { + it('routes Update PR metadata to the cloud branch backend and surfaces branch output', async () => { const output = (await handler.execute(ctx(), block, { mode: 'cloud_branch', task: 'continue it', @@ -240,6 +240,10 @@ describe('PiBlockHandler', () => { repo: 'r', githubToken: 'ghp', targetBranch: 'feature/existing', + baseBranch: 'staging', + prTitle: 'Updated title', + prBody: 'Updated body', + prState: 'ready', skills: [{ skillId: 'skill-1' }], memoryType: 'conversation', conversationId: 'thread-1', @@ -255,6 +259,10 @@ describe('PiBlockHandler', () => { repo: 'r', githubToken: 'ghp', targetBranch: 'feature/existing', + baseBranch: 'staging', + prTitle: 'Updated title', + prBody: 'Updated body', + prState: 'ready', skills: [], initialMessages: [], }) @@ -361,10 +369,10 @@ describe('PiBlockHandler', () => { }) }) - it('passes the same Babysit configuration through Update Branch', async () => { + it('passes the same Babysit configuration through Update PR', async () => { mockRunCloudBranch.mockResolvedValue({ totals: { - finalText: 'Update Branch:\nupdated\n\nBabysit:\nclean', + finalText: 'Update PR:\nupdated\n\nBabysit:\nclean', inputTokens: 1, outputTokens: 2, toolCalls: [], @@ -530,7 +538,7 @@ describe('PiBlockHandler', () => { ).rejects.toThrow(/Create PR requires/) }) - it('requires a target branch in Update Branch', async () => { + it('requires a target branch in Update PR', async () => { await expect( handler.execute(ctx(), block, { mode: 'cloud_branch', @@ -540,7 +548,23 @@ describe('PiBlockHandler', () => { repo: 'r', githubToken: 'ghp', }) - ).rejects.toThrow(/Update Branch requires a target branch/) + ).rejects.toThrow(/Update PR requires a target branch/) + expect(mockRunCloudBranch).not.toHaveBeenCalled() + }) + + it('rejects an invalid Update PR state before starting the backend', async () => { + await expect( + handler.execute(ctx(), block, { + mode: 'cloud_branch', + task: 'x', + model: 'claude', + owner: 'o', + repo: 'r', + githubToken: 'ghp', + targetBranch: 'feature/existing', + prState: 'closed', + }) + ).rejects.toThrow(/Invalid PR state/) expect(mockRunCloudBranch).not.toHaveBeenCalled() }) @@ -687,7 +711,7 @@ describe('PiBlockHandler', () => { }) }) - it('passes Update Branch the key without a host tool', async () => { + it('passes Update PR the key without a host tool', async () => { mockParseSearchProvider.mockReturnValue('exa') await handler.execute(ctx(), block, { diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts index f59bcd8bb6e..2f7d08cd22a 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -20,6 +20,7 @@ import type { PiCloudReviewRunParams, PiCloudRunParams, PiLocalRunParams, + PiPullRequestState, PiRunParams, PiRunResult, PiSearchConfig, @@ -89,10 +90,14 @@ function isSwitchEnabled(value: unknown, defaultValue = false): boolean { return defaultValue } +function isPullRequestState(value: string): value is PiPullRequestState { + return value === 'preserve' || value === 'draft' || value === 'ready' +} + function parsePiMode(value: unknown): PiRunParams['mode'] { if (value === 'babysit') { throw new Error( - 'Standalone Babysit mode was removed. Use Create PR or Update Branch with Babysit Mode enabled.' + 'Standalone Babysit mode was removed. Use Create PR or Update PR with Babysit Mode enabled.' ) } if ( @@ -265,7 +270,7 @@ export class PiBlockHandler implements BlockHandler { const repo = asOptString(inputs.repo) const githubToken = asRawString(inputs.githubToken) if (!owner || !repo || !githubToken) { - const label = mode === 'cloud_branch' ? 'Update Branch' : 'Create PR' + const label = mode === 'cloud_branch' ? 'Update PR' : 'Create PR' throw new Error(`${label} requires repository owner, name, and a GitHub token`) } // A `switch` subblock reaches a handler as the string 'true' when its value came @@ -276,7 +281,7 @@ export class PiBlockHandler implements BlockHandler { const babysitMode = isSwitchEnabled(inputs.babysitMode) const reviewMentions = babysitMode ? parsePiReviewMentions(inputs.reviewMentions) : [] if (babysitMode && reviewMentions.length === 0) { - const label = mode === 'cloud_branch' ? 'Update Branch' : 'Create PR' + const label = mode === 'cloud_branch' ? 'Update PR' : 'Create PR' throw new Error(`${label} Babysit Mode requires at least one reviewer mention`) } const maxRounds = babysitMode @@ -290,7 +295,11 @@ export class PiBlockHandler implements BlockHandler { if (mode === 'cloud_branch') { const targetBranch = asOptString(inputs.targetBranch) if (!targetBranch) { - throw new Error('Update Branch requires a target branch') + throw new Error('Update PR requires a target branch') + } + const prState = asOptString(inputs.prState) ?? 'preserve' + if (!isPullRequestState(prState)) { + throw new Error('Invalid PR state. Use preserve, draft, or ready.') } const params: PiCloudBranchRunParams = { ...contextualBase, @@ -299,6 +308,10 @@ export class PiBlockHandler implements BlockHandler { repo, githubToken, targetBranch, + baseBranch: asOptString(inputs.baseBranch), + prTitle: asOptString(inputs.prTitle), + prBody: asOptString(inputs.prBody), + prState, ...(babysitMode ? { babysit: { From c30089d8dedea8a108825572aa651aa2b97f7c87 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 28 Jul 2026 19:42:19 -0700 Subject: [PATCH 03/10] docs(pi): clarify closed PR behavior --- apps/docs/content/docs/en/workflows/blocks/pi.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/docs/en/workflows/blocks/pi.mdx b/apps/docs/content/docs/en/workflows/blocks/pi.mdx index 45a17d48fff..ca868b062ac 100644 --- a/apps/docs/content/docs/en/workflows/blocks/pi.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/pi.mdx @@ -61,7 +61,7 @@ Update PR uses the same disposable authoring sandbox and host-controlled PR oper - Requires sandbox execution and **your own provider API key (BYOK)**. - Needs the same **GitHub token permissions as Create PR**: permission to clone, push, and create or update a pull request. - Never creates, rebases, merges, or force-pushes the branch. If another commit reaches the branch while Pi is working, the push fails rather than overwriting it. -- Finds the exact open same-repository PR whose head is the target branch. One match is updated; no match is created after authoring; multiple matches fail as ambiguous. Fork, closed, moved, or mismatched PRs fail closed. +- Finds the exact open same-repository PR whose head is the target branch. One match is updated; no open match—including when an earlier PR was closed—creates a new PR after authoring; multiple open matches fail as ambiguous. A discovered PR that closes, moves, or no longer matches during validation aborts safely. - When explicitly set, **Base Branch**, **PR Title**, **PR Body**, and **PR State** update the existing PR. Blank metadata preserves it. A newly created PR uses generated metadata and the repository default base when those fields are blank. - A no-change authoring pass still creates or updates the PR. A rejected initial push stops the run before any PR mutation or Babysit continuation. - The deliverable is the updated branch and its PR — read `prUrl`, `branch`, `changedFiles`, and `diff`. From 401fdce2500dd6c03595d4b390df20deef842276 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 28 Jul 2026 20:12:08 -0700 Subject: [PATCH 04/10] fix(pi): handle update PR finalization races --- .../handlers/pi/cloud-backend.test.ts | 94 ++++++++++++++++++- .../sim/executor/handlers/pi/cloud-backend.ts | 20 +++- .../executor/handlers/pi/pi-handler.test.ts | 1 - 3 files changed, 108 insertions(+), 7 deletions(-) diff --git a/apps/sim/executor/handlers/pi/cloud-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-backend.test.ts index 030a862bd29..8e4d9f75c72 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.test.ts @@ -99,7 +99,7 @@ function existingPullRequestOutput() { ref: 'feature/existing', repo_full_name: 'octo/demo', }, - base: { sha: 'b'.repeat(40), ref: 'staging' }, + base: { sha: 'b'.repeat(40), ref: 'staging', repo_full_name: 'octo/demo' }, }, } } @@ -771,6 +771,98 @@ describe('runCloudPi', () => { ) }) + it('updates a PR created while the branch is being authored instead of creating a duplicate', async () => { + let listCalls = 0 + mockExecuteTool.mockImplementation((tool: string) => { + if (tool === 'github_list_prs_v2') { + listCalls += 1 + return Promise.resolve({ + success: true, + output: + listCalls === 1 ? { items: [], count: 0 } : { items: [{ number: 7 }], count: 1 }, + }) + } + if (tool === 'github_pr_v2') { + return Promise.resolve(existingPullRequestOutput()) + } + if (tool === 'github_create_pr') { + throw new Error('must not create a duplicate PR') + } + return Promise.resolve({ success: true, output: {} }) + }) + + const result = await runCloudBranchPi(branchParams(), { onEvent: vi.fn() }) + + expect(listCalls).toBe(2) + expect( + mockExecuteTool.mock.calls.some(([tool]: [string]) => tool === 'github_create_pr') + ).toBe(false) + expect(result.prUrl).toBe('https://github.com/octo/demo/pull/7') + }) + + it('does not claim a push happened when no-op authoring is followed by a PR error', async () => { + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + return Promise.resolve({ stdout: '__NO_CHANGES__=1', stderr: '', exitCode: 0 }) + }) + mockExecuteTool.mockImplementation((tool: string) => { + if (tool === 'github_list_prs_v2') { + return Promise.resolve({ success: true, output: { items: [], count: 0 } }) + } + if (tool === 'github_repo_info_v2') { + return Promise.resolve({ success: true, output: { default_branch: 'main' } }) + } + return Promise.resolve({ success: false, error: 'permission denied' }) + }) + + const error = (await runCloudBranchPi(branchParams(), { + onEvent: vi.fn(), + }).catch((caught) => caught)) as Error + + expect(error.message).toContain( + 'PR creation failed for branch feature/existing: permission denied' + ) + expect(error.message).not.toContain('pushed') + expect(mockRun.mock.calls.some(([command]: [string]) => command.includes('push'))).toBe(false) + }) + + it('uses neutral wording when a no-op run cannot update its existing PR', async () => { + mockExistingBranchPullRequest() + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + return Promise.resolve({ stdout: '__NO_CHANGES__=1', stderr: '', exitCode: 0 }) + }) + mockExecuteTool.mockImplementation((tool: string) => { + if (tool === 'github_pr_v2') { + return Promise.resolve(existingPullRequestOutput()) + } + if (tool === 'github_update_pr') { + return Promise.resolve({ success: false, error: 'permission denied' }) + } + return Promise.resolve({ success: true, output: {} }) + }) + + const error = (await runCloudBranchPi(branchParams({ prTitle: 'New title' }), { + onEvent: vi.fn(), + }).catch((caught) => caught)) as Error + + expect(error.message).toContain( + 'PR update failed for branch feature/existing: permission denied' + ) + expect(error.message).not.toContain('pushed') + expect(mockRun.mock.calls.some(([command]: [string]) => command.includes('push'))).toBe(false) + }) + it('updates explicit metadata and draft state on the exact existing PR', async () => { mockExistingBranchPullRequest() const mockFetch = vi diff --git a/apps/sim/executor/handlers/pi/cloud-backend.ts b/apps/sim/executor/handlers/pi/cloud-backend.ts index 25fbbc03159..5946728b5bb 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.ts @@ -189,13 +189,11 @@ async function openPullRequest( ) if (!result.success) { - throw new Error( - `Branch ${branch} pushed but PR creation failed: ${result.error ?? 'unknown error'}` - ) + throw new Error(`PR creation failed for branch ${branch}: ${result.error ?? 'unknown error'}`) } if (!isRecord(result.output)) { - throw new Error(`Branch ${branch} pushed but PR creation returned an invalid response`) + throw new Error(`PR creation returned an invalid response for branch ${branch}`) } const metadata = requiredRecord(result.output, 'metadata', 'GitHub create pull request response') const rawNumber = metadata.number @@ -272,7 +270,7 @@ async function updatePullRequest( ) if (!result.success) { throw new Error( - `Branch ${params.targetBranch} pushed but PR update failed: ${result.error ?? 'unknown error'}` + `PR update failed for branch ${params.targetBranch}: ${result.error ?? 'unknown error'}` ) } } @@ -304,6 +302,18 @@ async function ensureUpdatePullRequest( if (existingPullRequest) { return updatePullRequest(params, existingPullRequest, secrets, signal) } + const newlyCreatedPullRequest = await findOpenPrForBranch( + { + owner: params.owner, + repo: params.repo, + branch, + githubToken: params.githubToken, + }, + signal + ) + if (newlyCreatedPullRequest) { + return updatePullRequest(params, newlyCreatedPullRequest, secrets, signal) + } const base = params.baseBranch?.trim() || (await repositoryDefaultBranch(params, signal)) const draft = params.babysit ? false : params.prState !== 'ready' return openPullRequest(params, branch, base, draft, totals, secrets, signal) diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts index cc314d087fe..61110401c4e 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.test.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts @@ -729,7 +729,6 @@ describe('PiBlockHandler', () => { expect(mockRunCloudBranch.mock.calls[0][0].search).toEqual({ provider: 'exa', apiKey: 'search-key', - keySource: 'byok', }) }) From 7e378d56763d193959fb6dd595d8d91e45a3fc70 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 28 Jul 2026 20:18:46 -0700 Subject: [PATCH 05/10] fix(pi): accept renamed update PR repositories --- .../executor/handlers/pi/github-pr.test.ts | 19 +++++++++++++++++++ apps/sim/executor/handlers/pi/github-pr.ts | 5 +++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/sim/executor/handlers/pi/github-pr.test.ts b/apps/sim/executor/handlers/pi/github-pr.test.ts index 91fabd74d99..a56dd7224fe 100644 --- a/apps/sim/executor/handlers/pi/github-pr.test.ts +++ b/apps/sim/executor/handlers/pi/github-pr.test.ts @@ -145,6 +145,25 @@ describe('findOpenPrForBranch', () => { ) }) + it('accepts a renamed same-repository pull request by comparing head to base', async () => { + mockExecuteTool.mockResolvedValueOnce(list([{ number: 7 }])).mockResolvedValueOnce({ + success: true, + output: snapshot({ + head: { + sha: HEAD_SHA, + ref: 'feature/existing', + repo_full_name: 'octo-renamed/demo', + }, + base: { sha: BASE_SHA, ref: 'staging', repo_full_name: 'octo-renamed/demo' }, + }), + }) + + await expect(findOpenPrForBranch(params)).resolves.toMatchObject({ + pullNumber: 7, + snapshot: { headRepoFullName: 'octo-renamed/demo' }, + }) + }) + it('returns no match so Update PR can create it, but fails when ambiguous', async () => { mockExecuteTool.mockResolvedValueOnce(list([])) await expect(findOpenPrForBranch(params)).resolves.toBeUndefined() diff --git a/apps/sim/executor/handlers/pi/github-pr.ts b/apps/sim/executor/handlers/pi/github-pr.ts index c761452dfac..e01cce4df49 100644 --- a/apps/sim/executor/handlers/pi/github-pr.ts +++ b/apps/sim/executor/handlers/pi/github-pr.ts @@ -136,10 +136,11 @@ export async function fetchOpenPrForBranch( signal?: AbortSignal ): Promise { const snapshot = await fetchOpenPrSnapshot(params, signal) - const expectedRepo = `${params.owner}/${params.repo}`.toLowerCase() if ( snapshot.headRef !== params.branch || - snapshot.headRepoFullName?.toLowerCase() !== expectedRepo + !snapshot.headRepoFullName || + !snapshot.baseRepoFullName || + snapshot.headRepoFullName.toLowerCase() !== snapshot.baseRepoFullName.toLowerCase() ) { throw new Error( `PR #${params.pullNumber} no longer points to ${params.owner}/${params.repo}:${params.branch}` From 7d0f29c5f35dc013ff0304668d66fd1ab4879389 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 28 Jul 2026 20:26:01 -0700 Subject: [PATCH 06/10] fix(pi): clarify shared open PR errors --- apps/sim/executor/handlers/pi/cloud-review-backend.test.ts | 2 +- apps/sim/executor/handlers/pi/github-pr.test.ts | 2 +- apps/sim/executor/handlers/pi/github-pr.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts index 57a667b84c1..2aa7b116602 100644 --- a/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts @@ -530,7 +530,7 @@ describe('runCloudReviewPi', () => { mockExecuteTool.mockResolvedValue({ success: true, output: snapshot({ state: 'closed' }) }) await expect(runCloudReviewPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow( - /only open PRs can be reviewed/ + /only open PRs are supported/ ) expect(mockRun).not.toHaveBeenCalled() }) diff --git a/apps/sim/executor/handlers/pi/github-pr.test.ts b/apps/sim/executor/handlers/pi/github-pr.test.ts index a56dd7224fe..bd533374099 100644 --- a/apps/sim/executor/handlers/pi/github-pr.test.ts +++ b/apps/sim/executor/handlers/pi/github-pr.test.ts @@ -99,7 +99,7 @@ describe('fetchOpenPrSnapshot', () => { mockExecuteTool.mockResolvedValue({ success: true, output: snapshot({ state: 'closed' }) }) await expect(fetchOpenPrSnapshot(COORDINATES)).rejects.toThrow( - 'PR #7 is closed; only open PRs can be reviewed' + 'PR #7 is closed; only open PRs are supported' ) }) }) diff --git a/apps/sim/executor/handlers/pi/github-pr.ts b/apps/sim/executor/handlers/pi/github-pr.ts index e01cce4df49..bab384185d0 100644 --- a/apps/sim/executor/handlers/pi/github-pr.ts +++ b/apps/sim/executor/handlers/pi/github-pr.ts @@ -122,7 +122,7 @@ export async function fetchOpenPrSnapshot( ): Promise { const snapshot = await fetchPrSnapshot(params, signal) if (snapshot.state !== 'open') { - throw new Error(`PR #${params.pullNumber} is ${snapshot.state}; only open PRs can be reviewed`) + throw new Error(`PR #${params.pullNumber} is ${snapshot.state}; only open PRs are supported`) } return snapshot } From b6eec1ac9cb4e89baf2297e0b84933afb65901ec Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 28 Jul 2026 20:32:41 -0700 Subject: [PATCH 07/10] fix(pi): recheck update PR ambiguity --- .../handlers/pi/cloud-backend.test.ts | 22 +++++++++++++++++++ .../sim/executor/handlers/pi/cloud-backend.ts | 15 ++++++++----- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/apps/sim/executor/handlers/pi/cloud-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-backend.test.ts index 8e4d9f75c72..2439f506a37 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.test.ts @@ -800,6 +800,22 @@ describe('runCloudPi', () => { expect(result.prUrl).toBe('https://github.com/octo/demo/pull/7') }) + it('fails when a second PR for the branch appears during authoring', async () => { + mockExistingBranchPullRequest() + mockExecuteTool.mockResolvedValueOnce({ + success: true, + output: { items: [{ number: 7 }, { number: 8 }], count: 2 }, + }) + + await expect(runCloudBranchPi(branchParams(), { onEvent: vi.fn() })).rejects.toThrow( + /multiple open pull requests/ + ) + expect( + mockExecuteTool.mock.calls.some(([tool]: [string]) => tool === 'github_update_pr') + ).toBe(false) + expect(mockRunBabysit).not.toHaveBeenCalled() + }) + it('does not claim a push happened when no-op authoring is followed by a PR error', async () => { mockRun.mockImplementation((command: string) => { if (command.includes('git clone')) { @@ -843,6 +859,12 @@ describe('runCloudPi', () => { return Promise.resolve({ stdout: '__NO_CHANGES__=1', stderr: '', exitCode: 0 }) }) mockExecuteTool.mockImplementation((tool: string) => { + if (tool === 'github_list_prs_v2') { + return Promise.resolve({ + success: true, + output: { items: [{ number: 7 }], count: 1 }, + }) + } if (tool === 'github_pr_v2') { return Promise.resolve(existingPullRequestOutput()) } diff --git a/apps/sim/executor/handlers/pi/cloud-backend.ts b/apps/sim/executor/handlers/pi/cloud-backend.ts index 5946728b5bb..b295952c6c4 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.ts @@ -299,10 +299,7 @@ async function ensureUpdatePullRequest( secrets: readonly string[], signal?: AbortSignal ): Promise { - if (existingPullRequest) { - return updatePullRequest(params, existingPullRequest, secrets, signal) - } - const newlyCreatedPullRequest = await findOpenPrForBranch( + const currentPullRequest = await findOpenPrForBranch( { owner: params.owner, repo: params.repo, @@ -311,8 +308,14 @@ async function ensureUpdatePullRequest( }, signal ) - if (newlyCreatedPullRequest) { - return updatePullRequest(params, newlyCreatedPullRequest, secrets, signal) + if (existingPullRequest) { + if (currentPullRequest && currentPullRequest.pullNumber !== existingPullRequest.pullNumber) { + throw new Error(`The open pull request for branch ${branch} changed during authoring`) + } + return updatePullRequest(params, existingPullRequest, secrets, signal) + } + if (currentPullRequest) { + return updatePullRequest(params, currentPullRequest, secrets, signal) } const base = params.baseBranch?.trim() || (await repositoryDefaultBranch(params, signal)) const draft = params.babysit ? false : params.prState !== 'ready' From 926184717b93a31bd7a7e4349508a5c270a5092f Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 28 Jul 2026 21:05:35 -0700 Subject: [PATCH 08/10] fix(pi): recreate closed update PRs --- .../content/docs/en/workflows/blocks/pi.mdx | 2 +- .../handlers/pi/cloud-backend.test.ts | 46 +++++++++++++++++-- .../sim/executor/handlers/pi/cloud-backend.ts | 1 - 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/apps/docs/content/docs/en/workflows/blocks/pi.mdx b/apps/docs/content/docs/en/workflows/blocks/pi.mdx index ca868b062ac..dbe3a59c40a 100644 --- a/apps/docs/content/docs/en/workflows/blocks/pi.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/pi.mdx @@ -61,7 +61,7 @@ Update PR uses the same disposable authoring sandbox and host-controlled PR oper - Requires sandbox execution and **your own provider API key (BYOK)**. - Needs the same **GitHub token permissions as Create PR**: permission to clone, push, and create or update a pull request. - Never creates, rebases, merges, or force-pushes the branch. If another commit reaches the branch while Pi is working, the push fails rather than overwriting it. -- Finds the exact open same-repository PR whose head is the target branch. One match is updated; no open match—including when an earlier PR was closed—creates a new PR after authoring; multiple open matches fail as ambiguous. A discovered PR that closes, moves, or no longer matches during validation aborts safely. +- Finds the exact open same-repository PR whose head is the target branch and checks again after authoring. One match is updated; no open match—including when an earlier or preflight PR was closed—creates a new PR; multiple open matches fail as ambiguous. - When explicitly set, **Base Branch**, **PR Title**, **PR Body**, and **PR State** update the existing PR. Blank metadata preserves it. A newly created PR uses generated metadata and the repository default base when those fields are blank. - A no-change authoring pass still creates or updates the PR. A rejected initial push stops the run before any PR mutation or Babysit continuation. - The deliverable is the updated branch and its PR — read `prUrl`, `branch`, `changedFiles`, and `diff`. diff --git a/apps/sim/executor/handlers/pi/cloud-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-backend.test.ts index 2439f506a37..52bfc9bbdce 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.test.ts @@ -111,6 +111,11 @@ function mockExistingBranchPullRequest(): void { output: { items: [{ number: 7 }], count: 1 }, }) .mockResolvedValueOnce(existingPullRequestOutput()) + .mockResolvedValueOnce({ + success: true, + output: { items: [{ number: 7 }], count: 1 }, + }) + .mockResolvedValueOnce(existingPullRequestOutput()) } describe('runCloudPi', () => { @@ -801,11 +806,16 @@ describe('runCloudPi', () => { }) it('fails when a second PR for the branch appears during authoring', async () => { - mockExistingBranchPullRequest() - mockExecuteTool.mockResolvedValueOnce({ - success: true, - output: { items: [{ number: 7 }, { number: 8 }], count: 2 }, - }) + mockExecuteTool + .mockResolvedValueOnce({ + success: true, + output: { items: [{ number: 7 }], count: 1 }, + }) + .mockResolvedValueOnce(existingPullRequestOutput()) + .mockResolvedValueOnce({ + success: true, + output: { items: [{ number: 7 }, { number: 8 }], count: 2 }, + }) await expect(runCloudBranchPi(branchParams(), { onEvent: vi.fn() })).rejects.toThrow( /multiple open pull requests/ @@ -816,6 +826,32 @@ describe('runCloudPi', () => { expect(mockRunBabysit).not.toHaveBeenCalled() }) + it('creates a replacement when the preflight PR is no longer open after authoring', async () => { + mockExecuteTool + .mockResolvedValueOnce({ + success: true, + output: { items: [{ number: 7 }], count: 1 }, + }) + .mockResolvedValueOnce(existingPullRequestOutput()) + .mockResolvedValueOnce({ + success: true, + output: { items: [], count: 0 }, + }) + + const result = await runCloudBranchPi(branchParams(), { onEvent: vi.fn() }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'github_create_pr', + expect.objectContaining({ + head: 'feature/existing', + base: 'main', + draft: true, + }), + { signal: undefined } + ) + expect(result.prUrl).toBe('https://github.com/octo/demo/pull/1') + }) + it('does not claim a push happened when no-op authoring is followed by a PR error', async () => { mockRun.mockImplementation((command: string) => { if (command.includes('git clone')) { diff --git a/apps/sim/executor/handlers/pi/cloud-backend.ts b/apps/sim/executor/handlers/pi/cloud-backend.ts index b295952c6c4..8e6f2d94338 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.ts @@ -312,7 +312,6 @@ async function ensureUpdatePullRequest( if (currentPullRequest && currentPullRequest.pullNumber !== existingPullRequest.pullNumber) { throw new Error(`The open pull request for branch ${branch} changed during authoring`) } - return updatePullRequest(params, existingPullRequest, secrets, signal) } if (currentPullRequest) { return updatePullRequest(params, currentPullRequest, secrets, signal) From eef857fcd5a88a0d9be5996d6d66f083580cc1b2 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 28 Jul 2026 21:12:27 -0700 Subject: [PATCH 09/10] fix(pi): follow replacement update PRs --- .../handlers/pi/cloud-backend.test.ts | 26 +++++++++++++++++-- .../sim/executor/handlers/pi/cloud-backend.ts | 18 ++----------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/apps/sim/executor/handlers/pi/cloud-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-backend.test.ts index 52bfc9bbdce..34d78b14c96 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.test.ts @@ -84,13 +84,13 @@ function branchParams(overrides: Partial = {}): PiCloudB } } -function existingPullRequestOutput() { +function existingPullRequestOutput(pullNumber = 7) { return { success: true, output: { title: 'Feature', body: '', - html_url: 'https://github.com/octo/demo/pull/7', + html_url: `https://github.com/octo/demo/pull/${pullNumber}`, state: 'open', merged: false, mergeable: true, @@ -852,6 +852,28 @@ describe('runCloudPi', () => { expect(result.prUrl).toBe('https://github.com/octo/demo/pull/1') }) + it('updates the one replacement PR found after authoring', async () => { + mockExecuteTool + .mockResolvedValueOnce({ + success: true, + output: { items: [{ number: 7 }], count: 1 }, + }) + .mockResolvedValueOnce(existingPullRequestOutput()) + .mockResolvedValueOnce({ + success: true, + output: { items: [{ number: 8 }], count: 1 }, + }) + .mockResolvedValueOnce(existingPullRequestOutput(8)) + .mockResolvedValueOnce(existingPullRequestOutput(8)) + + const result = await runCloudBranchPi(branchParams(), { onEvent: vi.fn() }) + + expect(result.prUrl).toBe('https://github.com/octo/demo/pull/8') + expect( + mockExecuteTool.mock.calls.some(([tool]: [string]) => tool === 'github_create_pr') + ).toBe(false) + }) + it('does not claim a push happened when no-op authoring is followed by a PR error', async () => { mockRun.mockImplementation((command: string) => { if (command.includes('git clone')) { diff --git a/apps/sim/executor/handlers/pi/cloud-backend.ts b/apps/sim/executor/handlers/pi/cloud-backend.ts index 8e6f2d94338..1619c8148da 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.ts @@ -294,7 +294,6 @@ async function updatePullRequest( async function ensureUpdatePullRequest( params: PiCloudBranchRunParams, branch: string, - existingPullRequest: BranchPullRequest | undefined, totals: PiRunTotals, secrets: readonly string[], signal?: AbortSignal @@ -308,11 +307,6 @@ async function ensureUpdatePullRequest( }, signal ) - if (existingPullRequest) { - if (currentPullRequest && currentPullRequest.pullNumber !== existingPullRequest.pullNumber) { - throw new Error(`The open pull request for branch ${branch} changed during authoring`) - } - } if (currentPullRequest) { return updatePullRequest(params, currentPullRequest, secrets, signal) } @@ -431,10 +425,9 @@ async function runCloudAuthoringPi( ) const totals = createPiTotals() const thinking = mapThinkingLevel(params.thinkingLevel) ?? 'medium' - let existingPullRequest: BranchPullRequest | undefined if (params.mode === 'cloud_branch') { try { - existingPullRequest = await findOpenPrForBranch( + await findOpenPrForBranch( { owner: params.owner, repo: params.repo, @@ -614,14 +607,7 @@ async function runCloudAuthoringPi( let pullRequest: OpenedPullRequest if (params.mode === 'cloud_branch') { - pullRequest = await ensureUpdatePullRequest( - params, - branch, - existingPullRequest, - totals, - secrets, - context.signal - ) + pullRequest = await ensureUpdatePullRequest(params, branch, totals, secrets, context.signal) } else { const base = params.baseBranch?.trim() || detectedBase if (!base) { From 4d227710163f80b5a18a55930dc67a69bb1ccdb5 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 29 Jul 2026 18:05:17 -0700 Subject: [PATCH 10/10] fix(pi): preserve update PR BYOK after staging rebase --- apps/sim/blocks/blocks/pi.test.ts | 14 ++++++++++++++ apps/sim/blocks/blocks/pi.ts | 2 +- apps/sim/providers/pi-providers.ts | 16 ++++++++-------- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/sim/blocks/blocks/pi.test.ts b/apps/sim/blocks/blocks/pi.test.ts index e85491fd416..55724836117 100644 --- a/apps/sim/blocks/blocks/pi.test.ts +++ b/apps/sim/blocks/blocks/pi.test.ts @@ -242,6 +242,20 @@ describe('Pi cloud authoring surface', () => { babysitMode: true, }) ).toBe(false) + expect( + evaluateSubBlockCondition(prState?.condition, { + mode: 'cloud_branch', + babysitMode: 'true', + }) + ).toBe(false) expect(PiBlock.inputs.prState).toBeDefined() }) + + it.each(['cloud', 'cloud_branch'])( + 'always shows the model API key for sandbox authoring mode %s', + (mode) => { + const apiKey = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'apiKey') + expect(evaluateSubBlockCondition(apiKey?.condition, { mode })).toBe(true) + } + ) }) diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts index d52b065f6c3..a7eb65d60ae 100644 --- a/apps/sim/blocks/blocks/pi.ts +++ b/apps/sim/blocks/blocks/pi.ts @@ -153,7 +153,7 @@ const hostedModelApiKeyCondition = getApiKeyCondition() * rule and hide the field when Sim covers the key. */ const piApiKeyCondition = (values?: Record) => - isPiByokOnlyMode(values?.mode) ? CLOUD : hostedModelApiKeyCondition(values) + isPiByokOnlyMode(values?.mode) ? CLOUD_AUTHORING : hostedModelApiKeyCondition(values) export const PiBlock: BlockConfig = { type: 'pi', diff --git a/apps/sim/providers/pi-providers.ts b/apps/sim/providers/pi-providers.ts index 5251dc2ca2d..85b676bb2d7 100644 --- a/apps/sim/providers/pi-providers.ts +++ b/apps/sim/providers/pi-providers.ts @@ -29,16 +29,16 @@ export function isPiSupportedProvider(providerId: string): providerId is PiSuppo /** * Whether a Pi block mode hands the model API key into the sandbox and - * therefore always requires the user's own key. Create PR ('cloud') runs the - * model client inside the sandbox, so Sim never supplies a hosted key for it: - * the block always shows the API Key field, copilot validation never strips - * it, and execution requires BYOK. Review Code and Local Dev keep the model - * client in Sim and follow the normal hosted-key rules. All three enforcement - * sites (block condition, edit-workflow validation, key resolution) consume - * this predicate so they cannot drift. + * therefore always requires the user's own key. Create PR ('cloud') and Update + * PR ('cloud_branch') run the model client inside the sandbox, so Sim never + * supplies a hosted key for them: the block always shows the API Key field, + * copilot validation never strips it, and execution requires BYOK. Review Code + * and Local Dev keep the model client in Sim and follow the normal hosted-key + * rules. All three enforcement sites (block condition, edit-workflow + * validation, key resolution) consume this predicate so they cannot drift. */ export function isPiByokOnlyMode(mode: unknown): boolean { - return mode === 'cloud' + return mode === 'cloud' || mode === 'cloud_branch' } /** Returns Pi's provider ID for a supported Sim provider. */