From d3fdcac605f3d26f780bb0ad489ca37739873d67 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:30:25 -0700 Subject: [PATCH] fix(agent-launch): make where a launch prompt rides explicit, and delivery mean the agent got it Round-1 review of the rebuilt step 3. - One outcome, switched on by every caller. planLaunchPrompt answers none, on-line, launch-file or paste-after-ready; only the carried outcomes hold the plan to launch, so no caller can read "a plan exists" as "carried". buildAgentStartupPlan now builds only a prompt-less launch. The new agent tab and work items had dropped a prompt an agent could not take in a file (any agent but Claude and Codex) and reported it delivered; they now paste it. The composer paths, background sessions, agent.launch, agentSession.create and phone quick commands switch on the same outcome. - One rule for hosts that type the line raw. A Windows host or a paired Orca keeps a line to #24257's 512-byte typed budget; anything longer, multi-line or damaged by the shell rides a launch file for Claude and Codex and is pasted for others. A paired host is one predicate (launchHostIsPaired). - Delivered means the agent got it. A prompt the new tab's line carried resolves its delivery when the agent's own hook reports a turn or the agent shows its composer, and false when the PTY never spawns (a refused launch file) or exits first, so callers that resolve review threads or mark notes delivered wait for that, not for the tab. - Daemon protocol v41 writes launch files. A spawn routed to an older daemon is refused with the launch-file reason instead of typing a pointer to a file nobody wrote. - A refused launch file or WSL staged line reaches agent.launch as its reason instead of a 10 s handle timeout, and hands the prompt back with Copy prompt for a staged line too. - A WSL distro is probed only when the spawn needs its directory, and a failed probe is remembered for 30 s. - Every hook status row shows the prompt a launch file carries instead of its pointer sentence. --- .../first-work-branch-rename.test.ts | 2 +- .../agent-hooks/first-work-branch-rename.ts | 12 +- .../agent-hooks/first-work-rename-runtime.ts | 4 +- .../agent-hooks/launch-file-prompt-by-pane.ts | 14 +- .../server-launch-file-prompt.test.ts | 39 ++++ .../server/server-status-update.ts | 7 + .../host-agent-startup-call-sites.txt | 2 +- .../host-agent-startup-attribution.test.ts | 4 +- .../daemon/daemon-create-or-attach-request.ts | 2 +- ...n-foreground-confirmation-protocol.test.ts | 3 +- .../daemon-launch-file-protocol.test.ts | 78 ++++++++ .../daemon/daemon-protocol-version.test.ts | 4 +- src/main/daemon/daemon-protocol-version.ts | 8 +- src/main/daemon/daemon-pty-session-spawn.ts | 6 +- src/main/daemon/daemon-pty-spawn-request.ts | 15 +- .../daemon-pty-startup-delivery.test.ts | 4 +- ...terminal-host-wsl-launch-directory.test.ts | 4 +- .../ipc/pty/ipc/spawn-launch-refusal.test.ts | 39 ++++ .../pty/ipc/spawn-options-launch-file.test.ts | 13 +- src/main/ipc/pty/ipc/spawn.ts | 13 +- ...local-pty-provider-shell-readiness.test.ts | 4 +- ...local-pty-spawn-wsl-shutdown-order.test.ts | 12 +- src/main/providers/local-pty-spawn.ts | 5 +- .../ssh-plain-shell-pty-provider.test.ts | 6 + .../providers/ssh-plain-shell-pty-provider.ts | 8 +- .../wsl-launch-directory-resolution.test.ts | 46 ++++- .../wsl-launch-directory-resolution.ts | 43 ++++- .../agent-terminal-startup-prompt.test.ts | 6 +- .../orca-runtime-create-agent-session.ts | 69 ++++--- .../orca-runtime-create-terminal-desktop.ts | 6 - ...resolve-mobile-session-terminal-command.ts | 28 ++- ...runtime-resolve-worktree-removal-target.ts | 64 ++++--- ...renderer-session-owned-mobile-terminals.ts | 30 ++- .../mobile-session-tabs-part-12.spec.ts | 2 +- .../runtime/runtime-terminal-contracts.ts | 8 +- .../runtime/runtime-worktree-agent-startup.ts | 48 +++-- .../runtime/terminal-launch-refusals.test.ts | 46 +++++ src/main/runtime/terminal-launch-refusals.ts | 37 ++++ ...ty-handler-startup-command-staging.test.ts | 4 +- .../buildSourceControlAgentDeliveryPlan.ts | 7 +- .../useSourceControlAgentActionStart.ts | 11 +- .../sidebar/folder-workspace-agent-startup.ts | 1 - .../folder-workspace-composer-submit.test.ts | 3 +- .../folder-workspace-composer-submit.ts | 41 ++-- .../terminal-pane/ipc-pty-connect.ts | 10 +- .../terminal-pane/pty-connection-types.ts | 2 + .../pane-startup-command-options.ts | 18 ++ .../pty-connection/pty-input-recovery.ts | 5 +- .../pty-transport-launch-refusal.test.ts | 49 +++++ .../terminal-pane/pty-transport-types.ts | 2 + .../full-creation-startup.test.ts | 23 ++- .../composer-state/full-creation-startup.ts | 5 +- .../composer-state/full-submit-preparation.ts | 58 +++--- .../quick-creation-execution.ts | 4 +- .../composer-state/quick-startup-plan.ts | 45 +++-- .../agent-launch-caller-permissions.test.ts | 3 + .../lib/agent-launch-caller-placement.test.ts | 3 + ...ent-launch-caller-prompt-transport.test.ts | 48 ++++- .../lib/agent-launch-prompt-receipt.test.ts | 117 ++++++++++++ .../src/lib/agent-launch-prompt-receipt.ts | 98 ++++++++++ src/renderer/src/lib/agent-paste-draft.ts | 4 +- .../src/lib/agent-startup-delayed-delivery.ts | 6 +- .../src/lib/composer-agent-startup-plan.ts | 31 ++++ .../lib/launch-agent-background-session.ts | 36 +++- .../src/lib/launch-agent-in-new-tab.test.ts | 39 +++- .../src/lib/launch-agent-in-new-tab.ts | 71 +++---- .../launch-agent-new-tab-prompt-delivery.ts | 83 +++++++++ .../lib/launch-agent-startup-prompt-plan.ts | 88 +++++---- src/renderer/src/lib/launch-file-host.test.ts | 16 +- src/renderer/src/lib/launch-file-host.ts | 16 +- .../launch-work-item-direct-agent-routing.ts | 4 +- .../lib/launch-work-item-direct-agent.test.ts | 41 +++- .../src/lib/launch-work-item-direct-agent.ts | 86 +++++---- ...unch-work-item-direct-route-preparation.ts | 8 +- .../src/lib/launch-work-item-direct.test.ts | 30 ++- .../src/lib/launch-work-item-direct.ts | 2 +- src/renderer/src/lib/new-workspace.test.ts | 23 +-- src/renderer/src/lib/new-workspace.ts | 16 +- .../src/lib/pending-worktree-creation.ts | 4 +- .../lib/source-control-agent-action-plan.ts | 30 ++- .../src/lib/tui-agent-startup.test.ts | 88 +++++---- src/renderer/src/lib/tui-agent-startup.ts | 5 +- ...worktree-creation-background-mount.test.ts | 1 - ...worktree-creation-flow-ready-toast.test.ts | 1 - .../src/lib/worktree-creation-flow-startup.ts | 1 + ...ree-creation-flow-stranded-surface.test.ts | 2 +- .../src/lib/worktree-creation-flow.test.ts | 6 - .../src/lib/worktree-startup-payload.ts | 2 + .../src/store/terminals/terminal-actions.ts | 2 + .../src/store/terminals/terminal-state.ts | 1 + .../cmd-startup-arg-quoting.win32.test.ts | 6 +- src/shared/freebuff-launch.test.ts | 11 +- src/shared/freebuff-startup.test.ts | 3 +- .../launch-file-directory-grant.test.ts | 6 +- src/shared/launch-file-writing.test.ts | 48 ++--- src/shared/launch-file-writing.ts | 33 +++- src/shared/launch-prompt-carrier.test.ts | 36 ++-- src/shared/launch-prompt-carry.test.ts | 130 +++++++++++++ src/shared/launch-prompt-carry.ts | 167 +++++++++++++++++ src/shared/launch-prompt-file.ts | 28 ++- src/shared/launch-prompt-plan.test-fixture.ts | 44 +++++ .../local-build-compatibility-contract.json | 4 +- .../local-build-compatibility-contract.ts | 4 +- src/shared/omp-fresh-launch-shell.test.ts | 4 +- src/shared/omp-fresh-launch.test.ts | 6 +- src/shared/qoder-agent.test.ts | 6 +- .../startup-command-staging.node-pty.test.ts | 4 +- src/shared/startup-command-staging.ts | 7 +- src/shared/startup-line-prompt-carry.test.ts | 175 ------------------ src/shared/startup-line-prompt-carry.ts | 32 ---- src/shared/startup-plan-launch-file.ts | 126 ------------- src/shared/tui-agent-prompt-transport.test.ts | 15 +- src/shared/tui-agent-resume-startup.ts | 1 - src/shared/tui-agent-startup-hermes.test.ts | 40 ++-- src/shared/tui-agent-startup.test.ts | 95 +++++----- src/shared/tui-agent-startup.ts | 90 ++++----- src/shared/typed-startup-line.ts | 8 - .../windows-shell-prompt-damage.test.ts | 13 +- src/shared/wsl-launch-directory.test.ts | 4 +- .../zcode-orchestration-contract.test.ts | 9 +- 120 files changed, 2095 insertions(+), 1101 deletions(-) create mode 100644 src/main/agent-hooks/server-launch-file-prompt.test.ts create mode 100644 src/main/daemon/daemon-launch-file-protocol.test.ts create mode 100644 src/main/ipc/pty/ipc/spawn-launch-refusal.test.ts create mode 100644 src/main/runtime/terminal-launch-refusals.test.ts create mode 100644 src/main/runtime/terminal-launch-refusals.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-startup-command-options.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-transport-launch-refusal.test.ts create mode 100644 src/renderer/src/lib/agent-launch-prompt-receipt.test.ts create mode 100644 src/renderer/src/lib/agent-launch-prompt-receipt.ts create mode 100644 src/renderer/src/lib/composer-agent-startup-plan.ts create mode 100644 src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts create mode 100644 src/shared/launch-prompt-carry.test.ts create mode 100644 src/shared/launch-prompt-carry.ts create mode 100644 src/shared/launch-prompt-plan.test-fixture.ts delete mode 100644 src/shared/startup-line-prompt-carry.test.ts delete mode 100644 src/shared/startup-line-prompt-carry.ts delete mode 100644 src/shared/startup-plan-launch-file.ts diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index d1fad66280f..b7aef25e138 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -272,7 +272,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { }) it('names the branch from the prompt a launch file carried, when main kept it', async () => { - const { deps, onRenamed } = makeDeps({ getLaunchFilePrompt: () => 'Fix auth from note #1' }) + const { deps, onRenamed } = makeDeps({ showLaunchPrompt: () => 'Fix auth from note #1' }) await maybeAutoRenameBranchOnFirstWork( workingEvent({ prompt: buildLaunchFilePointer('/tmp/orca-launch-file-a1/task-context.md') }), deps diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index e5abfe9cc66..3576334b368 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -68,8 +68,8 @@ export type FirstWorkBranchRenameDeps = { resolveWorktreeIdForTab: (tabId: string) => string | undefined /** Invalidate caches + notify the renderer so the new branch name surfaces. */ onRenamed: (repoId: string) => void - /** The prompt a launch file carried for this pane, when its hook reports only the pointer. */ - getLaunchFilePrompt?: (paneKey: string) => string | undefined + /** The prompt Orca shows for a pane's reported one (`launchPromptShownForPane`). */ + showLaunchPrompt?: (paneKey: string, prompt: string) => string } // inFlight blocks concurrent generation; settled caches definitive verdicts (transient bails stay unsettled to retry later). @@ -121,10 +121,10 @@ export async function maybeAutoRenameBranchOnFirstWork( // Why the pointer: a launch that carried its prompt in a file names only that file here, which // would name the branch after "read the task file". Its real prompt, when main kept it, names it // instead; otherwise (a worker brief) a later prompt of the user's own can. - const prompt = - hookPrompt && isLaunchFilePointer(hookPrompt) - ? deps.getLaunchFilePrompt?.(event.paneKey)?.trim() - : hookPrompt + const shown = hookPrompt + ? (deps.showLaunchPrompt?.(event.paneKey, hookPrompt) ?? hookPrompt).trim() + : undefined + const prompt = shown && !isLaunchFilePointer(shown) ? shown : undefined if (!prompt) { return } diff --git a/src/main/agent-hooks/first-work-rename-runtime.ts b/src/main/agent-hooks/first-work-rename-runtime.ts index 8c35b802bf6..82229db59a2 100644 --- a/src/main/agent-hooks/first-work-rename-runtime.ts +++ b/src/main/agent-hooks/first-work-rename-runtime.ts @@ -1,4 +1,4 @@ -import { launchFilePromptForPane } from './launch-file-prompt-by-pane' +import { launchPromptShownForPane } from './launch-file-prompt-by-pane' import { existsSync } from 'node:fs' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' @@ -22,7 +22,7 @@ export function firstWorkRenameDeps( > ): FirstWorkBranchRenameDeps { return { - getLaunchFilePrompt: launchFilePromptForPane, + showLaunchPrompt: launchPromptShownForPane, getSettings: () => store.getSettings(), getRepo: (repoId) => store.getRepo(repoId), getAgentEnvResolvers: () => runtime.getCommitMessageAgentEnvironmentResolvers(), diff --git a/src/main/agent-hooks/launch-file-prompt-by-pane.ts b/src/main/agent-hooks/launch-file-prompt-by-pane.ts index 5229bc2d29c..8f881525580 100644 --- a/src/main/agent-hooks/launch-file-prompt-by-pane.ts +++ b/src/main/agent-hooks/launch-file-prompt-by-pane.ts @@ -1,4 +1,4 @@ -import type { LaunchFile } from '../../shared/launch-prompt-file' +import { isLaunchFilePointer, type LaunchFile } from '../../shared/launch-prompt-file' // Why bounded: an entry outlives its launch until newer ones push it out; the rename reads only a // prompt's opening, and a pane's rename runs within minutes of its launch. @@ -7,9 +7,9 @@ const KEPT_CHARS = 4_000 const promptByPane = new Map() /** - * Keeps a launch file's prompt for the pane it launched, for the first-work rename: the agent's hook - * reports only the pointer to the file. A sensitive file (a worker brief with its capability) is - * never kept. + * Keeps a launch file's prompt for the pane it launched: the agent's hook reports only the pointer + * to the file, and Orca shows the prompt instead (`launchPromptShownForPane`). A sensitive file (a + * worker brief with its capability) is never kept. */ export function rememberLaunchFilePrompt( paneKey: string, @@ -28,6 +28,8 @@ export function rememberLaunchFilePrompt( } } -export function launchFilePromptForPane(paneKey: string): string | undefined { - return promptByPane.get(paneKey) +/** The one place Orca reads a pane's reported prompt: a launch file's pointer reads as the prompt + * it points at, when this host kept it. */ +export function launchPromptShownForPane(paneKey: string, prompt: string): string { + return isLaunchFilePointer(prompt) ? (promptByPane.get(paneKey) ?? prompt) : prompt } diff --git a/src/main/agent-hooks/server-launch-file-prompt.test.ts b/src/main/agent-hooks/server-launch-file-prompt.test.ts new file mode 100644 index 00000000000..31fcfea9f1a --- /dev/null +++ b/src/main/agent-hooks/server-launch-file-prompt.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { buildLaunchFilePointer, carryInLaunchFile } from '../../shared/launch-prompt-file' +import { AgentHookServer } from './server' +import { rememberLaunchFilePrompt } from './launch-file-prompt-by-pane' + +const LAUNCHED = makePaneKey('tab-launched', '55555555-5555-4555-8555-555555555555') +const POINTER = buildLaunchFilePointer('/tmp/orca-launch-file-a1/task-context.md') + +describe('a status row whose agent reports its launch file’s pointer', () => { + // Why: the agent's own hook sees only the pointer sentence; every Orca reader of the row (status + // row, dashboard, phone) must see the prompt the user wrote. + it('shows the prompt the file carries', () => { + rememberLaunchFilePrompt(LAUNCHED, carryInLaunchFile('fix the flaky test', false).launchFile) + const server = new AgentHookServer() + server.ingestTerminalStatus({ + paneKey: LAUNCHED, + tabId: 'tab-launched', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: POINTER } + }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: LAUNCHED, prompt: 'fix the flaky test' }) + ]) + }) + + it('leaves a sensitive file’s pointer, whose content is never kept', () => { + const pane = makePaneKey('tab-sensitive', '66666666-6666-4666-8666-666666666666') + rememberLaunchFilePrompt(pane, carryInLaunchFile('token dcap_secret', true).launchFile) + const server = new AgentHookServer() + server.ingestTerminalStatus({ + paneKey: pane, + tabId: 'tab-sensitive', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: POINTER } + }) + expect(server.getStatusSnapshot()).toEqual([expect.objectContaining({ prompt: POINTER })]) + }) +}) diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index ebb87c3c9c2..f9e2e6c7557 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -19,6 +19,7 @@ import { import { isStaleGrokTurnEnd } from './server-grok-status-rules' import { resolveCancelVerdictLatch } from './server-cancel-verdict-latch' import { AgentHookServerStatusApplication } from './server-status-application' +import { launchPromptShownForPane } from '../launch-file-prompt-by-pane' export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { protected applyNormalizedStatus( @@ -28,6 +29,12 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload | undefined { + // Why here: every status row (local, relayed, replayed) passes through, so no reader of the + // prompt (status row, dashboard, mobile) shows a launch file's pointer instead of the prompt. + const prompt = launchPromptShownForPane(incoming.paneKey, incoming.payload.prompt) + if (prompt !== incoming.payload.prompt) { + incoming = { ...incoming, payload: { ...incoming.payload, prompt } } + } const transitioned = transitionHookPresence( incoming, this.state.lastStatusByPaneKey.get(incoming.paneKey) diff --git a/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt b/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt index c05556a2205..de078fd731e 100644 --- a/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt +++ b/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt @@ -4,7 +4,7 @@ # mobile-followup the phone's session-tab launch; attribution is a separate follow-up # The resolve-worktree-removal-target call also serves a bare typed command, which stays unattributed. src/main/runtime/orca-runtime-create-agent-session.ts 2 attributes -src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts 2 attributes +src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts 1 attributes src/main/runtime/runtime-worktree-agent-startup.ts 3 attributes src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts 1 resume src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts 1 mobile-followup diff --git a/src/main/agent-launch/host-agent-startup-attribution.test.ts b/src/main/agent-launch/host-agent-startup-attribution.test.ts index d9d15c5e61d..60851c0dd1e 100644 --- a/src/main/agent-launch/host-agent-startup-attribution.test.ts +++ b/src/main/agent-launch/host-agent-startup-attribution.test.ts @@ -22,9 +22,9 @@ const LISTED: ReadonlyMap = new Map }) ) -// planStartupWithLaunchPrompt wraps buildAgentStartupPlan in shared/, which this scan does not reach. +// planLaunchPrompt plans every prompted launch; buildAgentStartupPlan only a prompt-less one. const BUILDER_CALL = - /\b(?:buildAgentStartupPlan|buildAgentDraftLaunchPlan|buildAgentResumeStartupPlan|planStartupWithLaunchPrompt)\s*\(/g + /\b(?:buildAgentStartupPlan|buildAgentDraftLaunchPlan|buildAgentResumeStartupPlan|planLaunchPrompt)\s*\(/g const DECIDE = 'Decide attribution: a fresh agent the host builds must carry ' + diff --git a/src/main/daemon/daemon-create-or-attach-request.ts b/src/main/daemon/daemon-create-or-attach-request.ts index 653281f9c76..c74f2099152 100644 --- a/src/main/daemon/daemon-create-or-attach-request.ts +++ b/src/main/daemon/daemon-create-or-attach-request.ts @@ -21,7 +21,7 @@ export type CreateOrAttachRequest = { envToDelete?: string[] command?: string startupCommandDelivery?: StartupCommandDelivery - /** v39+: written by the daemon before it types `command`. */ + /** v41+ (`LAUNCH_FILE_DAEMON_PROTOCOL_VERSION`): written by the daemon before it types `command`. */ launchFile?: LaunchFile /** Where the daemon writes a WSL session's staged line and launch file; main resolves it. */ wslLaunchDirectory?: WslLaunchDirectory diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 3d87fa463af..b1e76a88b32 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(40) + expect(PROTOCOL_VERSION).toBe(41) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) @@ -23,5 +23,6 @@ describe('foreground-confirmation daemon protocol', () => { expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(37) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(38) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(39) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(40) }) }) diff --git a/src/main/daemon/daemon-launch-file-protocol.test.ts b/src/main/daemon/daemon-launch-file-protocol.test.ts new file mode 100644 index 00000000000..a15373cbe9e --- /dev/null +++ b/src/main/daemon/daemon-launch-file-protocol.test.ts @@ -0,0 +1,78 @@ +import './mock-descendant-sweep' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { rmSync } from 'node:fs' +import { DaemonClient } from './client' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { LAUNCH_FILE_DAEMON_PROTOCOL_VERSION, PROTOCOL_VERSION } from './daemon-protocol-version' +import type { DaemonServer } from './daemon-server' +import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness' +import { carryInLaunchFile } from '../../shared/launch-prompt-file' + +describe('a launch file sent to a terminal daemon', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + let adapter: DaemonPtyAdapter + + beforeEach(async () => { + const harness = await startDaemonAdapterHarness(() => createMockSubprocess()) + ;({ dir, socketPath, tokenPath, server, adapter } = harness) + }) + + afterEach(async () => { + vi.restoreAllMocks() + adapter?.dispose() + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + it('is a protocol the current daemon speaks', () => { + expect(PROTOCOL_VERSION).toBeGreaterThanOrEqual(LAUNCH_FILE_DAEMON_PROTOCOL_VERSION) + }) + + // Why: an older daemon drops `launchFile` and would type a line naming a file nobody wrote. + it('is refused, with the reason, by a daemon older than launch files', async () => { + vi.spyOn(DaemonClient.prototype, 'ensureConnected').mockResolvedValue() + const request = vi.spyOn(DaemonClient.prototype, 'request') + const legacy = new DaemonPtyAdapter({ + socketPath, + tokenPath, + protocolVersion: LAUNCH_FILE_DAEMON_PROTOCOL_VERSION - 1 + }) + const { prompt, launchFile } = carryInLaunchFile('the whole task', false) + try { + await expect( + legacy.spawn({ + sessionId: 'legacy-launch', + cols: 80, + rows: 24, + command: `claude '${prompt}'`, + launchAgent: 'claude', + launchFile + }) + ).rejects.toThrow(/launch_file_unavailable/) + expect(request).not.toHaveBeenCalledWith( + 'createOrAttach', + expect.anything(), + expect.anything() + ) + } finally { + legacy.dispose() + } + }) + + it('is sent to the current daemon, which writes it', async () => { + const request = vi.spyOn(DaemonClient.prototype, 'request') + const { prompt, launchFile } = carryInLaunchFile('the whole task', false) + await adapter.spawn({ + cols: 80, + rows: 24, + command: `claude '${prompt}'`, + launchAgent: 'claude', + launchFile + }) + const createCall = request.mock.calls.find(([method]) => method === 'createOrAttach') + expect(createCall?.[1]).toMatchObject({ launchFile: { placeholder: launchFile.placeholder } }) + }) +}) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index 1b77b8c20b5..aba47aeeea0 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -22,7 +22,7 @@ import { describe('daemon protocol version', () => { it('ships bounded history transfer after the 2031-unsubscribe fact', () => { - expect(PROTOCOL_VERSION).toBe(40) + expect(PROTOCOL_VERSION).toBe(41) expect(COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION).toBe(38) expect(CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION).toBe(37) expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36) @@ -38,7 +38,7 @@ describe('daemon protocol version', () => { expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 39 }, (_, index) => index + 1) + Array.from({ length: 40 }, (_, index) => index + 1) ) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 6dfec710e8b..c5875e22821 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,8 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v40 stages long startup commands as sourced scripts; older owners stay attachable. -export const PROTOCOL_VERSION = 40 +// v41 writes an agent's launch file before it types the line naming it; older owners stay attachable. +export const PROTOCOL_VERSION = 41 +// Why: an older daemon drops `launchFile` and would type a line naming a file nobody wrote. +export const LAUNCH_FILE_DAEMON_PROTOCOL_VERSION = 41 // Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. export const COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION = 38 export const CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION = 37 @@ -33,7 +35,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 ] as const export function supportsColorQueryReplyColors(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index bade2bcc3e2..b40907a9b72 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -18,7 +18,7 @@ import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segment import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types' import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' import { resolveWslSessionContext } from './wsl-session-context' -import { resolveWslLaunchDirectory } from '../providers/wsl-launch-directory-resolution' +import { resolveSpawnWslLaunchDirectory } from '../providers/wsl-launch-directory-resolution' import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' import { resolveUnixShellPath } from '../providers/local-pty-utils' import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' @@ -194,8 +194,8 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { } await this.ensureConnected() - const wslLaunchDirectory = - !attachOnly && opts.command ? await resolveWslLaunchDirectory(wslDistro) : undefined + const wslProbe = resolveSpawnWslLaunchDirectory(wslDistro, opts) + const wslLaunchDirectory = wslProbe ? await wslProbe : undefined // Pins the daemon's spawn to the distro the daemon writes the line and file into. if (wslLaunchDirectory) { opts = { ...opts, wslLaunchDirectory, terminalWindowsWslDistro: wslLaunchDirectory.distro } diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts index 22222c8b206..f453741535f 100644 --- a/src/main/daemon/daemon-pty-spawn-request.ts +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -7,7 +7,11 @@ import { type SnapshotCheckpointResult } from './daemon-pty-runtime-state' import { isDaemonGoneError } from './daemon-endpoint-errors' -import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version' +import { + HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, + LAUNCH_FILE_DAEMON_PROTOCOL_VERSION +} from './daemon-protocol-version' +import { LaunchFileUnavailableError } from '../../shared/launch-file-writing' import type { ColdRestoreInfo } from './history-reader' import { NdjsonLineTooLongError } from './ndjson' import { @@ -106,6 +110,15 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { if (opts.signal?.aborted) { throw new Error('client_disconnected') } + // Why refuse: an older daemon (a same-id respawn routed to it) would drop the file and type + // a line naming it. The refusal reaches the user with the prompt to copy. + if ( + !context.attachOnly && + opts.launchFile && + this.protocolVersion < LAUNCH_FILE_DAEMON_PROTOCOL_VERSION + ) { + throw new LaunchFileUnavailableError("this terminal's daemon is from an older Orca") + } const payload = { sessionId: context.sessionId, cols: context.effectiveCols, diff --git a/src/main/daemon/daemon-pty-startup-delivery.test.ts b/src/main/daemon/daemon-pty-startup-delivery.test.ts index b5ae04c5d1e..eb0b8e36654 100644 --- a/src/main/daemon/daemon-pty-startup-delivery.test.ts +++ b/src/main/daemon/daemon-pty-startup-delivery.test.ts @@ -9,7 +9,7 @@ import { type DaemonAdapterHarness, type SpawnSubprocess } from './daemon-pty-adapter-test-harness' -import { buildLaunchFilePointer, planLaunchPrompt } from '../../shared/launch-prompt-file' +import { buildLaunchFilePointer, carryInLaunchFile } from '../../shared/launch-prompt-file' const itOnPosix = process.platform === 'win32' ? it.skip : it @@ -141,7 +141,7 @@ describe('DaemonPtyAdapter startup delivery', () => { vi.stubEnv('TMPDIR', stagingDir) nextShellPath = '/bin/zsh' try { - const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true }) + const { prompt, launchFile } = carryInLaunchFile('secret brief', true) await adapter.spawn({ cols: 80, rows: 24, diff --git a/src/main/daemon/terminal-host-wsl-launch-directory.test.ts b/src/main/daemon/terminal-host-wsl-launch-directory.test.ts index 2b395205216..6e6354f385b 100644 --- a/src/main/daemon/terminal-host-wsl-launch-directory.test.ts +++ b/src/main/daemon/terminal-host-wsl-launch-directory.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import { createMockSubprocess } from './daemon-pty-adapter-test-harness' import { TerminalHost } from './terminal-host' import type { TerminalHostOptions } from './terminal-host-options' -import { buildLaunchFilePointer, planLaunchPrompt } from '../../shared/launch-prompt-file' +import { buildLaunchFilePointer, carryInLaunchFile } from '../../shared/launch-prompt-file' import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() })) @@ -33,7 +33,7 @@ describe('a daemon WSL session with a launch file', () => { }) function create(wslLaunchDirectory: WslLaunchDirectory | undefined) { - const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true }) + const { prompt, launchFile } = carryInLaunchFile('secret brief', true) return host.createOrAttach({ sessionId: 'wsl-session', cols: 80, diff --git a/src/main/ipc/pty/ipc/spawn-launch-refusal.test.ts b/src/main/ipc/pty/ipc/spawn-launch-refusal.test.ts new file mode 100644 index 00000000000..659986dd26e --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-launch-refusal.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' +import { describeLaunchFileUnavailable } from '../../../../shared/launch-prompt-file' +import { takeTerminalLaunchRefusal } from '../../../runtime/terminal-launch-refusals' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +const mocks = vi.hoisted(() => ({ + handlers: new Map Promise>(), + runSpawn: vi.fn() +})) + +vi.mock('../../pty-host-bindings', () => ({ + getPtyIpc: () => ({ + handle: (channel: string, handler: (event: unknown, args: unknown) => Promise) => + mocks.handlers.set(channel, handler) + }) +})) +vi.mock('./spawn-run', () => ({ runPtyIpcSpawn: mocks.runSpawn })) + +import { installPtySpawnIpcHandler } from './spawn' + +describe('a pane spawn refused for what carries its prompt', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only getLocalPtyStartupPromise before delegating to the mocked runner. + installPtySpawnIpcHandler({ getLocalPtyStartupPromise: () => null } as unknown as PtySpawnIpcDeps) + const spawn = (args: Partial): Promise => + mocks.handlers.get('pty:spawn')!({}, { cols: 80, rows: 24, ...args }) + + it('records the refusal for its tab, for a create waiting on that tab’s handle', async () => { + const refusal = describeLaunchFileUnavailable('disk full') + mocks.runSpawn.mockRejectedValueOnce(new Error(refusal)) + await expect(spawn({ tabId: 'tab-refused' })).rejects.toThrow(refusal) + expect(takeTerminalLaunchRefusal('tab-refused')).toBe(refusal) + }) + + it('records nothing for any other spawn failure', async () => { + mocks.runSpawn.mockRejectedValueOnce(new Error('spawn ENOENT')) + await expect(spawn({ tabId: 'tab-other' })).rejects.toThrow('spawn ENOENT') + expect(takeTerminalLaunchRefusal('tab-other')).toBeUndefined() + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts b/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts index 77e41293405..4656ee4f765 100644 --- a/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts +++ b/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import { buildPtyIpcSpawnOptions } from './spawn-options' -import { launchFilePromptForPane } from '../../../agent-hooks/launch-file-prompt-by-pane' +import { launchPromptShownForPane } from '../../../agent-hooks/launch-file-prompt-by-pane' +import { buildLaunchFilePointer } from '../../../../shared/launch-prompt-file' import { createPtyIpcSpawnState } from './spawn-state' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' @@ -59,8 +60,8 @@ describe('renderer pty spawn: launch file', () => { expect(options.launchFile).toBeUndefined() }) - // Why: the agent's hook reports only the pointer, so the first-work rename reads this instead. - it('keeps the prompt a launch file carries for its pane, never a sensitive one', async () => { + // Why: the agent's hook reports only the pointer; Orca shows the prompt it points at instead. + it('shows the prompt a launch file carries for its pane, never a sensitive one', async () => { await spawnOptionsFor( { cols: 80, rows: 24, command, launchFile: LAUNCH_FILE }, command, @@ -76,7 +77,9 @@ describe('renderer pty spawn: launch file', () => { command, 'tab-b:leaf-b' ) - expect(launchFilePromptForPane('tab-a:leaf-a')).toBe('the whole task') - expect(launchFilePromptForPane('tab-b:leaf-b')).toBeUndefined() + const pointer = buildLaunchFilePointer('/tmp/orca-launch-file-a1/task-context.md') + expect(launchPromptShownForPane('tab-a:leaf-a', pointer)).toBe('the whole task') + expect(launchPromptShownForPane('tab-b:leaf-b', pointer)).toBe(pointer) + expect(launchPromptShownForPane('tab-a:leaf-a', 'a later prompt')).toBe('a later prompt') }) }) diff --git a/src/main/ipc/pty/ipc/spawn.ts b/src/main/ipc/pty/ipc/spawn.ts index 6fd5183a36d..e35b40ee637 100644 --- a/src/main/ipc/pty/ipc/spawn.ts +++ b/src/main/ipc/pty/ipc/spawn.ts @@ -1,6 +1,8 @@ import { getPtyIpc } from '../../pty-host-bindings' import { runPtyIpcSpawn } from './spawn-run' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' +import { isLaunchFileUnavailableMessage } from '../../../../shared/launch-prompt-file' +import { recordTerminalLaunchRefusal } from '../../../runtime/terminal-launch-refusals' export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { const ipcMain = getPtyIpc() @@ -11,6 +13,15 @@ export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { if (startupPromise) { await startupPromise } - return runPtyIpcSpawn(deps, args) + try { + return await runPtyIpcSpawn(deps, args) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + // Why: a runtime create waits on this tab's handle; the refusal is the answer it waits for. + if (args.tabId && isLaunchFileUnavailableMessage(message)) { + recordTerminalLaunchRefusal(args.tabId, message) + } + throw error + } }) } diff --git a/src/main/providers/local-pty-provider-shell-readiness.test.ts b/src/main/providers/local-pty-provider-shell-readiness.test.ts index 6b1c0b64614..141dde0ff94 100644 --- a/src/main/providers/local-pty-provider-shell-readiness.test.ts +++ b/src/main/providers/local-pty-provider-shell-readiness.test.ts @@ -118,7 +118,7 @@ vi.mock('../shell-prompt-readiness-probe', () => ({ import { LocalPtyProvider } from './local-pty-provider' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' -import { buildLaunchFilePointer, planLaunchPrompt } from '../../shared/launch-prompt-file' +import { buildLaunchFilePointer, carryInLaunchFile } from '../../shared/launch-prompt-file' import { applyLocalPtyProviderMockDefaults, createLocalPtyMockProcess, @@ -273,7 +273,7 @@ describe('LocalPtyProvider', () => { vi.useFakeTimers() try { process.env.SHELL = '/bin/sh' - const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true }) + const { prompt, launchFile } = carryInLaunchFile('secret brief', true) await provider.spawn({ cols: 80, rows: 24, command: `claude '${prompt}'`, launchFile }) diff --git a/src/main/providers/local-pty-spawn-wsl-shutdown-order.test.ts b/src/main/providers/local-pty-spawn-wsl-shutdown-order.test.ts index dab27b9967e..528d6d6ab2f 100644 --- a/src/main/providers/local-pty-spawn-wsl-shutdown-order.test.ts +++ b/src/main/providers/local-pty-spawn-wsl-shutdown-order.test.ts @@ -4,11 +4,11 @@ import type * as SpawnStateModule from './local-pty-spawn-state' import { ptyShutdownOperations, type PtyShutdownOperation } from './local-pty-provider-state' import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' -const { resolveWslLaunchDirectory, reattachLocalPty } = vi.hoisted(() => ({ - resolveWslLaunchDirectory: vi.fn(), +const { resolveSpawnWslLaunchDirectory, reattachLocalPty } = vi.hoisted(() => ({ + resolveSpawnWslLaunchDirectory: vi.fn(), reattachLocalPty: vi.fn() })) -vi.mock('./wsl-launch-directory-resolution', () => ({ resolveWslLaunchDirectory })) +vi.mock('./wsl-launch-directory-resolution', () => ({ resolveSpawnWslLaunchDirectory })) vi.mock('./local-pty-launch-plan', async (importOriginal) => ({ ...(await importOriginal()), resolveLocalPtyWslDistro: () => 'Ubuntu' @@ -30,7 +30,7 @@ describe('a WSL respawn racing a shutdown of the same session id', () => { // starts during it would otherwise be missed and race the new PTY. it('waits for a shutdown that began while the distro directory was being resolved', async () => { let resolveDirectory!: (directory: WslLaunchDirectory | undefined) => void - resolveWslLaunchDirectory.mockReturnValue( + resolveSpawnWslLaunchDirectory.mockReturnValue( new Promise((resolve) => { resolveDirectory = resolve }) @@ -40,7 +40,9 @@ describe('a WSL respawn racing a shutdown of the same session id', () => { { cols: 80, rows: 24, sessionId: 'wsl-session', command: 'claude' }, () => ({}) ) - await vi.waitFor(() => expect(resolveWslLaunchDirectory).toHaveBeenCalledWith('Ubuntu')) + await vi.waitFor(() => + expect(resolveSpawnWslLaunchDirectory).toHaveBeenCalledWith('Ubuntu', expect.anything()) + ) let finishShutdown!: () => void const shutdown: PtyShutdownOperation = { diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index 48739c4ba3f..d7604864075 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -30,7 +30,7 @@ import { writeSpawnLaunchFile, type WrittenLaunchFile } from '../../shared/launch-file-writing' -import { resolveWslLaunchDirectory } from './wsl-launch-directory-resolution' +import { resolveSpawnWslLaunchDirectory } from './wsl-launch-directory-resolution' export async function spawnLocalPty( args: PtySpawnOptions, @@ -40,7 +40,8 @@ export async function spawnLocalPty( const wslDistro = args.command && !args.attachOnly ? resolveLocalPtyWslDistro(args, getOptions) : undefined // Why before the shutdown check: no await may sit between it and this spawn's registration. - const wslLaunchDirectory = wslDistro ? await resolveWslLaunchDirectory(wslDistro) : undefined + const wslProbe = resolveSpawnWslLaunchDirectory(wslDistro, args) + const wslLaunchDirectory = wslProbe ? await wslProbe : undefined if (reattachId) { const pendingShutdown = ptyShutdownOperations.get(reattachId) if (pendingShutdown) { diff --git a/src/main/providers/ssh-plain-shell-pty-provider.test.ts b/src/main/providers/ssh-plain-shell-pty-provider.test.ts index 064f37a5cef..72a73c8db46 100644 --- a/src/main/providers/ssh-plain-shell-pty-provider.test.ts +++ b/src/main/providers/ssh-plain-shell-pty-provider.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import type { ClientChannel, PseudoTtyOptions } from 'ssh2' import { SshPlainShellPtyProvider } from './ssh-plain-shell-pty-provider' +import { carryInLaunchFile } from '../../shared/launch-prompt-file' class FakeShellChannel extends EventEmitter { readonly stderr = new EventEmitter() @@ -147,6 +148,11 @@ describe('SshPlainShellPtyProvider', () => { await expect(provider.spawn({ cols: 80, rows: 24, attachOnly: true })).rejects.toThrow( '(no_runtime)' ) + // Why: nothing here writes a launch file, so its line would name a file that is not there. + const { prompt, launchFile } = carryInLaunchFile('the whole task', false) + await expect( + provider.spawn({ cols: 80, rows: 24, command: `claude '${prompt}'`, launchFile }) + ).rejects.toThrow('needs the Orca remote server') expect(opened).toEqual([]) }) }) diff --git a/src/main/providers/ssh-plain-shell-pty-provider.ts b/src/main/providers/ssh-plain-shell-pty-provider.ts index c0fbe34372a..5c4af1da90f 100644 --- a/src/main/providers/ssh-plain-shell-pty-provider.ts +++ b/src/main/providers/ssh-plain-shell-pty-provider.ts @@ -72,7 +72,13 @@ export class SshPlainShellPtyProvider implements IPtyProvider { if (this.disposed) { throw new Error('SSH connection is not active') } - if (opts.agentSessionEnsure || opts.agentSessionCreateOperationId || opts.launchAgent) { + // Why launchFile too: no host here writes it, so the line would name a file that is not there. + if ( + opts.agentSessionEnsure || + opts.agentSessionCreateOperationId || + opts.launchAgent || + opts.launchFile + ) { throw new PlainSshUnsupportedError('Launching agents', this.mode) } if (opts.attachOnly) { diff --git a/src/main/providers/wsl-launch-directory-resolution.test.ts b/src/main/providers/wsl-launch-directory-resolution.test.ts index be4fb992a50..78d4c18e510 100644 --- a/src/main/providers/wsl-launch-directory-resolution.test.ts +++ b/src/main/providers/wsl-launch-directory-resolution.test.ts @@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createLocalPtyLaunchPlan, resolveLocalPtyWslDistro } from './local-pty-launch-plan' import type { LocalPtyProviderOptions } from './local-pty-provider-types' import type { PtySpawnOptions } from './types' -import { resolveWslLaunchDirectory } from './wsl-launch-directory-resolution' +import { + resolveSpawnWslLaunchDirectory, + resolveWslLaunchDirectory, + WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS +} from './wsl-launch-directory-resolution' import type * as WslModule from '../wsl' const { runWslProcess } = vi.hoisted(() => ({ runWslProcess: vi.fn() })) @@ -57,13 +61,41 @@ describe('resolveWslLaunchDirectory', () => { ) }) - it('probes a distro once, and asks again only after a failure', async () => { - runWslProcess.mockResolvedValueOnce(answers('', 1)) - await expect(resolveWslLaunchDirectory('Arch')).resolves.toBeUndefined() + it('probes a distro once, and remembers a failure only for its TTL', async () => { + vi.useFakeTimers() + try { + runWslProcess.mockResolvedValueOnce(answers('', 1)) + await expect(resolveWslLaunchDirectory('Arch')).resolves.toBeUndefined() + runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n')) + // Why: a broken distro must not cost every spawn a wsl.exe round trip. + await expect(resolveWslLaunchDirectory('Arch')).resolves.toBeUndefined() + expect(runWslProcess).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS) + await expect(resolveWslLaunchDirectory('Arch')).resolves.toMatchObject({ + shell: '/bin/bash' + }) + await resolveWslLaunchDirectory('Arch') + expect(runWslProcess).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('probes only for a spawn that needs the directory', async () => { runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n')) - await expect(resolveWslLaunchDirectory('Arch')).resolves.toMatchObject({ shell: '/bin/bash' }) - await resolveWslLaunchDirectory('Arch') - expect(runWslProcess).toHaveBeenCalledTimes(2) + expect( + resolveSpawnWslLaunchDirectory('Gentoo', { command: 'claude', launchAgent: 'claude' }) + ).toBeUndefined() + expect( + resolveSpawnWslLaunchDirectory(undefined, { command: 'x'.repeat(600), launchAgent: 'claude' }) + ).toBeUndefined() + await expect( + resolveSpawnWslLaunchDirectory('Gentoo', { + command: `claude '${'x'.repeat(600)}'`, + launchAgent: 'claude' + }) + ).resolves.toMatchObject({ distro: 'Gentoo' }) + expect(runWslProcess).toHaveBeenCalledTimes(1) }) it('has none for a spawn outside WSL or a distro whose home cannot be read', async () => { diff --git a/src/main/providers/wsl-launch-directory-resolution.ts b/src/main/providers/wsl-launch-directory-resolution.ts index f357a627d72..87cf5c10cac 100644 --- a/src/main/providers/wsl-launch-directory-resolution.ts +++ b/src/main/providers/wsl-launch-directory-resolution.ts @@ -1,6 +1,9 @@ import { posix } from 'node:path' import { quotePosixShell, RESOLVE_WSL_LOGIN_SHELL } from '../../shared/wsl-login-shell-command' import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' +import type { LaunchFile } from '../../shared/launch-prompt-file' +import { spawnNeedsWslLaunchDirectory } from '../../shared/launch-file-writing' +import type { TuiAgent } from '../../shared/tui-agent' import { toWindowsWslUncPath } from '../../shared/wsl-paths' import { runWslProcess } from '../wsl/wsl-runner' @@ -8,11 +11,36 @@ const ORCA_CACHE_RELATIVE = '.cache/orca' const probes = new Map>() +/** A failed probe is answered from memory this long, so a broken distro does not cost every spawn + * a `wsl.exe` round trip, yet a distro that comes back is found again. */ +export const WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS = 30_000 + +/** The directory for a spawn that needs one (`spawnNeedsWslLaunchDirectory`); no probe, and no + * await for the caller, otherwise. */ +export function resolveSpawnWslLaunchDirectory( + distro: string | null | undefined, + spawn: { + command?: string + launchFile?: LaunchFile + launchAgent?: TuiAgent + attachOnly?: boolean + } +): Promise | undefined { + const needed = + Boolean(distro) && + spawn.attachOnly !== true && + spawnNeedsWslLaunchDirectory({ + ...spawn, + orcaBuiltLine: spawn.launchAgent !== undefined + }) + return needed ? resolveWslLaunchDirectory(distro) : undefined +} + /** * The distro directory a WSL spawn's staged line and launch file are written to, and the login * shell the pane runs, which decides how a staged line is sourced; undefined when the distro cannot - * be asked, and the write site then refuses a launch that needs one. Probed once per distro, and - * only a success is kept. + * be asked, and the write site then refuses a launch that needs one. Probed once per distro; a + * success is kept, a failure for `WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS`. */ export async function resolveWslLaunchDirectory( distro: string | null | undefined @@ -24,7 +52,16 @@ export async function resolveWslLaunchDirectory( if (!probe) { probe = probeWslLaunchDirectory(distro) probes.set(distro, probe) - void probe.then((found) => found ?? probes.delete(distro)) + const pending = probe + void pending.then((found) => { + if (!found) { + setTimeout(() => { + if (probes.get(distro) === pending) { + probes.delete(distro) + } + }, WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS).unref?.() + } + }) } return await probe } diff --git a/src/main/runtime/agent-terminal-startup-prompt.test.ts b/src/main/runtime/agent-terminal-startup-prompt.test.ts index 065db00d3fc..222f9c4bd38 100644 --- a/src/main/runtime/agent-terminal-startup-prompt.test.ts +++ b/src/main/runtime/agent-terminal-startup-prompt.test.ts @@ -2,9 +2,9 @@ * The launch command a terminal create builds when the launch hands it a prompt. * * This is the argv half of terminal prompt delivery, and the reason it is worth pinning is that - * its failure mode is silent: `buildAgentStartupPlan` answers a prompt it cannot fold by returning - * a bare command plus a `followupPrompt`, and this resolver returns options, not a live PTY, so a - * dropped `followupPrompt` would spawn the agent with no prompt and no error anywhere. + * its failure mode is silent: `planLaunchPrompt` answers a prompt it cannot fold with a clean plan + * and `paste-after-ready`, and this resolver returns options, not a live PTY, so a dropped paste + * would spawn the agent with no prompt and no error anywhere. */ import { describe, expect, it, vi } from 'vitest' diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 91fa61c85b9..4523e483d50 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -20,9 +20,12 @@ import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-i import { agentPromptRidesLaunchCommand, buildAgentDraftLaunchPlan, - buildAgentStartupPlan + planLaunchPrompt } from '../../shared/tui-agent-startup' -import { windowsDraftRefusal } from '../../shared/startup-plan-launch-file' +import { + launchPromptNeedsPasteRefusal, + windowsDraftRefusal +} from '../../shared/launch-prompt-carry' import { resolveStartupShell } from '../../shared/tui-agent-startup-shell' import type { RuntimeTerminalCreate } from '../../shared/runtime-types' import type { @@ -165,31 +168,39 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe ...(request.agentArgs !== undefined ? { agentArgs: request.agentArgs } : {}), sessionOptions: this.toAgentSessionOptions(request.launchPreferences) }) - const startup = - request.promptDelivery === 'draft' - ? buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' }) - : buildAgentStartupPlan({ - ...startupArgs, - prompt: request.prompt ?? '', - allowEmptyPromptLaunch: true - }) - if (!startup) { - const refusal = - request.promptDelivery === 'draft' - ? windowsDraftRefusal( - request.prompt ?? '', - resolveStartupShell(startupArgs.platform, startupArgs.shell) - ) - : null - throw new Error(refusal ?? 'agent_session_identity_required') - } - if (startup.followupPrompt && agentPromptRidesLaunchCommand(request.agent)) { - // Why: this create has no paste after ready, so a prompt left for one would be dropped. - throw new Error( - `${request.agent} cannot take this prompt on its command line here (too long, private, or ` + - "broken by the host's Windows shell), so the session was not started. Start it without " + - 'the prompt and paste the prompt once it opens.' - ) + let startup + let launchFile + if (request.promptDelivery === 'draft') { + startup = buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' }) + if (!startup) { + const refusal = windowsDraftRefusal( + request.prompt ?? '', + resolveStartupShell(startupArgs.platform, startupArgs.shell) + ) + throw new Error(refusal ?? 'agent_session_identity_required') + } + } else { + const planned = planLaunchPrompt({ ...startupArgs, prompt: request.prompt ?? '' }) + if (!planned) { + throw new Error('agent_session_identity_required') + } + switch (planned.carry) { + case 'none': + case 'on-line': + startup = planned.plan + break + case 'launch-file': + startup = planned.plan + launchFile = planned.launchFile + break + case 'paste-after-ready': + // Why: a stdin agent's prompt is the session's to submit; an argv agent's would be dropped. + if (agentPromptRidesLaunchCommand(request.agent)) { + throw new Error(launchPromptNeedsPasteRefusal(request.agent, 'session')) + } + startup = planned.cleanPlan + break + } } if (caller.signal?.aborted) { throw new Error('client_disconnected') @@ -222,9 +233,7 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe launchAgent: request.agent, terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol, startupCommandDelivery: startup.startupCommandDelivery, - ...('launchFile' in startup && startup.launchFile - ? { launchFile: startup.launchFile } - : {}), + ...(launchFile ? { launchFile } : {}), // A fresh agent this host built; the request has no surface field, so it counts as `unknown`. telemetry: agentStartedTelemetry(request.agent, undefined), cwd: startupCwd, diff --git a/src/main/runtime/orca-runtime-create-terminal-desktop.ts b/src/main/runtime/orca-runtime-create-terminal-desktop.ts index 54f5104f0ac..1bcf2d346bd 100644 --- a/src/main/runtime/orca-runtime-create-terminal-desktop.ts +++ b/src/main/runtime/orca-runtime-create-terminal-desktop.ts @@ -2,7 +2,6 @@ import * as dependencies from './orca-runtime-create-terminal-dependencies' import type { OrcaRuntimeWithCreateTerminal } from './orca-runtime-create-terminal' import type { RuntimeTerminalPresentation } from '../../shared/runtime-types' -import { removeLaunchFile, writeLaunchFile } from '../../shared/launch-file-writing' export async function createDesktopTerminal( runtime: OrcaRuntimeWithCreateTerminal, @@ -26,11 +25,6 @@ export async function createDesktopTerminal( `--shell ${opts.shellOverride} needs a workspace, because the shell is resolved on the workspace's execution host. No terminal was created.` ) } - if (launchOpts.launchFile && !workspace?.connectionId) { - // Why: a pane whose host refuses the launch-file write never registers a handle, so this create - // would only time out; failing the same local write here reports the refusal at once. - removeLaunchFile(writeLaunchFile({ launchFile: launchOpts.launchFile })) - } const worktreeId = workspace?.id const cwd = workspace ? runtime.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index 54ad4104fd6..26ff9f9d0f9 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -5,7 +5,8 @@ import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { TuiAgent } from '../../shared/tui-agent' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' -import { buildAgentStartupPlan } from '../../shared/tui-agent-startup' +import { planLaunchPrompt } from '../../shared/tui-agent-startup' +import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRuntimeWithRunCreateMobileSessionTerminal { @@ -47,7 +48,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt if (!isTuiAgentEnabled(opts.agent, settings.disabledTuiAgents)) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } - const startupPlan = buildAgentStartupPlan({ + const planned = planLaunchPrompt({ ...resolveAgentStartupPlanInputs({ agent: opts.agent, settings, @@ -56,14 +57,25 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt // Why: SSH runs the CLI through the relay shim (plain `orca`), so the Linux-only `orca-ide` rename must not apply. isRemote: Boolean(workspace.connectionId) }), - prompt: opts.agentPrompt ?? '', - allowEmptyPromptLaunch: true + prompt: opts.agentPrompt ?? '' }) - if (!startupPlan) { + if (!planned) { throw new Error(`Could not build launch command for ${opts.agent}.`) } - if (opts.agentPrompt && startupPlan.followupPrompt) { - throw new Error(`Agent ${opts.agent} does not support startup prompt quick commands.`) + let startupPlan + let launchFile + switch (planned.carry) { + case 'none': + case 'on-line': + startupPlan = planned.plan + break + case 'launch-file': + startupPlan = planned.plan + launchFile = planned.launchFile + break + case 'paste-after-ready': + // Why: a quick command has no paste after ready, so the prompt would be dropped. + throw new Error(launchPromptNeedsPasteRefusal(opts.agent, 'terminal')) } return { command: startupPlan.launchCommand, @@ -74,7 +86,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt launchConfig: startupPlan.launchConfig, launchAgent: opts.agent, startupCommandDelivery: startupPlan.startupCommandDelivery, - ...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}) + ...(launchFile ? { launchFile } : {}) } } } diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index e6ac7d11748..01f5c061420 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -19,14 +19,8 @@ import { terminalShellOverrideRefusal } from './terminal-shell-override-host-sup import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' -import { - agentPromptRidesLaunchCommand, - buildAgentStartupPlan -} from '../../shared/tui-agent-startup' -import { - planStartupWithLaunchPrompt, - type LaunchPromptStartupPlan -} from '../../shared/startup-line-prompt-carry' +import { agentPromptRidesLaunchCommand, planLaunchPrompt } from '../../shared/tui-agent-startup' +import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -277,21 +271,12 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith sessionOptions: this.toAgentSessionOptions(opts.launchPreferences) }) // A caller that wrote its own launch file already passes the pointer to it as the prompt. - const prompt = opts.startupPrompt ?? '' - let planned: LaunchPromptStartupPlan - if (opts.launchFile) { - const plan = buildAgentStartupPlan({ - ...planInputs, - prompt, - allowEmptyPromptLaunch: true, - launchFile: opts.launchFile - }) - planned = { plan, launchFile: plan?.launchFile ?? opts.launchFile } - } else { - planned = planStartupWithLaunchPrompt(planInputs, prompt) - } - const { plan: startupPlan, launchFile } = planned - if (!startupPlan) { + const planned = planLaunchPrompt({ + ...planInputs, + prompt: opts.startupPrompt ?? '', + ...(opts.launchFile ? { launchFile: opts.launchFile } : {}) + }) + if (!planned) { // Why: an explicit agent that yields no plan would otherwise spawn a bare // shell that never reaches agent readiness. if (opts.startupAgent) { @@ -299,16 +284,29 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith } return opts } - if (opts.startupPrompt?.trim()) { - const carried = startupPlan.followupPrompt === null - if (!carried && !opts.onStartupPromptCarry) { - // Why: this create returns options, not a live PTY, so the prompt would be dropped. - throw new Error( - `${agent} cannot take this prompt on its command line here, so no terminal was created. ` + - 'Start the agent without it and paste the prompt once it opens.' - ) - } - opts.onStartupPromptCarry?.(carried) + let startupPlan + let launchFile + switch (planned.carry) { + case 'none': + startupPlan = planned.plan + break + case 'on-line': + startupPlan = planned.plan + opts.onStartupPromptCarry?.(true) + break + case 'launch-file': + startupPlan = planned.plan + launchFile = planned.launchFile + opts.onStartupPromptCarry?.(true) + break + case 'paste-after-ready': + if (!opts.onStartupPromptCarry) { + // Why: this create returns options, not a live PTY, so the prompt would be dropped. + throw new Error(launchPromptNeedsPasteRefusal(agent, 'terminal')) + } + startupPlan = planned.cleanPlan + opts.onStartupPromptCarry(false) + break } return { diff --git a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts index 8e9ad65dc32..d540e6d497c 100644 --- a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts +++ b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts @@ -2,6 +2,7 @@ import { OrcaRuntimeWithWaitForMobileTerminalSurface } from './orca-runtime-wait-for-mobile-terminal-surface' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' import { parsePaneKey } from '../../shared/stable-pane-id' +import { onTerminalLaunchRefusal, takeTerminalLaunchRefusal } from './terminal-launch-refusals' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { RuntimeMobileSessionCreateTerminalResult } from '../../shared/runtime-types' @@ -137,26 +138,43 @@ export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals } return new Promise((resolve, reject) => { - const timer = setTimeout(() => { + const stop = (): void => { + clearTimeout(timer) + stopRefusal() const idx = this.graphSyncCallbacks.indexOf(check) if (idx !== -1) { this.graphSyncCallbacks.splice(idx, 1) } + } + const timer = setTimeout(() => { + stop() reject(new Error('Timed out waiting for terminal handle after creation')) }, timeoutMs) + // Why: a host that refused the spawn's launch file never registers a handle. + const refuse = (message: string): void => { + stop() + reject(new Error(message)) + } + const stopRefusal = onTerminalLaunchRefusal((refusedTabId) => { + const message = refusedTabId === tabId ? takeTerminalLaunchRefusal(tabId) : undefined + if (message) { + refuse(message) + } + }) const check = (): void => { const handle = this.resolveHandleForTab(tabId) if (handle) { - clearTimeout(timer) - const idx = this.graphSyncCallbacks.indexOf(check) - if (idx !== -1) { - this.graphSyncCallbacks.splice(idx, 1) - } + stop() resolve(handle) } } this.graphSyncCallbacks.push(check) + const recorded = takeTerminalLaunchRefusal(tabId) + if (recorded) { + refuse(recorded) + return + } // Why: graph sync may have fired between the initial check and registration; re-check to avoid a missed wake-up. check() }) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts index 92b29a8b449..28c9cc0dd2e 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts @@ -34,7 +34,7 @@ describe('OrcaRuntimeService', () => { agent: 'aider', agentPrompt: 'Review this diff' }) - ).rejects.toThrow('does not support startup prompt quick commands') + ).rejects.toThrow('aider takes its prompt only after it starts') expect(spawn).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 1c4d22826b6..1af1b767335 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -46,10 +46,10 @@ export type TerminalCreateOptions = { * Initial text for `startupAgent`'s launch command, for an agent whose CLI takes a prompt * argument. Not a general prompt channel: an agent that takes its text only after start has no * launch command to carry it, and a caller that sets this for one is refused rather than having - * the prompt silently dropped. It rides that command, directly or as a pointer to a launch file - * (`startup-line-prompt-carry`), unless it needs a launch file the agent is not known to read: - * then the agent starts clean, `onStartupPromptCarry` says so, and the caller pastes it once - * ready. A caller with no such callback is refused instead. + * the prompt silently dropped. It rides that command, directly or as a pointer to a launch file, + * unless `carryLaunchPrompt` says it must be pasted: then the agent starts clean, + * `onStartupPromptCarry` says so, and the caller pastes it once ready. A caller with no such + * callback is refused instead. */ startupPrompt?: string /** Main-internal: whether `startupPrompt` rode the launch command. Called once the plan is built. */ diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index a8565e594e9..0e859f1641c 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -8,8 +8,13 @@ import { getRepoSshConnectionId } from '../../shared/execution-host' import { isTuiAgent } from '../../shared/tui-agent-config' import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selection' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' -import { planStartupWithLaunchPrompt } from '../../shared/startup-line-prompt-carry' +import { + buildAgentDraftLaunchPlan, + buildAgentStartupPlan, + planLaunchPrompt, + type AgentStartupPlan +} from '../../shared/tui-agent-startup' +import type { LaunchFile } from '../../shared/launch-prompt-file' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents @@ -149,15 +154,31 @@ export function buildWorktreeStartupForAgent( ...(environment.agentArgs !== undefined ? { agentArgs: environment.agentArgs } : {}), sessionOptions: environment.toSessionOptions(environment.launchPreferences) }) - const { plan: startupPlan, launchFile } = planStartupWithLaunchPrompt( - planInputs, - environment.prompt ?? '' - ) - if (!startupPlan) { + const planned = planLaunchPrompt({ ...planInputs, prompt: environment.prompt ?? '' }) + if (!planned) { throw new Error(`Could not build launch command for ${agent}.`) } - if (environment.onPromptCarry && environment.prompt?.trim()) { - environment.onPromptCarry(startupPlan.followupPrompt === null) + let startupPlan: AgentStartupPlan + let launchFile: LaunchFile | undefined + let followup: WorktreeStartupFollowup | undefined + switch (planned.carry) { + case 'none': + startupPlan = planned.plan + break + case 'on-line': + case 'launch-file': + startupPlan = planned.plan + launchFile = planned.carry === 'launch-file' ? planned.launchFile : undefined + environment.onPromptCarry?.(true) + break + case 'paste-after-ready': + startupPlan = planned.cleanPlan + if (environment.onPromptCarry) { + environment.onPromptCarry(false) + } else { + followup = { expectedProcess: startupPlan.expectedProcess, prompt: planned.text } + } + break } return { agent, @@ -171,13 +192,6 @@ export function buildWorktreeStartupForAgent( ...(launchFile ? { launchFile } : {}), telemetry: agentStartedTelemetry(agent, environment.launchSource) }, - ...(startupPlan.followupPrompt && !environment.onPromptCarry - ? { - followup: { - expectedProcess: startupPlan.expectedProcess, - prompt: startupPlan.followupPrompt - } - } - : {}) + ...(followup ? { followup } : {}) } } diff --git a/src/main/runtime/terminal-launch-refusals.test.ts b/src/main/runtime/terminal-launch-refusals.test.ts new file mode 100644 index 00000000000..07c10e96e7b --- /dev/null +++ b/src/main/runtime/terminal-launch-refusals.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { recordTerminalLaunchRefusal, takeTerminalLaunchRefusal } from './terminal-launch-refusals' +import { describeLaunchFileUnavailable } from '../../shared/launch-prompt-file' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +function waitForHandle(runtime: OrcaRuntimeService, tabId: string): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wait under test is a protected member of the runtime; the assertion names only that method. + const internal = runtime as unknown as { + waitForTerminalHandle: (tabId: string, timeoutMs?: number) => Promise + } + return internal.waitForTerminalHandle(tabId, 5_000) +} + +describe('a pane spawn the host refused for its launch file', () => { + const refusal = describeLaunchFileUnavailable( + "the WSL distro's home directory could not be reached" + ) + + it('is taken once, by the tab it refused', () => { + recordTerminalLaunchRefusal('tab-taken', refusal) + expect(takeTerminalLaunchRefusal('tab-other')).toBeUndefined() + expect(takeTerminalLaunchRefusal('tab-taken')).toBe(refusal) + expect(takeTerminalLaunchRefusal('tab-taken')).toBeUndefined() + }) + + // Why: agent.launch waits on this handle; the refusal is its answer, not a 10 s timeout. + it('ends a create waiting on that tab’s handle with the reason', async () => { + const runtime = new OrcaRuntimeService() + const waiting = waitForHandle(runtime, 'tab-waiting') + recordTerminalLaunchRefusal('tab-waiting', refusal) + await expect(waiting).rejects.toThrow(refusal) + }) + + it('ends a create that starts waiting after the refusal arrived', async () => { + const runtime = new OrcaRuntimeService() + recordTerminalLaunchRefusal('tab-early', refusal) + await expect(waitForHandle(runtime, 'tab-early')).rejects.toThrow(refusal) + }) +}) diff --git a/src/main/runtime/terminal-launch-refusals.ts b/src/main/runtime/terminal-launch-refusals.ts new file mode 100644 index 00000000000..9ec35d90534 --- /dev/null +++ b/src/main/runtime/terminal-launch-refusals.ts @@ -0,0 +1,37 @@ +/** + * A pane spawn its host refused because what carries the agent's prompt (a launch file, or the + * staged script of a long line) could not be written. Kept by tab so a create waiting on that tab's + * handle reports the reason at once instead of timing out. Entries expire; nothing waits forever. + */ +const REFUSAL_TTL_MS = 60_000 +const MAX_REFUSALS = 64 + +const refusals = new Map() +const listeners = new Set<(tabId: string, message: string) => void>() + +export function recordTerminalLaunchRefusal(tabId: string, message: string): void { + const now = Date.now() + for (const [key, entry] of refusals) { + if (now - entry.at > REFUSAL_TTL_MS || refusals.size >= MAX_REFUSALS) { + refusals.delete(key) + } + } + refusals.set(tabId, { message, at: now }) + for (const listener of listeners) { + listener(tabId, message) + } +} + +/** The refusal recorded for `tabId`, consumed. */ +export function takeTerminalLaunchRefusal(tabId: string): string | undefined { + const entry = refusals.get(tabId) + refusals.delete(tabId) + return entry && Date.now() - entry.at <= REFUSAL_TTL_MS ? entry.message : undefined +} + +export function onTerminalLaunchRefusal( + listener: (tabId: string, message: string) => void +): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} diff --git a/src/relay/pty-handler-startup-command-staging.test.ts b/src/relay/pty-handler-startup-command-staging.test.ts index 0d750d5a69f..a3ab9150bcb 100644 --- a/src/relay/pty-handler-startup-command-staging.test.ts +++ b/src/relay/pty-handler-startup-command-staging.test.ts @@ -34,7 +34,7 @@ vi.mock('../main/shell-prompt-readiness-probe', () => ({ import type { PtyHandler } from './pty-handler' import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' -import { buildLaunchFilePointer, planLaunchPrompt } from '../shared/launch-prompt-file' +import { buildLaunchFilePointer, carryInLaunchFile } from '../shared/launch-prompt-file' import type { MockDispatcher } from './pty-handler-test-harness' const describePosix = process.platform === 'win32' ? describe.skip : describe @@ -114,7 +114,7 @@ describePosix('relay startup command staging', () => { }) it('writes a launch file before typing the line that names it, and removes it on exit', async () => { - const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true }) + const { prompt, launchFile } = carryInLaunchFile('secret brief', true) await dispatcher.callRequest('pty.spawn', { command: `claude '${prompt}'`, commandDelivery: 'provider', diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index d8320dd1075..c5307266a25 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -16,6 +16,7 @@ type BuildSourceControlAgentDeliveryPlanArgs = { /** Why: keep the previewed command label in sync with the real remote launch, * which omits the Linux-only `orca-ide` rename for SSH hosts. */ isRemote?: boolean + launchHostIsPaired?: boolean } export function buildSourceControlAgentDeliveryPlan({ @@ -26,7 +27,8 @@ export function buildSourceControlAgentDeliveryPlan({ detectedAgents, connectionUnavailable, launchPlatform, - isRemote + isRemote, + launchHostIsPaired }: BuildSourceControlAgentDeliveryPlanArgs): SourceControlAgentActionDeliveryPlanState { if (connectionUnavailable) { return buildSourceControlAgentConnectionErrorPlan() @@ -50,7 +52,8 @@ export function buildSourceControlAgentDeliveryPlan({ cmdOverrides: settings?.agentCmdOverrides, terminalWindowsShell: settings?.terminalWindowsShell, platform: launchPlatform, - isRemote + isRemote, + launchHostIsPaired }) if (!result.ok) { return { status: 'error', error: result.error } diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index 2209c1621fe..85fe301b820 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -9,6 +9,9 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' import type { TuiAgent } from '../../../../shared/tui-agent' import { buildSourceControlAgentDeliveryPlan } from './buildSourceControlAgentDeliveryPlan' +import { useAppStore } from '@/store' +import { launchHostIsPaired } from '@/lib/launch-file-host' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart' import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support' @@ -116,7 +119,10 @@ export function useSourceControlAgentActionStart({ detectedAgents: currentDetectedAgents, connectionUnavailable, launchPlatform, - isRemote + isRemote, + launchHostIsPaired: launchHostIsPaired( + getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) + ) }) }, [ @@ -128,7 +134,8 @@ export function useSourceControlAgentActionStart({ refreshDetectedAgents, selectedAgent, launchPlatform, - isRemote + isRemote, + worktreeId ] ) diff --git a/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts b/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts index d3746a38e82..9c9240011dc 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts @@ -62,7 +62,6 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } : {}), ...(draftLaunchPlan.startupCommandDelivery diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index 6976647ed8b..3b9ebe5cd1e 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -311,7 +311,6 @@ describe('submitFolderWorkspaceCreate', () => { startup: expect.objectContaining({ agent: 'codex', launchCommand: 'codex', - followupPrompt: null, draftPrompt: `Review this before starting\n\n${linkedWorkItem.url}` }) }) @@ -387,7 +386,7 @@ describe('submitFolderWorkspaceCreate', () => { startup: expect.objectContaining({ agent: 'aider', launchCommand: 'aider', - followupPrompt: 'Fix the failing folder prompt flow' + pastePromptAfterReady: 'Fix the failing folder prompt flow' }) }) }) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 1712f42f0a7..d942af2701f 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -1,7 +1,7 @@ import { ensureAgentStartupInTerminal, type LinkedWorkItemSummary } from '@/lib/new-workspace' import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' import { createBrowserUuid } from '@/lib/browser-uuid' -import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { planLaunchPrompt } from '@/lib/tui-agent-startup' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { activateAndRevealFolderWorkspace } from '@/lib/worktree-activation' import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' @@ -21,7 +21,11 @@ import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' import { getNewWorkspaceProjectGroupHostId } from '@/lib/new-workspace-project-options' import { useAppStore } from '@/store' -import { launchHostWritesLaunchFile } from '@/lib/launch-file-host' +import { launchHostIsPaired } from '@/lib/launch-file-host' +import { + composerAgentStartupPlan, + type ComposerAgentStartupPlan +} from '@/lib/composer-agent-startup-plan' import { buildFolderWorkspaceLinkedStartupPlan, getFolderWorkspaceAgentLaunchPlatform, @@ -99,7 +103,7 @@ export async function submitFolderWorkspaceCreate({ isRemote: launchIsRemote, terminalWindowsShell }) - const startupPlan = + const startupPlan: ComposerAgentStartupPlan | null = quickAgent && linkedWorkItem ? buildFolderWorkspaceLinkedStartupPlan({ agent: quickAgent, @@ -114,21 +118,21 @@ export async function submitFolderWorkspaceCreate({ isRemote: launchIsRemote }) : quickAgent - ? buildAgentStartupPlan({ - agent: quickAgent, - prompt: note, - cmdOverrides: agentCmdOverrides ?? {}, - agentArgs, - agentEnv, - sessionOptions, - platform: launchPlatform, - shell: launchShell, - isRemote: launchIsRemote, - hostWritesLaunchFile: launchHostWritesLaunchFile({ - activeRuntimeEnvironmentId: runtimeEnvironmentId + ? composerAgentStartupPlan( + planLaunchPrompt({ + agent: quickAgent, + prompt: note, + cmdOverrides: agentCmdOverrides ?? {}, + agentArgs, + agentEnv, + sessionOptions, + platform: launchPlatform, + shell: launchShell, + isRemote: launchIsRemote, + launchHostIsPaired: launchHostIsPaired(runtimeEnvironmentId) }), - allowEmptyPromptLaunch: true - }) + note + ) : null // Why: the argv-prefill plan carries the draft inside `launchCommand`, so // `startupPlan.draftPrompt` alone can't tell whether this launch has one. @@ -194,6 +198,7 @@ export async function submitFolderWorkspaceCreate({ ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), ...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}), + ...(startupPlan.launchPrompt ? { launchPrompt: startupPlan.launchPrompt } : {}), telemetry: { agent_kind: tuiAgentToAgentKind(quickAgent), launch_source: launchSource, @@ -246,7 +251,7 @@ export async function submitFolderWorkspaceCreate({ if ( !structuredLaunchAccepted && startupPlan && - (startupPlan.followupPrompt || startupPlan.draftPrompt) && + (startupPlan.pastePromptAfterReady || startupPlan.draftPrompt) && activation !== false ) { void ensureAgentStartupInTerminal({ diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index 15e857f239b..da09d847cfd 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -191,11 +191,13 @@ function handleConnectError( if (message.includes('was explicitly killed')) { return undefined } - // Why the transport's file: the spawn sends it unless the connect call named its own command. - const { launchFile } = context.transportOptions - if (launchFile && !options.command && isLaunchFileUnavailableMessage(message)) { + // Why the transport's prompt: the spawn carries it unless the connect call named its own command. + // The host refuses a launch file, or a WSL line it cannot stage, with the same code. + const { launchFile, launchPrompt } = context.transportOptions + const refusedPrompt = launchFile?.content ?? launchPrompt + if (refusedPrompt && !options.command && isLaunchFileUnavailableMessage(message)) { // The pane gets no shell; the notice holds the only copy of the prompt. - showAgentLaunchNotStartedNotice({ prompt: launchFile.content }) + showAgentLaunchNotStartedNotice({ prompt: refusedPrompt }) } if (connectionId && options.sessionId && message.includes(SSH_PTY_CONNECTION_MISMATCH_MARKER)) { // Why not `sessionExpired`: this string is minted by `toRelaySshPtyId`/`toAppSshPtyId` from a diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 9fc0f528040..0f358a74cbe 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -27,6 +27,8 @@ export type PtyPaneStartup = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery launchFile?: LaunchFile + /** The submitted prompt `command` carries; handed back to copy if the host refuses the spawn. */ + launchPrompt?: string env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-startup-command-options.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-startup-command-options.ts new file mode 100644 index 00000000000..d47777f7453 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-startup-command-options.ts @@ -0,0 +1,18 @@ +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** The startup command a pane spawn sends, with the launch file and prompt it carries; none when + * the startup is delivered by terminal paste instead. */ +export function paneStartupCommandOptions(session: ConnectPanePtySession): { + command?: string + launchFile?: NonNullable['launchFile'] + launchPrompt?: string +} { + if (session.shouldDeliverStartupViaTerminalPaste) { + return {} + } + return { + command: session.paneStartup?.command, + launchFile: session.paneStartup?.launchFile, + launchPrompt: session.paneStartup?.launchPrompt + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts index 84b98317b54..1a48b5a7a2b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -25,6 +25,7 @@ import { shouldRetainDisposedPaneSpawn } from './disposed-spawn-retention' import { buffersInputOnlyForSshReattach } from './ssh-reattach-input-buffering' import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { paneStartupCommandOptions } from './pane-startup-command-options' import { resolveTerminalInlineImagesEnabled } from '../../../../../shared/terminal-inline-images-settings' /** Transport creation, terminal capability replies, viewport claims, and undeliverable-input recovery. */ @@ -62,9 +63,7 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { : {}), env: session.paneEnv, ...(session.paneStartup?.envToDelete ? { envToDelete: session.paneStartup.envToDelete } : {}), - ...(session.shouldDeliverStartupViaTerminalPaste - ? {} - : { command: session.paneStartup?.command, launchFile: session.paneStartup?.launchFile }), + ...paneStartupCommandOptions(session), ...(session.shouldUseProviderSshStartupDelivery ? { commandDelivery: 'provider' as const } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-transport-launch-refusal.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-launch-refusal.test.ts new file mode 100644 index 00000000000..6bc07f350fa --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-transport-launch-refusal.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness' +import { describeLaunchFileUnavailable } from '../../../../shared/launch-prompt-file' + +const mocks = vi.hoisted(() => ({ showNotStarted: vi.fn() })) +vi.mock('@/lib/agent-launch-prompt-not-delivered-notice', () => ({ + showAgentLaunchNotStartedNotice: mocks.showNotStarted +})) + +describe('a pane spawn the host refused for what carries its prompt', () => { + const originalWindow = globalThis.window + + beforeEach(() => { + vi.resetModules() + mocks.showNotStarted.mockReset() + installIpcPtyWindow(originalWindow, { data: () => {}, exit: () => {} }) + }) + + afterEach(() => { + restorePtySpecWindow(originalWindow) + }) + + // Why: a WSL launch with no launch file still needs its staged line written into the distro. + it('hands back a staged line’s prompt to copy, as it does a launch file’s', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + vi.mocked(window.api.pty.spawn).mockRejectedValueOnce( + new Error(describeLaunchFileUnavailable('unreachable', 'staged-line')) + ) + + await createIpcPtyTransport({ + command: "claude 'a long prompt'", + launchPrompt: 'a long prompt' + }).connect({ url: '', callbacks: { onError: vi.fn() } }) + + expect(mocks.showNotStarted).toHaveBeenCalledWith({ prompt: 'a long prompt' }) + }) + + it('stays quiet for any other spawn failure', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + vi.mocked(window.api.pty.spawn).mockRejectedValueOnce(new Error('spawn ENOENT')) + + await createIpcPtyTransport({ command: 'claude', launchPrompt: 'fix it' }).connect({ + url: '', + callbacks: { onError: vi.fn() } + }) + + expect(mocks.showNotStarted).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 887a2b6ff43..b7b3b5d6150 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -270,6 +270,8 @@ export type IpcPtyTransportOptions = { startupCommandDelivery?: StartupCommandDelivery /** Local spawns only: a remote runtime writes no file the renderer names. */ launchFile?: LaunchFile + /** See `PtyPaneStartup.launchPrompt`. */ + launchPrompt?: string connectionId?: string | null executionHostId?: ExecutionHostId | null worktreeId?: string diff --git a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts index 75b08c0d9ec..1f48e5233e7 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { planLaunchPrompt } from '@/lib/tui-agent-startup' +import { composerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import { buildFullCreationStartup } from './full-creation-startup' import { tuiAgentToAgentKind } from '../../../../shared/agent-kind' @@ -7,13 +8,17 @@ describe('the full composer’s renderer-spawned startup', () => { // Why: SSH repos, folder repos, repos with default tabs and a failed backend spawn all take this // path; without the file the agent is pointed at nothing and the prompt is lost. it('carries the launch file its command points at', () => { - const startupPlan = buildAgentStartupPlan({ - agent: 'claude', - prompt: 'x'.repeat(200_000), - cmdOverrides: {}, - platform: 'linux', - isRemote: true - }) + const prompt = 'x'.repeat(200_000) + const startupPlan = composerAgentStartupPlan( + planLaunchPrompt({ + agent: 'claude', + prompt, + cmdOverrides: {}, + platform: 'linux', + isRemote: true + }), + prompt + ) const startup = buildFullCreationStartup({ startupPlan, backendSpawnedStartup: false, @@ -29,5 +34,7 @@ describe('the full composer’s renderer-spawned startup', () => { expect(startupPlan?.launchFile).toBeDefined() expect(startup?.launchFile).toEqual(startupPlan?.launchFile) expect(startup?.command).toContain(startupPlan?.launchFile?.placeholder) + // Handed back to copy if the host refuses the spawn. + expect(startup?.launchPrompt).toBe(prompt) }) }) diff --git a/src/renderer/src/hooks/composer-state/full-creation-startup.ts b/src/renderer/src/hooks/composer-state/full-creation-startup.ts index a00666c2112..c0ddc1bd215 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-startup.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-startup.ts @@ -1,9 +1,9 @@ import type { TuiAgent } from '../../../../shared/tui-agent' -import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import type { ComposerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' export function buildFullCreationStartup(args: { - startupPlan: AgentStartupPlan | null + startupPlan: ComposerAgentStartupPlan | null backendSpawnedStartup: boolean agent: TuiAgent shouldSeedInitialAgentStatus: boolean @@ -25,6 +25,7 @@ export function buildFullCreationStartup(args: { : {}), // Why: the command points at this file; without it the agent is told to read nothing. ...(args.startupPlan.launchFile ? { launchFile: args.startupPlan.launchFile } : {}), + ...(args.startupPlan.launchPrompt ? { launchPrompt: args.startupPlan.launchPrompt } : {}), ...(args.shouldSeedInitialAgentStatus ? { initialAgentStatus: { agent: args.agent, prompt: args.prompt.trim() } } : {}), diff --git a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts index e3136a48b39..98c273cde24 100644 --- a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts +++ b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts @@ -30,7 +30,7 @@ import { ensureHooksConfirmed, confirmRuntimeIssueCommandRead } from '@/lib/ensu import { useAppStore } from '@/store' import type { SetupDecision } from '../../../../shared/worktree/create-types' import { resolveComposerBranchNameOverrideForCreate } from '../composer-branch-selection' -import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { planLaunchPrompt } from '@/lib/tui-agent-startup' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -39,7 +39,8 @@ import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' import { tuiAgentToAgentKind } from '@/lib/telemetry' -import { launchHostWritesLaunchFile } from '@/lib/launch-file-host' +import { launchHostIsPaired } from '@/lib/launch-file-host' +import { composerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions' export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { @@ -181,30 +182,33 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { !effectiveBranchNameOverride && !createDisplayName - const startupPlan = buildAgentStartupPlan({ - agent: tuiAgent, - prompt: submitStartupPrompt, - cmdOverrides: settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv), - sessionOptions: resolveInitialNativeChatSessionOptions( - { - experimentalNativeChat: settings?.experimentalNativeChat, - openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, - nativeChatSessionOptions: settings?.nativeChatSessionOptions - }, - { - agent: tuiAgent, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - selectedRepo?.connectionId - ) - } - ), - platform: selectedRepoAgentLaunchPlatform, - shell: selectedRepoStartupShell, - isRemote: selectedRepoIsRemote, - hostWritesLaunchFile: launchHostWritesLaunchFile(selectedRepoSettings) - }) + const startupPlan = composerAgentStartupPlan( + planLaunchPrompt({ + agent: tuiAgent, + prompt: submitStartupPrompt, + cmdOverrides: settings?.agentCmdOverrides ?? {}, + agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv), + sessionOptions: resolveInitialNativeChatSessionOptions( + { + experimentalNativeChat: settings?.experimentalNativeChat, + openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, + nativeChatSessionOptions: settings?.nativeChatSessionOptions + }, + { + agent: tuiAgent, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + selectedRepo?.connectionId + ) + } + ), + platform: selectedRepoAgentLaunchPlatform, + shell: selectedRepoStartupShell, + isRemote: selectedRepoIsRemote, + launchHostIsPaired: launchHostIsPaired(selectedRepoSettings?.activeRuntimeEnvironmentId) + }), + submitStartupPrompt + ) const shouldSeedInitialAgentStatus = tuiAgent === 'command-code' && submitStartupPrompt.trim().length > 0 @@ -217,7 +221,7 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { } const backendStartup = - startupPlan && !startupPlan.draftPrompt && !startupPlan.followupPrompt + startupPlan && !startupPlan.draftPrompt && !startupPlan.pastePromptAfterReady ? { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index edca524131b..37a57f9056b 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -40,7 +40,7 @@ import { useAppStore } from '@/store' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { launchHostWritesLaunchFile } from '@/lib/launch-file-host' +import { launchHostIsPaired } from '@/lib/launch-file-host' import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow' import { translate } from '@/i18n/i18n' import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' @@ -140,7 +140,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, - hostWritesLaunchFile: launchHostWritesLaunchFile(selectedRepoSettings), + launchHostIsPaired: launchHostIsPaired(selectedRepoSettings?.activeRuntimeEnvironmentId), telemetrySource }) diff --git a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts index 30adf55de72..2b2eed7538f 100644 --- a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts +++ b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts @@ -3,8 +3,11 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' -import type { AgentStartupPlan } from '@/lib/tui-agent-startup' -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { buildAgentDraftLaunchPlan, planLaunchPrompt } from '@/lib/tui-agent-startup' +import { + composerAgentStartupPlan, + type ComposerAgentStartupPlan +} from '@/lib/composer-agent-startup-plan' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -22,12 +25,13 @@ export type QuickComposerStartupInput = { platform: NodeJS.Platform shell: AgentStartupShell | null | undefined isRemote: boolean - hostWritesLaunchFile: boolean + /** See `launchHostIsPaired`. */ + launchHostIsPaired: boolean telemetrySource: WorktreeCreationRequest['telemetrySource'] } export type QuickComposerStartup = { - startupPlan: AgentStartupPlan | null + startupPlan: ComposerAgentStartupPlan | null backendStartup: WorktreeCreationRequest['startup'] telemetry: AgentStartedTelemetry | null } @@ -67,13 +71,12 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui shell: input.shell ?? undefined, isRemote: input.isRemote }) - let startupPlan: AgentStartupPlan | null = null + let startupPlan: ComposerAgentStartupPlan | null = null if (draftLaunchPlan) { startupPlan = { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } : {}), ...(draftLaunchPlan.startupCommandDelivery @@ -82,19 +85,21 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) } } else if (agent !== null) { - startupPlan = buildAgentStartupPlan({ - agent, - prompt, - cmdOverrides: settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), - sessionOptions, - platform: input.platform, - shell: input.shell ?? undefined, - isRemote: input.isRemote, - hostWritesLaunchFile: input.hostWritesLaunchFile, - allowEmptyPromptLaunch: true - }) + startupPlan = composerAgentStartupPlan( + planLaunchPrompt({ + agent, + prompt, + cmdOverrides: settings?.agentCmdOverrides ?? {}, + agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), + sessionOptions, + platform: input.platform, + shell: input.shell ?? undefined, + isRemote: input.isRemote, + launchHostIsPaired: input.launchHostIsPaired + }), + prompt + ) if (startupPlan && draftPrompt) { startupPlan.draftPrompt = draftPrompt } @@ -109,7 +114,7 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui request_kind: 'new' } const backendStartup = - startupPlan && !startupPlan.draftPrompt && !startupPlan.followupPrompt + startupPlan && !startupPlan.draftPrompt && !startupPlan.pastePromptAfterReady ? { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), diff --git a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts index 7e7ffee9182..9323a9aa5a4 100644 --- a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts @@ -39,6 +39,9 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ seedNativeChatAppliedSessionOptions: vi.fn() })) vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn(async () => true) })) +vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ + waitForLaunchPromptReceipt: vi.fn(async () => true) +})) vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' })) })) diff --git a/src/renderer/src/lib/agent-launch-caller-placement.test.ts b/src/renderer/src/lib/agent-launch-caller-placement.test.ts index 03db8a68367..d2721aa2805 100644 --- a/src/renderer/src/lib/agent-launch-caller-placement.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-placement.test.ts @@ -39,6 +39,9 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ seedNativeChatAppliedSessionOptions: vi.fn() })) vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn(async () => true) })) +vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ + waitForLaunchPromptReceipt: vi.fn(async () => true) +})) vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' })) })) diff --git a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts index e1a3d474de9..696d3e40423 100644 --- a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts @@ -48,6 +48,10 @@ vi.mock('@/lib/agent-paste-draft', () => ({ vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' })) })) +const mockWaitForLaunchPromptReceipt = vi.fn(async () => true) +vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ + waitForLaunchPromptReceipt: mockWaitForLaunchPromptReceipt +})) vi.mock('@/runtime/local-runtime-capabilities', () => ({ readLocalRuntimeCapabilitiesOrUnknown: () => [] })) @@ -159,14 +163,16 @@ describe('agent launch caller prompt transport', () => { ) it.each(cases)( - 'exposes no delivery promise to %s, whose Codex prompt rides argv or is a draft', + 'exposes a delivery result to %s only for a prompt it waits to see submitted', async (_id, profile) => { const result = await launch(profile) - // Why: three call sites branch on this promise being present. A draft launch must NOT get one, - // because the composer owns the text until the user sends it, and a prompt the launch command - // carries has no paste to wait for. - expect(result?.promptDeliveryResult).toBeUndefined() + // Why: call sites branch on this result being present. A draft launch must NOT get one, + // because the composer owns the text until the user sends it; a submit-after-ready prompt the + // launch command carries still waits on the agent's receipt before the caller acts. + const waitsForSubmit = + profile.args.prompt !== undefined && profile.args.promptDelivery === 'submit-after-ready' + expect(result?.promptDeliveryResult !== undefined).toBe(waitsForSubmit) } ) @@ -215,6 +221,38 @@ describe('agent launch caller prompt transport', () => { } }) + // The bug class the explicit carry outcome removes: a plan that exists but does not carry the + // prompt was treated as delivered, and the prompt was dropped. + it.each([ + ['past the argv ceiling', 'x'.repeat(20_000), 'darwin' as const], + ['a Windows-damaged prompt', 'say "hi"', 'win32' as const] + ])( + 'pastes %s for an agent not measured reading a launch file, and waits for that paste', + async (_label, prompt, launchPlatform) => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'gemini', + worktreeId: 'wt-1', + prompt, + promptDelivery: 'submit-after-ready', + launchPlatform + }) + + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + expect(mockPasteDraftWhenAgentReady.mock.calls[0]?.[0]).toMatchObject({ + content: prompt, + submit: true + }) + expect(mockWaitForLaunchPromptReceipt).not.toHaveBeenCalled() + expect(queuedStartupCommand(store)).not.toContain(prompt.slice(0, 8)) + expect(queuedStartupPayload(store)?.launchFile).toBeUndefined() + } + ) + it('mirrors an argv-carried draft into the chat composer', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') diff --git a/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts b/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts new file mode 100644 index 00000000000..475a77660a6 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type ReceiptState = { + tabsByWorktree: Record + ptyIdsByTabId: Record + agentStatusByPaneKey: Record + settings: Record +} + +const mocks = vi.hoisted(() => { + let state: ReceiptState = { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + agentStatusByPaneKey: {}, + settings: {} + } + const listeners = new Set<(next: ReceiptState) => void>() + return { + getState: () => state, + setState: (next: Partial) => { + state = { ...state, ...next } + for (const listener of listeners) { + listener(state) + } + }, + subscribe: (listener: (next: ReceiptState) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + reset: () => { + listeners.clear() + state = { tabsByWorktree: {}, ptyIdsByTabId: {}, agentStatusByPaneKey: {}, settings: {} } + }, + readiness: vi.fn(), + waitForAgentReady: vi.fn() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: { getState: mocks.getState, subscribe: mocks.subscribe } +})) +vi.mock('./agent-paste-draft', () => ({ + PTY_SPAWN_TIMEOUT_MS: 8000, + getSettingsForAgentTabRuntimeOwner: () => ({}), + waitForAgentDraftInputReadyOnTab: mocks.readiness +})) +vi.mock('./agent-ready-wait', () => ({ waitForAgentReady: mocks.waitForAgentReady })) + +import { waitForLaunchPromptReceipt } from './agent-launch-prompt-receipt' + +const never = (): Promise => new Promise(() => {}) + +describe('whether a prompt that rode the launch command reached the agent', () => { + beforeEach(() => { + mocks.reset() + mocks.setState({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } }) + mocks.readiness.mockReset().mockImplementation(never) + mocks.waitForAgentReady.mockReset() + }) + + it('is delivered when the agent’s own hook reports a turn on the tab', async () => { + const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + mocks.setState({ + agentStatusByPaneKey: { 'tab-1:11111111-1111-4111-8111-111111111111': { updatedAt: 150 } } + }) + await expect(receipt).resolves.toBe(true) + }) + + it('ignores a status row older than the launch', async () => { + mocks.setState({ + agentStatusByPaneKey: { 'tab-1:11111111-1111-4111-8111-111111111111': { updatedAt: 50 } } + }) + mocks.readiness.mockResolvedValue(null) + const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) + await expect(receipt).resolves.toBe(false) + }) + + it('is not delivered when the PTY exits before the agent shows any sign of the prompt', async () => { + const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + mocks.setState({ ptyIdsByTabId: { 'tab-1': [] } }) + await expect(receipt).resolves.toBe(false) + }) + + it('is not delivered when the PTY never spawns, as when the host refused its launch file', async () => { + mocks.readiness.mockResolvedValue(null) + const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'codex', launchedAt: 100 }) + await expect(receipt).resolves.toBe(false) + }) + + it('is delivered when the agent shows the composer a paste would have waited for', async () => { + mocks.readiness.mockResolvedValue({ ptyId: 'pty-1', ready: true }) + const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'codex', launchedAt: 100 }) + await expect(receipt).resolves.toBe(true) + }) + + it('reports an unconfirmed delivery when only the agent process was seen', async () => { + mocks.readiness.mockResolvedValue({ ptyId: 'pty-1', ready: false }) + mocks.waitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) + const onUnconfirmedDelivery = vi.fn() + const receipt = waitForLaunchPromptReceipt({ + tabId: 'tab-1', + agent: 'codex', + launchedAt: 100, + onUnconfirmedDelivery + }) + await expect(receipt).resolves.toBe(true) + expect(onUnconfirmedDelivery).toHaveBeenCalledOnce() + }) + + it('is not delivered when the tab closes first', async () => { + const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) + mocks.setState({ tabsByWorktree: { 'wt-1': [] } }) + await expect(receipt).resolves.toBe(false) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-prompt-receipt.ts b/src/renderer/src/lib/agent-launch-prompt-receipt.ts new file mode 100644 index 00000000000..e2ef5c2c00a --- /dev/null +++ b/src/renderer/src/lib/agent-launch-prompt-receipt.ts @@ -0,0 +1,98 @@ +import type { TuiAgent } from '../../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' +import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { useAppStore } from '@/store' +import { waitForAgentReady } from './agent-ready-wait' +import { + getSettingsForAgentTabRuntimeOwner, + PTY_SPAWN_TIMEOUT_MS, + waitForAgentDraftInputReadyOnTab +} from './agent-paste-draft' + +type AppState = ReturnType + +function hookReportedTurnOnTab(state: AppState, tabId: string, launchedAt: number): boolean { + return Object.entries(state.agentStatusByPaneKey).some( + ([paneKey, entry]) => parsePaneKey(paneKey)?.tabId === tabId && entry.updatedAt >= launchedAt + ) +} + +function tabExists(state: AppState, tabId: string): boolean { + return Object.values(state.tabsByWorktree).some((tabs) => tabs?.some((tab) => tab.id === tabId)) +} + +/** + * Whether a prompt that rode an agent's launch command reached the agent. True at the first real + * signal: the agent's own hook reports a turn on the tab, or the agent shows the composer a paste + * would have waited for. False when the PTY never spawns (a refused launch file included), exits + * first, or the tab closes. Never "the tab exists". + */ +export function waitForLaunchPromptReceipt(args: { + tabId: string + agent: TuiAgent + launchedAt: number + /** The agent process was seen, but neither its hook nor its composer. */ + onUnconfirmedDelivery?: () => void +}): Promise { + const { tabId, agent, launchedAt } = args + return new Promise((resolve) => { + let settled = false + let sawPty = false + let unsubscribe: (() => void) | null = null + const finish = (delivered: boolean): void => { + if (settled) { + return + } + settled = true + unsubscribe?.() + resolve(delivered) + } + let scannedStatus: AppState['agentStatusByPaneKey'] | null = null + const observe = (state: AppState): void => { + // Why the identity check: the store writes often and replaces this map only when it changes. + if (state.agentStatusByPaneKey !== scannedStatus) { + scannedStatus = state.agentStatusByPaneKey + if (hookReportedTurnOnTab(state, tabId, launchedAt)) { + finish(true) + return + } + } + if ((state.ptyIdsByTabId[tabId]?.length ?? 0) > 0) { + sawPty = true + } else if (sawPty || !tabExists(state, tabId)) { + finish(false) + } + } + unsubscribe = useAppStore.subscribe(observe) + observe(useAppStore.getState()) + const config = TUI_AGENT_CONFIG[agent] + void waitForAgentDraftInputReadyOnTab({ + tabId, + spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS, + readinessTimeoutMs: resolveDraftPasteReadyTimeoutMs(agent), + readySignal: config.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste', + settings: getSettingsForAgentTabRuntimeOwner(tabId) + }).then( + async (readiness) => { + if (settled) { + return + } + if (!readiness) { + finish(false) + return + } + if (readiness.ready) { + finish(true) + return + } + const fallback = await waitForAgentReady(tabId, config.expectedProcess, { timeoutMs: 1000 }) + if (fallback.ready && !settled) { + args.onUnconfirmedDelivery?.() + } + finish(fallback.ready) + }, + () => finish(false) + ) + }) +} diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index f0df326b316..510ebb1aae0 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -42,7 +42,7 @@ export const POST_PASTE_SUBMIT_DELAY_MS = AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_M // hasn't appeared in 8s means the launch itself failed — waiting the (longer) // composer budget on top would only delay that verdict. Keeping them distinct // also stops one slow step from spending the other's budget (STA-3367). -const PTY_SPAWN_TIMEOUT_MS = 8000 +export const PTY_SPAWN_TIMEOUT_MS = 8000 export function getSettingsForAgentTabRuntimeOwner( tabId: string @@ -273,7 +273,7 @@ async function sendBracketedPasteToAgent(args: { } } -function waitForAgentDraftInputReadyOnTab(args: { +export function waitForAgentDraftInputReadyOnTab(args: { tabId: string spawnTimeoutMs: number readinessTimeoutMs: number diff --git a/src/renderer/src/lib/agent-startup-delayed-delivery.ts b/src/renderer/src/lib/agent-startup-delayed-delivery.ts index 2e38fad41d1..3a1cde634f3 100644 --- a/src/renderer/src/lib/agent-startup-delayed-delivery.ts +++ b/src/renderer/src/lib/agent-startup-delayed-delivery.ts @@ -1,5 +1,5 @@ import { useAppStore } from '@/store' -import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import type { ComposerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import { parsePaneKey } from '../../../shared/stable-pane-id' import { agentStartupDeliveryKey as deliveryKey, @@ -15,8 +15,8 @@ type PendingAgentStartupDelivery = { worktreeId: string tabId: string launchToken: string - startup: AgentStartupPlan - deliver: (tabId: string, ptyId: string, startup: AgentStartupPlan) => Promise + startup: ComposerAgentStartupPlan + deliver: (tabId: string, ptyId: string, startup: ComposerAgentStartupPlan) => Promise } const pendingAgentStartupDeliveries = new Map() diff --git a/src/renderer/src/lib/composer-agent-startup-plan.ts b/src/renderer/src/lib/composer-agent-startup-plan.ts new file mode 100644 index 00000000000..18cfaa41df6 --- /dev/null +++ b/src/renderer/src/lib/composer-agent-startup-plan.ts @@ -0,0 +1,31 @@ +import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import type { LaunchPromptPlan } from '../../../shared/launch-prompt-carry' +import type { LaunchFile } from '../../../shared/launch-prompt-file' + +/** What a created workspace launches, and the submitted prompt it still owes the agent, if any. */ +export type ComposerAgentStartupPlan = AgentStartupPlan & { + launchFile?: LaunchFile + /** The submitted prompt the launch carries; handed back to copy if the host refuses the spawn. */ + launchPrompt?: string + /** A submitted prompt the launch could not carry; pasted once the agent is ready. */ + pastePromptAfterReady?: string +} + +export function composerAgentStartupPlan( + planned: LaunchPromptPlan | null, + prompt: string +): ComposerAgentStartupPlan | null { + if (!planned) { + return null + } + switch (planned.carry) { + case 'none': + return planned.plan + case 'on-line': + return { ...planned.plan, launchPrompt: prompt.trim() } + case 'launch-file': + return { ...planned.plan, launchFile: planned.launchFile, launchPrompt: prompt.trim() } + case 'paste-after-ready': + return { ...planned.cleanPlan, pastePromptAfterReady: planned.text } + } +} diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 76e776c6be5..8ef6c3fe556 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -1,6 +1,7 @@ import { useAppStore } from '@/store' -import { launchHostWritesLaunchFile } from '@/lib/launch-file-host' -import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { launchHostIsPaired } from '@/lib/launch-file-host' +import { planLaunchPrompt, type AgentStartupPlan } from '@/lib/tui-agent-startup' +import type { LaunchFile } from '../../../shared/launch-prompt-file' import type { LaunchAgentBackgroundSessionArgs, LaunchAgentBackgroundSessionResult @@ -75,23 +76,38 @@ export async function launchAgentBackgroundSession( // Route by the worktree's owner host, not the focused runtime. const ownerSettings = getSettingsForWorktreeRuntimeOwner(store, worktreeId) const runtimeTarget = getActiveRuntimeTarget(ownerSettings) - const startupPlan = buildAgentStartupPlan({ + const planned = planLaunchPrompt({ agent, - prompt: hasPrompt && !isFollowupPath ? trimmedPrompt : '', + prompt: trimmedPrompt, cmdOverrides, agentArgs, agentEnv, platform: launchPlatform, shell: startupShell, isRemote, - hostWritesLaunchFile: launchHostWritesLaunchFile(ownerSettings), - allowEmptyPromptLaunch: !hasPrompt || isFollowupPath + launchHostIsPaired: launchHostIsPaired(ownerSettings?.activeRuntimeEnvironmentId) }) - if (!startupPlan) { + if (!planned) { return null } - // A prompt the host could not be sent in a launch file waits for the paste, as a stdin agent's does. - const promptLeftForPaste = hasPrompt && !isFollowupPath ? startupPlan.followupPrompt : null + let startupPlan: AgentStartupPlan + let launchFile: LaunchFile | undefined + // An argv agent's prompt the line could not carry waits for the paste, as a stdin agent's does. + let promptLeftForPaste: string | null = null + switch (planned.carry) { + case 'none': + case 'on-line': + startupPlan = planned.plan + break + case 'launch-file': + startupPlan = planned.plan + launchFile = planned.launchFile + break + case 'paste-after-ready': + startupPlan = planned.cleanPlan + promptLeftForPaste = isFollowupPath ? null : planned.text + break + } let pasteDraftAfterLaunch = hasPrompt && isFollowupPath ? trimmedPrompt : promptLeftForPaste // A hidden run tab must never be store-visible without its PTY (#2989). @@ -192,7 +208,7 @@ export async function launchAgentBackgroundSession( ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), env: paneEnv, - ...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}), + ...(launchFile ? { launchFile } : {}), launchConfig: startupPlan.launchConfig, launchToken, launchAgent: agent, diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 1352314224c..d44a7bb6739 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -14,6 +14,7 @@ const mockMarkNativeChatLaunchPromptFailed = vi.fn() const mockTrack = vi.fn() const mockToastMessage = vi.fn() const mockWaitForAgentReady = vi.fn() +const mockWaitForLaunchPromptReceipt = vi.fn() const store = { activeRepoId: 'repo-1', @@ -117,6 +118,10 @@ vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: mockWaitForAgentReady })) +vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ + waitForLaunchPromptReceipt: mockWaitForLaunchPromptReceipt +})) + vi.mock('@/lib/telemetry', () => ({ track: mockTrack, tuiAgentToAgentKind: (agent: string) => agent @@ -137,6 +142,7 @@ vi.mock('@/runtime/web-runtime-session', () => ({ describe('launchAgentInNewTab', () => { beforeEach(() => { vi.clearAllMocks() + mockWaitForLaunchPromptReceipt.mockResolvedValue(true) mockIsWebRuntimeSessionActive.mockReturnValue(false) mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft.mockResolvedValue({ status: 'created' }) @@ -699,7 +705,14 @@ describe('launchAgentInNewTab', () => { promptDelivery: 'submit-after-ready' }) - expect(result?.promptDeliveryResult).toBeUndefined() + // Why: the launch line carried it, so delivery is the agent's receipt, never the tab existing. + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + expect(mockWaitForLaunchPromptReceipt).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-1', agent: 'command-code' }) + ) expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() // Why: Command Code has no prompt-submit hook, so the spawn seeds working from this prompt. expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( @@ -839,14 +852,36 @@ describe('launchAgentInNewTab', () => { promptDelivery: 'submit-after-ready' }) - expect(result?.promptDeliveryResult).toBeUndefined() + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() const queued = mockQueueTabStartupCommand.mock.calls[0]?.[1] + // Handed back to copy if the host refuses to write the file. + expect(queued?.launchPrompt).toBe(prompt) expect(queued?.launchFile).toMatchObject({ content: prompt, sensitive: false }) expect(queued?.command).toContain(queued?.launchFile?.placeholder) expect(queued?.command).not.toContain('xxxx') }) + it('reports a carried prompt undelivered when the agent never received it', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + mockWaitForLaunchPromptReceipt.mockResolvedValue(false) + const onPromptDelivered = vi.fn() + + const result = launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'resolve these threads', + promptDelivery: 'submit-after-ready', + onPromptDelivered + }) + + await expect(result?.promptDeliveryResult).resolves.toMatchObject({ delivered: false }) + expect(onPromptDelivered).not.toHaveBeenCalled() + }) + it('queues per-launch CLI arguments ahead of the generated prompt on argv', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 208c51a9e45..096a2241d5c 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -3,12 +3,8 @@ import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan' import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' import { tuiAgentToAgentKind } from '@/lib/telemetry' -import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' -import { - deliverLaunchPromptToAgentTab, - seedNativeChatLaunchDraftForAgentTab, - seedNativeChatLaunchPromptForAgentTab -} from '@/lib/agent-launch-prompt-delivery' +import { launchHostIsPaired } from '@/lib/launch-file-host' +import { deliverNewTabLaunchPrompt } from '@/lib/launch-agent-new-tab-prompt-delivery' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -157,14 +153,14 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent sessionOptions: resolveInitialNativeChatSessionOptions(store.settings, initialViewModeOptions) } const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) - const launchesOnPairedHost = isWebRuntimeSessionActive(runtimeEnvironmentId) + const hostPublishesTab = isWebRuntimeSessionActive(runtimeEnvironmentId) const { startupPlan, launchFile, pasteDraftAfterLaunch, submitPastedPrompt } = planLaunchAgentStartupPrompt({ base: startupPlanBase, prompt: trimmedPrompt, promptDelivery, isFollowupPath, - launchesOnPairedHost + launchHostIsPaired: launchHostIsPaired(runtimeEnvironmentId) }) let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined @@ -172,7 +168,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent return null } - if (launchesOnPairedHost) { + if (hostPublishesTab) { if (beforeSurfaceOpen?.({ kind: 'host-published' }) === false) { return null } @@ -246,6 +242,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent if (beforeSurfaceOpen?.({ kind: 'local-terminal' }) === false) { return null } + const launchedAt = Date.now() // Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render. // Why: followup path pastes an unsubmitted draft, so gate the initial chat view like a draft launch, not auto-submit. const tab = store.createTab(worktreeId, groupId, undefined, { @@ -270,6 +267,9 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), ...(launchFile ? { launchFile } : {}), + ...(hasPrompt && pasteDraftAfterLaunch === null && promptDelivery !== 'draft' + ? { launchPrompt: trimmedPrompt } + : {}), // Why: Command Code has no prompt-submit hook, so a prompt its launch line submits seeds working. ...(agent === 'command-code' && hasPrompt && @@ -283,45 +283,20 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent request_kind: 'new' } }) - // Why: fire-and-forget the paste-after-ready delivery so callers keep the synchronous { tabId, startupPlan } signature. - // Why: safe to call unconditionally — the helper short-circuits (no paste) for native-prefill agents already holding the draft. - // Why: no paste runs, so no paste seeds the chat's copy: a draft rode in on argv (Claude --prefill - // etc.), and a submitted prompt rode the launch line. Not with a launch file: the transcript then - // shows the pointer sentence, which would never prune this copy. - if (hasPrompt && pasteDraftAfterLaunch === null && !launchFile) { - if (promptDelivery === 'draft') { - seedNativeChatLaunchDraftForAgentTab({ tabId: tab.id, agent, text: trimmedPrompt }) - } else if (promptDelivery === 'submit-after-ready') { - seedNativeChatLaunchPromptForAgentTab({ tabId: tab.id, agent, text: trimmedPrompt }) - } - } - if (pasteDraftAfterLaunch !== null) { - const timeoutNotice = createPasteReadinessTimeoutNotice({ - worktreeId, - tabId: tab.id, - agent, - submitted: submitPastedPrompt, - content: pasteDraftAfterLaunch - }) - const deliveryPromise = deliverLaunchPromptToAgentTab({ - tabId: tab.id, - content: pasteDraftAfterLaunch, - agent, - submit: submitPastedPrompt, - forcePaste: true, - onTimeout: timeoutNotice.onTimeout, - ...(onPromptDeliveryUnconfirmed ? { onUnconfirmedDelivery: onPromptDeliveryUnconfirmed } : {}) - }).then((delivered) => timeoutNotice.settle(delivered, onPromptDelivered)) - if (promptDelivery === 'submit-after-ready') { - promptDeliveryResult = deliveryPromise - } else { - void deliveryPromise.catch((error) => - console.error('Prompt delivery failed after launch', error) - ) - } - } else if (hasPrompt) { - onPromptDelivered?.() - } + // Why: fire-and-forget the delivery so callers keep the synchronous { tabId, startupPlan } signature. + promptDeliveryResult = deliverNewTabLaunchPrompt({ + worktreeId, + tabId: tab.id, + agent, + prompt: trimmedPrompt, + promptDelivery, + pasteDraftAfterLaunch, + submitPastedPrompt, + promptInLaunchFile: launchFile !== undefined, + launchedAt, + ...(onPromptDelivered ? { onPromptDelivered } : {}), + ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) + }) // Why: without setActiveTabType('terminal') an activated launch can stay hidden behind an editor. // Scoped to the launch's worktree so a floating or background launch leaves the main window's tab alone. diff --git a/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts new file mode 100644 index 00000000000..4bb2e43ab66 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts @@ -0,0 +1,83 @@ +import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' +import { waitForLaunchPromptReceipt } from '@/lib/agent-launch-prompt-receipt' +import { + deliverLaunchPromptToAgentTab, + seedNativeChatLaunchDraftForAgentTab, + seedNativeChatLaunchPromptForAgentTab +} from '@/lib/agent-launch-prompt-delivery' +import type { TuiAgent } from '../../../shared/tui-agent' + +export type NewTabPromptDeliveryResult = Promise<{ delivered: boolean; failureNotified: boolean }> + +/** + * How a new agent tab's prompt is delivered, and when it counts as delivered: a paste once the agent + * is ready, or for a prompt the launch carried, the agent's receipt of it. Never the tab existing. + * Returns the result a `submit-after-ready` caller waits on; other callers fire and forget. + */ +export function deliverNewTabLaunchPrompt(args: { + worktreeId: string + tabId: string + agent: TuiAgent + /** Trimmed; empty for a launch with no prompt. */ + prompt: string + promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' + pasteDraftAfterLaunch: string | null + submitPastedPrompt: boolean + promptInLaunchFile: boolean + launchedAt: number + onPromptDelivered?: () => void + onPromptDeliveryUnconfirmed?: () => void +}): NewTabPromptDeliveryResult | undefined { + const { tabId, agent, prompt, promptDelivery, pasteDraftAfterLaunch } = args + if (!prompt) { + return undefined + } + const unconfirmed = args.onPromptDeliveryUnconfirmed + ? { onUnconfirmedDelivery: args.onPromptDeliveryUnconfirmed } + : {} + // Why: no paste runs, so no paste seeds the chat's copy: a draft rode in on argv (Claude --prefill + // etc.), and a submitted prompt rode the launch line. Not with a launch file: the transcript then + // shows the pointer sentence, which would never prune this copy. + if (pasteDraftAfterLaunch === null && !args.promptInLaunchFile) { + if (promptDelivery === 'draft') { + seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: prompt }) + } else if (promptDelivery === 'submit-after-ready') { + seedNativeChatLaunchPromptForAgentTab({ tabId, agent, text: prompt }) + } + } + if (pasteDraftAfterLaunch === null && promptDelivery === 'draft') { + // A draft rode the launch command as an editable prefill; there is no turn to wait for. + args.onPromptDelivered?.() + return undefined + } + const content = pasteDraftAfterLaunch ?? prompt + const submitted = pasteDraftAfterLaunch === null || args.submitPastedPrompt + const timeoutNotice = createPasteReadinessTimeoutNotice({ + worktreeId: args.worktreeId, + tabId, + agent, + submitted, + content + }) + // Why the receipt for a carried prompt: callers post replies and resolve threads on this result. + const delivery = + pasteDraftAfterLaunch === null + ? waitForLaunchPromptReceipt({ tabId, agent, launchedAt: args.launchedAt, ...unconfirmed }) + : deliverLaunchPromptToAgentTab({ + tabId, + content, + agent, + submit: submitted, + forcePaste: true, + onTimeout: timeoutNotice.onTimeout, + ...unconfirmed + }) + const result = delivery.then((delivered) => + timeoutNotice.settle(delivered, args.onPromptDelivered) + ) + if (promptDelivery === 'submit-after-ready') { + return result + } + void result.catch((error) => console.error('Prompt delivery failed after launch', error)) + return undefined +} diff --git a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts index cd50ddff291..f82379f99a4 100644 --- a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts +++ b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts @@ -1,16 +1,12 @@ import { buildAgentDraftLaunchPlan, buildAgentStartupPlan, - type AgentStartupPlan + planLaunchPrompt, + type AgentStartupPlan, + type AgentStartupPlanInputs } from '@/lib/tui-agent-startup' -import { planStartupWithLaunchPrompt } from '../../../shared/startup-line-prompt-carry' import type { LaunchFile } from '../../../shared/launch-prompt-file' -type StartupPlanBase = Omit< - Parameters[0], - 'prompt' | 'allowEmptyPromptLaunch' -> - export type LaunchAgentStartupPromptPlan = { startupPlan: AgentStartupPlan | null /** Written by the host before it types the launch line naming it. */ @@ -21,37 +17,41 @@ export type LaunchAgentStartupPromptPlan = { } /** - * Decide how a new-tab launch delivers its prompt: agents whose CLI takes one get it on the launch - * command, while agents that take text only after start launch clean and paste once ready. + * Decide how a new-tab launch delivers its prompt: on the agent's launch command or in a launch file + * where `carryLaunchPrompt` says it can ride, else pasted once the agent is ready. */ export function planLaunchAgentStartupPrompt(args: { - base: StartupPlanBase + base: AgentStartupPlanInputs /** Already trimmed. */ prompt: string promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' isFollowupPath: boolean - /** A paired host of unknown version may neither stage a long line nor write a launch file. */ - launchesOnPairedHost: boolean + /** See `launchHostIsPaired`. */ + launchHostIsPaired: boolean }): LaunchAgentStartupPromptPlan { const { base, prompt, promptDelivery, isFollowupPath } = args - const hasPrompt = prompt.length > 0 - const pasteAfterReady = (submit: boolean): LaunchAgentStartupPromptPlan => ({ - startupPlan: buildAgentStartupPlan({ ...base, prompt: '', allowEmptyPromptLaunch: true }), - pasteDraftAfterLaunch: prompt, + const pasteAfterReady = ( + startupPlan: AgentStartupPlan | null, + text: string, + submit: boolean + ): LaunchAgentStartupPromptPlan => ({ + startupPlan, + pasteDraftAfterLaunch: text, submitPastedPrompt: submit }) + const cleanPlan = (): AgentStartupPlan | null => + buildAgentStartupPlan({ ...base, allowEmptyPromptLaunch: true }) - if (hasPrompt && promptDelivery === 'draft') { + if (prompt.length > 0 && promptDelivery === 'draft') { const draftLaunchPlan = buildAgentDraftLaunchPlan({ ...base, draft: prompt }) if (!draftLaunchPlan) { - return pasteAfterReady(false) + return pasteAfterReady(cleanPlan(), prompt, false) } return { startupPlan: { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } @@ -65,29 +65,35 @@ export function planLaunchAgentStartupPrompt(args: { submitPastedPrompt: false } } - // Temporary, until paired hosts advertise staging: keep their paste after readiness. - if ( - hasPrompt && - (isFollowupPath || (args.launchesOnPairedHost && promptDelivery === 'submit-after-ready')) - ) { - return pasteAfterReady(promptDelivery === 'submit-after-ready') + if (prompt.length > 0 && isFollowupPath) { + return pasteAfterReady(cleanPlan(), prompt, promptDelivery === 'submit-after-ready') } - if (!hasPrompt || args.launchesOnPairedHost) { - const startupPlan = buildAgentStartupPlan({ - ...base, - prompt, - allowEmptyPromptLaunch: !hasPrompt - }) - // A paired host is sent a command, never a launch file, so a prompt that needs one is pasted. - return startupPlan?.launchFile - ? pasteAfterReady(true) - : { startupPlan, pasteDraftAfterLaunch: null, submitPastedPrompt: false } + // Why: a caller that waits for delivery gets a verdict from a paste; this client cannot observe a + // paired host's carried prompt reach its agent. Temporary, until paired hosts report that receipt. + if (prompt.length > 0 && args.launchHostIsPaired && promptDelivery === 'submit-after-ready') { + return pasteAfterReady(cleanPlan(), prompt, true) } - const carried = planStartupWithLaunchPrompt(base, prompt) - return { - startupPlan: carried.plan, - ...(carried.launchFile ? { launchFile: carried.launchFile } : {}), - pasteDraftAfterLaunch: null, - submitPastedPrompt: false + const planned = planLaunchPrompt({ + ...base, + prompt, + launchHostIsPaired: args.launchHostIsPaired + }) + if (!planned) { + return { startupPlan: null, pasteDraftAfterLaunch: null, submitPastedPrompt: false } + } + switch (planned.carry) { + case 'none': + case 'on-line': + return { startupPlan: planned.plan, pasteDraftAfterLaunch: null, submitPastedPrompt: false } + case 'launch-file': + return { + startupPlan: planned.plan, + launchFile: planned.launchFile, + pasteDraftAfterLaunch: null, + submitPastedPrompt: false + } + case 'paste-after-ready': + // Every non-draft new-tab prompt is submitted; a draft returned above. + return pasteAfterReady(planned.cleanPlan, planned.text, true) } } diff --git a/src/renderer/src/lib/launch-file-host.test.ts b/src/renderer/src/lib/launch-file-host.test.ts index c9fe3fa233a..299c4672db5 100644 --- a/src/renderer/src/lib/launch-file-host.test.ts +++ b/src/renderer/src/lib/launch-file-host.test.ts @@ -1,17 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { launchHostWritesLaunchFile } from './launch-file-host' +import { launchHostIsPaired } from './launch-file-host' import { buildQuickComposerStartup } from '@/hooks/composer-state/quick-startup-plan' -describe('whether a launch host writes its launch file', () => { +describe('whether a launch lands on a paired host', () => { afterEach(() => { vi.unstubAllGlobals() }) - it('is true for this machine and false for a paired host or a web client', () => { - expect(launchHostWritesLaunchFile({ activeRuntimeEnvironmentId: null })).toBe(true) - expect(launchHostWritesLaunchFile({ activeRuntimeEnvironmentId: 'env-1' })).toBe(false) + it('is false for this machine and true for another Orca or a web client', () => { + expect(launchHostIsPaired(null)).toBe(false) + expect(launchHostIsPaired('env-1')).toBe(true) vi.stubGlobal('window', { __ORCA_WEB_CLIENT__: true }) - expect(launchHostWritesLaunchFile({ activeRuntimeEnvironmentId: null })).toBe(false) + expect(launchHostIsPaired(null)).toBe(true) }) }) @@ -26,11 +26,11 @@ describe('the quick composer on a paired host', () => { platform: 'win32', shell: 'powershell', isRemote: false, - hostWritesLaunchFile: false, + launchHostIsPaired: true, telemetrySource: 'sidebar' }) expect(startup.backendStartup).toBeUndefined() expect(startup.startupPlan?.launchFile).toBeUndefined() - expect(startup.startupPlan?.followupPrompt).toBe('fix the build\nthen run the tests') + expect(startup.startupPlan?.pastePromptAfterReady).toBe('fix the build\nthen run the tests') }) }) diff --git a/src/renderer/src/lib/launch-file-host.ts b/src/renderer/src/lib/launch-file-host.ts index 087e0e1402b..cb49325168c 100644 --- a/src/renderer/src/lib/launch-file-host.ts +++ b/src/renderer/src/lib/launch-file-host.ts @@ -1,14 +1,12 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' -import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' +import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment' import { isWebClientLocation } from './web-client-location' /** - * Whether the host a launch lands on writes the launch file its line names. A paired host (another - * Orca this client drives) is sent a command and never the file, so a prompt that would need one is - * pasted after the agent is ready instead. Temporary, until paired hosts are sent the prompt itself. + * Whether a launch lands on a paired Orca: another Orca this client drives, which may be an older + * build that neither stages a long line nor writes a launch file. `carryLaunchPrompt` then keeps + * the line to the typed budget and pastes the rest after the agent is ready. Temporary, until paired + * hosts advertise both. */ -export function launchHostWritesLaunchFile( - ownerSettings: Pick | null | undefined -): boolean { - return !isWebClientLocation() && getActiveRuntimeTarget(ownerSettings).kind === 'local' +export function launchHostIsPaired(runtimeEnvironmentId: string | null | undefined): boolean { + return isWebClientLocation() || isWebRuntimeSessionActive(runtimeEnvironmentId) } diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts index 639bce4be6f..8dee872c816 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts @@ -18,7 +18,7 @@ export function buildDirectWorkItemStartup(args: { launchConnectionId: string | null worktreePath: string repoProjectRuntime?: Parameters[0]['projectRuntime'] - launchesOnPairedHost: boolean + launchHostIsPaired: boolean }): ReturnType { const launchPlatform = args.launchPlatform ?? @@ -39,7 +39,7 @@ export function buildDirectWorkItemStartup(args: { ), // Why: SSH hosts run the plain `orca` shim, so the Linux-only `orca-ide` rename is not applied. isRemote: typeof args.launchConnectionId === 'string', - launchesOnPairedHost: args.launchesOnPairedHost + launchHostIsPaired: args.launchHostIsPaired }) } diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts index b5358ec6b3a..44b38b65066 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts @@ -26,7 +26,6 @@ describe('buildDirectWorkItemStartupOpts', () => { agent: 'codex', launchCommand: "codex 'review linked issue'", expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, startupCommandDelivery: 'shell-ready' } @@ -53,7 +52,6 @@ describe('buildDirectWorkItemStartupOpts', () => { agent: 'claude', launchCommand: "claude --prefill 'https://github.com/o/r/issues/12'", expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } } @@ -85,6 +83,7 @@ const settings = { describe('buildDirectWorkItemAgentStartupPlan', () => { it('omits native-chat preferences when the new workspace opens in terminal mode', () => { const result = buildDirectWorkItemAgentStartupPlan({ + launchHostIsPaired: false, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -99,6 +98,7 @@ describe('buildDirectWorkItemAgentStartupPlan', () => { it('applies native-chat preferences when the new workspace opens in chat', () => { const result = buildDirectWorkItemAgentStartupPlan({ + launchHostIsPaired: false, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -147,6 +147,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('resolves the global Agents arguments when the launch names none', () => { const result = buildDirectWorkItemAgentStartupPlan({ + launchHostIsPaired: false, agent: 'codex', draftContent: 'Fix the broken checks', promptDelivery: 'draft', @@ -160,6 +161,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('lets an explicit per-action value win over the global one', () => { const result = buildDirectWorkItemAgentStartupPlan({ + launchHostIsPaired: false, agent: 'codex', agentArgs: '--model gpt-5', draftContent: 'Fix the broken checks', @@ -173,3 +175,38 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => expect(result.startupPlan?.launchCommand).not.toContain('danger-full-access') }) }) + +describe('buildDirectWorkItemAgentStartupPlan submitted prompts', () => { + const submit = (agent: 'claude' | 'gemini', draftContent: string, launchHostIsPaired = false) => + buildDirectWorkItemAgentStartupPlan({ + launchHostIsPaired, + agent, + draftContent, + promptDelivery: 'submit-after-ready', + settings: { ...settings, openAgentTabsInChatByDefault: false }, + launchPlatform: 'darwin', + nativeChatTranscriptIsLocalReadable: true + }) + + // The bug class the explicit carry outcome removes: a plan that exists but does not carry the + // prompt was reported as on the launch command, so no paste ran and the prompt was dropped. + it('leaves a file-sized prompt for the paste for an agent not measured reading the file', () => { + const result = submit('gemini', 'g'.repeat(20_000)) + expect(result.promptOnLaunchCommand).toBe(false) + expect(result.startupPlan?.launchCommand).not.toContain('gggg') + expect(result.launchFile).toBeUndefined() + }) + + it('hands Claude the same prompt in a launch file, with the prompt to copy if refused', () => { + const prompt = 'c'.repeat(20_000) + const result = submit('claude', prompt) + expect(result.promptOnLaunchCommand).toBe(true) + expect(result.launchFile?.content).toBe(prompt) + expect(result.launchPrompt).toBe(prompt) + }) + + it('pastes on a paired host what its line cannot carry typed', () => { + expect(submit('claude', 'fix it', true).promptOnLaunchCommand).toBe(true) + expect(submit('claude', 'fix it\nthen run the tests', true).promptOnLaunchCommand).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index 2d3e50cee4b..cdd32ec8bed 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -3,6 +3,7 @@ import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan, + planLaunchPrompt, type AgentStartupPlan } from '@/lib/tui-agent-startup' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' @@ -18,8 +19,6 @@ import { translate } from '@/i18n/i18n' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import type { PersistedNativeChatSessionOptions } from '../../../shared/native-chat-session-options' import type { LaunchFile } from '../../../shared/launch-prompt-file' -import { planStartupWithLaunchPrompt } from '../../../shared/startup-line-prompt-carry' -import { agentPromptRidesLaunchCommand } from '../../../shared/tui-agent-startup' export function buildDirectWorkItemAgentStartupPlan(args: { agent: TuiAgent | null @@ -42,11 +41,13 @@ export function buildDirectWorkItemAgentStartupPlan(args: { /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must not be applied for remote launches. */ isRemote?: boolean - /** A paired host of unknown version may neither stage a long line nor write a launch file. */ - launchesOnPairedHost?: boolean + /** See `launchHostIsPaired`. */ + launchHostIsPaired: boolean }): { startupPlan: AgentStartupPlan | null launchFile?: LaunchFile + /** The submitted prompt the launch command carries (on its line or in `launchFile`). */ + launchPrompt?: string /** The launch command carries the content (a native draft or a submitted prompt); no paste runs. */ promptOnLaunchCommand: boolean startupPlanFailed: boolean @@ -76,33 +77,51 @@ export function buildDirectWorkItemAgentStartupPlan(args: { agentEnv: effectiveAgentEnv, sessionOptions } - // Temporary, until paired hosts advertise staging: they keep the paste after readiness. - if ( - args.promptDelivery === 'submit-after-ready' && - !args.launchesOnPairedHost && - agentPromptRidesLaunchCommand(args.agent) - ) { - const carried = planStartupWithLaunchPrompt(planInputs, args.draftContent) - return { - startupPlan: carried.plan, - ...(carried.launchFile ? { launchFile: carried.launchFile } : {}), - promptOnLaunchCommand: carried.plan !== null, - startupPlanFailed: carried.plan === null + if (args.promptDelivery === 'submit-after-ready') { + const planned = planLaunchPrompt({ + ...planInputs, + prompt: args.draftContent, + launchHostIsPaired: args.launchHostIsPaired + }) + if (!planned) { + return { startupPlan: null, promptOnLaunchCommand: false, startupPlanFailed: true } + } + switch (planned.carry) { + case 'none': + return { startupPlan: planned.plan, promptOnLaunchCommand: true, startupPlanFailed: false } + case 'on-line': + return { + startupPlan: planned.plan, + launchPrompt: args.draftContent, + promptOnLaunchCommand: true, + startupPlanFailed: false + } + case 'launch-file': + return { + startupPlan: planned.plan, + launchFile: planned.launchFile, + launchPrompt: args.draftContent, + promptOnLaunchCommand: true, + startupPlanFailed: false + } + case 'paste-after-ready': + return { + startupPlan: planned.cleanPlan, + promptOnLaunchCommand: false, + startupPlanFailed: false + } } } - const draftLaunchPlan = - args.promptDelivery === 'submit-after-ready' - ? null - : buildAgentDraftLaunchPlan({ - agent: args.agent, - draft: args.draftContent, - cmdOverrides: args.settings?.agentCmdOverrides ?? {}, - platform: args.launchPlatform, - isRemote: args.isRemote, - agentArgs: effectiveAgentArgs, - agentEnv: effectiveAgentEnv, - sessionOptions - }) + const draftLaunchPlan = buildAgentDraftLaunchPlan({ + agent: args.agent, + draft: args.draftContent, + cmdOverrides: args.settings?.agentCmdOverrides ?? {}, + platform: args.launchPlatform, + isRemote: args.isRemote, + agentArgs: effectiveAgentArgs, + agentEnv: effectiveAgentEnv, + sessionOptions + }) if (draftLaunchPlan) { return { @@ -110,7 +129,6 @@ export function buildDirectWorkItemAgentStartupPlan(args: { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } @@ -153,7 +171,9 @@ export function buildDirectWorkItemStartupOpts( /** Unsent launch context, for the view-mode decision only. Set it for every * draft launch — a natively-prefilled plan carries no `draftPrompt`. */ launchDraftText?: string, - launchFile?: LaunchFile + /** What the launch command carries: its launch file, and the submitted prompt (see + * `PtyPaneStartup.launchPrompt`). */ + carried: { launchFile?: LaunchFile; launchPrompt?: string } = {} ): { startup?: { command: string @@ -165,6 +185,7 @@ export function buildDirectWorkItemStartupOpts( sessionOptions?: AgentStartupPlan['sessionOptions'] startupCommandDelivery?: StartupCommandDelivery launchFile?: LaunchFile + launchPrompt?: string telemetry?: AgentStartedTelemetry } } { @@ -187,7 +208,8 @@ export function buildDirectWorkItemStartupOpts( ...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}), - ...(launchFile ? { launchFile } : {}), + ...(carried.launchFile ? { launchFile: carried.launchFile } : {}), + ...(carried.launchPrompt ? { launchPrompt: carried.launchPrompt } : {}), ...(telemetry ? { telemetry } : {}) } } diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index 5a4a38a40ce..f19f8632015 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -4,7 +4,7 @@ import { getConnectionId } from '@/lib/connection-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session' +import { launchHostIsPaired } from '@/lib/launch-file-host' import type { LaunchFile } from '../../../shared/launch-prompt-file' import type { AgentSessionLaunchPlan, @@ -21,6 +21,7 @@ export type DirectWorkItemAgentLaunchPreparation = { effectiveAgent: TuiAgent | null startupPlan: ReturnType['startupPlan'] launchFile?: LaunchFile + launchPrompt?: string promptOnLaunchCommand: boolean startupPlanFailed: boolean structuredLaunch: boolean @@ -74,7 +75,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { // Non-critical: activation still has the explicit startup below. }) } - const { startupPlan, launchFile, promptOnLaunchCommand, startupPlanFailed } = + const { startupPlan, launchFile, launchPrompt, promptOnLaunchCommand, startupPlanFailed } = buildDirectWorkItemStartup({ agent: effectiveAgent, agentArgs: args.agentArgs, @@ -92,7 +93,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { CLIENT_PLATFORM ) ?? args.repoProjectRuntime) : undefined, - launchesOnPairedHost: isWebRuntimeSessionActive( + launchHostIsPaired: launchHostIsPaired( getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId) ) }) @@ -115,6 +116,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { effectiveAgent, startupPlan, ...(launchFile ? { launchFile } : {}), + ...(launchPrompt ? { launchPrompt } : {}), promptOnLaunchCommand, startupPlanFailed, structuredLaunch, diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index ee14844e612..8c4c2cf3448 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store' import type * as TuiAgentSelectionModule from '../../../shared/tui-agent-selection' import type * as TuiAgentStartupModule from '@/lib/tui-agent-startup' -import type * as PromptCarryModule from '../../../shared/startup-line-prompt-carry' import type * as DirectAgentRoutingModule from '@/lib/launch-work-item-direct-agent-routing' const mocks = vi.hoisted(() => ({ @@ -104,17 +103,8 @@ vi.mock('@/lib/tui-agent-startup', async () => { return { ...actual, buildAgentDraftLaunchPlan: vi.fn(actual.buildAgentDraftLaunchPlan), - buildAgentStartupPlan: vi.fn(actual.buildAgentStartupPlan) - } -}) - -vi.mock('../../../shared/startup-line-prompt-carry', async () => { - const actual = await vi.importActual( - '../../../shared/startup-line-prompt-carry' - ) - return { - ...actual, - planStartupWithLaunchPrompt: vi.fn(actual.planStartupWithLaunchPrompt) + buildAgentStartupPlan: vi.fn(actual.buildAgentStartupPlan), + planLaunchPrompt: vi.fn(actual.planLaunchPrompt) } }) @@ -141,9 +131,12 @@ vi.mock('@/lib/launch-work-item-direct-agent-routing', async () => { import { launchWorkItemDirect } from './launch-work-item-direct' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { beginDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing' -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { + buildAgentDraftLaunchPlan, + buildAgentStartupPlan, + planLaunchPrompt +} from '@/lib/tui-agent-startup' import { pickTuiAgent } from '../../../shared/tui-agent-selection' -import { planStartupWithLaunchPrompt } from '../../../shared/startup-line-prompt-carry' const mockApi = { worktrees: { @@ -600,7 +593,6 @@ describe('launchWorkItemDirect', () => { agent: 'cursor', launchCommand: 'cursor-agent', expectedProcess: 'cursor-agent', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } }) mocks.store.createWorktree.mockResolvedValue({ @@ -813,12 +805,12 @@ describe('launchWorkItemDirect', () => { }) ).resolves.toBe(true) - expect(planStartupWithLaunchPrompt).toHaveBeenCalledWith( + expect(planLaunchPrompt).toHaveBeenCalledWith( expect.objectContaining({ agent: 'codex', - platform: 'linux' - }), - 'Fix the failing checks.' + platform: 'linux', + prompt: 'Fix the failing checks.' + }) ) }) }) diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index ac4d58a7b58..fe1180d2a99 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -249,7 +249,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom startupPlan, launchSource, promptDelivery === 'draft' ? draftContent : undefined, - launchPreparation.launchFile + launchPreparation )) }) return activationHolder.value !== false diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 28fab8326ff..b6d130b6656 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -288,7 +288,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -311,7 +311,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -333,7 +333,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -355,7 +355,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'claude', launchCommand: 'claude', expectedProcess: 'claude', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -374,7 +374,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -389,7 +389,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'claude', launchCommand: 'claude', expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'review this before sending' } @@ -416,7 +415,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -432,7 +431,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'claude', launchCommand: 'claude', expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'review this before sending' } @@ -476,7 +474,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'Linear context draft' } @@ -506,7 +503,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'https://github.com/stablyai/orca/pull/2051' } @@ -563,7 +559,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'linked draft' } @@ -645,7 +640,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'linked draft' } @@ -684,7 +678,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex' as const, launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'linked draft', launchToken: 'launch-token-1' @@ -714,7 +707,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex' as const, launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'linked draft', launchToken: 'launch-token-1' @@ -775,7 +767,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'old linked draft', launchToken: 'launch-token-old' @@ -827,7 +818,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 8a8d13f2652..e932c32811b 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -7,6 +7,7 @@ import { import { sendFollowupPromptWhenAgentReady } from '@/lib/agent-followup-delivery' import { showAgentLaunchPromptNotDeliveredNotice } from '@/lib/agent-launch-prompt-not-delivered-notice' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import type { ComposerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import type { LinkedWorkItemContext } from '@/lib/linked-work-item-context' import { beginAgentStartupDeliveryAttempt, @@ -228,11 +229,11 @@ export function getWorkspaceSeedName(args: { export async function ensureAgentStartupInTerminal(args: { worktreeId: string primaryTabId?: string | null - startup: AgentStartupPlan + startup: ComposerAgentStartupPlan }): Promise { const { worktreeId, primaryTabId, startup } = args const draftPrompt = startup.draftPrompt ?? null - if (startup.followupPrompt === null && draftPrompt === null) { + if (!startup.pastePromptAfterReady && draftPrompt === null) { return } const launchToken = ensureStartupLaunchToken(startup) @@ -280,15 +281,14 @@ export async function ensureAgentStartupInTerminal(args: { async function deliverAgentStartupToTerminal( tabId: string, ptyId: string, - startup: AgentStartupPlan + startup: ComposerAgentStartupPlan ): Promise { const draftPrompt = startup.draftPrompt ?? null const runtimeSettings = getSettingsForAgentTabRuntimeOwner(tabId) - // Why: followupPrompt is the legacy path for stdin-after-start agents - // (aider, goose, etc.) that need their initial prompt typed into the live - // session and submitted. Wait until the agent owns the PTY before writing. - if (startup.followupPrompt) { - const prompt = startup.followupPrompt + // Why: a stdin-after-start agent (aider, goose, etc.) needs its initial prompt typed into the + // live session and submitted. Wait until the agent owns the PTY before writing. + if (startup.pastePromptAfterReady) { + const prompt = startup.pastePromptAfterReady // Why: an argv agent's prompt left for paste (a WSL line too long to type, a paired host) waits // for the agent's composer and lands as one bracketed paste, not raw keys once the process shows. const delivered = agentPromptRidesLaunchCommand(startup.agent) diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index 1e703df742f..ef7536292e0 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -10,7 +10,7 @@ import type { WorkspaceLinkedItem, WorkspaceStatus } from '../../../shared/worktree/types' -import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import type { ComposerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' import type { AgentLaunchRoute } from '@/lib/agent-launch-routing' @@ -103,7 +103,7 @@ export type WorktreeCreationRequest = { note: string /** Renderer-side launch plan used to seed the first terminal when the backend * did not already spawn it. Null for blank-shell creates. */ - startupPlan: AgentStartupPlan | null + startupPlan: ComposerAgentStartupPlan | null quickPrompt: string /** Launch context delivered only as an unsent TUI-input draft (argv prefill or * startup paste); completion seeds the chat-composer copy from it. */ diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index 223b3b879f0..b32d6cbc669 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -2,6 +2,7 @@ import { buildAgentDraftLaunchPlan, buildAgentStartupPlan, planAgentCliArgsSuffix, + planLaunchPrompt, type AgentStartupPlan } from '@/lib/tui-agent-startup' import { CLIENT_PLATFORM } from '@/lib/new-workspace' @@ -14,6 +15,7 @@ import type { SessionOptionValue } from '../../../shared/native-chat-session-opt export type SourceControlLaunchPlanDelivery = | 'argv' + | 'argv-launch-file' | 'draft-native' | 'draft-paste' | 'paste-submit' @@ -43,6 +45,8 @@ export function planSourceControlAgentActionLaunch(args: { /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must not be applied for remote launches. */ isRemote?: boolean + /** See `launchHostIsPaired`. */ + launchHostIsPaired?: boolean }): SourceControlLaunchPlanResult { const agent = args.agent if (!agent) { @@ -133,7 +137,6 @@ export function planSourceControlAgentActionLaunch(args: { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } @@ -172,7 +175,7 @@ export function planSourceControlAgentActionLaunch(args: { }) delivery = 'draft-paste' } else { - startupPlan = buildAgentStartupPlan({ + const planned = planLaunchPrompt({ agent, prompt: trimmedInput, cmdOverrides, @@ -181,9 +184,26 @@ export function planSourceControlAgentActionLaunch(args: { isRemote, agentArgs: args.agentArgs, sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: false + launchHostIsPaired: args.launchHostIsPaired ?? false }) - delivery = 'argv' + switch (planned?.carry) { + case undefined: + delivery = 'argv' + break + case 'none': + case 'on-line': + startupPlan = planned.plan + delivery = 'argv' + break + case 'launch-file': + startupPlan = planned.plan + delivery = 'argv-launch-file' + break + case 'paste-after-ready': + startupPlan = planned.cleanPlan + delivery = 'paste-submit' + break + } } if (!startupPlan) { @@ -203,7 +223,7 @@ export function planSourceControlAgentActionLaunch(args: { ? 'The command input is prefilled as an editable draft by the agent launch command.' : delivery === 'draft-paste' ? 'The agent starts with no prompt, then Orca pastes the command input as an editable draft after the TUI is ready.' - : startupPlan.launchFile + : delivery === 'argv-launch-file' ? 'The command input goes in a private file the host writes; the launch command tells the agent to read it, submitted as the first turn.' : 'The command input is included in the launch command and submitted as the first turn.' diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index 7ce665544b3..095ac81920c 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - buildAgentDraftLaunchPlan, - buildAgentStartupPlan, - isShellProcess -} from './tui-agent-startup' +import { planLaunchForTest } from '../../../shared/launch-prompt-plan.test-fixture' +import { buildAgentDraftLaunchPlan, isShellProcess } from './tui-agent-startup' import { resolveTuiAgentLaunchArgs } from '../../../shared/tui-agent-launch-defaults' const emptyLaunchConfig = (agentCommand: string) => ({ @@ -15,7 +12,7 @@ const emptyLaunchConfig = (agentCommand: string) => ({ describe('buildAgentStartupPlan', () => { it('passes Claude prompts as a positional interactive argument', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'claude', prompt: 'Fix the bug', cmdOverrides: {}, @@ -25,14 +22,15 @@ describe('buildAgentStartupPlan', () => { agent: 'claude', launchCommand: "claude 'Fix the bug'", expectedProcess: 'claude', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('claude') }) }) it('uses Gemini interactive prompt mode instead of dropping the prompt', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'gemini', prompt: 'Investigate this regression', cmdOverrides: {}, @@ -42,14 +40,15 @@ describe('buildAgentStartupPlan', () => { agent: 'gemini', launchCommand: "gemini --prompt-interactive 'Investigate this regression'", expectedProcess: 'gemini', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('gemini') }) }) it('uses Antigravity interactive prompt mode with the agy binary', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'antigravity', prompt: 'Investigate this regression', cmdOverrides: {}, @@ -59,14 +58,15 @@ describe('buildAgentStartupPlan', () => { agent: 'antigravity', launchCommand: "agy --prompt-interactive 'Investigate this regression'", expectedProcess: 'agy', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('agy') }) }) it('launches aider first and injects the draft prompt after startup', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'aider', prompt: 'Refactor the parser', cmdOverrides: {}, @@ -76,14 +76,15 @@ describe('buildAgentStartupPlan', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'Refactor the parser', + carry: 'paste-after-ready', + pasteAfterReady: 'Refactor the parser', launchConfig: emptyLaunchConfig('aider') }) }) it('launches Autohand Code first and injects the draft prompt after startup', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'autohand', prompt: 'Add tests for the parser', cmdOverrides: {}, @@ -93,14 +94,15 @@ describe('buildAgentStartupPlan', () => { agent: 'autohand', launchCommand: 'autohand', expectedProcess: 'autohand', - followupPrompt: 'Add tests for the parser', + carry: 'paste-after-ready', + pasteAfterReady: 'Add tests for the parser', launchConfig: emptyLaunchConfig('autohand') }) }) it('launches Ante first and injects the draft prompt after startup', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'ante', prompt: 'Summarize the failing tests', cmdOverrides: {}, @@ -110,14 +112,15 @@ describe('buildAgentStartupPlan', () => { agent: 'ante', launchCommand: 'ante', expectedProcess: 'ante', - followupPrompt: 'Summarize the failing tests', + carry: 'paste-after-ready', + pasteAfterReady: 'Summarize the failing tests', launchConfig: emptyLaunchConfig('ante') }) }) it('passes the prompt to Trae as a positional argv behind a `--` separator', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'trae', prompt: 'Summarize the failing tests', cmdOverrides: {}, @@ -127,7 +130,8 @@ describe('buildAgentStartupPlan', () => { agent: 'trae', launchCommand: "traecli -- 'Summarize the failing tests'", expectedProcess: 'traecli', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('traecli') }) }) @@ -135,7 +139,7 @@ describe('buildAgentStartupPlan', () => { // Why: without the separator these dispatch to Trae's `help`/`config` subcommands instead. it('keeps subcommand-shaped Trae prompts as the positional prompt', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'trae', prompt: 'help me name this config', cmdOverrides: {}, @@ -146,7 +150,7 @@ describe('buildAgentStartupPlan', () => { it('delivers the Muse prompt after its composer is ready', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'muse', prompt: 'Summarize the failing tests', cmdOverrides: {}, @@ -156,7 +160,8 @@ describe('buildAgentStartupPlan', () => { agent: 'muse', launchCommand: 'muse --trust-workspace', expectedProcess: 'muse', - followupPrompt: 'Summarize the failing tests', + carry: 'paste-after-ready', + pasteAfterReady: 'Summarize the failing tests', launchConfig: { ...emptyLaunchConfig('muse'), agentCommand: 'muse --trust-workspace' @@ -166,7 +171,7 @@ describe('buildAgentStartupPlan', () => { it('passes the prompt to Prime Agent as a positional argv behind a `--` separator', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'prime-agent', prompt: 'Summarize the failing tests', cmdOverrides: {}, @@ -176,7 +181,8 @@ describe('buildAgentStartupPlan', () => { agent: 'prime-agent', launchCommand: "prime-agent -- 'Summarize the failing tests'", expectedProcess: 'prime-agent', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('prime-agent') }) }) @@ -184,7 +190,7 @@ describe('buildAgentStartupPlan', () => { // Why: without the separator these dispatch to Prime Agent's `help`/`agents` subcommands instead. it('keeps subcommand-shaped Prime Agent prompts as the positional prompt', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'prime-agent', prompt: 'help me name this config', cmdOverrides: {}, @@ -195,7 +201,7 @@ describe('buildAgentStartupPlan', () => { it('uses cursor-agent as the actual launch binary', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'cursor', prompt: 'Review this file', cmdOverrides: {}, @@ -205,14 +211,15 @@ describe('buildAgentStartupPlan', () => { agent: 'cursor', launchCommand: "cursor-agent 'Review this file'", expectedProcess: 'cursor-agent', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('cursor-agent') }) }) it('applies command overrides without changing the prompt syntax contract', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'droid', prompt: 'Ship the fix', cmdOverrides: { droid: '/opt/factory/bin/droid' }, @@ -222,14 +229,15 @@ describe('buildAgentStartupPlan', () => { agent: 'droid', launchCommand: "/opt/factory/bin/droid 'Ship the fix'", expectedProcess: 'droid', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('/opt/factory/bin/droid') }) }) it('passes Copilot prompts with the -i flag for an interactive session', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'copilot', prompt: 'Fix the bug', cmdOverrides: {}, @@ -239,14 +247,15 @@ describe('buildAgentStartupPlan', () => { agent: 'copilot', launchCommand: "copilot -i 'Fix the bug'", expectedProcess: 'copilot', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('copilot') }) }) it('launches Grok with the prompt as a positional argv', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'grok', prompt: 'Trace the failing test', cmdOverrides: {}, @@ -256,14 +265,15 @@ describe('buildAgentStartupPlan', () => { agent: 'grok', launchCommand: "grok -- 'Trace the failing test'", expectedProcess: 'grok', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('grok') }) }) it('launches Devin first and injects the prompt after startup', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'devin', prompt: 'Trace the failing test', cmdOverrides: {}, @@ -274,7 +284,8 @@ describe('buildAgentStartupPlan', () => { agent: 'devin', launchCommand: "devin '--permission-mode' 'bypass' '--respect-workspace-trust' 'false'", expectedProcess: 'devin', - followupPrompt: 'Trace the failing test', + carry: 'paste-after-ready', + pasteAfterReady: 'Trace the failing test', launchConfig: { agentCommand: "devin '--permission-mode' 'bypass' '--respect-workspace-trust' 'false'", agentArgs: '--permission-mode bypass --respect-workspace-trust false', @@ -285,7 +296,7 @@ describe('buildAgentStartupPlan', () => { it('launches Command Code by its unambiguous binary with a positional prompt', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'command-code', prompt: 'Fix the issue', cmdOverrides: {}, @@ -295,14 +306,15 @@ describe('buildAgentStartupPlan', () => { agent: 'command-code', launchCommand: "command-code --trust 'Fix the issue'", expectedProcess: 'command-code', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('command-code --trust') }) }) it('returns null when there is no prompt to inject', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'codex', prompt: ' ', cmdOverrides: {}, diff --git a/src/renderer/src/lib/tui-agent-startup.ts b/src/renderer/src/lib/tui-agent-startup.ts index a61e9003d60..7b51cdc27f2 100644 --- a/src/renderer/src/lib/tui-agent-startup.ts +++ b/src/renderer/src/lib/tui-agent-startup.ts @@ -3,6 +3,7 @@ export { buildAgentDraftLaunchPlan, buildAgentStartupPlan, planAgentCliArgsSuffix, + planLaunchPrompt, isShellProcess, quoteStartupArg, resolveStartupShell @@ -10,5 +11,7 @@ export { export type { AgentCliArgsPlan, AgentDraftLaunchPlan, - AgentStartupPlan + AgentLaunchPromptArgs, + AgentStartupPlan, + AgentStartupPlanInputs } from '../../../shared/tui-agent-startup' diff --git a/src/renderer/src/lib/worktree-creation-background-mount.test.ts b/src/renderer/src/lib/worktree-creation-background-mount.test.ts index b44e87687be..955ed1779c8 100644 --- a/src/renderer/src/lib/worktree-creation-background-mount.test.ts +++ b/src/renderer/src/lib/worktree-creation-background-mount.test.ts @@ -40,7 +40,6 @@ function makeAgentRequest(): WorktreeCreationRequest { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } }, quickPrompt: '', diff --git a/src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts b/src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts index 68af47babfd..d0de17e0681 100644 --- a/src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts @@ -206,7 +206,6 @@ describe('a creation that finishes after the user moved on (#9944)', () => { agent: 'claude', launchCommand: 'claude', expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } } }) diff --git a/src/renderer/src/lib/worktree-creation-flow-startup.ts b/src/renderer/src/lib/worktree-creation-flow-startup.ts index 5201719192a..98698278abc 100644 --- a/src/renderer/src/lib/worktree-creation-flow-startup.ts +++ b/src/renderer/src/lib/worktree-creation-flow-startup.ts @@ -28,6 +28,7 @@ export function buildWorktreeCreationStartupOpt( ...(request.launchDraftPrompt ? { launchDraftText: request.launchDraftPrompt } : {}), ...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}), ...(plan.launchFile ? { launchFile: plan.launchFile } : {}), + ...(plan.launchPrompt ? { launchPrompt: plan.launchPrompt } : {}), // Why: command-code shows its prompt in the tab status before the first // hook fires, so the prompt is threaded through here. ...(request.agent === 'command-code' && request.quickPrompt.trim().length > 0 diff --git a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts index 7a678434a4e..97db288744f 100644 --- a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts @@ -232,7 +232,7 @@ describe('a throw after createWorktree succeeds no longer strands the creation s launchCommand: 'codex', expectedProcess: 'codex', draftPrompt: 'draft context', - followupPrompt: 'follow-up context', + pastePromptAfterReady: 'follow-up context', launchConfig: { agentArgs: '', agentEnv: {} } } }) diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 92dea7e3991..cd1cd6c2666 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -626,7 +626,6 @@ describe('staged background worktree creation', () => { agent: 'claude', launchCommand: 'claude --prefill x', expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } }, launchDraftPrompt: 'https://github.com/o/r/issues/12' @@ -664,7 +663,6 @@ describe('staged background worktree creation', () => { agent: 'claude', launchCommand: 'claude --prefill x', expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } }, launchDraftPrompt: 'https://github.com/o/r/issues/12' @@ -697,7 +695,6 @@ describe('staged background worktree creation', () => { agent, launchCommand: `${agent} --prefill x`, expectedProcess: agent, - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } }, launchDraftPrompt: draft @@ -732,7 +729,6 @@ describe('staged background worktree creation', () => { agent: 'claude', launchCommand: "claude --prefill 'https://github.com/o/r/issues/12'", expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } }, launchDraftPrompt: 'https://github.com/o/r/issues/12' @@ -761,7 +757,6 @@ describe('staged background worktree creation', () => { agent: 'claude', launchCommand: 'claude', expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -789,7 +784,6 @@ describe('staged background worktree creation', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agent: 'codex', command: 'codex' }, draftPrompt: 'ship it' } as never diff --git a/src/renderer/src/lib/worktree-startup-payload.ts b/src/renderer/src/lib/worktree-startup-payload.ts index ab3b3b44d5b..46244464315 100644 --- a/src/renderer/src/lib/worktree-startup-payload.ts +++ b/src/renderer/src/lib/worktree-startup-payload.ts @@ -33,6 +33,8 @@ export type WorktreeStartupPayload = { startupCommandDelivery?: StartupCommandDelivery /** Written by the host before it types `command`, which names it by placeholder. */ launchFile?: LaunchFile + /** The submitted prompt `command` carries; handed back to copy if the host refuses the spawn. */ + launchPrompt?: string initialAgentStatus?: { agent: TuiAgent; prompt: string } sessionOptions?: Record telemetry?: AgentStartedTelemetry diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index c0448be5ee3..0b1306ec71f 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -207,6 +207,7 @@ export type TerminalActions = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery launchFile?: LaunchFile + launchPrompt?: string env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig @@ -234,6 +235,7 @@ export type TerminalActions = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery launchFile?: LaunchFile + launchPrompt?: string env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index a91dfbdbd26..f1b9f0f38bb 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -67,6 +67,7 @@ export type TerminalState = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery launchFile?: LaunchFile + launchPrompt?: string env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig diff --git a/src/shared/cmd-startup-arg-quoting.win32.test.ts b/src/shared/cmd-startup-arg-quoting.win32.test.ts index 1e7dd8424b4..132dbed105f 100644 --- a/src/shared/cmd-startup-arg-quoting.win32.test.ts +++ b/src/shared/cmd-startup-arg-quoting.win32.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { runProcess } from './child-process/run-process' import { WINDOWS_ARGUMENT_CORPUS, @@ -9,7 +10,6 @@ import { } from './child-process/__fixtures__/windows-argument-corpus' import { removeTreeSync } from './windows-transient-lock-removal' import { quoteStartupArg } from './tui-agent-startup-shell' -import { buildAgentStartupPlan } from './tui-agent-startup' import { removeLaunchFile, writeLaunchFile } from './launch-file-writing' /** @@ -64,7 +64,7 @@ describeOnWindows('cmd launch-line prompt quoting', () => { it('never lets a multi-line prompt reach cmd as commands: the line names a launch file', async () => { const marker = join(dir, 'pwned.txt') const prompt = `Fix the build\r\n& echo PWNED> "${marker}"\nthen run the tests` - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt, cmdOverrides: { claude: shim }, @@ -97,7 +97,7 @@ describeOnWindows('cmd launch-line prompt quoting', () => { it('names a launch file under a home with an apostrophe, accents, CJK and `%`', async () => { const home = join(dir, "O'Brien José 张伟 100%") mkdirSync(home) - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'Fix the build\nthen run the tests', cmdOverrides: { claude: shim }, diff --git a/src/shared/freebuff-launch.test.ts b/src/shared/freebuff-launch.test.ts index b58dc4bd091..2714f36fdbe 100644 --- a/src/shared/freebuff-launch.test.ts +++ b/src/shared/freebuff-launch.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from './tui-agent-startup' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' +import { buildAgentDraftLaunchPlan } from './tui-agent-startup' import { isExpectedAgentProcess, recognizeAgentProcess } from './agent-process-recognition' import { pickTuiAgent } from './tui-agent-selection' @@ -8,7 +9,7 @@ describe('Freebuff terminal launches', () => { 'delivers task text through the interactive terminal on %s', (platform) => { const prompt = '--help\nFix "quoted" paths and $HOME handling' - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'freebuff', prompt, cmdOverrides: {}, @@ -17,7 +18,7 @@ describe('Freebuff terminal launches', () => { expect(plan).toMatchObject({ launchCommand: 'freebuff', expectedProcess: 'freebuff', - followupPrompt: prompt + pasteAfterReady: prompt }) expect( buildAgentDraftLaunchPlan({ @@ -32,14 +33,14 @@ describe('Freebuff terminal launches', () => { it('uses the same launch contract on SSH hosts', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'freebuff', prompt: 'Inspect this folder', cmdOverrides: {}, platform: 'linux', isRemote: true }) - ).toMatchObject({ launchCommand: 'freebuff', followupPrompt: 'Inspect this folder' }) + ).toMatchObject({ launchCommand: 'freebuff', pasteAfterReady: 'Inspect this folder' }) }) it.each(['/usr/local/bin/freebuff', 'C:\\tools\\freebuff.exe'])( diff --git a/src/shared/freebuff-startup.test.ts b/src/shared/freebuff-startup.test.ts index 65806790af4..34616b61eb2 100644 --- a/src/shared/freebuff-startup.test.ts +++ b/src/shared/freebuff-startup.test.ts @@ -23,8 +23,7 @@ describe('Freebuff startup', () => { expect(freebuff).toMatchObject({ launchCommand: 'freebuff --debug', - expectedProcess: 'freebuff', - followupPrompt: null + expectedProcess: 'freebuff' }) expect(codebuff).toMatchObject({ launchCommand: 'codebuff', expectedProcess: 'codebuff' }) }) diff --git a/src/shared/launch-file-directory-grant.test.ts b/src/shared/launch-file-directory-grant.test.ts index bf50b3e03dc..5b5b1114342 100644 --- a/src/shared/launch-file-directory-grant.test.ts +++ b/src/shared/launch-file-directory-grant.test.ts @@ -2,18 +2,18 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node: import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { writeLaunchFile } from './launch-file-writing' import { buildLaunchFilePointer, carryInLaunchFile, launchFileDirectoryPlaceholder } from './launch-prompt-file' -import { buildAgentStartupPlan } from './tui-agent-startup' import type { TuiAgent } from './tui-agent' function planWithLaunchFile(agent: TuiAgent, shell: 'posix' | 'cmd' | 'powershell' = 'posix') { const { prompt, launchFile } = carryInLaunchFile('the brief', true) - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent, prompt, cmdOverrides: {}, @@ -53,7 +53,7 @@ describe('the launch-file directory grant on the agent command line', () => { const { plan, pointer } = planWithLaunchFile('codex') expect(plan?.launchCommand).toBe(`codex '${pointer}'`) expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'claude', prompt: 'fix it', cmdOverrides: {}, diff --git a/src/shared/launch-file-writing.test.ts b/src/shared/launch-file-writing.test.ts index 5f8620e36cd..cbdf971d4c2 100644 --- a/src/shared/launch-file-writing.test.ts +++ b/src/shared/launch-file-writing.test.ts @@ -20,12 +20,7 @@ import { sweepStaleLaunchFiles, writeLaunchFile } from './launch-file-writing' -import { - MAX_INLINE_LAUNCH_PROMPT_CHARS, - buildLaunchFilePointer, - parseLaunchFile, - planLaunchPrompt -} from './launch-prompt-file' +import { buildLaunchFilePointer, carryInLaunchFile, parseLaunchFile } from './launch-prompt-file' import { quoteStartupArg } from './tui-agent-startup-shell' let baseDirectory: string @@ -38,35 +33,30 @@ afterEach(() => { rmSync(baseDirectory, { recursive: true, force: true }) }) -describe('planLaunchPrompt', () => { - it('keeps a prompt at the ceiling inline', () => { - const prompt = 'a'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS) - expect(planLaunchPrompt(prompt)).toEqual({ prompt }) - }) - - it('moves a 20,000-character prompt into a launch file', () => { +describe('carryInLaunchFile', () => { + it('moves a prompt into a launch file and names it by a pointer', () => { const prompt = 'b'.repeat(20_000) - const planned = planLaunchPrompt(prompt) - expect(planned.launchFile).toMatchObject({ content: prompt, sensitive: false }) - expect(planned.prompt).toBe(buildLaunchFilePointer(planned.launchFile!.placeholder)) + const carried = carryInLaunchFile(prompt, false) + expect(carried.launchFile).toMatchObject({ content: prompt, sensitive: false }) + expect(carried.prompt).toBe(buildLaunchFilePointer(carried.launchFile.placeholder)) }) - it('moves a short sensitive prompt into a launch file', () => { - const planned = planLaunchPrompt('token dcap_secret', { sensitive: true }) - expect(planned.prompt).not.toContain('dcap_secret') - expect(planned.launchFile).toMatchObject({ content: 'token dcap_secret', sensitive: true }) + it('keeps a sensitive prompt off the pointer', () => { + const carried = carryInLaunchFile('token dcap_secret', true) + expect(carried.prompt).not.toContain('dcap_secret') + expect(carried.launchFile).toMatchObject({ content: 'token dcap_secret', sensitive: true }) }) it('mints a fresh placeholder each time', () => { - const first = planLaunchPrompt('x', { sensitive: true }).launchFile!.placeholder - const second = planLaunchPrompt('x', { sensitive: true }).launchFile!.placeholder + const first = carryInLaunchFile('x', true).launchFile.placeholder + const second = carryInLaunchFile('x', true).launchFile.placeholder expect(first).not.toBe(second) }) }) describe('parseLaunchFile', () => { - it('accepts what planLaunchPrompt mints and rejects anything else', () => { - const { launchFile } = planLaunchPrompt('x', { sensitive: true }) + it('accepts what carryInLaunchFile mints and rejects anything else', () => { + const { launchFile } = carryInLaunchFile('x', true) expect(parseLaunchFile(launchFile)).toEqual(launchFile) expect(parseLaunchFile({ ...launchFile, placeholder: 'HOME' })).toBeUndefined() expect(parseLaunchFile({ ...launchFile, sensitive: 'yes' })).toBeUndefined() @@ -81,7 +71,7 @@ describe('parseLaunchFile', () => { describe('writeLaunchFile', () => { it('writes a 0600 file in a private 0700 directory and puts its path in the line and env', () => { - const { prompt, launchFile } = planLaunchPrompt('c'.repeat(20_000)) + const { prompt, launchFile } = carryInLaunchFile('c'.repeat(20_000), false) const written = writeLaunchFile({ launchFile: launchFile!, command: `claude '${prompt}'`, @@ -102,7 +92,7 @@ describe('writeLaunchFile', () => { it("refuses an apostrophe in the path when the line's quoting is unknown, leaving nothing behind", () => { const quoted = join(baseDirectory, "it's") mkdirSync(quoted) - const { launchFile } = planLaunchPrompt('x', { sensitive: true }) + const { launchFile } = carryInLaunchFile('x', true) expect(() => writeLaunchFile({ launchFile: launchFile!, baseDirectory: quoted })).toThrow( LaunchFileUnavailableError ) @@ -110,7 +100,7 @@ describe('writeLaunchFile', () => { }) it('refuses when the temp directory cannot be written', () => { - const { launchFile } = planLaunchPrompt('x', { sensitive: true }) + const { launchFile } = carryInLaunchFile('x', true) expect(() => writeLaunchFile({ launchFile: launchFile!, baseDirectory: join(baseDirectory, 'missing') }) ).toThrow( @@ -119,7 +109,7 @@ describe('writeLaunchFile', () => { }) it('removes the whole directory', () => { - const { launchFile } = planLaunchPrompt('x', { sensitive: true }) + const { launchFile } = carryInLaunchFile('x', true) const written = writeLaunchFile({ launchFile: launchFile!, baseDirectory }) removeLaunchFile(written) expect(existsSync(written.directory)).toBe(false) @@ -178,7 +168,7 @@ describePosix('a launch line naming a file under an unusual home directory', () async (user) => { const home = join(baseDirectory, user) mkdirSync(home) - const { prompt, launchFile } = planLaunchPrompt('brief', { sensitive: true }) + const { prompt, launchFile } = carryInLaunchFile('brief', true) const written = writeLaunchFile({ launchFile: { ...launchFile!, quoting: 'posix' }, command: `printf '%s' ${quoteStartupArg(prompt, 'posix')}`, diff --git a/src/shared/launch-file-writing.ts b/src/shared/launch-file-writing.ts index b857163344a..b3d55e20b98 100644 --- a/src/shared/launch-file-writing.ts +++ b/src/shared/launch-file-writing.ts @@ -20,7 +20,7 @@ import { type LaunchFile } from './launch-prompt-file' import { quoteStartupArg, type AgentStartupShell } from './tui-agent-startup-shell' -import { typedStartupLineFits } from './typed-startup-line' +import { startupLineNeedsStaging } from './startup-command-staging' import type { WslLaunchDirectory } from './wsl-launch-directory' const LAUNCH_FILE_DIR_PREFIX = 'orca-launch-file-' @@ -69,8 +69,8 @@ export function launchFilePathInQuotedRun( } export class LaunchFileUnavailableError extends Error { - constructor(reason: string) { - super(describeLaunchFileUnavailable(reason)) + constructor(reason: string, carrier: 'file' | 'staged-line' = 'file') { + super(describeLaunchFileUnavailable(reason, carrier)) } } @@ -161,6 +161,19 @@ export function writeLaunchFile(args: { } } +/** Whether a WSL spawn needs its distro's launch directory: for a launch file, or to stage the + * long or multi-line agent line Orca built. Only then is the distro probed. */ +export function spawnNeedsWslLaunchDirectory(args: { + launchFile?: LaunchFile + command?: string + orcaBuiltLine: boolean +}): boolean { + return ( + args.launchFile !== undefined || + (args.orcaBuiltLine && args.command !== undefined && startupLineNeedsStaging(args.command)) + ) +} + /** * The write site's launch file for a spawn. A WSL session writes it into the distro, and refuses * when the distro directory is unknown and a file or a staged line needs it: a Windows path means @@ -175,11 +188,15 @@ export function writeSpawnLaunchFile(args: { wslDirectory: WslLaunchDirectory | undefined }): WrittenLaunchFile | undefined { const wslDirectory = args.wslDistro ? args.wslDirectory : undefined - const needsDirectory = - args.launchFile !== undefined || - (args.orcaBuiltLine && args.command !== undefined && !typedStartupLineFits(args.command)) - if (args.wslDistro && needsDirectory && wslDirectory?.distro !== args.wslDistro) { - throw new LaunchFileUnavailableError("the WSL distro's home directory could not be reached") + if ( + args.wslDistro && + spawnNeedsWslLaunchDirectory(args) && + wslDirectory?.distro !== args.wslDistro + ) { + throw new LaunchFileUnavailableError( + "the WSL distro's home directory could not be reached", + args.launchFile ? 'file' : 'staged-line' + ) } return args.launchFile ? writeLaunchFile({ diff --git a/src/shared/launch-prompt-carrier.test.ts b/src/shared/launch-prompt-carrier.test.ts index 3f73193b164..81b69019970 100644 --- a/src/shared/launch-prompt-carrier.test.ts +++ b/src/shared/launch-prompt-carrier.test.ts @@ -1,43 +1,51 @@ import { describe, expect, it } from 'vitest' -import { buildAgentStartupPlan } from './tui-agent-startup' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' const base = { agent: 'claude' as const, cmdOverrides: {} } describe('the one launch-prompt decision every launch path builds through', () => { // Why: the composer, phone quick commands and background sessions call the builder directly. - it('points a single-line prompt past cmd’s line cap at a launch file', () => { + it('points a single-line Windows prompt past the typed budget at a launch file', () => { const prompt = 'y'.repeat(9_000) - const plan = buildAgentStartupPlan({ ...base, prompt, platform: 'win32', shell: 'cmd' }) + const plan = planLaunchForTest({ ...base, prompt, platform: 'win32', shell: 'cmd' }) expect(plan?.launchFile?.content).toBe(prompt) expect(plan?.launchCommand.length).toBeLessThan(8_191) }) it('points a prompt past the argv ceiling at a launch file on a POSIX host too', () => { const prompt = 'z'.repeat(20_000) - expect(buildAgentStartupPlan({ ...base, prompt, platform: 'linux' })?.launchFile?.content).toBe( + expect(planLaunchForTest({ ...base, prompt, platform: 'linux' })?.launchFile?.content).toBe( prompt ) }) - it('keeps a sensitive prompt off the line however short it is', () => { - const plan = buildAgentStartupPlan({ + it('keeps a paired host to the typed budget, pasting what it may neither stage nor read', () => { + const short = planLaunchForTest({ ...base, - prompt: 'token dcap_secret', + prompt: 'fix it', platform: 'linux', - sensitive: true + launchHostIsPaired: true }) - expect(plan?.launchFile).toMatchObject({ content: 'token dcap_secret', sensitive: true }) - expect(plan?.launchCommand).not.toContain('dcap_secret') + expect(short?.carry).toBe('on-line') + const long = planLaunchForTest({ + ...base, + prompt: 'fix it\nthen run the tests', + platform: 'linux', + launchHostIsPaired: true + }) + expect(long?.carry).toBe('paste-after-ready') + expect(long?.pasteAfterReady).toBe('fix it\nthen run the tests') + expect(long?.launchCommand).not.toContain('run the tests') }) // Why: the host writes a WSL session's staged line and launch file into the distro. it('plans a WSL launch like any Linux launch: long lines stay typed, huge ones get a file', () => { - const typed = buildAgentStartupPlan({ ...base, platform: 'linux', prompt: 'one\ntwo' }) + const typed = planLaunchForTest({ ...base, platform: 'linux', prompt: 'one\ntwo' }) expect(typed?.launchCommand).toContain('two') - expect(typed?.followupPrompt).toBeNull() + expect(typed?.pasteAfterReady).toBeNull() const huge = 'c'.repeat(20_000) - const pointed = buildAgentStartupPlan({ ...base, platform: 'linux', prompt: huge }) + const pointed = planLaunchForTest({ ...base, platform: 'linux', prompt: huge }) expect(pointed?.launchFile?.content).toBe(huge) - expect(pointed?.followupPrompt).toBeNull() + expect(pointed?.pasteAfterReady).toBeNull() }) }) diff --git a/src/shared/launch-prompt-carry.test.ts b/src/shared/launch-prompt-carry.test.ts new file mode 100644 index 00000000000..f058bfc7bb9 --- /dev/null +++ b/src/shared/launch-prompt-carry.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { MAX_INLINE_LAUNCH_PROMPT_CHARS, carryInLaunchFile } from './launch-prompt-file' +import { planLaunchPrompt, type AgentLaunchPromptArgs } from './tui-agent-startup' +import type { TuiAgent } from './tui-agent' +import { RUNTIME_CAPABILITIES } from './protocol-version' +import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability' +import { TYPED_STARTUP_LINE_BUDGET_BYTES, typedStartupLineFits } from './typed-startup-line' + +function plan( + agent: TuiAgent, + prompt: string, + extra: Partial> = {} +) { + return planLaunchPrompt({ agent, prompt, cmdOverrides: {}, platform: 'darwin', ...extra }) +} + +describe('where a launch prompt rides', () => { + it('builds a plain launch for an empty prompt', () => { + expect(plan('claude', ' ')).toMatchObject({ carry: 'none' }) + }) + + it('carries a short prompt on the line', () => { + const planned = plan('claude', 'explain this repo') + expect(planned?.carry).toBe('on-line') + expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toContain( + 'explain this repo' + ) + }) + + it.each([ + ['a 600-byte', 'x'.repeat(600)], + ['a multi-line', 'first line\nsecond line'], + ['a key-bearing', 'see\tthis \x1b[31mred'] + ])('carries %s prompt on the line of a POSIX host, which stages it', (_label, prompt) => { + const planned = plan('codex', prompt) + expect(planned?.carry).toBe('on-line') + expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toContain(prompt) + }) + + it.each(['claude', 'codex'])( + 'points %s at a launch file past the argv ceiling, with the full text in the file', + (agent) => { + const prompt = `${'y'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS)}z` + const planned = plan(agent, prompt) + if (planned?.carry !== 'launch-file') { + throw new Error(`expected a launch file, got ${planned?.carry}`) + } + expect(planned.launchFile).toMatchObject({ content: prompt, sensitive: false }) + expect(planned.plan.launchCommand).toContain(planned.launchFile.placeholder) + expect(planned.plan.launchCommand).not.toContain('yyyy') + } + ) + + // The bug class this outcome removes: a plan that exists but does not carry the prompt. + it('leaves a file-sized prompt for the paste for an agent not measured reading the file', () => { + const prompt = 'g'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS + 1) + const planned = plan('gemini', prompt) + if (planned?.carry !== 'paste-after-ready') { + throw new Error(`expected the paste, got ${planned?.carry}`) + } + expect(planned.text).toBe(prompt) + expect(planned.cleanPlan.launchCommand).not.toContain('gggg') + }) + + it('leaves a stdin-after-start agent’s prompt for the paste', () => { + const planned = plan('aider', 'fix it') + expect(planned).toMatchObject({ carry: 'paste-after-ready', text: 'fix it' }) + }) + + it('names a launch file its caller wrote, quoted for the line', () => { + const { prompt, launchFile } = carryInLaunchFile('worker brief', true) + const planned = plan('claude', prompt, { launchFile }) + expect(planned).toMatchObject({ + carry: 'launch-file', + launchFile: { ...launchFile, quoting: 'posix' } + }) + }) +}) + +describe('a host that types the line raw', () => { + it.each<['cmd' | 'powershell']>([['cmd'], ['powershell']])( + 'keeps a %s line to the typed budget and points Claude at a file past it', + (shell) => { + const short = plan('claude', 'fix it', { platform: 'win32', shell }) + expect(short?.carry).toBe('on-line') + const long = plan('claude', 'y'.repeat(600), { platform: 'win32', shell }) + expect(long?.carry).toBe('launch-file') + } + ) + + it('points Codex at a file for a Windows-damaged prompt, and pastes it for Gemini', () => { + const prompt = 'fix the build\nthen run the tests' + expect(plan('codex', prompt, { platform: 'win32', shell: 'cmd' })?.carry).toBe('launch-file') + expect(plan('gemini', prompt, { platform: 'win32', shell: 'cmd' })).toMatchObject({ + carry: 'paste-after-ready', + text: prompt + }) + }) + + it('points a PowerShell prompt with a double quote at a file', () => { + expect(plan('claude', 'say "hi"', { platform: 'win32', shell: 'powershell' })?.carry).toBe( + 'launch-file' + ) + }) + + it('pastes on a paired host what its line cannot carry typed, even for Claude', () => { + const extra = { platform: 'linux' as const, launchHostIsPaired: true } + expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line') + expect(plan('claude', 'first line\nsecond line', extra)?.carry).toBe('paste-after-ready') + expect(plan('claude', 'z'.repeat(20_000), extra)?.carry).toBe('paste-after-ready') + }) +}) + +describe('whether a line can be typed as it is', () => { + it('holds a line to half of macOS MAX_CANON', () => { + expect(typedStartupLineFits('x'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES))).toBe(true) + expect(typedStartupLineFits('x'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES + 1))).toBe(false) + }) + + it.each(['\n', '\r', '\t', '\x1b', '\x03', '\x7f'])('never types a line with %j', (byte) => { + expect(typedStartupLineFits(`a${byte}b`)).toBe(false) + }) +}) + +describe('the capability clients gate a prompted launch on', () => { + it('is advertised by every host that delivers a prompt its typed line cannot carry as typed', () => { + // An older host types any prompt into the line, so its absence is the gate. + expect(RUNTIME_CAPABILITIES).toContain(AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY) + }) +}) diff --git a/src/shared/launch-prompt-carry.ts b/src/shared/launch-prompt-carry.ts new file mode 100644 index 00000000000..c9e708f45f8 --- /dev/null +++ b/src/shared/launch-prompt-carry.ts @@ -0,0 +1,167 @@ +/** Where a launch prompt rides: decided once here, for every launch path, and switched on by each. */ +import { + MAX_INLINE_LAUNCH_PROMPT_CHARS, + carryInLaunchFile, + launchFileDirectoryPlaceholder, + type LaunchFile +} from './launch-prompt-file' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { + isPosixStartupShell, + quoteStartupArg, + resolveStartupShell, + type AgentStartupShell +} from './tui-agent-startup-shell' +import { hasControlByte, typedStartupLineFits } from './typed-startup-line' +import type { TuiAgent } from './tui-agent' + +/** + * Whether a Windows shell would damage `prompt` as one quoted argument. No quoting keeps a control + * byte literal: cmd types the line and a line break submits it early. PowerShell runs a short line + * from its argv, so its damage is the hand-off to the agent (measured): legacy argument passing (5.1 + * always, 7.x into a `.cmd` shim) splits at an inner `"` and turns a trailing `\` into `"`, and a + * shim's cmd.exe expands `%NAME%`; a lone `%` stays literal. + */ +export function windowsShellDamagesPrompt(prompt: string, shell: AgentStartupShell): boolean { + if (isPosixStartupShell(shell)) { + return false + } + return ( + hasControlByte(prompt) || + (shell === 'powershell' && + (prompt.includes('"') || /%[^%]+%/.test(prompt) || prompt.endsWith('\\'))) + ) +} + +/** Why a prefill draft could not be launched, in the user's words, when the Windows shell is why. */ +export function windowsDraftRefusal(draft: string, shell: AgentStartupShell): string | null { + return windowsShellDamagesPrompt(draft.trim(), shell) + ? "The host's Windows shell would break this draft on the agent's command line (it has a line " + + 'break or other control character, or on PowerShell a double quote, a %NAME% pair or a ' + + 'trailing backslash), so the agent was not started. Start it without the draft and paste ' + + 'the draft once it opens.' + : null +} + +/** + * Where a launch prompt went. Only the carried outcomes hold the plan to launch, so a caller must + * switch on `carry` before it can launch anything, and cannot mistake a paste for a delivery. + */ +export type LaunchPromptPlan

= + | { carry: 'none'; plan: P } + | { carry: 'on-line'; plan: P } + | { carry: 'launch-file'; plan: P; launchFile: LaunchFile } + | { carry: 'paste-after-ready'; cleanPlan: P; text: string } + +export type LaunchPromptCarry = LaunchPromptPlan['carry'] + +/** Why a create that cannot paste after the agent is ready refuses a prompt that needs it. */ +export function launchPromptNeedsPasteRefusal( + agent: TuiAgent, + created: 'terminal' | 'session' +): string { + return TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' + ? `${agent} takes its prompt only after it starts, so no ${created} was created. Start the ` + + 'agent and paste the prompt once it opens.' + : `${agent} cannot take this prompt on its command line here (too long, or broken by the ` + + `host's Windows shell), so no ${created} was created. Start the agent without it and ` + + 'paste the prompt once it opens.' +} + +export type CarriedPlanArgs = { + agent: TuiAgent + prompt: string + platform: NodeJS.Platform + shell?: AgentStartupShell + /** The file `prompt` already points at, when the caller wrote its own. */ + launchFile?: LaunchFile + /** Another Orca this client drives, possibly an older one: it is sent a command it may neither + * stage nor accompany with a launch file. Temporary, until paired hosts advertise both. */ + launchHostIsPaired?: boolean +} + +export function agentReadsLaunchFile(agent: TuiAgent): boolean { + return TUI_AGENT_CONFIG[agent].readsLaunchFile === true +} + +/** + * The one carry rule. The prompt rides the agent's line, which a host that stages (POSIX, SSH, WSL) + * stages when it is long or multi-line. It rides a launch file when it is past the argv ceiling, + * when a Windows shell would damage it, or when the host types the line raw (a Windows host, a + * paired Orca) and the line is past the typed budget. A launch file goes only to an agent measured + * reading one, on a host that writes it; otherwise the agent starts clean and the prompt is pasted + * once it is ready. + */ +export function carryLaunchPrompt( + args: A, + buildLine: (args: A) => P | null +): LaunchPromptPlan

| null { + const text = args.prompt.trim() + const shell = resolveStartupShell(args.platform, args.shell) + const clean = (): P | null => buildLine({ ...args, prompt: '', launchFile: undefined }) + if (!text) { + const plan = clean() + return plan && { carry: 'none', plan } + } + if (args.launchFile) { + const plan = buildLine(args) + return plan && { carry: 'launch-file', plan, launchFile: withQuoting(args.launchFile, shell) } + } + const pasteAfterReady = (): LaunchPromptPlan

| null => { + const cleanPlan = clean() + return cleanPlan && { carry: 'paste-after-ready', cleanPlan, text } + } + const mode = TUI_AGENT_CONFIG[args.agent].promptInjectionMode + if (mode === 'stdin-after-start') { + return pasteAfterReady() + } + const viaLaunchFile = (): LaunchPromptPlan

| null => { + // Why: an agent not measured reading the file would stop on an approval or refuse the path. + if (args.launchHostIsPaired === true || !agentReadsLaunchFile(args.agent)) { + return pasteAfterReady() + } + const pointer = carryInLaunchFile(text, false) + const plan = buildLine({ ...args, prompt: pointer.prompt, launchFile: pointer.launchFile }) + return ( + plan && { carry: 'launch-file', plan, launchFile: withQuoting(pointer.launchFile, shell) } + ) + } + const readsEnv = mode === 'hermes-query' + if ( + text.length > MAX_INLINE_LAUNCH_PROMPT_CHARS || + (!readsEnv && windowsShellDamagesPrompt(text, shell)) + ) { + return viaLaunchFile() + } + const plan = buildLine(args) + if (!plan) { + // Hermes reads its prompt from the env and refuses one past that budget, counted in bytes; a + // one-character query building proves the budget, not the command, refused it. + return readsEnv && buildLine({ ...args, prompt: '.' }) ? viaLaunchFile() : null + } + // Why the typed budget (#24257's): nothing stages a line the host types raw, and a long typed + // line is truncated or submitted early. Hermes's line never holds the text. + const hostTypesLineRaw = args.launchHostIsPaired === true || args.platform === 'win32' + if (!readsEnv && hostTypesLineRaw && !typedStartupLineFits(plan.launchCommand)) { + return viaLaunchFile() + } + return { carry: 'on-line', plan } +} + +/** The host writes the path inside this line's quoting, so the file carries which one it is. */ +function withQuoting(launchFile: LaunchFile, shell: AgentStartupShell): LaunchFile { + return { ...launchFile, quoting: shell } +} + +/** The host puts the launch file's private directory where the placeholder is, like its path. */ +export function launchFileDirectoryGrant( + agent: TuiAgent, + launchFile: LaunchFile | undefined, + shell: AgentStartupShell +): string { + const flag = TUI_AGENT_CONFIG[agent].launchFileDirectoryFlag + if (!launchFile || !flag) { + return '' + } + return ` ${quoteStartupArg(`${flag}=${launchFileDirectoryPlaceholder(launchFile.placeholder)}`, shell)}` +} diff --git a/src/shared/launch-prompt-file.ts b/src/shared/launch-prompt-file.ts index 4c563404fc7..0c99fc1251e 100644 --- a/src/shared/launch-prompt-file.ts +++ b/src/shared/launch-prompt-file.ts @@ -49,20 +49,6 @@ export function isLaunchFilePointer(prompt: string): boolean { ) } -/** - * The prompt a launch line carries, and the file behind it when the prompt is too - * long or sensitive. The pointer names a placeholder the execution host replaces. - */ -export function planLaunchPrompt( - prompt: string, - options: { sensitive?: boolean } = {} -): { prompt: string; launchFile?: LaunchFile } { - const sensitive = options.sensitive === true - return sensitive || prompt.length > MAX_INLINE_LAUNCH_PROMPT_CHARS - ? carryInLaunchFile(prompt, sensitive) - : { prompt } -} - export function carryInLaunchFile( content: string, sensitive: boolean @@ -103,9 +89,17 @@ export function parseLaunchFile(value: unknown): LaunchFile | undefined { const LAUNCH_FILE_UNAVAILABLE_CODE = 'launch_file_unavailable' -/** The refusal a host sends when it could not write a launch file; it reaches the user as is. */ -export function describeLaunchFileUnavailable(reason: string): string { - return `Orca could not write the file that carries the agent's prompt (${reason}), so the agent was not started. [${LAUNCH_FILE_UNAVAILABLE_CODE}]` +/** The refusal a host sends when it could not write what carries the prompt (a launch file, or the + * staged script holding a long line); it reaches the user as is. */ +export function describeLaunchFileUnavailable( + reason: string, + carrier: 'file' | 'staged-line' = 'file' +): string { + const what = + carrier === 'file' + ? "the file that carries the agent's prompt" + : "the script that carries the agent's launch line and prompt" + return `Orca could not write ${what} (${reason}), so the agent was not started. [${LAUNCH_FILE_UNAVAILABLE_CODE}]` } /** Also matches the `launch_file_unavailable: ` form earlier builds sent. */ diff --git a/src/shared/launch-prompt-plan.test-fixture.ts b/src/shared/launch-prompt-plan.test-fixture.ts new file mode 100644 index 00000000000..782eba00086 --- /dev/null +++ b/src/shared/launch-prompt-plan.test-fixture.ts @@ -0,0 +1,44 @@ +import type { LaunchFile } from './launch-prompt-file' +import type { LaunchPromptCarry } from './launch-prompt-carry' +import { + planLaunchPrompt, + type AgentLaunchPromptArgs, + type AgentStartupPlan +} from './tui-agent-startup' + +/** A prompted plan flattened for tests that assert on the line it builds. */ +export type PlannedLaunchForTest = AgentStartupPlan & { + carry: LaunchPromptCarry + launchFile?: LaunchFile + /** The prompt left for the paste after ready; null when the launch carried it. */ + pasteAfterReady: string | null +} + +/** `planLaunchPrompt`, flattened; null where it answers null, or for an empty prompt unless + * `allowEmptyPromptLaunch`, as the builder did before the outcome was explicit. */ +export function planLaunchForTest( + args: AgentLaunchPromptArgs & { allowEmptyPromptLaunch?: boolean } +): PlannedLaunchForTest | null { + const { allowEmptyPromptLaunch, ...launchArgs } = args + if (allowEmptyPromptLaunch !== true && !args.prompt.trim()) { + return null + } + const planned = planLaunchPrompt(launchArgs) + if (!planned) { + return null + } + switch (planned.carry) { + case 'none': + case 'on-line': + return { ...planned.plan, carry: planned.carry, pasteAfterReady: null } + case 'launch-file': + return { + ...planned.plan, + carry: planned.carry, + launchFile: planned.launchFile, + pasteAfterReady: null + } + case 'paste-after-ready': + return { ...planned.cleanPlan, carry: planned.carry, pasteAfterReady: planned.text } + } +} diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index 0e90a402914..bfebe8c975c 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -3,9 +3,9 @@ "appId": "com.stablyai.orca", "stateSchemaVersion": 1, "readableStateSchemaVersions": [1], - "daemonProtocolVersion": 40, + "daemonProtocolVersion": 41, "attachableDaemonProtocolVersions": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index 75a414b2a5f..afda2d8f227 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = { appId: 'com.stablyai.orca', stateSchemaVersion: 1, readableStateSchemaVersions: [1], - daemonProtocolVersion: 40, + daemonProtocolVersion: 41, attachableDaemonProtocolVersions: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41 ] } as const diff --git a/src/shared/omp-fresh-launch-shell.test.ts b/src/shared/omp-fresh-launch-shell.test.ts index ebdc7581a9a..5c41cdde06f 100644 --- a/src/shared/omp-fresh-launch-shell.test.ts +++ b/src/shared/omp-fresh-launch-shell.test.ts @@ -3,8 +3,8 @@ import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { afterEach, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { runProcess } from './child-process/run-process' -import { buildAgentStartupPlan } from './tui-agent-startup' const roots: string[] = [] afterEach(async () => { @@ -27,7 +27,7 @@ it.each(shells)( const config = join(root, 'fresh settings.yml') const capture = join(root, 'argv') await writeFile(config, 'autoResume: false\n') - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'omp', prompt: 'task with spaces', cmdOverrides: {}, diff --git a/src/shared/omp-fresh-launch.test.ts b/src/shared/omp-fresh-launch.test.ts index eb8c94e2e5c..f7c6bf68d22 100644 --- a/src/shared/omp-fresh-launch.test.ts +++ b/src/shared/omp-fresh-launch.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { withFreshOmpLaunch } from './omp-fresh-launch' -import { buildAgentStartupPlan } from './tui-agent-startup' describe('OMP fresh launch intent', () => { it.each(['omp', 'omp launch', 'omp --model provider/model', 'omp --config user.yml'])( @@ -45,7 +45,7 @@ describe('OMP fresh launch intent', () => { expect(withFreshOmpLaunch('omp', 'cmd')).toContain('omp --config "%ORCA_OMP_FRESH_CONFIG%"') }) it('keeps fresh intent out of saved resume command and environment', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'omp', prompt: 'new task', cmdOverrides: {}, @@ -56,7 +56,7 @@ describe('OMP fresh launch intent', () => { expect(plan?.env).toBeUndefined() }) it('requires host-owned configuration for fresh SSH launches', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'omp', prompt: 'new task', cmdOverrides: {}, diff --git a/src/shared/qoder-agent.test.ts b/src/shared/qoder-agent.test.ts index 8db665e7209..2ad44aab180 100644 --- a/src/shared/qoder-agent.test.ts +++ b/src/shared/qoder-agent.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { recognizeAgentProcessFromCommandLine, isExpectedAgentProcess @@ -11,7 +12,6 @@ import { normalizeTerminalTitle, detectAgentStatusFromTitle } from './agent-detection' -import { buildAgentStartupPlan } from './tui-agent-startup' import { getAgentResumeArgv } from './agent-session-resume' import { createHookListenerState } from './agent-hook-listener/listener-state' import { normalizeAndAccept } from './agent-hook-listener-test-harness' @@ -121,14 +121,14 @@ it('replays captured unauthenticated startup, prompt, idle and resume hooks', () it.each(['darwin', 'linux', 'win32'] as const)( 'launches a Qoder prompt safely on %s', (platform) => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'qoder', prompt: "fix Bob's branch; echo $HOME", cmdOverrides: {}, platform }) expect(plan?.expectedProcess).toBe('qodercli') - expect(plan?.followupPrompt).toBeNull() + expect(plan?.pasteAfterReady).toBeNull() expect(plan?.launchCommand).toBe( platform === 'win32' ? "qodercli --prompt-interactive 'fix Bob''s branch; echo $HOME'" diff --git a/src/shared/startup-command-staging.node-pty.test.ts b/src/shared/startup-command-staging.node-pty.test.ts index 2619a92c805..f0e202b4393 100644 --- a/src/shared/startup-command-staging.node-pty.test.ts +++ b/src/shared/startup-command-staging.node-pty.test.ts @@ -23,7 +23,7 @@ import * as pty from 'node-pty' import { afterAll, describe, expect, it } from 'vitest' import { resolveFishBinary } from './fish-binary-requirement' import { writeLaunchFile, type WrittenLaunchFile } from './launch-file-writing' -import { buildLaunchFilePointer, planLaunchPrompt } from './launch-prompt-file' +import { buildLaunchFilePointer, carryInLaunchFile } from './launch-prompt-file' import { stageStartupCommand } from './startup-command-staging' import { buildStartupCommandSubmission } from './startup-command-submission' import { quoteStartupArg } from './tui-agent-startup-shell' @@ -181,7 +181,7 @@ describeShells('a launch file named on a real command line', () => { for (const shell of SHELLS) { it(`hands ${shell.name}'s agent a pointer to the full prompt`, async () => { const prompt = `${HOSTILE}\n${'z'.repeat(20_000)}\n` - const planned = planLaunchPrompt(prompt) + const planned = carryInLaunchFile(prompt, false) let written: WrittenLaunchFile | undefined const run = await launchInRealShell(shell, planned.prompt, (command) => { written = writeLaunchFile({ diff --git a/src/shared/startup-command-staging.ts b/src/shared/startup-command-staging.ts index 84f66c3d89b..2f3129e9a4e 100644 --- a/src/shared/startup-command-staging.ts +++ b/src/shared/startup-command-staging.ts @@ -68,11 +68,16 @@ export function shouldStageStartupCommand(args: { return args.orcaBuiltLine === true } return ( - !typedStartupLineFits(stripSubmitTerminator(args.command)) && + startupLineNeedsStaging(args.command) && (SOURCING_SHELLS.has(shellName) || args.orcaBuiltLine === true) ) } +/** Whether a line is too long or multi-line to type as it is, in a shell that can source a script. */ +export function startupLineNeedsStaging(command: string): boolean { + return !typedStartupLineFits(stripSubmitTerminator(command)) +} + function stagedScriptLine(shellName: string | null, quotedPath: string): string { if (shellName === 'fish') { // Why eval: fish runs a sourced file without job control; eval runs it as if typed. diff --git a/src/shared/startup-line-prompt-carry.test.ts b/src/shared/startup-line-prompt-carry.test.ts deleted file mode 100644 index cbcc56b871e..00000000000 --- a/src/shared/startup-line-prompt-carry.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MAX_INLINE_LAUNCH_PROMPT_CHARS } from './launch-prompt-file' -import { planStartupWithLaunchPrompt } from './startup-line-prompt-carry' -import type { TuiAgent } from './tui-agent' -import { RUNTIME_CAPABILITIES } from './protocol-version' -import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability' -import { - TYPED_STARTUP_LINE_BUDGET_BYTES, - WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS, - typedStartupLineFits, - windowsTypedStartupLineFits -} from './typed-startup-line' - -function plan( - agent: TuiAgent, - prompt: string, - extra: { - platform?: NodeJS.Platform - shell?: 'cmd' | 'powershell' - sensitive?: boolean - } = {} -) { - return planStartupWithLaunchPrompt( - { - agent, - cmdOverrides: {}, - platform: extra.platform ?? 'darwin', - ...(extra.shell ? { shell: extra.shell } : {}) - }, - prompt, - extra.sensitive ? { sensitive: true } : {} - ) -} - -describe('a launch prompt on the command line', () => { - it('carries a short prompt inline', () => { - const { plan: startup, launchFile } = plan('claude', 'explain this repo') - expect(launchFile).toBeUndefined() - expect(startup?.launchCommand).toContain('explain this repo') - expect(startup?.followupPrompt).toBeNull() - }) - - it.each([ - ['a 600-byte', 'x'.repeat(600)], - ['a multi-line', 'first line\nsecond line'], - ['a key-bearing', 'see\tthis \x1b[31mred'] - ])('carries %s prompt whole on a POSIX host, which stages the typed line', (_label, prompt) => { - const { plan: startup, launchFile } = plan('codex', prompt) - expect(launchFile).toBeUndefined() - expect(startup?.launchCommand).toContain(prompt) - expect(startup?.followupPrompt).toBeNull() - }) - - it('points at a launch file past the argv ceiling, with the full text in the file', () => { - const prompt = `${'y'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS)}z` - const { plan: startup, launchFile } = plan('claude', prompt) - expect(launchFile?.content).toBe(prompt) - expect(launchFile?.sensitive).toBe(false) - expect(startup?.launchCommand).toContain(launchFile?.placeholder) - expect(startup?.launchCommand).not.toContain('yyyy') - }) - - it('points a sensitive prompt at a launch file whatever its size', () => { - const { plan: startup, launchFile } = plan('claude', 'token dcap_abc', { sensitive: true }) - expect(launchFile).toMatchObject({ content: 'token dcap_abc', sensitive: true }) - expect(startup?.launchCommand).not.toContain('dcap_abc') - }) - - it.each([ - ['cmd', 'first line\nsecond line'], - ['powershell', 'first line\r\nsecond line'] - ] as const)( - 'points a multi-line prompt at a launch file on a Windows %s host, which cannot stage', - (shell, prompt) => { - const { plan: startup, launchFile } = plan('codex', prompt, { platform: 'win32', shell }) - expect(launchFile?.content).toBe(prompt) - expect(startup?.launchCommand).not.toContain('first line') - expect(typedStartupLineFits(startup?.launchCommand ?? '\n')).toBe(true) - } - ) - - it('keeps a long single-line prompt inline on Windows up to cmd’s line cap', () => { - const inline = plan('claude', 'x'.repeat(600), { platform: 'win32', shell: 'cmd' }) - expect(inline.launchFile).toBeUndefined() - expect(inline.plan?.launchCommand).toContain('x'.repeat(600)) - - const pastCap = plan('claude', 'x'.repeat(WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS), { - platform: 'win32', - shell: 'cmd' - }) - expect(pastCap.launchFile?.content).toBe('x'.repeat(WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS)) - }) - - it('carries a multi-line Hermes prompt in its spawn env, whose typed line never holds it', () => { - const multiLine = 'line one\nline two' - const { plan: startup, launchFile } = plan('hermes', multiLine, { platform: 'win32' }) - expect(launchFile).toBeUndefined() - expect(startup?.launchCommand).not.toContain('line one') - expect(Object.values(startup?.env ?? {})).toContain(multiLine) - }) - - it('leaves a Hermes prompt past its env budget for the paste after ready', () => { - const { plan: startup, launchFile } = plan('hermes', 'x'.repeat(30_000)) - expect(launchFile).toBeUndefined() - expect(startup?.launchCommand).toBe('hermes --tui') - expect(startup?.followupPrompt).toBe('x'.repeat(30_000)) - }) - - it('leaves CJK Hermes text under 16,384 chars but past its env bytes for the paste', () => { - const cjk = '修'.repeat(9_000) - const { plan: startup, launchFile } = plan('hermes', cjk) - expect(launchFile).toBeUndefined() - expect(startup?.followupPrompt).toBe(cjk) - }) - - it('puts a prompt that needs a launch file there only for an agent measured reading it', () => { - const long = 'y'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS + 1) - expect(plan('claude', long).launchFile?.content).toBe(long) - expect(plan('codex', long).launchFile?.content).toBe(long) - for (const agent of ['gemini', 'cursor', 'antigravity', 'grok'] as const) { - const { plan: startup, launchFile } = plan(agent, long) - expect(launchFile).toBeUndefined() - expect(startup?.followupPrompt).toBe(long) - expect(startup?.launchCommand).not.toContain(long) - } - }) - - it('pastes a Windows-damaged prompt for an agent not measured reading its launch file', () => { - const { plan: startup, launchFile } = plan('gemini', 'line one\nline two', { - platform: 'win32', - shell: 'powershell' - }) - expect(launchFile).toBeUndefined() - expect(startup?.followupPrompt).toBe('line one\nline two') - }) - - it('leaves a stdin-after-start agent’s prompt for its caller to paste', () => { - const { plan: startup, launchFile } = plan('aider', 'fix it') - expect(launchFile).toBeUndefined() - expect(startup?.followupPrompt).toBe('fix it') - }) - - it('launches clean for an empty prompt', () => { - const { plan: startup, launchFile } = plan('claude', ' ') - expect(launchFile).toBeUndefined() - expect(startup?.launchCommand).toBe('claude') - }) -}) - -describe('whether a line can be typed as it is', () => { - it('holds a POSIX line to half of macOS MAX_CANON', () => { - expect(typedStartupLineFits('x'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES))).toBe(true) - expect(typedStartupLineFits('x'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES + 1))).toBe(false) - expect(typedStartupLineFits('é'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES / 2 + 1))).toBe(false) - }) - - it('holds a Windows line only to cmd’s cap', () => { - expect(windowsTypedStartupLineFits('x'.repeat(WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS))).toBe(true) - expect(windowsTypedStartupLineFits('x'.repeat(WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS + 1))).toBe( - false - ) - }) - - it.each(['\n', '\r', '\t', '\x1b', '\x03', '\x7f'])('never types a line with %j', (byte) => { - expect(typedStartupLineFits(`a${byte}b`)).toBe(false) - expect(windowsTypedStartupLineFits(`a${byte}b`)).toBe(false) - }) -}) - -describe('the capability clients gate a prompted launch on', () => { - it('is advertised by every host that delivers a prompt its typed line cannot carry as typed', () => { - // An older host types any prompt into the line, so its absence is the gate. - expect(RUNTIME_CAPABILITIES).toContain(AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY) - }) -}) diff --git a/src/shared/startup-line-prompt-carry.ts b/src/shared/startup-line-prompt-carry.ts deleted file mode 100644 index cdf89e5978c..00000000000 --- a/src/shared/startup-line-prompt-carry.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * The launch-prompt plan for callers that route sensitive prompts: the decision itself (line, launch - * file, or paste) is the builder's (`carryLaunchPrompt`), so every launch path shares it. - */ - -import type { LaunchFile } from './launch-prompt-file' -import { buildAgentStartupPlan, type AgentStartupPlan } from './tui-agent-startup' - -type StartupPlanInputs = Omit< - Parameters[0], - 'prompt' | 'allowEmptyPromptLaunch' -> - -export type LaunchPromptStartupPlan = { - plan: AgentStartupPlan | null - /** Written by the execution host before it types the line naming it. */ - launchFile?: LaunchFile -} - -export function planStartupWithLaunchPrompt( - inputs: StartupPlanInputs, - prompt: string, - options: { sensitive?: boolean } = {} -): LaunchPromptStartupPlan { - const plan = buildAgentStartupPlan({ - ...inputs, - prompt, - allowEmptyPromptLaunch: true, - ...(options.sensitive ? { sensitive: true } : {}) - }) - return { plan, ...(plan?.launchFile ? { launchFile: plan.launchFile } : {}) } -} diff --git a/src/shared/startup-plan-launch-file.ts b/src/shared/startup-plan-launch-file.ts deleted file mode 100644 index 6cff8596509..00000000000 --- a/src/shared/startup-plan-launch-file.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** How a startup plan carries a launch file: which prompts move into one, and what the line adds. */ -import { - MAX_INLINE_LAUNCH_PROMPT_CHARS, - carryInLaunchFile, - launchFileDirectoryPlaceholder, - type LaunchFile -} from './launch-prompt-file' -import { TUI_AGENT_CONFIG } from './tui-agent-config' -import { - isPosixStartupShell, - quoteStartupArg, - resolveStartupShell, - type AgentStartupShell -} from './tui-agent-startup-shell' -import { hasControlByte, windowsTypedStartupLineFits } from './typed-startup-line' -import type { TuiAgent } from './tui-agent' - -/** - * Whether a Windows shell would damage `prompt` as one quoted argument; the closed rule is this plus - * cmd's 8,191-character line cap (`carryLaunchPrompt`). No quoting keeps a control byte literal: cmd - * types the line and a line break submits it early. PowerShell runs a short line from its argv, so - * its damage is the hand-off to the agent (measured): legacy argument passing (5.1 always, 7.x into a - * `.cmd` shim) splits at an inner `"` and turns a trailing `\` into `"`, and a shim's cmd.exe expands - * `%NAME%`; a lone `%` stays literal. - */ -export function windowsShellDamagesPrompt(prompt: string, shell: AgentStartupShell): boolean { - if (isPosixStartupShell(shell)) { - return false - } - return ( - hasControlByte(prompt) || - (shell === 'powershell' && - (prompt.includes('"') || /%[^%]+%/.test(prompt) || prompt.endsWith('\\'))) - ) -} - -/** Why a prefill draft could not be launched, in the user's words, when the Windows shell is why. */ -export function windowsDraftRefusal(draft: string, shell: AgentStartupShell): string | null { - return windowsShellDamagesPrompt(draft.trim(), shell) - ? "The host's Windows shell would break this draft on the agent's command line (it has a line " + - 'break or other control character, or on PowerShell a double quote, a %NAME% pair or a ' + - 'trailing backslash), so the agent was not started. Start it without the draft and paste ' + - 'the draft once it opens.' - : null -} - -type CarriedPlanArgs = { - agent: TuiAgent - prompt: string - platform: NodeJS.Platform - shell?: AgentStartupShell - allowEmptyPromptLaunch?: boolean - launchFile?: LaunchFile - hostWritesLaunchFile?: boolean - sensitive?: boolean -} - -export function agentReadsLaunchFile(agent: TuiAgent): boolean { - return TUI_AGENT_CONFIG[agent].readsLaunchFile === true -} - -/** - * Where a launch prompt rides, decided once for every launch path: on the line, which a POSIX host - * stages when it is long or multi-line; in a launch file named by a pointer when the prompt is past - * the argv ceiling, sensitive, damaged by a Windows shell, or past cmd's line cap; or, where no - * launch file can be written (a paired host) or the agent is not known to read one, left in - * `followupPrompt` for the paste after ready. - */ -export function carryLaunchPrompt< - A extends CarriedPlanArgs, - P extends { launchCommand: string; followupPrompt: string | null } ->(args: A, buildOnLine: (args: A) => P | null): P | null { - const text = args.prompt.trim() - const mode = TUI_AGENT_CONFIG[args.agent].promptInjectionMode - if (!text || args.launchFile || mode === 'stdin-after-start') { - return buildOnLine(args) - } - const readsEnv = mode === 'hermes-query' - const pasteAfterReady = (): P | null => { - const clean = buildOnLine({ ...args, prompt: '', allowEmptyPromptLaunch: true }) - return clean && { ...clean, followupPrompt: text } - } - const viaLaunchFile = (): P | null => { - // Why: an agent not measured reading the file would stop on an approval or refuse the path. - if (args.hostWritesLaunchFile === false || !agentReadsLaunchFile(args.agent)) { - return pasteAfterReady() - } - const pointer = carryInLaunchFile(text, args.sensitive === true) - return buildOnLine({ ...args, prompt: pointer.prompt, launchFile: pointer.launchFile }) - } - const shell = resolveStartupShell(args.platform, args.shell) - if ( - args.sensitive === true || - text.length > MAX_INLINE_LAUNCH_PROMPT_CHARS || - (!readsEnv && windowsShellDamagesPrompt(text, shell)) - ) { - return viaLaunchFile() - } - const onLine = buildOnLine(args) - if (!onLine || readsEnv) { - // Hermes reads its prompt from the env and refuses one past that budget, counted in bytes; a - // one-character query building proves the budget, not the command, refused it. - return !onLine && readsEnv && buildOnLine({ ...args, prompt: '.' }) ? viaLaunchFile() : onLine - } - return args.platform === 'win32' && !windowsTypedStartupLineFits(onLine.launchCommand) - ? viaLaunchFile() - : onLine -} - -/** The host writes the path inside this line's quoting, so the file carries which one it is. */ -export function launchFileProps(launchFile: LaunchFile | undefined, shell: AgentStartupShell) { - return launchFile ? { launchFile: { ...launchFile, quoting: shell } } : {} -} - -/** The host puts the launch file's private directory where the placeholder is, like its path. */ -export function launchFileDirectoryGrant( - agent: TuiAgent, - launchFile: LaunchFile | undefined, - shell: AgentStartupShell -): string { - const flag = TUI_AGENT_CONFIG[agent].launchFileDirectoryFlag - if (!launchFile || !flag) { - return '' - } - return ` ${quoteStartupArg(`${flag}=${launchFileDirectoryPlaceholder(launchFile.placeholder)}`, shell)}` -} diff --git a/src/shared/tui-agent-prompt-transport.test.ts b/src/shared/tui-agent-prompt-transport.test.ts index f117493dc71..a10ee091a4a 100644 --- a/src/shared/tui-agent-prompt-transport.test.ts +++ b/src/shared/tui-agent-prompt-transport.test.ts @@ -1,8 +1,8 @@ /** * The two answers to "how does this agent take its prompt" must stay one answer. * - * `buildAgentStartupPlan` decides by building a command and leaving `followupPrompt` null when the - * text went into it. `agentPromptRidesLaunchCommand` has to give the same verdict BEFORE a command + * `planLaunchPrompt` decides by building a command and answering `paste-after-ready` when the text + * did not go into it. `agentPromptRidesLaunchCommand` has to give the same verdict BEFORE a command * exists, because `agent.launch` picks a delivery while it is still choosing what to create. Two * readings of one table is exactly the shape that drifts, so this pins them together across every * agent: add an agent, or change its injection mode, and the disagreement fails here rather than @@ -10,8 +10,9 @@ */ import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { TUI_AGENT_CONFIG } from './tui-agent-config' -import { agentPromptRidesLaunchCommand, buildAgentStartupPlan } from './tui-agent-startup' +import { agentPromptRidesLaunchCommand } from './tui-agent-startup' import type { TuiAgent } from './tui-agent' // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the config is declared as a total record over TuiAgent, so its own keys are that union. @@ -20,7 +21,7 @@ const ALL_AGENTS = Object.keys(TUI_AGENT_CONFIG) as TuiAgent[] const PROMPT = 'summarize the diff' function planFor(agent: TuiAgent) { - return buildAgentStartupPlan({ + return planLaunchForTest({ agent, prompt: PROMPT, cmdOverrides: {}, @@ -39,9 +40,9 @@ describe('the prompt-transport predicate against the plan it predicts', () => { it.each(ALL_AGENTS)('agrees with the built plan for %s', (agent) => { const plan = planFor(agent) expect(plan).not.toBeNull() - // `followupPrompt` is the plan saying "the command does NOT carry this"; the predicate must + // `paste-after-ready` is the plan saying "the command does NOT carry this"; the predicate must // say the same thing, and it is read before any plan is built. - expect(plan!.followupPrompt === null).toBe(agentPromptRidesLaunchCommand(agent)) + expect(plan!.pasteAfterReady === null).toBe(agentPromptRidesLaunchCommand(agent)) }) it('puts the prompt in the launch command exactly when it says it does', () => { @@ -50,7 +51,7 @@ describe('the prompt-transport predicate against the plan it predicts', () => { if (!agentPromptRidesLaunchCommand(agent)) { // The command must not smuggle the text in some other way. expect(plan!.launchCommand).not.toContain(PROMPT) - expect(plan!.followupPrompt).toBe(PROMPT) + expect(plan!.pasteAfterReady).toBe(PROMPT) continue } // Hermes hands long text through an env var, so the command names the variable, not the text. diff --git a/src/shared/tui-agent-resume-startup.ts b/src/shared/tui-agent-resume-startup.ts index f4924b83c47..659e6dadd49 100644 --- a/src/shared/tui-agent-resume-startup.ts +++ b/src/shared/tui-agent-resume-startup.ts @@ -62,7 +62,6 @@ export function buildAgentResumeStartupPlan(args: { agent: args.agent, launchCommand, expectedProcess: TUI_AGENT_CONFIG[args.agent].expectedProcess, - followupPrompt: null, launchConfig, ...(args.agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), ...(Object.keys(applied).length > 0 ? { sessionOptions: { ...applied } } : {}), diff --git a/src/shared/tui-agent-startup-hermes.test.ts b/src/shared/tui-agent-startup-hermes.test.ts index 4cb34a4ab7f..401f7e8b658 100644 --- a/src/shared/tui-agent-startup-hermes.test.ts +++ b/src/shared/tui-agent-startup-hermes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildAgentStartupPlan } from './tui-agent-startup' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { unwrapPosixShellScript, unwrapPowerShellScript @@ -11,7 +11,7 @@ import { describe('hermes startup plans', () => { it('moves Hermes command override flags after the chat subcommand', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run privately', cmdOverrides: { hermes: 'hermes --tui --provider anthropic' }, @@ -36,7 +36,7 @@ describe('hermes startup plans', () => { expected: "& 'C:\\Tools\\hermes.exe' 'chat'" } ])('preserves Windows paths in Hermes command overrides on $shell', (testCase) => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: testCase.override }, @@ -48,7 +48,7 @@ describe('hermes startup plans', () => { }) it('removes a configured duplicate chat subcommand', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'hermes --provider copilot chat --tui' }, @@ -62,7 +62,7 @@ describe('hermes startup plans', () => { }) it('preserves an option value named chat', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'hermes --profile chat --tui' }, @@ -73,7 +73,7 @@ describe('hermes startup plans', () => { }) it('keeps Orca ownership of the Hermes startup query and TUI mode', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'automation prompt', cmdOverrides: { hermes: 'hermes --query override --cli' }, @@ -90,7 +90,7 @@ describe('hermes startup plans', () => { }) it('preserves wrapper tokens before the Hermes executable', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'uv run hermes --tui' }, @@ -101,7 +101,7 @@ describe('hermes startup plans', () => { }) it('selects the final Hermes executable token in a wrapper', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'sudo -u hermes hermes --tui' }, @@ -114,7 +114,7 @@ describe('hermes startup plans', () => { }) it('selects the wrapped executable when the wrapper and command both name Hermes', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'sudo -u hermes hermes chat --tui' }, @@ -127,7 +127,7 @@ describe('hermes startup plans', () => { }) it('does not mistake a Hermes option value for a wrapped executable', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'hermes chat --resume hermes --tui' }, @@ -140,7 +140,7 @@ describe('hermes startup plans', () => { }) it('preserves POSIX environment-assignment command prefixes', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'HERMES_HOME=/tmp/test uv run hermes --tui' }, @@ -154,7 +154,7 @@ describe('hermes startup plans', () => { it('rejects a Hermes command override with no identifiable executable', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'custom-agent --tui' }, @@ -164,7 +164,7 @@ describe('hermes startup plans', () => { }) it('leaves a Hermes query past the safe Windows environment limit for the paste', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'x'.repeat(24_000), cmdOverrides: {}, @@ -172,13 +172,13 @@ describe('hermes startup plans', () => { }) expect(plan?.launchFile).toBeUndefined() - expect(plan?.followupPrompt).toBe('x'.repeat(24_000)) + expect(plan?.pasteAfterReady).toBe('x'.repeat(24_000)) }) it.each(['quote "this"', 'print %PATH%', 'toggle !feature!', 'inspect C:\\repo\\'])( 'keeps a complex cmd Hermes query out of command text: %s', (prompt) => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt, cmdOverrides: {}, @@ -192,7 +192,7 @@ describe('hermes startup plans', () => { ) it('uses the Windows remote default shell for SSH Hermes queries', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run remotely', cmdOverrides: {}, @@ -206,7 +206,7 @@ describe('hermes startup plans', () => { }) it('measures POSIX Hermes query limits in UTF-8 bytes, pasting past them', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: '界'.repeat(9_000), cmdOverrides: {}, @@ -214,11 +214,11 @@ describe('hermes startup plans', () => { }) expect(plan?.launchFile).toBeUndefined() - expect(plan?.followupPrompt).toBe('界'.repeat(9_000)) + expect(plan?.pasteAfterReady).toBe('界'.repeat(9_000)) }) it('keeps empty Hermes launches on the interactive TUI command', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: '', cmdOverrides: {}, @@ -227,6 +227,6 @@ describe('hermes startup plans', () => { }) expect(plan?.launchCommand).toBe('hermes --tui') - expect(plan?.followupPrompt).toBeNull() + expect(plan?.pasteAfterReady).toBeNull() }) }) diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 27b487ba598..5ba4245faf1 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { buildAgentDraftLaunchPlan, buildAgentResumeStartupPlan, - buildAgentStartupPlan, buildShellCommandFromArgv, planAgentCliArgsSuffix } from './tui-agent-startup' @@ -48,7 +48,7 @@ describe('tui agent startup plans', () => { // Structured native chat stopped reading the configured arguments; a terminal launch must // still spell every token of them, in order, exactly as the user wrote them. it('passes the whole configured argument string to a terminal launch', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: '', agentArgs: resolveTuiAgentLaunchArgs('claude', { @@ -64,7 +64,7 @@ describe('tui agent startup plans', () => { }) it('uses POSIX quoting when the target shell is Linux', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: "fix Bob's branch", cmdOverrides: {}, @@ -75,7 +75,7 @@ describe('tui agent startup plans', () => { }) it('uses PowerShell quoting by default when the target shell is Windows', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: "fix Bob's \u2018quoted\u2019 branch", cmdOverrides: {}, @@ -93,7 +93,7 @@ describe('tui agent startup plans', () => { }) it('uses cmd escaping when requested explicitly', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'fix "quoted" & %PATH%', cmdOverrides: {}, @@ -108,7 +108,7 @@ describe('tui agent startup plans', () => { 'never types a line break into %s: a multi-line prompt rides a launch file', (shell) => { const prompt = 'fix the build\r\n& echo PWNED\nthen run tests' - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt, cmdOverrides: {}, @@ -124,7 +124,7 @@ describe('tui agent startup plans', () => { ) it('keeps a multi-line prompt inline for a POSIX shell on a POSIX host, which stages it', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'line one\nline two', cmdOverrides: {}, @@ -138,7 +138,7 @@ describe('tui agent startup plans', () => { // Why: a Windows host stages nothing, so Git Bash would get the raw line break typed. it('points Git Bash on a Windows host at a launch file for a multi-line prompt', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'line one\nline two', cmdOverrides: {}, @@ -163,7 +163,7 @@ describe('tui agent startup plans', () => { }) it('terminates Grok options before a flag-shaped POSIX prompt', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'grok', prompt: '--version', cmdOverrides: {}, @@ -174,7 +174,7 @@ describe('tui agent startup plans', () => { }) it('terminates Grok options before a flag-shaped PowerShell prompt', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'grok', prompt: '-h', cmdOverrides: {}, @@ -185,7 +185,7 @@ describe('tui agent startup plans', () => { }) it('terminates Grok options before a subcommand-shaped cmd prompt', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'grok', prompt: 'help', cmdOverrides: {}, @@ -197,7 +197,7 @@ describe('tui agent startup plans', () => { }) it('places the Grok prompt separator after configured agent arguments', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'grok', prompt: '--version', cmdOverrides: {}, @@ -209,7 +209,7 @@ describe('tui agent startup plans', () => { }) it('does not add the Grok prompt separator to other argv agents', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'codex', prompt: '--version', cmdOverrides: {}, @@ -225,7 +225,7 @@ describe('tui agent startup plans', () => { { platform: 'win32' as const, shell: 'cmd' as const } ])('delivers multiline Hermes queries through a child-only expansion on $shell', (testCase) => { const prompt = 'first line\nsecond "quoted" line with %PATH%' - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt, cmdOverrides: {}, @@ -235,7 +235,7 @@ describe('tui agent startup plans', () => { }) expect(plan?.launchCommand).not.toContain(prompt) - expect(plan?.followupPrompt).toBeNull() + expect(plan?.pasteAfterReady).toBeNull() expect(plan?.launchConfig.agentCommand).toBe('hermes --tui') expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe(prompt) const script = @@ -259,7 +259,7 @@ describe('tui agent startup plans', () => { }) it('uses a sh invocation that POSIX-host PowerShell can parse', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: {}, @@ -276,7 +276,7 @@ describe('tui agent startup plans', () => { }) it('does not launch Codex with the Orca profile when agent status hooks are enabled', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'codex', prompt: 'fix it', cmdOverrides: {}, @@ -288,7 +288,7 @@ describe('tui agent startup plans', () => { }) it('keeps plain empty Codex startup on the fast delivery path', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'codex', prompt: '', cmdOverrides: {}, @@ -300,13 +300,14 @@ describe('tui agent startup plans', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, + carry: 'none', + pasteAfterReady: null, launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} } }) }) it('launches Claude without Orca settings injection', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'fix it', cmdOverrides: {}, @@ -318,7 +319,7 @@ describe('tui agent startup plans', () => { }) it('uses the Linux Orca CLI command for Claude Agent Teams launches', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude-agent-teams', prompt: '', cmdOverrides: {}, @@ -334,7 +335,7 @@ describe('tui agent startup plans', () => { // `orca-ide` GNOME-screen-reader workaround), so a remote launch must not // emit `orca-ide claude-teams` — that name is not on the remote PATH and // `claude-teams` is rejected by the relay's CLI switch (issue #6500). - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude-agent-teams', prompt: '', cmdOverrides: {}, @@ -349,7 +350,7 @@ describe('tui agent startup plans', () => { it('keeps the Windows orca.cmd shim for Claude Agent Teams on SSH remotes', () => { // Why: the Windows remote shim is also `orca.cmd`, matching the local // win32 override, so remoteness must not alter the Windows command. - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude-agent-teams', prompt: '', cmdOverrides: {}, @@ -365,7 +366,7 @@ describe('tui agent startup plans', () => { // Why: the `orca-ide` rename is still required for a local Linux desktop // install (avoids shadowing the GNOME Orca screen reader), so an explicit // isRemote:false must preserve it. - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude-agent-teams', prompt: '', cmdOverrides: {}, @@ -378,7 +379,7 @@ describe('tui agent startup plans', () => { }) it('launches OpenClaude as a distinct argv agent', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'openclaude', prompt: 'fix it', cmdOverrides: {}, @@ -389,13 +390,14 @@ describe('tui agent startup plans', () => { agent: 'openclaude', launchCommand: "openclaude 'fix it'", expectedProcess: 'openclaude', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: { agentCommand: 'openclaude', agentArgs: '', agentEnv: {} } }) }) it('launches Mistral Vibe through the installed vibe executable', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'mistral-vibe', prompt: 'fix it', cmdOverrides: {}, @@ -406,13 +408,14 @@ describe('tui agent startup plans', () => { agent: 'mistral-vibe', launchCommand: 'vibe', expectedProcess: 'vibe', - followupPrompt: 'fix it', + carry: 'paste-after-ready', + pasteAfterReady: 'fix it', launchConfig: { agentCommand: 'vibe', agentArgs: '', agentEnv: {} } }) }) it('launches Qwen Code through the installed qwen executable', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'qwen-code', prompt: 'fix it', cmdOverrides: {}, @@ -423,7 +426,8 @@ describe('tui agent startup plans', () => { agent: 'qwen-code', launchCommand: 'qwen', expectedProcess: 'qwen', - followupPrompt: 'fix it', + carry: 'paste-after-ready', + pasteAfterReady: 'fix it', launchConfig: { agentCommand: 'qwen', agentArgs: '', agentEnv: {} } }) }) @@ -440,7 +444,7 @@ describe('tui agent startup plans', () => { ] as const)( 'launches Muse in %s mode on %s/%s before delivering its prompt', (_, platform, shell, defaults, command) => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'muse', prompt: 'fix it', cmdOverrides: {}, @@ -453,7 +457,7 @@ describe('tui agent startup plans', () => { agent: 'muse', launchCommand: command, expectedProcess: 'muse', - followupPrompt: 'fix it' + pasteAfterReady: 'fix it' }) } ) @@ -461,19 +465,19 @@ describe('tui agent startup plans', () => { it.each(['exec', 'resume', '--help'])( 'delivers the reserved Muse prompt %s as text', (prompt) => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'muse', prompt, cmdOverrides: {}, platform: 'linux' }) expect(plan?.launchCommand).toBe('muse --trust-workspace') - expect(plan?.followupPrompt).toBe(prompt) + expect(plan?.pasteAfterReady).toBe(prompt) } ) it('leaves Claude command overrides untouched', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'fix it', cmdOverrides: { claude: 'claude --dangerously-skip-permissions' }, @@ -484,7 +488,7 @@ describe('tui agent startup plans', () => { }) it('leaves Codex command overrides untouched', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'codex', prompt: 'fix it', cmdOverrides: { codex: 'codex --profile work' }, @@ -587,7 +591,7 @@ describe('tui agent startup plans', () => { }) it('appends shell-quoted CLI arguments before prompt delivery flags', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'fix it', cmdOverrides: {}, @@ -601,7 +605,7 @@ describe('tui agent startup plans', () => { }) it('uses PowerShell quoting for CLI arguments on Windows', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'fix it', cmdOverrides: {}, @@ -613,7 +617,7 @@ describe('tui agent startup plans', () => { }) it('carries agent launch environment defaults into startup plans', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'goose', prompt: '', cmdOverrides: {}, @@ -632,7 +636,7 @@ describe('tui agent startup plans', () => { }) it('captures empty args and env as explicit launch config values', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: '', cmdOverrides: {}, @@ -649,7 +653,7 @@ describe('tui agent startup plans', () => { const agentDefaultArgs = normalizeTuiAgentArgsRecord({ opencode: '--dangerously-skip-permissions' }) - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'opencode', prompt: 'fix it', cmdOverrides: {}, @@ -709,7 +713,7 @@ describe('tui agent startup plans', () => { }) it('appends Kiro trust defaults to the chat subcommand that accepts them', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'kiro', prompt: 'fix it', cmdOverrides: {}, @@ -721,7 +725,7 @@ describe('tui agent startup plans', () => { }) it('launches Continue through the documented cn binary', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'continue', prompt: 'fix it', cmdOverrides: {}, @@ -795,7 +799,7 @@ describe('tui agent startup plans', () => { }) it('launches Devin with stdin-after-start prompt delivery', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'devin', prompt: 'fix the tests', cmdOverrides: {}, @@ -806,7 +810,8 @@ describe('tui agent startup plans', () => { agent: 'devin', launchCommand: "devin '--permission-mode' 'bypass' '--respect-workspace-trust' 'false'", expectedProcess: 'devin', - followupPrompt: 'fix the tests', + carry: 'paste-after-ready', + pasteAfterReady: 'fix the tests', launchConfig: { agentCommand: "devin '--permission-mode' 'bypass' '--respect-workspace-trust' 'false'", agentArgs: '--permission-mode bypass --respect-workspace-trust false', diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 99009b63ffa..ee016edf1f5 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -13,9 +13,9 @@ import type { LaunchFile } from './launch-prompt-file' import { carryLaunchPrompt, launchFileDirectoryGrant, - launchFileProps, - windowsShellDamagesPrompt -} from './startup-plan-launch-file' + windowsShellDamagesPrompt, + type LaunchPromptPlan +} from './launch-prompt-carry' import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { StartupCommandDelivery } from './codex-startup-delivery' import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config' @@ -31,7 +31,6 @@ export type AgentStartupPlan = { agent: TuiAgent launchCommand: string expectedProcess: string - followupPrompt: string | null launchConfig: SleepingAgentLaunchConfig launchToken?: string draftPrompt?: string | null @@ -40,21 +39,17 @@ export type AgentStartupPlan = { /** Values actually emitted into this launch command, kept as base model ids * so the native-chat surface can render only launch-backed state. */ sessionOptions?: Record - /** Holds the prompt the command points at; the host writes it before typing the command. */ - launchFile?: LaunchFile } function appliedSessionOptionProps(values: Record) { return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} } -export type AgentStartupPlanArgs = { +export type AgentStartupPlanInputs = { agent: TuiAgent - prompt: string cmdOverrides: Partial> platform: NodeJS.Platform shell?: AgentStartupShell - allowEmptyPromptLaunch?: boolean agentArgs?: string | null agentEnv?: Record | null sessionOptions?: Record @@ -62,25 +57,38 @@ export type AgentStartupPlanArgs = { /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must be skipped for remote launches. */ isRemote?: boolean - /** The file `prompt` points at, when the caller already moved the prompt into one. */ +} + +/** An agent launch with no prompt; a launch that offers one is planned by `planLaunchPrompt`. */ +export function buildAgentStartupPlan( + args: AgentStartupPlanInputs & { prompt?: ''; allowEmptyPromptLaunch?: boolean } +): AgentStartupPlan | null { + return args.allowEmptyPromptLaunch === true + ? buildPlanWithPromptOnLine({ ...args, prompt: '' }) + : null +} + +export type AgentLaunchPromptArgs = AgentStartupPlanInputs & { + prompt: string + /** The file `prompt` already points at, when the caller wrote its own. */ launchFile?: LaunchFile - /** False for a paired host, which is sent a command and never a launch file: a prompt that would - * need one launches clean and is left in `followupPrompt` for the paste after ready. */ - hostWritesLaunchFile?: boolean - /** The caller minted a secret in the prompt: it rides a launch file, never argv or history. */ - sensitive?: boolean + /** Another Orca this client drives; see `CarriedPlanArgs`. */ + launchHostIsPaired?: boolean } /** - * The one place that decides whether a launch prompt rides the line, a launch file, or the paste - * after ready (`carryLaunchPrompt`); every launch path builds through it. + * A launch that offers a prompt: where it rides (`carryLaunchPrompt`) and the plan to launch. Every + * launch path plans its prompt here, and must switch on the outcome to reach the plan. */ -export function buildAgentStartupPlan(args: AgentStartupPlanArgs): AgentStartupPlan | null { +export function planLaunchPrompt( + args: AgentLaunchPromptArgs +): LaunchPromptPlan | null { return carryLaunchPrompt(args, buildPlanWithPromptOnLine) } -function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan | null { - const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args +/** Builds the line with `prompt` on it; the carry rule decides whether it should. */ +function buildPlanWithPromptOnLine(args: AgentLaunchPromptArgs): AgentStartupPlan | null { + const { agent, prompt, cmdOverrides, platform } = args const shell = resolveStartupShell(platform, args.shell) const trimmedPrompt = prompt.trim() const config = TUI_AGENT_CONFIG[agent] @@ -108,24 +116,18 @@ function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan }) if (!trimmedPrompt) { - if (!allowEmptyPromptLaunch) { - return null - } return { agent, launchCommand, expectedProcess: config.expectedProcess, - followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } - const launchFile = args.launchFile const quotedPrompt = quoteStartupArg(trimmedPrompt, shell) - const fileProps = launchFileProps(launchFile, shell) - const grant = launchFileDirectoryGrant(agent, launchFile, shell) + const grant = launchFileDirectoryGrant(agent, args.launchFile, shell) if (config.promptInjectionMode === 'argv') { const promptSeparator = config.argvPromptSeparator ? ` ${config.argvPromptSeparator}` : '' @@ -140,12 +142,10 @@ function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan ) : `${launchCommand}${grant}${promptSeparator} ${quotedPrompt}`, expectedProcess: config.expectedProcess, - followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}), - ...fileProps + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -154,11 +154,9 @@ function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan agent, launchCommand: `${launchCommand}${grant} --prompt ${quotedPrompt}`, expectedProcess: config.expectedProcess, - followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}), - ...fileProps + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -181,12 +179,9 @@ function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan // only bounds and quotes the native invocation before starting the TUI. launchCommand: queryPlan.command, expectedProcess: config.expectedProcess, - followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(queryPlan.env ? { env: queryPlan.env } : {}), - // Hermes reads its prompt from the env, where a line break is harmless. - ...launchFileProps(args.launchFile, shell) + ...(queryPlan.env ? { env: queryPlan.env } : {}) } } @@ -195,11 +190,9 @@ function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan agent, launchCommand: `${launchCommand}${grant} --prompt-interactive ${quotedPrompt}`, expectedProcess: config.expectedProcess, - followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}), - ...fileProps + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -208,29 +201,20 @@ function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan agent, launchCommand: `${launchCommand}${grant} -i ${quotedPrompt}`, expectedProcess: config.expectedProcess, - followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}), - ...fileProps + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } - return { - agent, - launchCommand, - expectedProcess: config.expectedProcess, - followupPrompt: trimmedPrompt, - launchConfig, - ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) - } + // `stdin-after-start`: no line carries its prompt; `carryLaunchPrompt` pastes it instead. + return null } /** * Whether this agent's prompt rides the launch command rather than the live PTY. * - * The same question `buildAgentStartupPlan` answers by returning `followupPrompt: null`, asked + * The same question `planLaunchPrompt` answers with any outcome but `paste-after-ready`, asked * before a command exists — a caller deciding how to deliver a prompt has to know which half it is * getting while it is still choosing what to create. Derived from the one injection table rather * than restating it, and pinned against the builder for every agent by diff --git a/src/shared/typed-startup-line.ts b/src/shared/typed-startup-line.ts index 409b6bc03a3..4829179e303 100644 --- a/src/shared/typed-startup-line.ts +++ b/src/shared/typed-startup-line.ts @@ -2,9 +2,6 @@ * and 1 KiB did not. */ export const TYPED_STARTUP_LINE_BUDGET_BYTES = 512 -/** cmd.exe's documented line cap, the smallest of the Windows shells Orca types into. */ -export const WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS = 8191 - const encoder = new TextEncoder() /** Any C0 byte or DEL: no quoter escapes them, so a line editor reads them as keys. */ @@ -23,8 +20,3 @@ export function hasControlByte(line: string): boolean { export function typedStartupLineFits(line: string): boolean { return !hasControlByte(line) && encoder.encode(line).byteLength <= TYPED_STARTUP_LINE_BUDGET_BYTES } - -/** The same question for a Windows host, which cannot stage: only its own line cap applies. */ -export function windowsTypedStartupLineFits(line: string): boolean { - return !hasControlByte(line) && line.length <= WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS -} diff --git a/src/shared/windows-shell-prompt-damage.test.ts b/src/shared/windows-shell-prompt-damage.test.ts index 2ae35845503..579f2639981 100644 --- a/src/shared/windows-shell-prompt-damage.test.ts +++ b/src/shared/windows-shell-prompt-damage.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { buildLaunchFilePointer, isLaunchFilePointer } from './launch-prompt-file' -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from './tui-agent-startup' +import { buildAgentDraftLaunchPlan } from './tui-agent-startup' import type { AgentStartupShell } from './tui-agent-startup-shell' -import { windowsDraftRefusal } from './startup-plan-launch-file' +import { windowsDraftRefusal } from './launch-prompt-carry' function plan(prompt: string, shell: AgentStartupShell) { - return buildAgentStartupPlan({ + return planLaunchForTest({ agent: 'claude', prompt, cmdOverrides: {}, @@ -76,17 +77,17 @@ describe('a prompt a Windows shell would damage on the launch line', () => { }) it('launches clean and leaves the prompt for the paste when the host writes no launch file', () => { - const startup = buildAgentStartupPlan({ + const startup = planLaunchForTest({ agent: 'claude', prompt: 'fix the build\nthen run the tests', cmdOverrides: {}, platform: 'win32', shell: 'powershell', - hostWritesLaunchFile: false + launchHostIsPaired: true }) expect(startup?.launchFile).toBeUndefined() expect(startup?.launchCommand).toBe('claude') - expect(startup?.followupPrompt).toBe('fix the build\nthen run the tests') + expect(startup?.pasteAfterReady).toBe('fix the build\nthen run the tests') }) it('tells its own pointer apart from a prompt that merely mentions one', () => { diff --git a/src/shared/wsl-launch-directory.test.ts b/src/shared/wsl-launch-directory.test.ts index c14dee379a2..4b4c20687db 100644 --- a/src/shared/wsl-launch-directory.test.ts +++ b/src/shared/wsl-launch-directory.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { LaunchFileUnavailableError, writeSpawnLaunchFile } from './launch-file-writing' -import { buildLaunchFilePointer, planLaunchPrompt, type LaunchFile } from './launch-prompt-file' +import { buildLaunchFilePointer, carryInLaunchFile, type LaunchFile } from './launch-prompt-file' import { stageStartupCommand } from './startup-command-staging' import { parseWslLaunchDirectory, type WslLaunchDirectory } from './wsl-launch-directory' @@ -21,7 +21,7 @@ afterEach(() => { }) function launchFile(content = 'fix the build'): LaunchFile { - const planned = planLaunchPrompt(content, { sensitive: true }) + const planned = carryInLaunchFile(content, true) return { ...planned.launchFile!, quoting: 'posix' } } diff --git a/src/shared/zcode-orchestration-contract.test.ts b/src/shared/zcode-orchestration-contract.test.ts index ba25cbceaef..039dd522f28 100644 --- a/src/shared/zcode-orchestration-contract.test.ts +++ b/src/shared/zcode-orchestration-contract.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' -import { buildAgentStartupPlan, agentPromptRidesLaunchCommand } from './tui-agent-startup' +import { agentPromptRidesLaunchCommand } from './tui-agent-startup' import { TUI_AGENT_CONFIG } from './tui-agent-config' import { YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' import { recognizeAgentProcess } from './agent-process-recognition' @@ -32,7 +33,7 @@ describe('ZCode orchestration and send-to-agent contract', () => { }) it('keeps the prompt out of the launch command and hands it back as a follow-up', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'zcode', prompt: 'refactor the parser', cmdOverrides: {}, @@ -40,11 +41,11 @@ describe('ZCode orchestration and send-to-agent contract', () => { }) expect(plan?.launchCommand).toBe('zcode') expect(plan?.launchCommand).not.toContain('refactor the parser') - expect(plan?.followupPrompt).toBe('refactor the parser') + expect(plan?.pasteAfterReady).toBe('refactor the parser') }) it('carries a mode chosen through agent args onto the launch command', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'zcode', prompt: '', cmdOverrides: {},