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: {},