From c2123839d250d9c537627c1222fe16338cc43c70 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 6 Aug 2026 18:51:01 -0700 Subject: [PATCH 1/7] chore(pi): organize mode implementations --- apps/sim/blocks/blocks/pi.test.ts | 2 +- .../authoring/backend.test.ts} | 10 ++--- .../authoring/backend.ts} | 38 +++++++++---------- .../babysit/backend.test.ts} | 13 ++++--- .../babysit/backend.ts} | 24 ++++++------ .../babysit/github.test.ts} | 2 +- .../babysit/github.ts} | 6 +-- .../babysit/round.test.ts} | 2 +- .../babysit/round.ts} | 2 +- .../handlers/pi/{ => cloud}/github-pr.test.ts | 2 +- .../handlers/pi/{ => cloud}/github-pr.ts | 0 .../review/backend.test.ts} | 14 +++---- .../review/backend.ts} | 28 +++++++------- .../review/tools-script.ts} | 0 .../review/tools.test.ts} | 2 +- .../review/tools.ts} | 8 ++-- .../shared.test.ts} | 2 +- .../pi/{cloud-shared.ts => cloud/shared.ts} | 2 +- .../handlers/pi/{ => core}/backend.ts | 4 +- .../handlers/pi/{ => core}/context.ts | 2 +- .../handlers/pi/{ => core}/events.test.ts | 2 +- .../executor/handlers/pi/{ => core}/events.ts | 0 .../handlers/pi/{ => core}/keys.test.ts | 2 +- .../executor/handlers/pi/{ => core}/keys.ts | 0 .../executor/handlers/pi/{ => core}/pi-sdk.ts | 4 +- .../handlers/pi/{ => core}/redaction.test.ts | 2 +- .../handlers/pi/{ => core}/redaction.ts | 2 +- .../backend.test.ts} | 14 +++---- .../pi/{local-backend.ts => local/backend.ts} | 14 +++---- .../handlers/pi/{ => local}/sim-tools.test.ts | 2 +- .../handlers/pi/{ => local}/sim-tools.ts | 2 +- .../handlers/pi/{ => local}/ssh-tools.test.ts | 3 +- .../handlers/pi/{ => local}/ssh-tools.ts | 2 +- .../executor/handlers/pi/pi-handler.test.ts | 12 +++--- apps/sim/executor/handlers/pi/pi-handler.ts | 16 ++++---- .../pi/search/extension-source.test.ts | 2 +- .../executor/handlers/pi/search/normalize.ts | 2 +- .../handlers/pi/search/parity.test.ts | 2 +- apps/sim/executor/handlers/pi/search/tool.ts | 4 +- 39 files changed, 125 insertions(+), 125 deletions(-) rename apps/sim/executor/handlers/pi/{cloud-backend.test.ts => cloud/authoring/backend.test.ts} (99%) rename apps/sim/executor/handlers/pi/{cloud-backend.ts => cloud/authoring/backend.ts} (98%) rename apps/sim/executor/handlers/pi/{babysit-backend.test.ts => cloud/babysit/backend.test.ts} (98%) rename apps/sim/executor/handlers/pi/{babysit-backend.ts => cloud/babysit/backend.ts} (99%) rename apps/sim/executor/handlers/pi/{babysit-github.test.ts => cloud/babysit/github.test.ts} (99%) rename apps/sim/executor/handlers/pi/{babysit-github.ts => cloud/babysit/github.ts} (99%) rename apps/sim/executor/handlers/pi/{babysit-round.test.ts => cloud/babysit/round.test.ts} (98%) rename apps/sim/executor/handlers/pi/{babysit-round.ts => cloud/babysit/round.ts} (98%) rename apps/sim/executor/handlers/pi/{ => cloud}/github-pr.test.ts (99%) rename apps/sim/executor/handlers/pi/{ => cloud}/github-pr.ts (100%) rename apps/sim/executor/handlers/pi/{cloud-review-backend.test.ts => cloud/review/backend.test.ts} (98%) rename apps/sim/executor/handlers/pi/{cloud-review-backend.ts => cloud/review/backend.ts} (97%) rename apps/sim/executor/handlers/pi/{cloud-review-tools-script.ts => cloud/review/tools-script.ts} (100%) rename apps/sim/executor/handlers/pi/{cloud-review-tools.test.ts => cloud/review/tools.test.ts} (99%) rename apps/sim/executor/handlers/pi/{cloud-review-tools.ts => cloud/review/tools.ts} (97%) rename apps/sim/executor/handlers/pi/{cloud-shared.test.ts => cloud/shared.test.ts} (98%) rename apps/sim/executor/handlers/pi/{cloud-shared.ts => cloud/shared.ts} (99%) rename apps/sim/executor/handlers/pi/{ => core}/backend.ts (98%) rename apps/sim/executor/handlers/pi/{ => core}/context.ts (99%) rename apps/sim/executor/handlers/pi/{ => core}/events.test.ts (99%) rename apps/sim/executor/handlers/pi/{ => core}/events.ts (100%) rename apps/sim/executor/handlers/pi/{ => core}/keys.test.ts (99%) rename apps/sim/executor/handlers/pi/{ => core}/keys.ts (100%) rename apps/sim/executor/handlers/pi/{ => core}/pi-sdk.ts (97%) rename apps/sim/executor/handlers/pi/{ => core}/redaction.test.ts (97%) rename apps/sim/executor/handlers/pi/{ => core}/redaction.ts (96%) rename apps/sim/executor/handlers/pi/{local-backend.test.ts => local/backend.test.ts} (94%) rename apps/sim/executor/handlers/pi/{local-backend.ts => local/backend.ts} (94%) rename apps/sim/executor/handlers/pi/{ => local}/sim-tools.test.ts (99%) rename apps/sim/executor/handlers/pi/{ => local}/sim-tools.ts (99%) rename apps/sim/executor/handlers/pi/{ => local}/ssh-tools.test.ts (96%) rename apps/sim/executor/handlers/pi/{ => local}/ssh-tools.ts (99%) diff --git a/apps/sim/blocks/blocks/pi.test.ts b/apps/sim/blocks/blocks/pi.test.ts index 55724836117..70371fdaaba 100644 --- a/apps/sim/blocks/blocks/pi.test.ts +++ b/apps/sim/blocks/blocks/pi.test.ts @@ -18,7 +18,7 @@ vi.mock('@/lib/core/config/env', async (importOriginal) => { import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' import { PiBlock } from '@/blocks/blocks/pi' -import { PI_SEARCH_PROVIDERS } from '@/executor/handlers/pi/keys' +import { PI_SEARCH_PROVIDERS } from '@/executor/handlers/pi/core/keys' const searchProviderField = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'searchProvider') const searchApiKeyField = PiBlock.subBlocks.find((subBlock) => subBlock.id === 'searchApiKey') diff --git a/apps/sim/executor/handlers/pi/cloud-backend.test.ts b/apps/sim/executor/handlers/pi/cloud/authoring/backend.test.ts similarity index 99% rename from apps/sim/executor/handlers/pi/cloud-backend.test.ts rename to apps/sim/executor/handlers/pi/cloud/authoring/backend.test.ts index 20d485e0f5f..20bfe39ad98 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud/authoring/backend.test.ts @@ -30,19 +30,19 @@ vi.mock('@/lib/execution/remote-sandbox/pi-lifetime', () => ({ // lifetime is the ceiling because there is nothing shorter to narrow to. resolvePiRunLifetimeMs: () => 40 * 60 * 1000, })) -vi.mock('@/executor/handlers/pi/babysit-backend', () => ({ +vi.mock('@/executor/handlers/pi/cloud/babysit/backend', () => ({ runBabysitPi: mockRunBabysit, })) vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) -vi.mock('@/executor/handlers/pi/keys', () => ({ +vi.mock('@/executor/handlers/pi/core/keys', () => ({ providerApiKeyEnvVar: mockProviderEnvVar, mapThinkingLevel: () => 'medium', })) -vi.mock('@/executor/handlers/pi/context', () => ({ buildPiPrompt: () => 'PROMPT' })) +vi.mock('@/executor/handlers/pi/core/context', () => ({ buildPiPrompt: () => 'PROMPT' })) import { createTimeoutAbortController } from '@/lib/core/execution-limits' -import type { PiCloudBranchRunParams, PiCloudRunParams } from '@/executor/handlers/pi/backend' -import { runCloudBranchPi, runCloudPi } from '@/executor/handlers/pi/cloud-backend' +import { runCloudBranchPi, runCloudPi } from '@/executor/handlers/pi/cloud/authoring/backend' +import type { PiCloudBranchRunParams, PiCloudRunParams } from '@/executor/handlers/pi/core/backend' function baseParams(overrides: Partial = {}): PiCloudRunParams { return { diff --git a/apps/sim/executor/handlers/pi/cloud-backend.ts b/apps/sim/executor/handlers/pi/cloud/authoring/backend.ts similarity index 98% rename from apps/sim/executor/handlers/pi/cloud-backend.ts rename to apps/sim/executor/handlers/pi/cloud/authoring/backend.ts index f6faee07d04..6b6934a29a9 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.ts +++ b/apps/sim/executor/handlers/pi/cloud/authoring/backend.ts @@ -28,14 +28,13 @@ import { resolvePiRunLifetimeMs, resolvePiSandboxLifetimeMs, } from '@/lib/execution/remote-sandbox/pi-lifetime' -import { runBabysitPi } from '@/executor/handlers/pi/babysit-backend' -import type { - PiBackendRun, - PiCloudBranchRunParams, - PiCloudRunParams, - PiRunContext, - PiRunResult, -} from '@/executor/handlers/pi/backend' +import { runBabysitPi } from '@/executor/handlers/pi/cloud/babysit/backend' +import { + type BranchPullRequest, + fetchOpenPrForBranch, + findOpenPrForBranch, + setPullRequestDraftState, +} from '@/executor/handlers/pi/cloud/github-pr' import { buildPiScript, CLONE_TIMEOUT_MS, @@ -54,26 +53,27 @@ import { raceAbort, resolvePiTimeoutMs, scrubGitSecrets, -} from '@/executor/handlers/pi/cloud-shared' -import { buildPiPrompt } from '@/executor/handlers/pi/context' +} from '@/executor/handlers/pi/cloud/shared' +import type { + PiBackendRun, + PiCloudBranchRunParams, + PiCloudRunParams, + PiRunContext, + PiRunResult, +} from '@/executor/handlers/pi/core/backend' +import { buildPiPrompt } from '@/executor/handlers/pi/core/context' import { applyPiEvent, createPiTotals, type PiRunTotals, parseJsonLine, -} from '@/executor/handlers/pi/events' -import { - type BranchPullRequest, - fetchOpenPrForBranch, - findOpenPrForBranch, - setPullRequestDraftState, -} from '@/executor/handlers/pi/github-pr' -import { mapThinkingLevel, providerApiKeyEnvVar } from '@/executor/handlers/pi/keys' +} from '@/executor/handlers/pi/core/events' +import { mapThinkingLevel, providerApiKeyEnvVar } from '@/executor/handlers/pi/core/keys' import { createScrubbedPiError, scrubPiEvent, scrubPiSecrets, -} from '@/executor/handlers/pi/redaction' +} from '@/executor/handlers/pi/core/redaction' import { PI_SEARCH_API_KEY_ENV_VAR, PI_SEARCH_EXTENSION_PATH, diff --git a/apps/sim/executor/handlers/pi/babysit-backend.test.ts b/apps/sim/executor/handlers/pi/cloud/babysit/backend.test.ts similarity index 98% rename from apps/sim/executor/handlers/pi/babysit-backend.test.ts rename to apps/sim/executor/handlers/pi/cloud/babysit/backend.test.ts index e80812d7edd..179f808ee48 100644 --- a/apps/sim/executor/handlers/pi/babysit-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud/babysit/backend.test.ts @@ -46,8 +46,9 @@ vi.mock('@/lib/execution/remote-sandbox/pi-lifetime', async (importOriginal) => resolvePiSandboxLifetimeMs: mockResolvePiSandboxLifetime, } }) -vi.mock('@/executor/handlers/pi/babysit-github', async (importOriginal) => { - const original = await importOriginal() +vi.mock('@/executor/handlers/pi/cloud/babysit/github', async (importOriginal) => { + const original = + await importOriginal() return { ...original, fetchBabysitSnapshot: mockFetchSnapshot, @@ -64,10 +65,10 @@ import { createTimeoutAbortController, getMaxExecutionTimeout } from '@/lib/core import { resolveBabysitExecutionBudgetMs, runBabysitPiWithOptions, -} from '@/executor/handlers/pi/babysit-backend' -import { BABYSIT_ROUND_PATH } from '@/executor/handlers/pi/babysit-round' -import type { PiBabysitContinuationParams } from '@/executor/handlers/pi/backend' -import { DIFF_PATH } from '@/executor/handlers/pi/cloud-shared' +} from '@/executor/handlers/pi/cloud/babysit/backend' +import { BABYSIT_ROUND_PATH } from '@/executor/handlers/pi/cloud/babysit/round' +import { DIFF_PATH } from '@/executor/handlers/pi/cloud/shared' +import type { PiBabysitContinuationParams } from '@/executor/handlers/pi/core/backend' afterAll(resetEnvMock) diff --git a/apps/sim/executor/handlers/pi/babysit-backend.ts b/apps/sim/executor/handlers/pi/cloud/babysit/backend.ts similarity index 99% rename from apps/sim/executor/handlers/pi/babysit-backend.ts rename to apps/sim/executor/handlers/pi/cloud/babysit/backend.ts index 6fa3f1c94fa..12975c1b810 100644 --- a/apps/sim/executor/handlers/pi/babysit-backend.ts +++ b/apps/sim/executor/handlers/pi/cloud/babysit/backend.ts @@ -29,18 +29,13 @@ import { fetchBabysitThreads, replyAndResolveBabysitThreads, requestBabysitReview, -} from '@/executor/handlers/pi/babysit-github' +} from '@/executor/handlers/pi/cloud/babysit/github' import { BABYSIT_ROUND_PATH, MAX_ROUND_FILE_BYTES, MAX_THREADS_PER_ROUND, parseBabysitRound, -} from '@/executor/handlers/pi/babysit-round' -import type { - PiBabysitContinuationParams, - PiRunContext, - PiRunResult, -} from '@/executor/handlers/pi/backend' +} from '@/executor/handlers/pi/cloud/babysit/round' import { buildPiScript, CLONE_TIMEOUT_MS, @@ -58,20 +53,25 @@ import { raceAbort, resolvePiTimeoutMs, scrubGitSecrets, -} from '@/executor/handlers/pi/cloud-shared' -import { buildPiPrompt } from '@/executor/handlers/pi/context' +} from '@/executor/handlers/pi/cloud/shared' +import type { + PiBabysitContinuationParams, + PiRunContext, + PiRunResult, +} from '@/executor/handlers/pi/core/backend' +import { buildPiPrompt } from '@/executor/handlers/pi/core/context' import { applyPiEvent, createPiTotals, type PiRunTotals, parseJsonLine, -} from '@/executor/handlers/pi/events' -import { mapThinkingLevel, providerApiKeyEnvVar } from '@/executor/handlers/pi/keys' +} from '@/executor/handlers/pi/core/events' +import { mapThinkingLevel, providerApiKeyEnvVar } from '@/executor/handlers/pi/core/keys' import { createScrubbedPiError, scrubPiEvent, scrubPiSecrets, -} from '@/executor/handlers/pi/redaction' +} from '@/executor/handlers/pi/core/redaction' import { PI_SEARCH_API_KEY_ENV_VAR, PI_SEARCH_EXTENSION_PATH, diff --git a/apps/sim/executor/handlers/pi/babysit-github.test.ts b/apps/sim/executor/handlers/pi/cloud/babysit/github.test.ts similarity index 99% rename from apps/sim/executor/handlers/pi/babysit-github.test.ts rename to apps/sim/executor/handlers/pi/cloud/babysit/github.test.ts index a0c1eb443b0..804b4d75bb5 100644 --- a/apps/sim/executor/handlers/pi/babysit-github.test.ts +++ b/apps/sim/executor/handlers/pi/cloud/babysit/github.test.ts @@ -12,7 +12,7 @@ import { fetchBabysitSnapshot, fetchBabysitThreads, replyAndResolveBabysitThreads, -} from '@/executor/handlers/pi/babysit-github' +} from '@/executor/handlers/pi/cloud/babysit/github' const HEAD_SHA = 'a'.repeat(40) const BASE_SHA = 'b'.repeat(40) diff --git a/apps/sim/executor/handlers/pi/babysit-github.ts b/apps/sim/executor/handlers/pi/cloud/babysit/github.ts similarity index 99% rename from apps/sim/executor/handlers/pi/babysit-github.ts rename to apps/sim/executor/handlers/pi/cloud/babysit/github.ts index 8d08169996b..870e37d6ca2 100644 --- a/apps/sim/executor/handlers/pi/babysit-github.ts +++ b/apps/sim/executor/handlers/pi/cloud/babysit/github.ts @@ -1,13 +1,13 @@ import { getErrorMessage } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' -import type { BabysitRoundDecision } from '@/executor/handlers/pi/babysit-round' +import type { BabysitRoundDecision } from '@/executor/handlers/pi/cloud/babysit/round' import { fetchPrSnapshot, type PullRequestCoordinates, type PullRequestSnapshot, validateRepositoryCoordinates, -} from '@/executor/handlers/pi/github-pr' -import { scrubPiSecrets } from '@/executor/handlers/pi/redaction' +} from '@/executor/handlers/pi/cloud/github-pr' +import { scrubPiSecrets } from '@/executor/handlers/pi/core/redaction' import { executeTool } from '@/tools' import { isRecord, diff --git a/apps/sim/executor/handlers/pi/babysit-round.test.ts b/apps/sim/executor/handlers/pi/cloud/babysit/round.test.ts similarity index 98% rename from apps/sim/executor/handlers/pi/babysit-round.test.ts rename to apps/sim/executor/handlers/pi/cloud/babysit/round.test.ts index 501898c8fc5..007bd01fb9a 100644 --- a/apps/sim/executor/handlers/pi/babysit-round.test.ts +++ b/apps/sim/executor/handlers/pi/cloud/babysit/round.test.ts @@ -7,7 +7,7 @@ import { MAX_ROUND_REPLY_LENGTH, MAX_THREADS_PER_ROUND, parseBabysitRound, -} from '@/executor/handlers/pi/babysit-round' +} from '@/executor/handlers/pi/cloud/babysit/round' const allowed = new Set(['thread-1', 'thread-2']) diff --git a/apps/sim/executor/handlers/pi/babysit-round.ts b/apps/sim/executor/handlers/pi/cloud/babysit/round.ts similarity index 98% rename from apps/sim/executor/handlers/pi/babysit-round.ts rename to apps/sim/executor/handlers/pi/cloud/babysit/round.ts index 920db25aa3e..030f7f81dc1 100644 --- a/apps/sim/executor/handlers/pi/babysit-round.ts +++ b/apps/sim/executor/handlers/pi/cloud/babysit/round.ts @@ -1,6 +1,6 @@ import { type Static, type TSchema, Type } from 'typebox' import { Check, Errors } from 'typebox/schema' -import { scrubPiSecrets } from '@/executor/handlers/pi/redaction' +import { scrubPiSecrets } from '@/executor/handlers/pi/core/redaction' /** Sandbox path used for the single-use, agent-authored round decision file. */ export const BABYSIT_ROUND_PATH = '/workspace/sim-babysit-round.json' diff --git a/apps/sim/executor/handlers/pi/github-pr.test.ts b/apps/sim/executor/handlers/pi/cloud/github-pr.test.ts similarity index 99% rename from apps/sim/executor/handlers/pi/github-pr.test.ts rename to apps/sim/executor/handlers/pi/cloud/github-pr.test.ts index bd533374099..ce5627c8c19 100644 --- a/apps/sim/executor/handlers/pi/github-pr.test.ts +++ b/apps/sim/executor/handlers/pi/cloud/github-pr.test.ts @@ -13,7 +13,7 @@ import { findOpenPrForBranch, setPullRequestDraftState, validateRepositoryCoordinates, -} from '@/executor/handlers/pi/github-pr' +} from '@/executor/handlers/pi/cloud/github-pr' const HEAD_SHA = 'a'.repeat(40) const BASE_SHA = 'b'.repeat(40) diff --git a/apps/sim/executor/handlers/pi/github-pr.ts b/apps/sim/executor/handlers/pi/cloud/github-pr.ts similarity index 100% rename from apps/sim/executor/handlers/pi/github-pr.ts rename to apps/sim/executor/handlers/pi/cloud/github-pr.ts diff --git a/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts b/apps/sim/executor/handlers/pi/cloud/review/backend.test.ts similarity index 98% rename from apps/sim/executor/handlers/pi/cloud-review-backend.test.ts rename to apps/sim/executor/handlers/pi/cloud/review/backend.test.ts index 2aa7b116602..b10b8365a9f 100644 --- a/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud/review/backend.test.ts @@ -64,11 +64,11 @@ vi.mock('@/lib/execution/remote-sandbox', () => ({ fn({ run: mockRun, writeFile: mockWriteFile }), })) vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) -vi.mock('@/executor/handlers/pi/keys', () => ({ mapThinkingLevel: () => 'medium' })) -vi.mock('@/executor/handlers/pi/context', () => ({ +vi.mock('@/executor/handlers/pi/core/keys', () => ({ mapThinkingLevel: () => 'medium' })) +vi.mock('@/executor/handlers/pi/core/context', () => ({ buildPiPrompt: ({ task, guidance }: { task: string; guidance: string }) => `${guidance}\n${task}`, })) -vi.mock('@/executor/handlers/pi/cloud-review-tools', () => ({ +vi.mock('@/executor/handlers/pi/cloud/review/tools', () => ({ CLOUD_REVIEW_TOOL_NAMES: [ 'read_repo_file', 'search_repo', @@ -83,16 +83,16 @@ vi.mock('@/executor/handlers/pi/cloud-review-tools', () => ({ createCloudReviewTools: mockCreateTools, })) // `toPiTool` stays real so the search tool's scrubbing boundary is the one shipped, not a stub. -vi.mock('@/executor/handlers/pi/pi-sdk', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@/executor/handlers/pi/core/pi-sdk', async (importOriginal) => ({ + ...(await importOriginal()), loadPiSdk: () => Promise.resolve(mockSdk), createPiModelRuntime: mockCreatePiModelRuntime, resolvePiSdkModel: () => ({ id: 'claude', provider: 'anthropic' }), createSealedPiResourceLoader: mockCreateSealedResourceLoader, })) -import type { PiCloudReviewRunParams } from '@/executor/handlers/pi/backend' -import { runCloudReviewPi } from '@/executor/handlers/pi/cloud-review-backend' +import { runCloudReviewPi } from '@/executor/handlers/pi/cloud/review/backend' +import type { PiCloudReviewRunParams } from '@/executor/handlers/pi/core/backend' /** * The mock logger instance the global `@sim/logger` mock handed to the module diff --git a/apps/sim/executor/handlers/pi/cloud-review-backend.ts b/apps/sim/executor/handlers/pi/cloud/review/backend.ts similarity index 97% rename from apps/sim/executor/handlers/pi/cloud-review-backend.ts rename to apps/sim/executor/handlers/pi/cloud/review/backend.ts index e82a648554e..c402f2cf9bb 100644 --- a/apps/sim/executor/handlers/pi/cloud-review-backend.ts +++ b/apps/sim/executor/handlers/pi/cloud/review/backend.ts @@ -12,42 +12,42 @@ import { createLogger } from '@sim/logger' import { truncate } from '@sim/utils/string' import { withPiSandbox } from '@/lib/execution/remote-sandbox' import { resolvePiRunLifetimeMs } from '@/lib/execution/remote-sandbox/pi-lifetime' -import type { PiBackendRun, PiCloudReviewRunParams } from '@/executor/handlers/pi/backend' +import { + fetchOpenPrSnapshot, + MAX_REVIEW_BODY_LENGTH, + type PullRequestSnapshot, + validateRepositoryCoordinates, +} from '@/executor/handlers/pi/cloud/github-pr' import { CLOUD_REVIEW_TOOL_NAMES, createCloudReviewTools, installCloudReviewTools, preflightCloudReviewCheckout, -} from '@/executor/handlers/pi/cloud-review-tools' +} from '@/executor/handlers/pi/cloud/review/tools' import { CLONE_TIMEOUT_MS, extractMarkerValues, REPO_DIR, raceAbort, scrubGitSecrets, -} from '@/executor/handlers/pi/cloud-shared' -import { buildPiPrompt } from '@/executor/handlers/pi/context' -import { applyPiEvent, createPiTotals, normalizePiEvent } from '@/executor/handlers/pi/events' -import { - fetchOpenPrSnapshot, - MAX_REVIEW_BODY_LENGTH, - type PullRequestSnapshot, - validateRepositoryCoordinates, -} from '@/executor/handlers/pi/github-pr' -import { mapThinkingLevel } from '@/executor/handlers/pi/keys' +} from '@/executor/handlers/pi/cloud/shared' +import type { PiBackendRun, PiCloudReviewRunParams } from '@/executor/handlers/pi/core/backend' +import { buildPiPrompt } from '@/executor/handlers/pi/core/context' +import { applyPiEvent, createPiTotals, normalizePiEvent } from '@/executor/handlers/pi/core/events' +import { mapThinkingLevel } from '@/executor/handlers/pi/core/keys' import { createPiModelRuntime, createSealedPiResourceLoader, loadPiSdk, resolvePiSdkModel, toPiTool, -} from '@/executor/handlers/pi/pi-sdk' +} from '@/executor/handlers/pi/core/pi-sdk' import { createScrubbedPiError, getScrubbedPiErrorMessage, scrubPiEvent, scrubPiSecrets, -} from '@/executor/handlers/pi/redaction' +} from '@/executor/handlers/pi/core/redaction' import { PI_SEARCH_TOOL_NAME, PI_SEARCH_UNTRUSTED_SENTENCE, diff --git a/apps/sim/executor/handlers/pi/cloud-review-tools-script.ts b/apps/sim/executor/handlers/pi/cloud/review/tools-script.ts similarity index 100% rename from apps/sim/executor/handlers/pi/cloud-review-tools-script.ts rename to apps/sim/executor/handlers/pi/cloud/review/tools-script.ts diff --git a/apps/sim/executor/handlers/pi/cloud-review-tools.test.ts b/apps/sim/executor/handlers/pi/cloud/review/tools.test.ts similarity index 99% rename from apps/sim/executor/handlers/pi/cloud-review-tools.test.ts rename to apps/sim/executor/handlers/pi/cloud/review/tools.test.ts index 357a4dfcd77..d43d1f1c4ae 100644 --- a/apps/sim/executor/handlers/pi/cloud-review-tools.test.ts +++ b/apps/sim/executor/handlers/pi/cloud/review/tools.test.ts @@ -19,7 +19,7 @@ import { CLOUD_REVIEW_TOOL_NAMES, createCloudReviewTools, installCloudReviewTools, -} from '@/executor/handlers/pi/cloud-review-tools' +} from '@/executor/handlers/pi/cloud/review/tools' const BASE_SHA = 'b'.repeat(40) const HEAD_SHA = 'a'.repeat(40) diff --git a/apps/sim/executor/handlers/pi/cloud-review-tools.ts b/apps/sim/executor/handlers/pi/cloud/review/tools.ts similarity index 97% rename from apps/sim/executor/handlers/pi/cloud-review-tools.ts rename to apps/sim/executor/handlers/pi/cloud/review/tools.ts index 7c5c329c36e..aaea6e735bb 100644 --- a/apps/sim/executor/handlers/pi/cloud-review-tools.ts +++ b/apps/sim/executor/handlers/pi/cloud/review/tools.ts @@ -1,10 +1,10 @@ import type { ToolDefinition } from '@earendil-works/pi-coding-agent' import { Type } from 'typebox' import type { PiSandboxRunner } from '@/lib/execution/remote-sandbox' -import { REVIEW_TOOLS_SCRIPT } from '@/executor/handlers/pi/cloud-review-tools-script' -import { raceAbort } from '@/executor/handlers/pi/cloud-shared' -import type { PiSdk } from '@/executor/handlers/pi/pi-sdk' -import { scrubPiSecrets } from '@/executor/handlers/pi/redaction' +import { REVIEW_TOOLS_SCRIPT } from '@/executor/handlers/pi/cloud/review/tools-script' +import { raceAbort } from '@/executor/handlers/pi/cloud/shared' +import type { PiSdk } from '@/executor/handlers/pi/core/pi-sdk' +import { scrubPiSecrets } from '@/executor/handlers/pi/core/redaction' import { parseReviewFindings, type ReviewFindings, diff --git a/apps/sim/executor/handlers/pi/cloud-shared.test.ts b/apps/sim/executor/handlers/pi/cloud/shared.test.ts similarity index 98% rename from apps/sim/executor/handlers/pi/cloud-shared.test.ts rename to apps/sim/executor/handlers/pi/cloud/shared.test.ts index 2b7f547f955..f58c020064d 100644 --- a/apps/sim/executor/handlers/pi/cloud-shared.test.ts +++ b/apps/sim/executor/handlers/pi/cloud/shared.test.ts @@ -12,7 +12,7 @@ import { FINALIZE_TIMEOUT_MS, MIN_PI_TIMEOUT_MS, resolvePiTimeoutMs, -} from '@/executor/handlers/pi/cloud-shared' +} from '@/executor/handlers/pi/cloud/shared' describe('resolvePiTimeoutMs', () => { it('reserves every command budget that brackets the agent turn', () => { diff --git a/apps/sim/executor/handlers/pi/cloud-shared.ts b/apps/sim/executor/handlers/pi/cloud/shared.ts similarity index 99% rename from apps/sim/executor/handlers/pi/cloud-shared.ts rename to apps/sim/executor/handlers/pi/cloud/shared.ts index f1b46e25b75..f83ba0e5a0f 100644 --- a/apps/sim/executor/handlers/pi/cloud-shared.ts +++ b/apps/sim/executor/handlers/pi/cloud/shared.ts @@ -7,7 +7,7 @@ import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { resolvePiSandboxLifetimeMs } from '@/lib/execution/remote-sandbox/pi-lifetime' -import { scrubPiSecrets } from '@/executor/handlers/pi/redaction' +import { scrubPiSecrets } from '@/executor/handlers/pi/core/redaction' export const REPO_DIR = '/workspace/repo' export const PROMPT_PATH = '/workspace/pi-prompt.txt' diff --git a/apps/sim/executor/handlers/pi/backend.ts b/apps/sim/executor/handlers/pi/core/backend.ts similarity index 98% rename from apps/sim/executor/handlers/pi/backend.ts rename to apps/sim/executor/handlers/pi/core/backend.ts index dbca3e2779b..cf4fcd3b63b 100644 --- a/apps/sim/executor/handlers/pi/backend.ts +++ b/apps/sim/executor/handlers/pi/core/backend.ts @@ -11,8 +11,8 @@ import type { TSchema } from 'typebox' import type { SSHConnectionConfig } from '@/app/api/tools/ssh/utils' import type { Message } from '@/executor/handlers/agent/types' -import type { PiEvent, PiRunTotals } from '@/executor/handlers/pi/events' -import type { PiSearchProvider } from '@/executor/handlers/pi/keys' +import type { PiEvent, PiRunTotals } from '@/executor/handlers/pi/core/events' +import type { PiSearchProvider } from '@/executor/handlers/pi/core/keys' import type { PiSupportedProvider } from '@/providers/pi-provider-configs' /** A conversation message seeded into the Pi run (subset of the Agent block's message). */ diff --git a/apps/sim/executor/handlers/pi/context.ts b/apps/sim/executor/handlers/pi/core/context.ts similarity index 99% rename from apps/sim/executor/handlers/pi/context.ts rename to apps/sim/executor/handlers/pi/core/context.ts index b6e41e74e50..e5b2a899aa2 100644 --- a/apps/sim/executor/handlers/pi/context.ts +++ b/apps/sim/executor/handlers/pi/core/context.ts @@ -11,7 +11,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { memoryService } from '@/executor/handlers/agent/memory' import { resolveSkillContentById } from '@/executor/handlers/agent/skills-resolver' import type { AgentInputs, Message, SkillInput } from '@/executor/handlers/agent/types' -import type { PiMessage, PiSkill } from '@/executor/handlers/pi/backend' +import type { PiMessage, PiSkill } from '@/executor/handlers/pi/core/backend' import type { ExecutionContext } from '@/executor/types' const logger = createLogger('PiContext') diff --git a/apps/sim/executor/handlers/pi/events.test.ts b/apps/sim/executor/handlers/pi/core/events.test.ts similarity index 99% rename from apps/sim/executor/handlers/pi/events.test.ts rename to apps/sim/executor/handlers/pi/core/events.test.ts index 1df73a40035..7f342bc603d 100644 --- a/apps/sim/executor/handlers/pi/events.test.ts +++ b/apps/sim/executor/handlers/pi/core/events.test.ts @@ -8,7 +8,7 @@ import { normalizePiEvent, parseJsonLine, streamTextForEvent, -} from '@/executor/handlers/pi/events' +} from '@/executor/handlers/pi/core/events' describe('normalizePiEvent', () => { it('maps a text_delta message_update to a text event', () => { diff --git a/apps/sim/executor/handlers/pi/events.ts b/apps/sim/executor/handlers/pi/core/events.ts similarity index 100% rename from apps/sim/executor/handlers/pi/events.ts rename to apps/sim/executor/handlers/pi/core/events.ts diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/core/keys.test.ts similarity index 99% rename from apps/sim/executor/handlers/pi/keys.test.ts rename to apps/sim/executor/handlers/pi/core/keys.test.ts index 5c0181d85b4..67553ee0a26 100644 --- a/apps/sim/executor/handlers/pi/keys.test.ts +++ b/apps/sim/executor/handlers/pi/core/keys.test.ts @@ -33,7 +33,7 @@ import { providerApiKeyEnvVar, resolvePiModelKey, resolvePiSearchKey, -} from '@/executor/handlers/pi/keys' +} from '@/executor/handlers/pi/core/keys' beforeAll(() => { envFlagsMockFns.getCostMultiplier.mockReturnValue(2) diff --git a/apps/sim/executor/handlers/pi/keys.ts b/apps/sim/executor/handlers/pi/core/keys.ts similarity index 100% rename from apps/sim/executor/handlers/pi/keys.ts rename to apps/sim/executor/handlers/pi/core/keys.ts diff --git a/apps/sim/executor/handlers/pi/pi-sdk.ts b/apps/sim/executor/handlers/pi/core/pi-sdk.ts similarity index 97% rename from apps/sim/executor/handlers/pi/pi-sdk.ts rename to apps/sim/executor/handlers/pi/core/pi-sdk.ts index 4599285e69e..3477839c2a6 100644 --- a/apps/sim/executor/handlers/pi/pi-sdk.ts +++ b/apps/sim/executor/handlers/pi/core/pi-sdk.ts @@ -1,7 +1,7 @@ import { InMemoryCredentialStore } from '@earendil-works/pi-ai' import type { ModelRuntime, ResourceLoader, ToolDefinition } from '@earendil-works/pi-coding-agent' -import type { PiToolSpec } from '@/executor/handlers/pi/backend' -import { createScrubbedPiError, scrubPiSecrets } from '@/executor/handlers/pi/redaction' +import type { PiToolSpec } from '@/executor/handlers/pi/core/backend' +import { createScrubbedPiError, scrubPiSecrets } from '@/executor/handlers/pi/core/redaction' /** The Pi SDK module, loaded dynamically so it stays externalized from the bundle. */ export type PiSdk = typeof import('@earendil-works/pi-coding-agent') diff --git a/apps/sim/executor/handlers/pi/redaction.test.ts b/apps/sim/executor/handlers/pi/core/redaction.test.ts similarity index 97% rename from apps/sim/executor/handlers/pi/redaction.test.ts rename to apps/sim/executor/handlers/pi/core/redaction.test.ts index 529deaf9b2e..e9835769b9e 100644 --- a/apps/sim/executor/handlers/pi/redaction.test.ts +++ b/apps/sim/executor/handlers/pi/core/redaction.test.ts @@ -7,7 +7,7 @@ import { getScrubbedPiErrorMessage, scrubPiEvent, scrubPiSecrets, -} from '@/executor/handlers/pi/redaction' +} from '@/executor/handlers/pi/core/redaction' describe('Pi secret redaction', () => { it('redacts literal and URL-encoded secret representations', () => { diff --git a/apps/sim/executor/handlers/pi/redaction.ts b/apps/sim/executor/handlers/pi/core/redaction.ts similarity index 96% rename from apps/sim/executor/handlers/pi/redaction.ts rename to apps/sim/executor/handlers/pi/core/redaction.ts index 904d9ae9f8d..faa5f27aabd 100644 --- a/apps/sim/executor/handlers/pi/redaction.ts +++ b/apps/sim/executor/handlers/pi/core/redaction.ts @@ -1,5 +1,5 @@ import { getErrorMessage } from '@sim/utils/errors' -import type { PiEvent } from '@/executor/handlers/pi/events' +import type { PiEvent } from '@/executor/handlers/pi/core/events' /** Redacts exact secret values and their URL-encoded forms from surfaced text. */ export function scrubPiSecrets(text: string, secrets: readonly string[]): string { diff --git a/apps/sim/executor/handlers/pi/local-backend.test.ts b/apps/sim/executor/handlers/pi/local/backend.test.ts similarity index 94% rename from apps/sim/executor/handlers/pi/local-backend.test.ts rename to apps/sim/executor/handlers/pi/local/backend.test.ts index 97c9da9e563..ec27dde622a 100644 --- a/apps/sim/executor/handlers/pi/local-backend.test.ts +++ b/apps/sim/executor/handlers/pi/local/backend.test.ts @@ -50,26 +50,26 @@ const mockModelRuntime = { removeRuntimeApiKey: mockRemoveRuntimeApiKey, } -vi.mock('@/executor/handlers/pi/context', () => ({ +vi.mock('@/executor/handlers/pi/core/context', () => ({ buildPiPrompt: ({ task }: { task: string }) => task, })) -vi.mock('@/executor/handlers/pi/keys', () => ({ mapThinkingLevel: () => 'medium' })) +vi.mock('@/executor/handlers/pi/core/keys', () => ({ mapThinkingLevel: () => 'medium' })) // `toPiTool` stays real: the scrubbing boundary it applies to tool results is what these tests // assert, and a stub would make them pass while the boundary was gone. -vi.mock('@/executor/handlers/pi/pi-sdk', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@/executor/handlers/pi/core/pi-sdk', async (importOriginal) => ({ + ...(await importOriginal()), loadPiSdk: () => Promise.resolve(mockSdk), createPiModelRuntime: mockCreatePiModelRuntime, resolvePiSdkModel: () => ({ id: 'claude', provider: 'anthropic' }), })) -vi.mock('@/executor/handlers/pi/ssh-tools', () => ({ +vi.mock('@/executor/handlers/pi/local/ssh-tools', () => ({ openSshSession: mockOpenSshSession, buildSshToolSpecs: mockBuildSshToolSpecs, captureRepoChanges: mockCaptureRepoChanges, })) -import type { PiLocalRunParams } from '@/executor/handlers/pi/backend' -import { runLocalPi } from '@/executor/handlers/pi/local-backend' +import type { PiLocalRunParams } from '@/executor/handlers/pi/core/backend' +import { runLocalPi } from '@/executor/handlers/pi/local/backend' function baseParams(): PiLocalRunParams { return { diff --git a/apps/sim/executor/handlers/pi/local-backend.ts b/apps/sim/executor/handlers/pi/local/backend.ts similarity index 94% rename from apps/sim/executor/handlers/pi/local-backend.ts rename to apps/sim/executor/handlers/pi/local/backend.ts index 63f6ad87b87..1f9371a3c24 100644 --- a/apps/sim/executor/handlers/pi/local-backend.ts +++ b/apps/sim/executor/handlers/pi/local/backend.ts @@ -18,29 +18,29 @@ import type { PiLocalRunParams, PiRunContext, PiRunResult, -} from '@/executor/handlers/pi/backend' -import { buildPiPrompt } from '@/executor/handlers/pi/context' -import { applyPiEvent, createPiTotals, normalizePiEvent } from '@/executor/handlers/pi/events' -import { mapThinkingLevel } from '@/executor/handlers/pi/keys' +} from '@/executor/handlers/pi/core/backend' +import { buildPiPrompt } from '@/executor/handlers/pi/core/context' +import { applyPiEvent, createPiTotals, normalizePiEvent } from '@/executor/handlers/pi/core/events' +import { mapThinkingLevel } from '@/executor/handlers/pi/core/keys' import { createPiModelRuntime, loadPiSdk, type PiSdk, resolvePiSdkModel, toPiTool, -} from '@/executor/handlers/pi/pi-sdk' +} from '@/executor/handlers/pi/core/pi-sdk' import { createScrubbedPiError, getScrubbedPiErrorMessage, scrubPiEvent, scrubPiSecrets, -} from '@/executor/handlers/pi/redaction' +} from '@/executor/handlers/pi/core/redaction' import { buildSshToolSpecs, captureRepoChanges, openSshSession, type PiSshSession, -} from '@/executor/handlers/pi/ssh-tools' +} from '@/executor/handlers/pi/local/ssh-tools' import { getPiProviderId } from '@/providers/pi-providers' const logger = createLogger('PiLocalBackend') diff --git a/apps/sim/executor/handlers/pi/sim-tools.test.ts b/apps/sim/executor/handlers/pi/local/sim-tools.test.ts similarity index 99% rename from apps/sim/executor/handlers/pi/sim-tools.test.ts rename to apps/sim/executor/handlers/pi/local/sim-tools.test.ts index 2e1760cfbcc..c2f7945a631 100644 --- a/apps/sim/executor/handlers/pi/sim-tools.test.ts +++ b/apps/sim/executor/handlers/pi/local/sim-tools.test.ts @@ -13,7 +13,7 @@ vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) vi.mock('@/tools/utils', () => ({ getTool: vi.fn() })) vi.mock('@/tools/utils.server', () => ({ getToolAsync: vi.fn() })) -import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools' +import { buildSimToolSpecs } from '@/executor/handlers/pi/local/sim-tools' import type { ExecutionContext } from '@/executor/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { ToolSchemaEnrichmentError } from '@/tools/params' diff --git a/apps/sim/executor/handlers/pi/sim-tools.ts b/apps/sim/executor/handlers/pi/local/sim-tools.ts similarity index 99% rename from apps/sim/executor/handlers/pi/sim-tools.ts rename to apps/sim/executor/handlers/pi/local/sim-tools.ts index f49c511ce48..785d1477c6a 100644 --- a/apps/sim/executor/handlers/pi/sim-tools.ts +++ b/apps/sim/executor/handlers/pi/local/sim-tools.ts @@ -12,7 +12,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { resolveCustomBlockToolBinding } from '@/lib/workflows/custom-blocks/operations' import { getAllBlocks } from '@/blocks/registry' import type { ToolInput } from '@/executor/handlers/agent/types' -import type { PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend' +import type { PiToolResult, PiToolSpec } from '@/executor/handlers/pi/core/backend' import type { ExecutionContext } from '@/executor/types' import { projectResolvedSecretModelContent, diff --git a/apps/sim/executor/handlers/pi/ssh-tools.test.ts b/apps/sim/executor/handlers/pi/local/ssh-tools.test.ts similarity index 96% rename from apps/sim/executor/handlers/pi/ssh-tools.test.ts rename to apps/sim/executor/handlers/pi/local/ssh-tools.test.ts index ff70cdb2669..05a4b21e9be 100644 --- a/apps/sim/executor/handlers/pi/ssh-tools.test.ts +++ b/apps/sim/executor/handlers/pi/local/ssh-tools.test.ts @@ -20,8 +20,7 @@ vi.mock('@/app/api/tools/ssh/utils', () => ({ }, })) -import type { PiSshSession } from '@/executor/handlers/pi/ssh-tools' -import { buildSshToolSpecs } from '@/executor/handlers/pi/ssh-tools' +import { buildSshToolSpecs, type PiSshSession } from '@/executor/handlers/pi/local/ssh-tools' function createSession(files: Record): PiSshSession { const sftp = { diff --git a/apps/sim/executor/handlers/pi/ssh-tools.ts b/apps/sim/executor/handlers/pi/local/ssh-tools.ts similarity index 99% rename from apps/sim/executor/handlers/pi/ssh-tools.ts rename to apps/sim/executor/handlers/pi/local/ssh-tools.ts index 0fa471e2190..182d8caf6fb 100644 --- a/apps/sim/executor/handlers/pi/ssh-tools.ts +++ b/apps/sim/executor/handlers/pi/local/ssh-tools.ts @@ -15,7 +15,7 @@ import { sanitizeCommand, sanitizePath, } from '@/app/api/tools/ssh/utils' -import type { PiSshConnection, PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend' +import type { PiSshConnection, PiToolResult, PiToolSpec } from '@/executor/handlers/pi/core/backend' const logger = createLogger('PiSshTools') diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts index a236123c412..784f4f9e325 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.test.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts @@ -39,7 +39,7 @@ const { MockToolNotAllowedError: class ToolNotAllowedError extends Error {}, })) -vi.mock('@/executor/handlers/pi/keys', () => ({ +vi.mock('@/executor/handlers/pi/core/keys', () => ({ resolvePiModelKey: mockResolveKey, computePiCost: () => ({ input: 0, output: 0, total: 0 }), parsePiSearchProvider: mockParseSearchProvider, @@ -56,20 +56,20 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ assertPermissionsAllowed: mockAssertPermissionsAllowed, ToolNotAllowedError: MockToolNotAllowedError, })) -vi.mock('@/executor/handlers/pi/context', () => ({ +vi.mock('@/executor/handlers/pi/core/context', () => ({ resolvePiSkills: mockResolveSkills, loadPiMemory: mockLoadMemory, appendPiMemory: mockAppendMemory, })) -vi.mock('@/executor/handlers/pi/sim-tools', () => ({ +vi.mock('@/executor/handlers/pi/local/sim-tools', () => ({ buildSimToolSpecs: vi.fn().mockResolvedValue([]), })) -vi.mock('@/executor/handlers/pi/local-backend', () => ({ runLocalPi: mockRunLocal })) -vi.mock('@/executor/handlers/pi/cloud-backend', () => ({ +vi.mock('@/executor/handlers/pi/local/backend', () => ({ runLocalPi: mockRunLocal })) +vi.mock('@/executor/handlers/pi/cloud/authoring/backend', () => ({ runCloudPi: mockRunCloud, runCloudBranchPi: mockRunCloudBranch, })) -vi.mock('@/executor/handlers/pi/cloud-review-backend', () => ({ +vi.mock('@/executor/handlers/pi/cloud/review/backend', () => ({ runCloudReviewPi: mockRunCloudReview, })) vi.mock('@/providers/pi-providers', () => ({ diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts index 7a847270ee9..130f152f25a 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -14,6 +14,8 @@ import { ToolNotAllowedError, } from '@/ee/access-control/utils/permission-check' import { BlockType } from '@/executor/constants' +import { runCloudBranchPi, runCloudPi } from '@/executor/handlers/pi/cloud/authoring/backend' +import { runCloudReviewPi } from '@/executor/handlers/pi/cloud/review/backend' import type { PiBackendRun, PiCloudBranchRunParams, @@ -24,26 +26,24 @@ import type { PiRunParams, PiRunResult, PiSearchConfig, -} from '@/executor/handlers/pi/backend' -import { runCloudBranchPi, runCloudPi } from '@/executor/handlers/pi/cloud-backend' -import { runCloudReviewPi } from '@/executor/handlers/pi/cloud-review-backend' +} from '@/executor/handlers/pi/core/backend' import { appendPiMemory, loadPiMemory, type PiMemoryConfig, resolvePiSkills, -} from '@/executor/handlers/pi/context' -import { streamTextForEvent } from '@/executor/handlers/pi/events' +} from '@/executor/handlers/pi/core/context' +import { streamTextForEvent } from '@/executor/handlers/pi/core/events' import { computePiCost, PI_SEARCH_PROVIDERS, parsePiSearchProvider, resolvePiModelKey, resolvePiSearchKey, -} from '@/executor/handlers/pi/keys' -import { runLocalPi } from '@/executor/handlers/pi/local-backend' +} from '@/executor/handlers/pi/core/keys' +import { runLocalPi } from '@/executor/handlers/pi/local/backend' +import { buildSimToolSpecs } from '@/executor/handlers/pi/local/sim-tools' import { buildPiSearchToolSpec } from '@/executor/handlers/pi/search/tool' -import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools' import type { BlockHandler, ExecutionContext, diff --git a/apps/sim/executor/handlers/pi/search/extension-source.test.ts b/apps/sim/executor/handlers/pi/search/extension-source.test.ts index 0926057faab..e04fb7bb085 100644 --- a/apps/sim/executor/handlers/pi/search/extension-source.test.ts +++ b/apps/sim/executor/handlers/pi/search/extension-source.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { PI_SEARCH_PROVIDERS, type PiSearchProvider } from '@/executor/handlers/pi/keys' +import { PI_SEARCH_PROVIDERS, type PiSearchProvider } from '@/executor/handlers/pi/core/keys' import { PI_SEARCH_API_KEY_ENV_VAR, PI_SEARCH_EXTENSION_PATH, diff --git a/apps/sim/executor/handlers/pi/search/normalize.ts b/apps/sim/executor/handlers/pi/search/normalize.ts index 39fc79d844d..9bc556d331f 100644 --- a/apps/sim/executor/handlers/pi/search/normalize.ts +++ b/apps/sim/executor/handlers/pi/search/normalize.ts @@ -9,7 +9,7 @@ * holds the two request paths together. */ -import type { PiSearchProvider } from '@/executor/handlers/pi/keys' +import type { PiSearchProvider } from '@/executor/handlers/pi/core/keys' /** The tool name Pi sees, in every mode. */ export const PI_SEARCH_TOOL_NAME = 'web_search' diff --git a/apps/sim/executor/handlers/pi/search/parity.test.ts b/apps/sim/executor/handlers/pi/search/parity.test.ts index 6c894042f59..d3ce8752aae 100644 --- a/apps/sim/executor/handlers/pi/search/parity.test.ts +++ b/apps/sim/executor/handlers/pi/search/parity.test.ts @@ -15,7 +15,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { PI_SEARCH_PROVIDERS, type PiSearchProvider } from '@/executor/handlers/pi/keys' +import { PI_SEARCH_PROVIDERS, type PiSearchProvider } from '@/executor/handlers/pi/core/keys' import { PI_SEARCH_API_KEY_ENV_VAR, PI_SEARCH_EXTENSION_SOURCE, diff --git a/apps/sim/executor/handlers/pi/search/tool.ts b/apps/sim/executor/handlers/pi/search/tool.ts index d86ff6d546d..13e9d034bdc 100644 --- a/apps/sim/executor/handlers/pi/search/tool.ts +++ b/apps/sim/executor/handlers/pi/search/tool.ts @@ -8,8 +8,8 @@ */ import { createLogger } from '@sim/logger' -import type { PiSearchConfig, PiToolSpec } from '@/executor/handlers/pi/backend' -import { PI_SEARCH_PROVIDERS } from '@/executor/handlers/pi/keys' +import type { PiSearchConfig, PiToolSpec } from '@/executor/handlers/pi/core/backend' +import { PI_SEARCH_PROVIDERS } from '@/executor/handlers/pi/core/keys' import { buildPiSearchProviderArgs, extractPiSearchRecords, From e948398d862bf7942d048928954f7bd851dd991b Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 6 Aug 2026 23:07:52 -0700 Subject: [PATCH 2/7] feat(pi): add plan mode --- apps/sim/blocks/blocks/pi.test.ts | 68 ++++++- apps/sim/blocks/blocks/pi.ts | 67 ++++--- apps/sim/blocks/pi-api-key-condition.test.ts | 4 + .../handlers/pi/cloud/plan/backend.test.ts | 179 ++++++++++++++++++ .../handlers/pi/cloud/plan/backend.ts | 158 ++++++++++++++++ apps/sim/executor/handlers/pi/core/backend.ts | 16 +- .../executor/handlers/pi/core/keys.test.ts | 14 ++ apps/sim/executor/handlers/pi/core/keys.ts | 12 +- .../executor/handlers/pi/pi-handler.test.ts | 90 +++++++++ apps/sim/executor/handlers/pi/pi-handler.ts | 24 ++- .../workflow/edit-workflow/validation.test.ts | 15 +- apps/sim/providers/pi-providers.ts | 8 +- 12 files changed, 610 insertions(+), 45 deletions(-) create mode 100644 apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts create mode 100644 apps/sim/executor/handlers/pi/cloud/plan/backend.ts diff --git a/apps/sim/blocks/blocks/pi.test.ts b/apps/sim/blocks/blocks/pi.test.ts index 70371fdaaba..c172cf6c106 100644 --- a/apps/sim/blocks/blocks/pi.test.ts +++ b/apps/sim/blocks/blocks/pi.test.ts @@ -86,14 +86,20 @@ 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.each(['cloud', 'cloud_branch'])( @@ -195,6 +201,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..03fccc8725e 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,14 @@ export const PiBlock: BlockConfig = { inputs: { mode: { type: 'string', - description: 'Execution mode: Create PR, Update PR, Review Code, or Local Dev', + description: 'Execution mode: Plan, 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 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 +680,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..7f77db361d9 --- /dev/null +++ b/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts @@ -0,0 +1,179 @@ +/** + * @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(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('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..b23ed0d927b --- /dev/null +++ b/apps/sim/executor/handlers/pi/cloud/plan/backend.ts @@ -0,0 +1,158 @@ +/** + * 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) + 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), + 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/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/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/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts index 784f4f9e325..43ff7c477f9 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', + changedFiles: [], + diff: '', + 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') + }) + 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') diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts index 130f152f25a..a63a2be404b 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) } } 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. */ From 07949ce06ec36e691f2e00ef2a39312b5d5c9cda Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 6 Aug 2026 23:16:53 -0700 Subject: [PATCH 3/7] fix(pi): preserve plan exploration timeout --- .../handlers/pi/cloud/plan/backend.test.ts | 1 + .../handlers/pi/cloud/plan/backend.ts | 2 +- .../executor/handlers/pi/cloud/shared.test.ts | 7 +++++++ apps/sim/executor/handlers/pi/cloud/shared.ts | 19 +++++++++++++------ 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts b/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts index 7f77db361d9..0db553c099b 100644 --- a/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts @@ -105,6 +105,7 @@ describe('runCloudPlanPi', () => { 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({ diff --git a/apps/sim/executor/handlers/pi/cloud/plan/backend.ts b/apps/sim/executor/handlers/pi/cloud/plan/backend.ts index b23ed0d927b..0799aa75de4 100644 --- a/apps/sim/executor/handlers/pi/cloud/plan/backend.ts +++ b/apps/sim/executor/handlers/pi/cloud/plan/backend.ts @@ -133,7 +133,7 @@ export const runCloudPlanPi: PiBackendRun = async (params, } : {}), }, - timeoutMs: resolvePiTimeoutMs(lifetimeMs), + timeoutMs: resolvePiTimeoutMs(lifetimeMs, { finalizePhases: 0 }), onStdout: handleChunk, } ), 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 + ) ) } From 8874a99522c25ea4577bf93a2ee747c3f9e6e683 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 7 Aug 2026 00:04:27 -0700 Subject: [PATCH 4/7] fix(pi): clean plan mode output --- apps/sim/blocks/blocks/pi.test.ts | 6 +++ apps/sim/blocks/blocks/pi.ts | 3 +- .../handlers/pi/cloud/plan/backend.test.ts | 47 +++++++++++++++++++ .../handlers/pi/cloud/plan/backend.ts | 3 ++ .../executor/handlers/pi/core/events.test.ts | 26 ++++++++++ apps/sim/executor/handlers/pi/core/events.ts | 14 +++++- .../handlers/pi/core/redaction.test.ts | 4 ++ .../executor/handlers/pi/core/redaction.ts | 2 + .../executor/handlers/pi/pi-handler.test.ts | 4 +- apps/sim/executor/handlers/pi/pi-handler.ts | 27 +++++++++-- 10 files changed, 128 insertions(+), 8 deletions(-) diff --git a/apps/sim/blocks/blocks/pi.test.ts b/apps/sim/blocks/blocks/pi.test.ts index c172cf6c106..1dd219ce2d3 100644 --- a/apps/sim/blocks/blocks/pi.test.ts +++ b/apps/sim/blocks/blocks/pi.test.ts @@ -102,6 +102,12 @@ describe('Pi cloud authoring surface', () => { ]) }) + 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'])( 'declares Babysit controls and outputs for %s', (authoringMode) => { diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts index 03fccc8725e..ed8f4f9ab8c 100644 --- a/apps/sim/blocks/blocks/pi.ts +++ b/apps/sim/blocks/blocks/pi.ts @@ -620,7 +620,8 @@ export const PiBlock: BlockConfig = { inputs: { mode: { type: 'string', - description: 'Execution mode: Plan, 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' }, diff --git a/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts b/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts index 0db553c099b..a8dbba717b7 100644 --- a/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts @@ -129,6 +129,53 @@ describe('runCloudPlanPi', () => { 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(), diff --git a/apps/sim/executor/handlers/pi/cloud/plan/backend.ts b/apps/sim/executor/handlers/pi/cloud/plan/backend.ts index 0799aa75de4..9a219f5a068 100644 --- a/apps/sim/executor/handlers/pi/cloud/plan/backend.ts +++ b/apps/sim/executor/handlers/pi/cloud/plan/backend.ts @@ -106,6 +106,9 @@ export const runCloudPlanPi: PiBackendRun = async (params, 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) => { 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/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 43ff7c477f9..4c352a1a321 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.test.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts @@ -367,8 +367,6 @@ describe('PiBlockHandler', () => { expect(output).toMatchObject({ content: '# Plan\nDo it', model: 'claude', - changedFiles: [], - diff: '', tokens: { input: 3, output: 4, total: 7 }, cost: { input: 0, output: 0, total: 0 }, providerTiming: { @@ -379,6 +377,8 @@ describe('PiBlockHandler', () => { }) 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 () => { diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts index a63a2be404b..576f7f3c4b5 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -439,6 +439,7 @@ export class PiBlockHandler implements BlockHandler { private buildOutput( result: PiRunResult, + mode: PiRunParams['mode'], model: string, isBYOK: boolean, startTime: number, @@ -449,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 } : {}), @@ -516,7 +521,14 @@ export class PiBlockHandler implements BlockHandler { } 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( @@ -558,6 +570,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 + ) } } From 2b653a6850997449331a2b910a1128db2ae55409 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 7 Aug 2026 00:10:15 -0700 Subject: [PATCH 5/7] fix(pi): stream only final plan content --- .../executor/handlers/pi/pi-handler.test.ts | 37 +++++++++++++++++++ apps/sim/executor/handlers/pi/pi-handler.ts | 4 ++ 2 files changed, 41 insertions(+) diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts index 4c352a1a321..2979f276519 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.test.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts @@ -931,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 576f7f3c4b5..a58971d1ff5 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -510,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)) }, @@ -519,6 +520,9 @@ 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( From f32e76fc9a02c207d88fea9625336e97d1f5ec3f Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 8 Aug 2026 16:09:29 -0700 Subject: [PATCH 6/7] chore: trigger CI after retargeting From 43c1b997505ac428dfa90841aa9b564103d503a7 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 8 Aug 2026 18:41:34 -0700 Subject: [PATCH 7/7] fix(pi): compact plan mode event streams --- .../handlers/pi/cloud/event-filter-source.ts | 13 +++++++++++++ .../handlers/pi/cloud/plan/backend.test.ts | 17 +++++++++++++++++ .../executor/handlers/pi/cloud/plan/backend.ts | 5 +++++ .../executor/handlers/pi/cloud/shared.test.ts | 16 +++++++++++----- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/sim/executor/handlers/pi/cloud/event-filter-source.ts b/apps/sim/executor/handlers/pi/cloud/event-filter-source.ts index aa0a0a396a5..5456607eb78 100644 --- a/apps/sim/executor/handlers/pi/cloud/event-filter-source.ts +++ b/apps/sim/executor/handlers/pi/cloud/event-filter-source.ts @@ -46,6 +46,18 @@ function asString(value) { return typeof value === 'string' ? value : undefined } +function compactAssistantContent(value) { + if (!Array.isArray(value)) return undefined + const content = [] + for (const valueBlock of value) { + const block = asRecord(valueBlock) + if (block?.type === 'text' && typeof block.text === 'string') { + content.push({ type: 'text', text: block.text }) + } + } + return content.length > 0 ? content : undefined +} + function compactUsage(value) { const usage = asRecord(value) if (!usage) return null @@ -64,6 +76,7 @@ function compactAssistantMessage(value) { if (!message || message.role !== 'assistant') return null return { role: 'assistant', + content: compactAssistantContent(message.content), stopReason: asString(message.stopReason), errorMessage: asString(message.errorMessage), } diff --git a/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts b/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts index a8dbba717b7..e9140d6b6c7 100644 --- a/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud/plan/backend.test.ts @@ -30,6 +30,10 @@ vi.mock('@/executor/handlers/pi/core/keys', () => ({ mapThinkingLevel: () => 'high', })) +import { + PI_EVENT_FILTER_PATH, + PI_EVENT_FILTER_SOURCE, +} from '@/executor/handlers/pi/cloud/event-filter-source' import { runCloudPlanPi } from '@/executor/handlers/pi/cloud/plan/backend' import type { PiCloudPlanRunParams } from '@/executor/handlers/pi/core/backend' import { @@ -99,6 +103,7 @@ describe('runCloudPlanPi', () => { const [piCommand, piOptions] = mockRun.mock.calls[1] expect(piCommand).toContain('--no-extensions --no-prompt-templates --no-skills --no-approve') + expect(piCommand).toContain(`| node ${PI_EVENT_FILTER_PATH}`) expect(piCommand).not.toContain('git commit') expect(piCommand).not.toContain('git push') expect(piOptions.envs.ANTHROPIC_API_KEY).toBe('sk-model-secret') @@ -116,6 +121,18 @@ describe('runCloudPlanPi', () => { }) ) expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-prompt.txt', 'PLAN PROMPT') + expect(mockWriteFile).toHaveBeenCalledWith(PI_EVENT_FILTER_PATH, PI_EVENT_FILTER_SOURCE) + const filterWrites = mockWriteFile.mock.calls.filter(([path]) => path === PI_EVENT_FILTER_PATH) + expect(filterWrites).toHaveLength(1) + const filterWrite = mockWriteFile.mock.calls.findIndex( + ([path]) => path === PI_EVENT_FILTER_PATH + ) + expect(mockRun.mock.invocationCallOrder[0]).toBeLessThan( + mockWriteFile.mock.invocationCallOrder[filterWrite] + ) + expect(mockWriteFile.mock.invocationCallOrder[filterWrite]).toBeLessThan( + mockRun.mock.invocationCallOrder[1] + ) expect(onEvent).toHaveBeenCalledWith({ type: 'text', text: '# Plan\nDo it' }) expect(result.totals.finalText).toBe('# Plan\nDo it') expect(result).not.toHaveProperty('changedFiles') diff --git a/apps/sim/executor/handlers/pi/cloud/plan/backend.ts b/apps/sim/executor/handlers/pi/cloud/plan/backend.ts index 9a219f5a068..27444a4493b 100644 --- a/apps/sim/executor/handlers/pi/cloud/plan/backend.ts +++ b/apps/sim/executor/handlers/pi/cloud/plan/backend.ts @@ -8,6 +8,10 @@ import { createLogger } from '@sim/logger' import { withPiSandbox } from '@/lib/execution/remote-sandbox' import { resolvePiRunLifetimeMs } from '@/lib/execution/remote-sandbox/pi-lifetime' +import { + PI_EVENT_FILTER_PATH, + PI_EVENT_FILTER_SOURCE, +} from '@/executor/handlers/pi/cloud/event-filter-source' import { buildPiScript, CLONE_TIMEOUT_MS, @@ -97,6 +101,7 @@ export const runCloudPlanPi: PiBackendRun = async (params, } await runner.writeFile(PROMPT_PATH, prompt) + await runner.writeFile(PI_EVENT_FILTER_PATH, PI_EVENT_FILTER_SOURCE) if (params.search) { await runner.writeFile(PI_SEARCH_EXTENSION_PATH, PI_SEARCH_EXTENSION_SOURCE) } diff --git a/apps/sim/executor/handlers/pi/cloud/shared.test.ts b/apps/sim/executor/handlers/pi/cloud/shared.test.ts index eb4c7a2604a..bbd54dd6c32 100644 --- a/apps/sim/executor/handlers/pi/cloud/shared.test.ts +++ b/apps/sim/executor/handlers/pi/cloud/shared.test.ts @@ -207,12 +207,18 @@ describe('PI_EVENT_FILTER_SOURCE', () => { event.messages.length > 0 && (event.messages[0] as { stopReason?: string }).stopReason === 'stop' ) - // Reduced to the one message `normalizePiEvent` inspects, and to the three fields it reads off - // it. The transcript, the thinking block, and the final text all go: the run's text reaches - // Sim through the deltas, so carrying it again here would be the whole answer twice. + // Reduced to the one message `normalizePiEvent` inspects and only the fields it reads. Keeping + // the final text once lets Plan replace streamed progress with the authoritative final answer; + // the transcript, thinking blocks, and tool payloads still go. expect(completed).toEqual({ type: 'agent_end', - messages: [{ role: 'assistant', stopReason: 'stop' }], + messages: [ + { + role: 'assistant', + content: [{ type: 'text', text: 'final answer' }], + stopReason: 'stop', + }, + ], }) expect(output.some((event) => event.type === 'tool_execution_update')).toBe(false) }) @@ -297,6 +303,6 @@ describe('PI_EVENT_FILTER_SOURCE', () => { expect(text).toHaveLength(totalCharacters) expect(events).toContainEqual({ type: 'usage', inputTokens: 10, outputTokens: 20 }) expect(events).toContainEqual({ type: 'tool_end', toolName: 'read', isError: false }) - expect(events).toContainEqual({ type: 'final' }) + expect(events).toContainEqual({ type: 'final', text: 'x'.repeat(totalCharacters) }) }) })