diff --git a/apps/sim/blocks/blocks/pi.test.ts b/apps/sim/blocks/blocks/pi.test.ts index 70371fdaaba..1dd219ce2d3 100644 --- a/apps/sim/blocks/blocks/pi.test.ts +++ b/apps/sim/blocks/blocks/pi.test.ts @@ -86,14 +86,26 @@ describe('Pi block search fields', () => { }) describe('Pi cloud authoring surface', () => { - it('offers Create PR, Update PR, Review Code, and Local Dev as top-level modes', () => { + it('offers Create PR, Update PR, Plan, 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_branch', 'cloud_review', 'local']) + expect(options?.map(({ id }) => id)).toEqual([ + 'cloud', + 'cloud_branch', + 'cloud_plan', + 'cloud_review', + 'local', + ]) + }) + + it('documents each mode label with its serialized ID', () => { + expect(PiBlock.inputs.mode.description).toBe( + 'Execution mode: Plan (cloud_plan), Create PR (cloud), Update PR (cloud_branch), Review Code (cloud_review), or Local Dev (local)' + ) }) it.each(['cloud', 'cloud_branch'])( @@ -195,6 +207,64 @@ describe('Pi cloud authoring surface', () => { expect(evaluateSubBlockCondition(targetBranchField?.condition, { mode: 'local' })).toBe(false) }) + it('shows only shared planning inputs in Plan mode', () => { + for (const id of [ + 'task', + 'model', + 'apiKey', + 'searchProvider', + 'owner', + 'repo', + 'githubToken', + 'baseBranch', + 'skills', + 'thinkingLevel', + 'memoryType', + ]) { + const field = PiBlock.subBlocks.find((subBlock) => subBlock.id === id) + expect(evaluateSubBlockCondition(field?.condition, { mode: 'cloud_plan' }), id).toBe(true) + } + + for (const id of [ + 'targetBranch', + 'babysitMode', + 'reviewMentions', + 'branchName', + 'draft', + 'prState', + 'prTitle', + 'prBody', + 'pullNumber', + 'reviewEvent', + 'maxRounds', + 'host', + 'username', + 'authMethod', + 'password', + 'privateKey', + 'repoPath', + 'port', + 'passphrase', + 'tools', + ]) { + const field = PiBlock.subBlocks.find((subBlock) => subBlock.id === id) + expect(evaluateSubBlockCondition(field?.condition, { mode: 'cloud_plan' }), id).toBe(false) + } + + for (const id of ['changedFiles', 'diff', 'prUrl', 'branch', 'reviewUrl', 'commentsPosted']) { + expect( + evaluateSubBlockCondition(PiBlock.outputs[id]?.condition, { mode: 'cloud_plan' }), + id + ).toBe(false) + } + for (const id of ['content', 'model', 'tokens', 'cost', 'providerTiming']) { + expect( + evaluateSubBlockCondition(PiBlock.outputs[id]?.condition, { mode: 'cloud_plan' }), + id + ).toBe(true) + } + }) + it('declares the target branch input and branch output for cloud authoring modes', () => { expect(PiBlock.inputs.targetBranch).toBeDefined() expect( diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts index a7eb65d60ae..ed8f4f9ab8c 100644 --- a/apps/sim/blocks/blocks/pi.ts +++ b/apps/sim/blocks/blocks/pi.ts @@ -56,15 +56,22 @@ const CLOUD_BRANCH: { field: 'mode'; value: 'cloud_branch' } = { } const CLOUD_ANY: { field: 'mode' - value: Array<'cloud' | 'cloud_branch' | 'cloud_review'> + value: Array<'cloud' | 'cloud_branch' | 'cloud_plan' | 'cloud_review'> } = { field: 'mode', - value: ['cloud', 'cloud_branch', 'cloud_review'], + value: ['cloud', 'cloud_branch', 'cloud_plan', 'cloud_review'], } const CLOUD_AUTHORING: { field: 'mode'; value: Array<'cloud' | 'cloud_branch'> } = { field: 'mode', value: ['cloud', 'cloud_branch'], } +const CLOUD_SANDBOX: { + field: 'mode' + value: Array<'cloud' | 'cloud_branch' | 'cloud_plan'> +} = { + field: 'mode', + value: ['cloud', 'cloud_branch', 'cloud_plan'], +} const BABYSIT_ENABLED_VALUES: Array = [true, 'true'] const CLOUD_WITH_BABYSIT: { field: 'mode' @@ -106,12 +113,12 @@ function getCloudBranchWithoutBabysitCondition(values?: Record) } } const LOCAL: { field: 'mode'; value: 'local' } = { field: 'mode', value: 'local' } -const AUTHORING_MODES: { +const CONTEXTUAL_MODES: { field: 'mode' - value: Array<'cloud' | 'cloud_branch' | 'local'> + value: Array<'cloud' | 'cloud_branch' | 'cloud_plan' | 'local'> } = { field: 'mode', - value: ['cloud', 'cloud_branch', 'local'], + value: ['cloud', 'cloud_branch', 'cloud_plan', 'local'], } const MEMORY_TYPES = ['conversation', 'sliding_window', 'sliding_window_tokens'] @@ -146,14 +153,14 @@ const hostedModelApiKeyCondition = getApiKeyCondition() /** * API Key visibility for the Pi block. * - * Create PR hands the model key to the sandbox as an environment variable, so + * Plan, Create PR, and Update PR hand the model key to the sandbox as an environment variable, so * Sim never supplies a hosted key there — the field is shown for every model, * including ones that are hosted elsewhere in Sim. Review Code and Local Dev * keep the model client inside Sim, so they follow the standard hosted-model * rule and hide the field when Sim covers the key. */ const piApiKeyCondition = (values?: Record) => - isPiByokOnlyMode(values?.mode) ? CLOUD_AUTHORING : hostedModelApiKeyCondition(values) + isPiByokOnlyMode(values?.mode) ? CLOUD_SANDBOX : hostedModelApiKeyCondition(values) export const PiBlock: BlockConfig = { type: 'pi', @@ -161,14 +168,15 @@ 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 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.', + 'The Pi Coding Agent runs the Pi harness against a real repository. Plan explores a disposable sandbox checkout and returns an implementation plan without pushing changes. 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. Plan, 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 Plan to inspect a GitHub repo and produce an implementation plan without persisting changes. - Use Create PR for hands-off changes against a GitHub repo where a reviewable PR is the deliverable. - 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 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. + - Plan, 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', @@ -202,6 +210,11 @@ export const PiBlock: BlockConfig = { id: 'cloud_branch', description: 'Updates an existing branch and creates or updates its pull request', }, + { + label: 'Plan', + id: 'cloud_plan', + description: 'Explores a disposable checkout and returns an implementation plan', + }, { label: 'Review Code', id: 'cloud_review', @@ -246,7 +259,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 cloud authoring 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 sandbox modes place the key inside the coding sandbox.', }, { id: 'searchApiKey', @@ -294,7 +307,7 @@ export const PiBlock: BlockConfig = { paramVisibility: 'user-only', placeholder: 'GitHub personal access token', tooltip: - '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.', + 'Personal access token used for GitHub access. Plan needs clone access only. 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, }, @@ -304,8 +317,8 @@ export const PiBlock: BlockConfig = { type: 'short-input', placeholder: 'e.g., main (defaults to the repository default branch)', 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, + 'Plan and Create PR clone this branch, defaulting to the repository default. Create PR opens against it. Update PR changes an existing pull request only when set, or uses it when creating a missing pull request.', + condition: CLOUD_SANDBOX, }, { id: 'targetBranch', @@ -524,7 +537,7 @@ export const PiBlock: BlockConfig = { type: 'skill-input', defaultValue: [], mode: 'advanced', - condition: AUTHORING_MODES, + condition: CONTEXTUAL_MODES, }, { id: 'thinkingLevel', @@ -554,7 +567,7 @@ export const PiBlock: BlockConfig = { { label: 'Sliding window (tokens)', id: 'sliding_window_tokens' }, ], mode: 'advanced', - condition: AUTHORING_MODES, + condition: CONTEXTUAL_MODES, }, { id: 'conversationId', @@ -564,12 +577,12 @@ export const PiBlock: BlockConfig = { mode: 'advanced', required: { field: 'mode', - value: ['cloud', 'cloud_branch', 'local'], + value: ['cloud', 'cloud_branch', 'cloud_plan', 'local'], and: { field: 'memoryType', value: MEMORY_TYPES }, }, condition: { field: 'mode', - value: ['cloud', 'cloud_branch', 'local'], + value: ['cloud', 'cloud_branch', 'cloud_plan', 'local'], and: { field: 'memoryType', value: MEMORY_TYPES }, }, dependsOn: ['memoryType'], @@ -582,7 +595,7 @@ export const PiBlock: BlockConfig = { mode: 'advanced', condition: { field: 'mode', - value: ['cloud', 'cloud_branch', 'local'], + value: ['cloud', 'cloud_branch', 'cloud_plan', 'local'], and: { field: 'memoryType', value: ['sliding_window'] }, }, dependsOn: ['memoryType'], @@ -595,7 +608,7 @@ export const PiBlock: BlockConfig = { mode: 'advanced', condition: { field: 'mode', - value: ['cloud', 'cloud_branch', 'local'], + value: ['cloud', 'cloud_branch', 'cloud_plan', 'local'], and: { field: 'memoryType', value: ['sliding_window_tokens'] }, }, dependsOn: ['memoryType'], @@ -607,14 +620,15 @@ export const PiBlock: BlockConfig = { inputs: { mode: { type: 'string', - description: 'Execution mode: Create PR, Update PR, Review Code, or Local Dev', + description: + 'Execution mode: Plan (cloud_plan), Create PR (cloud), Update PR (cloud_branch), Review Code (cloud_review), or Local Dev (local)', }, 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 pull request' }, + baseBranch: { type: 'string', description: 'Branch to inspect or use as the PR base' }, branchName: { type: 'string', description: 'Branch to create (Create PR)' }, targetBranch: { type: 'string', description: 'Existing branch to update (Update PR)' }, draft: { type: 'boolean', description: 'Open the PR as a draft (Create PR)' }, @@ -667,8 +681,16 @@ export const PiBlock: BlockConfig = { outputs: { content: { type: 'string', description: 'Final agent message / run summary' }, model: { type: 'string', description: 'Model used for the run' }, - changedFiles: { type: 'json', description: 'Files changed by the agent' }, - diff: { type: 'string', description: 'Unified diff of the changes' }, + changedFiles: { + type: 'json', + description: 'Files changed by the agent', + condition: { field: 'mode', value: ['cloud', 'cloud_branch', 'cloud_review', 'local'] }, + }, + diff: { + type: 'string', + description: 'Unified diff of the changes', + condition: { field: 'mode', value: ['cloud', 'cloud_branch', 'cloud_review', 'local'] }, + }, prUrl: { type: 'string', description: 'URL of the created or babysat pull request', diff --git a/apps/sim/blocks/pi-api-key-condition.test.ts b/apps/sim/blocks/pi-api-key-condition.test.ts index 3646bf76b4c..5793b8fa5dd 100644 --- a/apps/sim/blocks/pi-api-key-condition.test.ts +++ b/apps/sim/blocks/pi-api-key-condition.test.ts @@ -42,6 +42,10 @@ describe('Pi API Key visibility', () => { expect(isApiKeyVisible({ mode: 'cloud', model: 'some-unhosted-model' })).toBe(true) }) + it('shows the field in Plan even for a model Sim hosts', () => { + expect(isApiKeyVisible({ mode: 'cloud_plan', model: hostedModel })).toBe(true) + }) + it.each([['local'], ['cloud_review']])( 'hides the field in %s mode for a model Sim hosts', (mode) => { diff --git a/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts b/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts new file mode 100644 index 00000000000..a8dbba717b7 --- /dev/null +++ b/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts @@ -0,0 +1,227 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockBuildPrompt, + mockCleanup, + mockProviderEnvVar, + mockRun, + mockWithPiSandbox, + mockWriteFile, +} = vi.hoisted(() => ({ + mockBuildPrompt: vi.fn(), + mockCleanup: vi.fn(), + mockProviderEnvVar: vi.fn(), + mockRun: vi.fn(), + mockWithPiSandbox: vi.fn(), + mockWriteFile: vi.fn(), +})) + +vi.mock('@/lib/execution/remote-sandbox', () => ({ withPiSandbox: mockWithPiSandbox })) +vi.mock('@/lib/execution/remote-sandbox/pi-lifetime', () => ({ + resolvePiRunLifetimeMs: () => 40 * 60 * 1000, + resolvePiSandboxLifetimeMs: () => 40 * 60 * 1000, +})) +vi.mock('@/executor/handlers/pi/core/context', () => ({ buildPiPrompt: mockBuildPrompt })) +vi.mock('@/executor/handlers/pi/core/keys', () => ({ + providerApiKeyEnvVar: mockProviderEnvVar, + mapThinkingLevel: () => 'high', +})) + +import { runCloudPlanPi } from '@/executor/handlers/pi/cloud/plan/backend' +import type { PiCloudPlanRunParams } from '@/executor/handlers/pi/core/backend' +import { + PI_SEARCH_API_KEY_ENV_VAR, + PI_SEARCH_EXTENSION_PATH, + PI_SEARCH_PROVIDER_ENV_VAR, +} from '@/executor/handlers/pi/search/extension-source' + +function params(overrides: Partial = {}): PiCloudPlanRunParams { + return { + mode: 'cloud_plan', + model: 'claude', + piModel: 'claude-sonnet-4-6', + providerId: 'anthropic', + apiKey: 'sk-model-secret', + isBYOK: true, + task: 'Plan the feature', + skills: [{ name: 'style', content: 'Prefer small changes.' }], + initialMessages: [{ role: 'user', content: 'Earlier context' }], + owner: 'octo', + repo: 'demo', + githubToken: 'ghp_clone_secret', + ...overrides, + } +} + +describe('runCloudPlanPi', () => { + beforeEach(() => { + vi.clearAllMocks() + mockBuildPrompt.mockReturnValue('PLAN PROMPT') + mockProviderEnvVar.mockReturnValue('ANTHROPIC_API_KEY') + mockWithPiSandbox.mockImplementation(async (_options, callback) => { + try { + return await callback({ run: mockRun, writeFile: mockWriteFile }) + } finally { + mockCleanup() + } + }) + mockRun.mockImplementation( + (command: string, options: { onStdout?: (chunk: string) => void }) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + options.onStdout?.( + '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"# Plan\\nDo it"}}\n' + ) + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + ) + }) + + it('clones the selected branch, removes the remote, and runs Pi without GitHub credentials', async () => { + const onEvent = vi.fn() + const result = await runCloudPlanPi(params({ baseBranch: 'staging' }), { onEvent }) + + expect(mockRun).toHaveBeenCalledTimes(2) + const [cloneCommand, cloneOptions] = mockRun.mock.calls[0] + expect(cloneCommand).toContain('git clone --no-tags') + expect(cloneCommand).toContain('git checkout --detach "origin/$BASE_BRANCH"') + expect(cloneCommand.trim().endsWith('git remote remove origin')).toBe(true) + expect(cloneCommand).not.toContain('git commit') + expect(cloneCommand).not.toContain('git push') + expect(cloneOptions.envs).toMatchObject({ + GITHUB_TOKEN: 'ghp_clone_secret', + BASE_BRANCH: 'staging', + }) + + const [piCommand, piOptions] = mockRun.mock.calls[1] + expect(piCommand).toContain('--no-extensions --no-prompt-templates --no-skills --no-approve') + expect(piCommand).not.toContain('git commit') + expect(piCommand).not.toContain('git push') + expect(piOptions.envs.ANTHROPIC_API_KEY).toBe('sk-model-secret') + expect(piOptions.envs.GITHUB_TOKEN).toBeUndefined() + expect(piOptions.envs.PI_MODEL).toBe('claude-sonnet-4-6') + expect(piOptions.envs.PI_THINKING).toBe('high') + expect(piOptions.timeoutMs).toBe(30 * 60 * 1000) + + expect(mockBuildPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + skills: [{ name: 'style', content: 'Prefer small changes.' }], + initialMessages: [{ role: 'user', content: 'Earlier context' }], + task: 'Plan the feature', + guidance: expect.stringMatching(/Markdown plan.*relevant files and symbols.*tests.*risks/i), + }) + ) + expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-prompt.txt', 'PLAN PROMPT') + expect(onEvent).toHaveBeenCalledWith({ type: 'text', text: '# Plan\nDo it' }) + expect(result.totals.finalText).toBe('# Plan\nDo it') + expect(result).not.toHaveProperty('changedFiles') + expect(result).not.toHaveProperty('diff') + }) + + it('uses the repository default branch when Base Branch is blank', async () => { + await runCloudPlanPi(params({ baseBranch: ' ' }), { onEvent: vi.fn() }) + + expect(mockRun.mock.calls[0][0]).toContain('git checkout --detach HEAD') + expect(mockRun.mock.calls[0][1].envs.BASE_BRANCH).toBe('') + }) + + it('returns only the final assistant response while preserving live progress events', async () => { + mockRun.mockImplementation( + (command: string, options: { onStdout?: (chunk: string) => void }) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + options.onStdout?.( + `${[ + JSON.stringify({ + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'Inspecting files...' }, + }), + JSON.stringify({ + type: 'agent_end', + messages: [ + { + role: 'assistant', + stopReason: 'stop', + content: [{ type: 'text', text: 'Inspecting files...' }], + }, + { + role: 'assistant', + stopReason: 'stop', + content: [ + { type: 'thinking', thinking: 'Hidden reasoning' }, + { type: 'text', text: '# Final Plan\n\n1. Make the change.' }, + ], + }, + ], + }), + ].join('\n')}\n` + ) + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + ) + const onEvent = vi.fn() + + const result = await runCloudPlanPi(params(), { onEvent }) + + expect(onEvent).toHaveBeenCalledWith({ type: 'text', text: 'Inspecting files...' }) + expect(onEvent).toHaveBeenCalledWith({ + type: 'final', + text: '# Final Plan\n\n1. Make the change.', + }) + expect(result.totals.finalText).toBe('# Final Plan\n\n1. Make the change.') + }) + + it('loads only the Sim search extension and scopes its key to the Pi command', async () => { + await runCloudPlanPi(params({ search: { provider: 'exa', apiKey: 'exa-secret' } }), { + onEvent: vi.fn(), + }) + + expect(mockWriteFile).toHaveBeenCalledWith( + PI_SEARCH_EXTENSION_PATH, + expect.stringContaining('web_search') + ) + const [piCommand, piOptions] = mockRun.mock.calls[1] + expect(piCommand).toContain(`-e ${PI_SEARCH_EXTENSION_PATH}`) + expect(piOptions.envs[PI_SEARCH_PROVIDER_ENV_VAR]).toBe('exa') + expect(piOptions.envs[PI_SEARCH_API_KEY_ENV_VAR]).toBe('exa-secret') + expect(mockRun.mock.calls[0][1].envs[PI_SEARCH_API_KEY_ENV_VAR]).toBeUndefined() + }) + + it('requires BYOK before creating a sandbox', async () => { + await expect(runCloudPlanPi(params({ isBYOK: false }), { onEvent: vi.fn() })).rejects.toThrow( + /Plan requires your own provider API key/ + ) + expect(mockWithPiSandbox).not.toHaveBeenCalled() + }) + + it('redacts clone credentials from failures and still releases the sandbox', async () => { + const leakedCloneUrl = `fatal: https://x-access-token:${params().githubToken}@github.com/octo/demo.git` + mockRun.mockResolvedValueOnce({ + stdout: '', + stderr: leakedCloneUrl, + exitCode: 1, + }) + + const error = await runCloudPlanPi(params(), { onEvent: vi.fn() }).catch((caught) => caught) + + expect(error).toBeInstanceOf(Error) + expect(error.message).not.toContain('ghp_clone_secret') + expect(error.message).toContain('***') + expect(mockCleanup).toHaveBeenCalledOnce() + }) + + it('propagates cancellation and still releases the sandbox', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + runCloudPlanPi(params(), { onEvent: vi.fn(), signal: controller.signal }) + ).rejects.toThrow(/aborted/) + expect(mockCleanup).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/executor/handlers/pi/cloud/plan/backend.ts b/apps/sim/executor/handlers/pi/cloud/plan/backend.ts new file mode 100644 index 00000000000..9a219f5a068 --- /dev/null +++ b/apps/sim/executor/handlers/pi/cloud/plan/backend.ts @@ -0,0 +1,161 @@ +/** + * Cloud Plan backend: clones a GitHub repository into an ephemeral sandbox, + * removes its authenticated remote, and lets Pi inspect the disposable checkout + * before returning an implementation plan. There is deliberately no finalize, + * push, pull-request, review, or other GitHub-write phase. + */ + +import { createLogger } from '@sim/logger' +import { withPiSandbox } from '@/lib/execution/remote-sandbox' +import { resolvePiRunLifetimeMs } from '@/lib/execution/remote-sandbox/pi-lifetime' +import { + buildPiScript, + CLONE_TIMEOUT_MS, + PROMPT_PATH, + REPO_DIR, + raceAbort, + resolvePiTimeoutMs, + scrubGitSecrets, +} from '@/executor/handlers/pi/cloud/shared' +import type { PiBackendRun, PiCloudPlanRunParams } from '@/executor/handlers/pi/core/backend' +import { buildPiPrompt } from '@/executor/handlers/pi/core/context' +import { applyPiEvent, createPiTotals, parseJsonLine } from '@/executor/handlers/pi/core/events' +import { mapThinkingLevel, providerApiKeyEnvVar } from '@/executor/handlers/pi/core/keys' +import { + createScrubbedPiError, + scrubPiEvent, + scrubPiSecrets, +} from '@/executor/handlers/pi/core/redaction' +import { + PI_SEARCH_API_KEY_ENV_VAR, + PI_SEARCH_EXTENSION_PATH, + PI_SEARCH_EXTENSION_SOURCE, + PI_SEARCH_PROVIDER_ENV_VAR, +} from '@/executor/handlers/pi/search/extension-source' +import { getPiProviderId } from '@/providers/pi-providers' + +const logger = createLogger('PiCloudPlanBackend') + +const PLAN_GUIDANCE = + 'Explore the repository thoroughly and produce an implementation plan for the task. You may read and search files, run shell commands and tests, use web_search when available, and make scratch edits when useful; the checkout is disposable. Do not commit, push, open or modify pull requests, submit reviews, or make any other external write. Your final response must be a Markdown plan covering the recommended approach, relevant files and symbols, ordered implementation steps, tests, and material risks or open questions.' + +const PLAN_CLONE_SCRIPT = `set -e +rm -rf ${REPO_DIR} +git clone --no-tags "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" ${REPO_DIR} +cd ${REPO_DIR} +if [ -n "$BASE_BRANCH" ]; then + git check-ref-format "refs/heads/$BASE_BRANCH" >/dev/null + git checkout --detach "origin/$BASE_BRANCH" +else + git checkout --detach HEAD +fi +git remote remove origin` + +export const runCloudPlanPi: PiBackendRun = async (params, context) => { + if (!params.isBYOK) { + throw new Error('Plan 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 Plan. Use a key-based provider.` + ) + } + + const secrets = [params.apiKey, params.githubToken, params.search?.apiKey ?? ''] + const prompt = scrubPiSecrets( + buildPiPrompt({ + skills: params.skills, + initialMessages: params.initialMessages, + task: params.task, + guidance: PLAN_GUIDANCE, + }), + secrets + ) + const totals = createPiTotals() + const thinking = mapThinkingLevel(params.thinkingLevel) ?? 'medium' + const lifetimeMs = resolvePiRunLifetimeMs(context.signal) + + return withPiSandbox({ lifetimeMs }, async (runner) => { + try { + const clone = await raceAbort( + runner.run(PLAN_CLONE_SCRIPT, { + envs: { + GITHUB_TOKEN: params.githubToken, + REPO_OWNER: params.owner, + REPO_NAME: params.repo, + BASE_BRANCH: params.baseBranch?.trim() ?? '', + }, + timeoutMs: CLONE_TIMEOUT_MS, + }), + context.signal + ) + if (clone.exitCode !== 0) { + throw new Error( + `git clone failed: ${scrubGitSecrets(clone.stderr || clone.stdout || 'unknown error', params.githubToken)}` + ) + } + + await runner.writeFile(PROMPT_PATH, prompt) + if (params.search) { + await runner.writeFile(PI_SEARCH_EXTENSION_PATH, PI_SEARCH_EXTENSION_SOURCE) + } + + let buffer = '' + const handleEvent = (raw: ReturnType) => { + const event = scrubPiEvent(raw, secrets) + if (!event) return + applyPiEvent(totals, event) + if (event.type === 'final' && event.text) { + totals.finalText = event.text + } + context.onEvent(event) + } + const handleChunk = (chunk: string) => { + buffer += chunk + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) handleEvent(parseJsonLine(line)) + } + + const piRun = await raceAbort( + runner.run( + buildPiScript(params.search ? PI_SEARCH_EXTENSION_PATH : undefined, { + disableRepositoryResources: true, + }), + { + envs: { + [keyEnvVar]: params.apiKey, + PI_PROVIDER: getPiProviderId(params.providerId), + PI_MODEL: params.piModel, + PI_THINKING: thinking, + ...(params.search + ? { + [PI_SEARCH_PROVIDER_ENV_VAR]: params.search.provider, + [PI_SEARCH_API_KEY_ENV_VAR]: params.search.apiKey, + } + : {}), + }, + timeoutMs: resolvePiTimeoutMs(lifetimeMs, { finalizePhases: 0 }), + onStdout: handleChunk, + } + ), + context.signal + ) + if (buffer.trim()) handleEvent(parseJsonLine(buffer)) + if (piRun.exitCode !== 0) { + throw new Error( + `Pi agent failed (exit ${piRun.exitCode}): ${piRun.stderr || piRun.stdout}`.trim() + ) + } + if (totals.errorMessage) throw new Error(`Pi agent failed: ${totals.errorMessage}`) + + return { totals } + } catch (error) { + if (context.signal?.aborted) { + logger.info('Pi cloud plan run aborted', { owner: params.owner, repo: params.repo }) + } + throw createScrubbedPiError(error, secrets, 'Pi cloud plan run failed') + } + }) +} diff --git a/apps/sim/executor/handlers/pi/cloud/shared.test.ts b/apps/sim/executor/handlers/pi/cloud/shared.test.ts index f58c020064d..87d482e40fe 100644 --- a/apps/sim/executor/handlers/pi/cloud/shared.test.ts +++ b/apps/sim/executor/handlers/pi/cloud/shared.test.ts @@ -40,6 +40,13 @@ describe('resolvePiTimeoutMs', () => { expect(resolvePiTimeoutMs(shortLifetime)).toBeLessThanOrEqual(shortLifetime) }) + it('does not reserve nonexistent finalize phases for Plan', () => { + const timeout = resolvePiTimeoutMs(PI_SANDBOX_MAX_LIFETIME_MS, { finalizePhases: 0 }) + + expect(timeout).toBeLessThanOrEqual(PI_SANDBOX_MAX_LIFETIME_MS - CLONE_TIMEOUT_MS) + expect(timeout).toBeGreaterThan(resolvePiTimeoutMs(PI_SANDBOX_MAX_LIFETIME_MS)) + }) + it('falls back to the single-turn floor when the reserves exhaust the lifetime', () => { // A deadline shorter than the bracketing commands' worst case is legitimate // (a free-plan sync run). Those ceilings are pessimistic, so leave a short diff --git a/apps/sim/executor/handlers/pi/cloud/shared.ts b/apps/sim/executor/handlers/pi/cloud/shared.ts index f83ba0e5a0f..06dda4c86dd 100644 --- a/apps/sim/executor/handlers/pi/cloud/shared.ts +++ b/apps/sim/executor/handlers/pi/cloud/shared.ts @@ -34,10 +34,10 @@ export const MIN_PI_TIMEOUT_MS = 60 * 1000 * reaped the sandbox and surface as an opaque SDK error. * * The reserve matters as much as the cap. The sandbox clock starts at create, - * and three commands bracket the agent turn: the clone before it, then the - * commit and the push after it, the last two sharing - * {@link FINALIZE_TIMEOUT_MS}. Capping at the bare lifetime would mean the - * sandbox always died first, taking the agent's finished work with it unpushed. + * and authoring has three commands around the agent turn: the clone before it, + * then the commit and push after it, the last two sharing + * {@link FINALIZE_TIMEOUT_MS}. Plan has no finalize phase and sets that reserve + * to zero. Capping at the bare lifetime would mean the sandbox died first. * * Takes the lifetime as an argument rather than reading the provider ceiling * itself, because that ceiling is no longer the only lifetime a run can get: a @@ -53,10 +53,17 @@ export const MIN_PI_TIMEOUT_MS = 60 * 1000 * through `ttlMinutes`. Reserving the surrounding commands keeps the agent turn * inside the lifetime the selected provider actually received. */ -export function resolvePiTimeoutMs(lifetimeMs = resolvePiSandboxLifetimeMs()): number { +export function resolvePiTimeoutMs( + lifetimeMs = resolvePiSandboxLifetimeMs(), + options?: { finalizePhases?: number } +): number { + const finalizePhases = options?.finalizePhases ?? 2 return Math.min( getMaxExecutionTimeout(), - Math.max(lifetimeMs - CLONE_TIMEOUT_MS - 2 * FINALIZE_TIMEOUT_MS, MIN_PI_TIMEOUT_MS) + Math.max( + lifetimeMs - CLONE_TIMEOUT_MS - finalizePhases * FINALIZE_TIMEOUT_MS, + MIN_PI_TIMEOUT_MS + ) ) } diff --git a/apps/sim/executor/handlers/pi/core/backend.ts b/apps/sim/executor/handlers/pi/core/backend.ts index cf4fcd3b63b..3a044615af3 100644 --- a/apps/sim/executor/handlers/pi/core/backend.ts +++ b/apps/sim/executor/handlers/pi/core/backend.ts @@ -2,7 +2,7 @@ * The seam between the Pi handler and its execution environments. The handler * resolves shared credentials and mode-specific context, then hands a * {@link PiRunParams} to one backend ({@link PiBackendRun}) selected by `mode`. - * Authoring modes receive skills. Create PR may then compose the internal Babysit + * Contextual modes receive skills and memory. Create PR may then compose the internal Babysit * continuation without exposing pull-request content to conversation memory. * Backends own environment-specific execution and report progress through * {@link PiRunContext.onEvent}. @@ -61,8 +61,8 @@ export interface PiSearchConfig { provider: PiSearchProvider apiKey: string /** - * Host-side tool for the two SDK modes. Absent for `cloud`, which has no host in the loop and - * registers a sandbox extension instead, so a spec built there could never execute. + * Host-side tool for the SDK modes. Absent for sandbox modes, which register a sandbox extension + * instead, so a spec built there could never execute. */ tool?: PiToolSpec } @@ -107,6 +107,15 @@ export interface PiCloudRunParams extends PiContextualRunParams { babysit?: PiCloudBabysitOptions } +/** Parameters for a cloud (E2B) Pi run that inspects a disposable checkout and returns a plan. */ +export interface PiCloudPlanRunParams extends PiContextualRunParams { + mode: 'cloud_plan' + owner: string + repo: string + githubToken: string + baseBranch?: string +} + /** Optional post-creation Babysit configuration for Create PR. */ export interface PiCloudBabysitOptions { maxRounds: number @@ -155,6 +164,7 @@ export interface PiBabysitContinuationParams extends PiContextualRunParams { export type PiRunParams = | PiLocalRunParams | PiCloudRunParams + | PiCloudPlanRunParams | PiCloudBranchRunParams | PiCloudReviewRunParams diff --git a/apps/sim/executor/handlers/pi/core/events.test.ts b/apps/sim/executor/handlers/pi/core/events.test.ts index 7f342bc603d..3fb216b56b6 100644 --- a/apps/sim/executor/handlers/pi/core/events.test.ts +++ b/apps/sim/executor/handlers/pi/core/events.test.ts @@ -103,6 +103,32 @@ describe('normalizePiEvent', () => { }) }) + it('uses only text blocks from the last assistant message as final text', () => { + expect( + normalizePiEvent({ + type: 'agent_end', + messages: [ + { + role: 'assistant', + stopReason: 'stop', + content: [{ type: 'text', text: 'Earlier narration' }], + }, + { role: 'toolResult', content: [{ type: 'text', text: 'Tool output' }] }, + { + role: 'assistant', + stopReason: 'stop', + content: [ + { type: 'thinking', thinking: 'Hidden reasoning' }, + { type: 'text', text: '# Plan' }, + { type: 'toolCall', name: 'read' }, + { type: 'text', text: 'Do it' }, + ], + }, + ], + }) + ).toEqual({ type: 'final', text: '# Plan\nDo it' }) + }) + it('returns other for unknown types and null for non-objects', () => { expect(normalizePiEvent({ type: 'queue_update' })).toEqual({ type: 'other' }) expect(normalizePiEvent('nope')).toBeNull() diff --git a/apps/sim/executor/handlers/pi/core/events.ts b/apps/sim/executor/handlers/pi/core/events.ts index 4d79683f09a..ebf72ba5cea 100644 --- a/apps/sim/executor/handlers/pi/core/events.ts +++ b/apps/sim/executor/handlers/pi/core/events.ts @@ -83,6 +83,17 @@ function asNumber(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? value : 0 } +function extractAssistantText(message: Record): string { + if (!Array.isArray(message.content)) return '' + return message.content + .map((block) => asRecord(block)) + .filter((block): block is Record => block !== null) + .filter((block) => asString(block.type) === 'text') + .map((block) => asString(block.text)) + .filter(Boolean) + .join('\n') +} + /** * Extracts token usage from an event, tolerating the field names Pi and common * provider payloads use (`input`/`output`, `inputTokens`/`outputTokens`, @@ -149,7 +160,8 @@ export function normalizePiEvent(raw: unknown): PiEvent | null { message: asString(message.errorMessage) || `Pi request ${stopReason}`, } } - break + const text = extractAssistantText(message) + return text ? { type: 'final', text } : { type: 'final' } } return { type: 'final' } } diff --git a/apps/sim/executor/handlers/pi/core/keys.test.ts b/apps/sim/executor/handlers/pi/core/keys.test.ts index 67553ee0a26..b2f0583ab2a 100644 --- a/apps/sim/executor/handlers/pi/core/keys.test.ts +++ b/apps/sim/executor/handlers/pi/core/keys.test.ts @@ -207,6 +207,20 @@ describe('resolvePiModelKey', () => { expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() }) + it('Plan rejects when no user key is available (never a hosted key)', async () => { + mockGetBYOKKey.mockResolvedValue(null) + + await expect( + resolvePiModelKey({ + providerId: 'anthropic', + model: 'claude', + mode: 'cloud_plan', + workspaceId: 'ws-1', + }) + ).rejects.toThrow(/Plan 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/core/keys.ts b/apps/sim/executor/handlers/pi/core/keys.ts index 8cba20437f9..563ef81cc59 100644 --- a/apps/sim/executor/handlers/pi/core/keys.ts +++ b/apps/sim/executor/handlers/pi/core/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 PR require the user's own key (the + * boundary. Create PR, Update PR, and Plan 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,13 @@ interface PiKeyResolution { isBYOK: boolean } -type PiKeyMode = 'cloud' | 'cloud_branch' | 'cloud_review' | 'local' +type PiKeyMode = 'cloud' | 'cloud_branch' | 'cloud_plan' | 'cloud_review' | 'local' + +function piByokModeLabel(mode: PiKeyMode): string { + if (mode === 'cloud') return 'Create PR' + if (mode === 'cloud_branch') return 'Update PR' + return 'Plan' +} interface ResolvePiModelKeyParams { providerId: PiSupportedProvider @@ -47,7 +53,7 @@ export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promis } if (isPiByokOnlyMode(params.mode)) { - const modeLabel = params.mode === 'cloud' ? 'Create PR' : 'Update PR' + const modeLabel = piByokModeLabel(params.mode) const workspaceBYOKProviderId = getPiWorkspaceBYOKProviderId(providerId) if (params.workspaceId && workspaceBYOKProviderId) { const byok = await getBYOKKey(params.workspaceId, workspaceBYOKProviderId) diff --git a/apps/sim/executor/handlers/pi/core/redaction.test.ts b/apps/sim/executor/handlers/pi/core/redaction.test.ts index e9835769b9e..0b798dce6cd 100644 --- a/apps/sim/executor/handlers/pi/core/redaction.test.ts +++ b/apps/sim/executor/handlers/pi/core/redaction.test.ts @@ -32,6 +32,10 @@ describe('Pi secret redaction', () => { type: 'error', message: 'failed ***', }) + expect(scrubPiEvent({ type: 'final', text: 'plan sk-hosted' }, ['sk-hosted'])).toEqual({ + type: 'final', + text: 'plan ***', + }) }) it('creates sanitized errors without retaining the raw cause', () => { diff --git a/apps/sim/executor/handlers/pi/core/redaction.ts b/apps/sim/executor/handlers/pi/core/redaction.ts index faa5f27aabd..d5470ec7288 100644 --- a/apps/sim/executor/handlers/pi/core/redaction.ts +++ b/apps/sim/executor/handlers/pi/core/redaction.ts @@ -22,6 +22,8 @@ export function scrubPiEvent(event: PiEvent | null, secrets: readonly string[]): case 'text': case 'thinking': return { ...event, text: scrubPiSecrets(event.text, secrets) } + case 'final': + return event.text ? { ...event, text: scrubPiSecrets(event.text, secrets) } : event case 'tool_start': case 'tool_end': return { ...event, toolName: scrubPiSecrets(event.toolName, secrets) } diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts index 784f4f9e325..2979f276519 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.test.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts @@ -7,6 +7,7 @@ const { mockRunLocal, mockRunCloud, mockRunCloudBranch, + mockRunCloudPlan, mockRunCloudReview, mockResolveKey, mockResolveSkills, @@ -24,6 +25,7 @@ const { mockRunLocal: vi.fn(), mockRunCloud: vi.fn(), mockRunCloudBranch: vi.fn(), + mockRunCloudPlan: vi.fn(), mockRunCloudReview: vi.fn(), mockResolveKey: vi.fn(), mockResolveSkills: vi.fn(), @@ -69,6 +71,7 @@ vi.mock('@/executor/handlers/pi/cloud/authoring/backend', () => ({ runCloudPi: mockRunCloud, runCloudBranchPi: mockRunCloudBranch, })) +vi.mock('@/executor/handlers/pi/cloud/plan/backend', () => ({ runCloudPlanPi: mockRunCloudPlan })) vi.mock('@/executor/handlers/pi/cloud/review/backend', () => ({ runCloudReviewPi: mockRunCloudReview, })) @@ -169,6 +172,9 @@ describe('PiBlockHandler', () => { changedFiles: ['b.ts'], diff: 'branch diff', }) + mockRunCloudPlan.mockResolvedValue({ + totals: { finalText: '# Plan\nDo it', inputTokens: 3, outputTokens: 4, toolCalls: [] }, + }) mockRunCloudReview.mockResolvedValue({ totals: { finalText: 'looks good', inputTokens: 0, outputTokens: 0, toolCalls: [] }, reviewUrl: 'https://github.com/o/r/pull/7#pullrequestreview-1', @@ -318,6 +324,63 @@ describe('PiBlockHandler', () => { expect(output.content).toBe('updated') }) + it('routes Plan inputs and context to the cloud plan backend', async () => { + mockResolveSkills.mockResolvedValue([{ name: 'style', content: 'Keep it small.' }]) + mockLoadMemory.mockResolvedValue([{ role: 'user', content: 'Earlier context' }]) + + const output = (await handler.execute(ctx(), block, { + mode: 'cloud_plan', + task: 'plan it', + model: 'claude', + owner: 'o', + repo: 'r', + githubToken: 'ghp', + baseBranch: 'staging', + skills: [{ skillId: 'skill-1' }], + memoryType: 'conversation', + conversationId: 'thread-1', + })) as Record + + expect(mockRunCloudPlan).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'cloud_plan', + task: 'plan it', + owner: 'o', + repo: 'r', + githubToken: 'ghp', + baseBranch: 'staging', + skills: [{ name: 'style', content: 'Keep it small.' }], + initialMessages: [{ role: 'user', content: 'Earlier context' }], + }), + expect.anything() + ) + expect(mockRunCloud).not.toHaveBeenCalled() + expect(mockRunCloudBranch).not.toHaveBeenCalled() + expect(mockRunCloudReview).not.toHaveBeenCalled() + expect(mockRunLocal).not.toHaveBeenCalled() + expect(mockAppendMemory).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'plan it', + '# Plan\nDo it' + ) + expect(output).toMatchObject({ + content: '# Plan\nDo it', + model: 'claude', + tokens: { input: 3, output: 4, total: 7 }, + cost: { input: 0, output: 0, total: 0 }, + providerTiming: { + startTime: expect.any(String), + endTime: expect.any(String), + duration: expect.any(Number), + }, + }) + expect(output).not.toHaveProperty('prUrl') + expect(output).not.toHaveProperty('branch') + expect(output).not.toHaveProperty('changedFiles') + expect(output).not.toHaveProperty('diff') + }) + it('routes cloud_review mode and surfaces review output', async () => { const output = (await handler.execute(ctx(), block, { mode: 'cloud_review', @@ -582,6 +645,13 @@ describe('PiBlockHandler', () => { ).rejects.toThrow(/Create PR requires/) }) + it('requires repo + token in Plan', async () => { + await expect( + handler.execute(ctx(), block, { mode: 'cloud_plan', task: 'x', model: 'claude', owner: 'o' }) + ).rejects.toThrow(/Plan requires/) + expect(mockRunCloudPlan).not.toHaveBeenCalled() + }) + it('requires a target branch in Update PR', async () => { await expect( handler.execute(ctx(), block, { @@ -733,6 +803,26 @@ describe('PiBlockHandler', () => { }) }) + it('passes Plan the key without a host tool, which the sandbox extension handles', async () => { + mockParseSearchProvider.mockReturnValue('exa') + + await handler.execute(ctx(), block, { + mode: 'cloud_plan', + task: 'plan it', + model: 'claude', + owner: 'o', + repo: 'r', + githubToken: 'ghp', + searchProvider: 'exa', + }) + + expect(mockBuildSearchTool).not.toHaveBeenCalled() + expect(mockRunCloudPlan.mock.calls[0][0].search).toEqual({ + provider: 'exa', + apiKey: 'search-key', + }) + }) + it('passes Babysit-enabled Create PR the key without constructing a host search tool', async () => { mockParseSearchProvider.mockReturnValue('exa') @@ -841,4 +931,41 @@ describe('PiBlockHandler', () => { expect(text).toContain('streamed') expect(result.execution.output.content).toBe('streamed') }) + + it('streams only the canonical final document for Plan mode', async () => { + mockRunCloudPlan.mockImplementation(async (_params, runCtx) => { + runCtx.onEvent({ type: 'text', text: 'Inspecting files...' }) + runCtx.onEvent({ type: 'final', text: '# Final Plan\n\n1. Make the change.' }) + return { + totals: { + finalText: '# Final Plan\n\n1. Make the change.', + inputTokens: 0, + outputTokens: 0, + toolCalls: [], + }, + } + }) + + const result = (await handler.execute(ctx({ stream: true, selectedOutputs: ['blk'] }), block, { + mode: 'cloud_plan', + task: 'plan it', + model: 'claude', + owner: 'o', + repo: 'r', + githubToken: 'ghp', + })) as StreamingExecution + + const reader = result.stream.getReader() + const decoder = new TextDecoder() + let text = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + text += decoder.decode(value) + } + + expect(text).toBe('# Final Plan\n\n1. Make the change.') + expect(text).not.toContain('Inspecting files...') + expect(result.execution.output.content).toBe('# Final Plan\n\n1. Make the change.') + }) }) diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts index 130f152f25a..a58971d1ff5 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -15,10 +15,12 @@ import { } from '@/ee/access-control/utils/permission-check' import { BlockType } from '@/executor/constants' import { runCloudBranchPi, runCloudPi } from '@/executor/handlers/pi/cloud/authoring/backend' +import { runCloudPlanPi } from '@/executor/handlers/pi/cloud/plan/backend' import { runCloudReviewPi } from '@/executor/handlers/pi/cloud/review/backend' import type { PiBackendRun, PiCloudBranchRunParams, + PiCloudPlanRunParams, PiCloudReviewRunParams, PiCloudRunParams, PiLocalRunParams, @@ -104,6 +106,7 @@ function parsePiMode(value: unknown): PiRunParams['mode'] { if ( value === 'cloud' || value === 'cloud_branch' || + value === 'cloud_plan' || value === 'cloud_review' || value === 'local' ) { @@ -279,9 +282,22 @@ 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 PR' : 'Create PR' + const label = + mode === 'cloud_branch' ? 'Update PR' : mode === 'cloud_plan' ? 'Plan' : 'Create PR' throw new Error(`${label} requires repository owner, name, and a GitHub token`) } + + if (mode === 'cloud_plan') { + const params: PiCloudPlanRunParams = { + ...contextualBase, + mode: 'cloud_plan', + owner, + repo, + githubToken, + baseBranch: asOptString(inputs.baseBranch), + } + return this.runPi(ctx, block, runCloudPlanPi, params, memoryConfig) + } // 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 // workflow (see the same coercion in `wait-handler`). A strict boolean compare @@ -366,8 +382,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 }`. - * Cloud authoring gets no host tool: it registers a sandbox extension instead, so a spec built - * here could never execute. + * Sandbox modes get no host tool: they register a sandbox extension instead, so a spec built here + * could never execute. */ private async resolveSearch( ctx: ExecutionContext, @@ -406,7 +422,7 @@ export class PiBlockHandler implements BlockHandler { }) const credentials = { provider, apiKey } - return mode === 'cloud' || mode === 'cloud_branch' + return mode === 'cloud' || mode === 'cloud_branch' || mode === 'cloud_plan' ? credentials : { ...credentials, tool: buildPiSearchToolSpec(ctx, credentials, mode) } } @@ -423,6 +439,7 @@ export class PiBlockHandler implements BlockHandler { private buildOutput( result: PiRunResult, + mode: PiRunParams['mode'], model: string, isBYOK: boolean, startTime: number, @@ -433,8 +450,12 @@ export class PiBlockHandler implements BlockHandler { return { content: totals.finalText, model, - changedFiles: result.changedFiles ?? [], - diff: result.diff ?? '', + ...(mode === 'cloud_plan' + ? {} + : { + changedFiles: result.changedFiles ?? [], + diff: result.diff ?? '', + }), ...(result.prUrl ? { prUrl: result.prUrl } : {}), ...(result.branch ? { branch: result.branch } : {}), ...(result.reviewUrl ? { reviewUrl: result.reviewUrl } : {}), @@ -489,6 +510,7 @@ export class PiBlockHandler implements BlockHandler { try { const result = await backend(params, { onEvent: (event) => { + if (params.mode === 'cloud_plan') return const text = streamTextForEvent(event) if (text) controller.enqueue(encoder.encode(text)) }, @@ -498,9 +520,19 @@ export class PiBlockHandler implements BlockHandler { controller.error(new Error(result.totals.errorMessage)) return } + if (params.mode === 'cloud_plan' && result.totals.finalText) { + controller.enqueue(encoder.encode(result.totals.finalText)) + } Object.assign( output, - this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO) + this.buildOutput( + result, + params.mode, + params.model, + params.isBYOK, + startTime, + startTimeISO + ) ) if (memoryConfig) { await appendPiMemory( @@ -542,6 +574,13 @@ export class PiBlockHandler implements BlockHandler { result.memoryText ?? result.totals.finalText ) } - return this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO) + return this.buildOutput( + result, + params.mode, + params.model, + params.isBYOK, + startTime, + startTimeISO + ) } } diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index 939798f6d18..abc89f9cf4d 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -1030,14 +1030,17 @@ describe('preValidateCredentialInputs (hosted models)', () => { expect(result.errors[0]?.error).toContain('hosted model') }) - // Create PR hands the key to the sandbox, so Sim never covers it with a hosted + // Sandbox modes hand the key to the sandbox, so Sim never covers it with a hosted // key -- stripping it would leave the copilot authoring a block that cannot run. - it('preserves apiKey on a Create PR Pi block when the model is hosted', async () => { - const result = await preValidateCredentialInputs(piAddOperation('cloud'), CTX) + it.each([['cloud'], ['cloud_branch'], ['cloud_plan']])( + 'preserves apiKey on a Pi block in %s mode when the model is hosted', + async (mode) => { + const result = await preValidateCredentialInputs(piAddOperation(mode), CTX) - expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBe('user-anthropic-key') - expect(result.errors).toHaveLength(0) - }) + expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBe('user-anthropic-key') + expect(result.errors).toHaveLength(0) + } + ) // Local Dev and Review Code keep the model client in Sim, so the hosted key applies. it.each([['local'], ['cloud_review']])( diff --git a/apps/sim/providers/pi-providers.ts b/apps/sim/providers/pi-providers.ts index 85b676bb2d7..1053e911ce8 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') 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, + * therefore always requires the user's own key. Create PR (`cloud`), Update PR + * (`cloud_branch`), and Plan (`cloud_plan`) 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' || mode === 'cloud_branch' + return mode === 'cloud' || mode === 'cloud_branch' || mode === 'cloud_plan' } /** Returns Pi's provider ID for a supported Sim provider. */