From cde44f4605c319e72e71ca49c84314ce6f0560ec Mon Sep 17 00:00:00 2001 From: bbingz Date: Sat, 11 Jul 2026 21:06:08 +0800 Subject: [PATCH] fix(agents): recover startup draft delivery --- src/main/daemon/pty-subprocess.test.ts | 5 +- src/main/ipc/pty.test.ts | 7 +- src/main/runtime/orca-runtime.test.ts | 17 +- src/relay/pty-handler.test.ts | 12 +- .../terminal-pane/pty-connection.test.ts | 330 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 109 +++++- .../src/lib/agent-draft-paste-content.ts | 40 ++- .../src/lib/agent-followup-delivery.ts | 20 +- .../src/lib/agent-paste-draft.test.ts | 39 ++- src/renderer/src/lib/agent-paste-draft.ts | 62 +++- ...gent-startup-delayed-delivery-perf.test.ts | 62 +++- .../src/lib/agent-startup-delayed-delivery.ts | 51 ++- .../launch-agent-background-session.test.ts | 13 +- src/renderer/src/lib/new-workspace.test.ts | 85 ++++- src/renderer/src/lib/new-workspace.ts | 62 +++- src/shared/agent-draft-platform-limit.ts | 12 +- src/shared/codex-startup-delivery.test.ts | 53 ++- src/shared/codex-startup-delivery.ts | 81 +---- src/shared/draft-paste-ready-scanner.test.ts | 28 +- src/shared/draft-paste-ready-scanner.ts | 58 +-- src/shared/tui-agent-config.ts | 7 +- src/shared/tui-agent-launch-command.ts | 26 ++ src/shared/tui-agent-startup.test.ts | 41 +++ src/shared/tui-agent-startup.ts | 57 ++- 24 files changed, 1001 insertions(+), 276 deletions(-) create mode 100644 src/shared/tui-agent-launch-command.ts diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index ca21dd5e58b..7f80d7e80f4 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1559,7 +1559,7 @@ describe('createPtySubprocess', () => { expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('1') }) - it('uses shell-ready wrapper for Codex native prefill flags', () => { + it('uses shell-ready wrapper for Codex positional prompts when the plan opts in', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) const platform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -1571,7 +1571,8 @@ describe('createPtySubprocess', () => { cols: 80, rows: 24, cwd: '/repo', - command: "codex --prefill 'linked issue context'", + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready', env: { SHELL: '/bin/zsh' } }) } finally { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 751245fb62d..c28ba8b3da5 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6944,7 +6944,7 @@ describe('registerPtyHandlers', () => { } ) - posixOnlyIt('waits for shell-ready when Codex uses the native prefill flag', async () => { + posixOnlyIt('waits for shell-ready when a Codex positional prompt opts in', async () => { vi.useFakeTimers() const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) @@ -6955,7 +6955,8 @@ describe('registerPtyHandlers', () => { cols: 80, rows: 24, cwd: '/tmp', - command: "codex --prefill 'linked issue context'" + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready' }) const [, , options] = spawnMock.mock.calls[0]! @@ -6966,7 +6967,7 @@ describe('registerPtyHandlers', () => { await Promise.resolve() vi.runAllTimers() await Promise.resolve() - expect(mockProc.proc.write).toHaveBeenCalledWith("codex --prefill 'linked issue context'\n") + expect(mockProc.proc.write).toHaveBeenCalledWith("codex 'linked issue context'\n") } finally { vi.useRealTimers() } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b8d9eee175f..ffd9ce3b208 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -30,10 +30,7 @@ import { removeWorktree } from '../git/worktree' import * as gitRunner from '../git/runner' -import { - clearSubmodulePathsCacheForTests, - listSubmodulePaths -} from '../git/status' +import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status' import { createSetupRunnerScript, getEffectiveHooks, @@ -24822,7 +24819,11 @@ describe('OrcaRuntimeService', () => { ) expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) - runtime.onPtyData('pty-startup-draft', '\x1b[?2004h›', Date.now()) + runtime.onPtyData( + 'pty-startup-draft', + '\x1b[?2004h\x1b[1m›\x1b[0m Ask Codex to do anything', + Date.now() + ) await vi.waitFor(() => { expect(write).toHaveBeenCalledWith('pty-startup-draft', `\x1b[200~${draftUrl}\x1b[201~`) }) @@ -25420,7 +25421,11 @@ describe('OrcaRuntimeService', () => { ) expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) - runtime.onPtyData('pty-explicit-draft', '\x1b[?2004h›', Date.now()) + runtime.onPtyData( + 'pty-explicit-draft', + '\x1b[?2004h\x1b[1m›\x1b[0m Ask Codex to do anything', + Date.now() + ) await vi.waitFor(() => { expect(write).toHaveBeenCalledWith('pty-explicit-draft', `\x1b[200~${draftUrl}\x1b[201~`) }) diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 332828408bd..1d077e3183b 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -387,18 +387,19 @@ describe('PtyHandler', () => { ) it.skipIf(process.platform === 'win32')( - 'emits shell-ready markers for renderer-delivered Codex native prefill commands', + 'emits shell-ready markers for renderer-delivered Codex positional prompt commands', async () => { const oldShell = process.env.SHELL const oldHome = process.env.HOME - const homeDir = mkdtempSync(join(tmpdir(), 'relay-codex-prefill-spawn-')) + const homeDir = mkdtempSync(join(tmpdir(), 'relay-codex-prompt-spawn-')) process.env.SHELL = '/bin/bash' process.env.HOME = homeDir try { await dispatcher.callRequest('pty.spawn', { env: { HOME: homeDir }, - command: "codex --prefill 'linked issue context'" + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready' }) } finally { if (oldShell === undefined) { @@ -472,10 +473,11 @@ describe('PtyHandler', () => { await dispatcher.callRequest('pty.spawn', { env: { HOME: homeDir, - [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'" + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex 'linked issue context'" }, command: 'bash -lc wait-for-setup-wrapper', - commandDelivery: 'provider' + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' }) } finally { if (oldShell === undefined) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index c2815c88496..4392dcfe79b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -25,6 +25,7 @@ import { beginAgentStartupDeliveryAttempt, resetAgentStartupDelayedDeliveryForTests } from '@/lib/agent-startup-delayed-delivery' +import type { AgentDraftPasteContentOutcome } from '@/lib/agent-draft-paste-content' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' // Repro command: @@ -187,7 +188,17 @@ type StoreState = { suppressedPtyExitIds: Record agentLaunchConfigByPaneKey: Record< string, - { launchConfig: unknown; identity?: { agentType?: string } } + { + launchConfig: unknown + registeredAt?: number + identity?: { + agentType?: string + launchToken?: string + tabId?: string + leafId?: string + terminalHandle?: string + } + } > getAgentLaunchConfigForStatusEntry: ReturnType getAgentLaunchConfigForStatusMetadata: ReturnType @@ -284,6 +295,37 @@ function notifyStoreSubscribers(): void { } } +function seedLaunchBoundStartupDraftState( + ptyId = 'pty-codex', + launchToken = 'launch-token-1' +): void { + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: null }], + ptyIdsByTabId: { 'tab-1': [ptyId] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: ptyId } + } + }, + agentLaunchConfigByPaneKey: { + [makePaneKey('tab-1', LEAF_1)]: { + launchConfig: { agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { + tabId: 'tab-1', + leafId: LEAF_1, + launchToken + } + } + } + } +} + vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal>() const isGeminiTerminalTitle = actual.isGeminiTerminalTitle as (title: string) => boolean @@ -4626,7 +4668,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(capturedDataCallback.current).not.toBeNull() - capturedDataCallback.current?.('\x1b[?2004h\x1b[2K› ') + capturedDataCallback.current?.('\x1b[?2004h\x1b[2K\x1b[1m›\x1b[0m Ask Codex to do anything') await flushAsyncTicks() expect(window.api.pty.writeAccepted).toHaveBeenCalledWith( @@ -4635,6 +4677,278 @@ describe('connectPanePty', () => { ) }) + it('retries the same startup draft after an explicit no-write result', async () => { + const draftPaste = await import('@/lib/agent-draft-paste-content') + const sendDraft = vi + .spyOn(draftPaste, 'sendAgentDraftPasteContentWithOutcome') + .mockResolvedValueOnce('not-written') + .mockResolvedValueOnce('delivered') + try { + const { connectPanePty } = await import('./pty-connection') + const { beginAgentStartupDeliveryAttempt: beginCurrentDeliveryAttempt } = + await import('@/lib/agent-startup-delayed-delivery') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-codex') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-codex' + } + ) + transportFactoryQueue.push(transport) + seedLaunchBoundStartupDraftState() + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + startup: { + command: 'codex', + launchAgent: 'codex', + launchConfig: { agentArgs: '', agentEnv: {} }, + launchToken: 'launch-token-1', + draftPrompt: 'linked draft' + } + }) as never + ) + await flushAsyncTicks() + + capturedDataCallback.current?.('\x1b[?2004h› Ask Codex') + await flushAsyncTicks() + expect(sendDraft).toHaveBeenCalledTimes(2) + + capturedDataCallback.current?.('composer redraw') + await flushAsyncTicks() + + expect(sendDraft).toHaveBeenCalledTimes(2) + expect(sendDraft).toHaveBeenLastCalledWith(expect.anything(), 'pty-codex', 'linked draft') + expect( + beginCurrentDeliveryAttempt({ + worktreeId: 'wt-1', + tabId: 'tab-1', + launchToken: 'launch-token-1' + }) + ).toBe(false) + } finally { + sendDraft.mockRestore() + } + }) + + it.each(['delivery-uncertain', 'rejected promise'] as const)( + 'does not retry a startup draft after %s', + async (failureMode) => { + const draftPaste = await import('@/lib/agent-draft-paste-content') + const sendDraft = vi.spyOn(draftPaste, 'sendAgentDraftPasteContentWithOutcome') + if (failureMode === 'rejected promise') { + sendDraft.mockRejectedValueOnce(new Error('runtime acknowledgement lost')) + } else { + sendDraft.mockResolvedValueOnce(failureMode) + } + try { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-codex') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-codex' + } + ) + transportFactoryQueue.push(transport) + seedLaunchBoundStartupDraftState() + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + startup: { + command: 'codex', + launchAgent: 'codex', + launchConfig: { agentArgs: '', agentEnv: {} }, + launchToken: 'launch-token-1', + draftPrompt: 'linked draft' + } + }) as never + ) + await flushAsyncTicks() + + capturedDataCallback.current?.('\x1b[?2004h› Ask Codex') + await flushAsyncTicks() + capturedDataCallback.current?.('composer redraw') + await flushAsyncTicks() + + expect(sendDraft).toHaveBeenCalledTimes(1) + } finally { + sendDraft.mockRestore() + } + } + ) + + it.each(['delivered', 'not-written', 'delivery-uncertain', 'rejected promise'] as const)( + 'keeps startup draft ownership across dispose and remount until an in-flight write settles with %s', + async (outcome) => { + vi.useFakeTimers() + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + let resolveFirstWrite!: (value: AgentDraftPasteContentOutcome) => void + let rejectFirstWrite!: (reason: unknown) => void + let firstWriteSettled = false + const firstWrite = new Promise((resolve, reject) => { + resolveFirstWrite = resolve + rejectFirstWrite = reject + }) + const settleFirstWrite = (): void => { + firstWriteSettled = true + if (outcome === 'rejected promise') { + rejectFirstWrite(new Error('late write rejection')) + } else { + resolveFirstWrite(outcome) + } + } + const draftPaste = await import('@/lib/agent-draft-paste-content') + const sendDraft = vi + .spyOn(draftPaste, 'sendAgentDraftPasteContentWithOutcome') + .mockImplementationOnce(() => firstWrite) + .mockResolvedValueOnce('delivered') + let remountedBinding: { dispose: () => void } | null = null + try { + const { connectPanePty } = await import('./pty-connection') + const { beginAgentStartupDeliveryAttempt: beginCurrentDeliveryAttempt } = + await import('@/lib/agent-startup-delayed-delivery') + const dataCallbacks: ((data: string) => void)[] = [] + const createConnectedTransport = (): MockTransport => { + const transport = createMockTransport('pty-codex') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + if (callbacks.onData) { + dataCallbacks.push(callbacks.onData) + } + return 'pty-codex' + } + ) + return transport + } + const startup = { + command: 'codex', + launchAgent: 'codex' as const, + launchConfig: { agentArgs: '', agentEnv: {} }, + launchToken: 'launch-token-1', + draftPrompt: 'linked draft' + } + seedLaunchBoundStartupDraftState() + + transportFactoryQueue.push(createConnectedTransport()) + const firstBinding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + dataCallbacks[0]?.('\x1b[?2004h› Ask Codex') + await flushAsyncTicks() + expect(sendDraft).toHaveBeenCalledTimes(1) + + firstBinding.dispose() + transportFactoryQueue.push(createConnectedTransport()) + remountedBinding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + dataCallbacks[1]?.('\x1b[?2004h› Ask Codex') + await flushAsyncTicks() + + expect(sendDraft).toHaveBeenCalledTimes(1) + settleFirstWrite() + await flushAsyncTicks() + dataCallbacks[1]?.('composer redraw') + await flushAsyncTicks() + + expect(sendDraft).toHaveBeenCalledTimes(outcome === 'not-written' ? 2 : 1) + expect( + beginCurrentDeliveryAttempt({ + worktreeId: 'wt-1', + tabId: 'tab-1', + launchToken: 'launch-token-1' + }) + ).toBe(false) + } finally { + if (!firstWriteSettled) { + resolveFirstWrite('delivered') + } + remountedBinding?.dispose() + await flushAsyncTicks() + vi.clearAllTimers() + sendDraft.mockRestore() + } + } + ) + + it('drops a failed startup draft retry after its tab is deleted', async () => { + const firstWrite = createDeferred() + const draftPaste = await import('@/lib/agent-draft-paste-content') + const sendDraft = vi + .spyOn(draftPaste, 'sendAgentDraftPasteContentWithOutcome') + .mockImplementationOnce(() => firstWrite.promise) + .mockResolvedValueOnce('delivered') + try { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-codex') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-codex' + } + ) + transportFactoryQueue.push(transport) + seedLaunchBoundStartupDraftState() + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + startup: { + command: 'codex', + launchAgent: 'codex', + launchConfig: { agentArgs: '', agentEnv: {} }, + launchToken: 'launch-token-1', + draftPrompt: 'linked draft' + } + }) as never + ) + await flushAsyncTicks() + + capturedDataCallback.current?.('\x1b[?2004h› Ask Codex') + await flushAsyncTicks() + expect(sendDraft).toHaveBeenCalledTimes(1) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [] } + } + firstWrite.resolve('not-written') + await flushAsyncTicks() + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] } + } + notifyStoreSubscribers() + capturedDataCallback.current?.('composer redraw') + await flushAsyncTicks() + + expect(sendDraft).toHaveBeenCalledTimes(1) + } finally { + sendDraft.mockRestore() + } + }) + it('does not consume startup draft delivery before deferred connect starts', async () => { const { connectPanePty } = await import('./pty-connection') globalThis.requestAnimationFrame = vi.fn(() => 1) @@ -4792,7 +5106,7 @@ describe('connectPanePty', () => { } }) - it('waits for shell-ready for SSH Codex native prefill commands without an explicit hint', async () => { + it('waits for shell-ready for SSH Codex positional prompts when the plan opts in', async () => { const pendingTimeouts: (() => void)[] = [] const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = vi.fn((fn: () => void) => { @@ -4827,7 +5141,10 @@ describe('connectPanePty', () => { const pane = createPane(1) const manager = createManager(1) const deps = createDeps({ - startup: { command: "codex --prefill 'linked issue context'" } + startup: { + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready' + } }) connectPanePty(pane as never, manager as never, deps as never) @@ -4842,7 +5159,7 @@ describe('connectPanePty', () => { fn() } - expect(transport.sendInput).toHaveBeenCalledWith("codex --prefill 'linked issue context'\r") + expect(transport.sendInput).toHaveBeenCalledWith("codex 'linked issue context'\r") } finally { globalThis.setTimeout = originalSetTimeout } @@ -4886,8 +5203,9 @@ describe('connectPanePty', () => { const deps = createDeps({ startup: { command: wrapperCommand, + startupCommandDelivery: 'shell-ready', env: { - [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'" + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex 'linked issue context'" } } }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8399d469d08..44dccfec130 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -218,10 +218,13 @@ import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types' import { isWslUncPath } from '../../../../shared/wsl-paths' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { createDraftPasteReadyScanner } from '../../../../shared/draft-paste-ready-scanner' -import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content' +import { sendAgentDraftPasteContentWithOutcome } from '@/lib/agent-draft-paste-content' +import { pasteDraftToAgentPtyWhenReadyWithOutcome } from '@/lib/agent-paste-draft' import { beginAgentStartupDeliveryAttempt, - releaseAgentStartupDeliveryAttempt + queuePendingAgentStartupDelivery, + releaseAgentStartupDeliveryAttempt, + type AgentStartupDeliveryOutcome } from '@/lib/agent-startup-delayed-delivery' import { AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, @@ -1108,7 +1111,9 @@ export function connectPanePty( !startupDraftAgentConfig?.draftPromptFlag && !startupDraftAgentConfig?.draftPromptEnvVar let startupDraftDeliveryClaimed = false - let startupDraftPasteAttempted = false + let startupDraftPasteDelivered = false + let startupDraftPasteDeliveryUncertain = false + let startupDraftPasteInFlight = false const claimStartupDraftPasteDelivery = (): boolean => { if (!startupDraftPromptNeedsPaste || launchToken === undefined) { return false @@ -1126,8 +1131,14 @@ export function connectPanePty( }) return startupDraftDeliveryClaimed } - const releaseUnattemptedStartupDraftPasteDelivery = (): void => { - if (!startupDraftDeliveryClaimed || startupDraftPasteAttempted || launchToken === undefined) { + const releaseUndeliveredStartupDraftPasteDelivery = (): void => { + if ( + !startupDraftDeliveryClaimed || + startupDraftPasteDelivered || + startupDraftPasteDeliveryUncertain || + startupDraftPasteInFlight || + launchToken === undefined + ) { return } releaseAgentStartupDeliveryAttempt({ @@ -3937,7 +3948,6 @@ export function connectPanePty( : null let startupDraftReadinessArmed = false let startupDraftPasteSettled = !ownsStartupDraftPaste - let startupDraftPasteInFlight = false let startupDraftQuietTimer: ReturnType | null = null let startupDraftHardTimer: ReturnType | null = null const clearStartupDraftPasteTimers = (): void => { @@ -3963,6 +3973,58 @@ export function connectPanePty( } return ptyId } + const handOffFailedStartupDraftPaste = (failedPtyId: string): void => { + startupDraftPasteSettled = true + cleanupStartupDraftPasteTimers() + releaseUndeliveredStartupDraftPasteDelivery() + if ( + !startupDraftPrompt || + !startupDraftAgent || + !startupDraftAgentConfig || + !paneStartup?.launchConfig || + launchToken === undefined + ) { + return + } + const retryStartup = { + agent: startupDraftAgent, + launchCommand: paneStartup.command, + expectedProcess: startupDraftAgentConfig.expectedProcess, + followupPrompt: null, + launchConfig: paneStartup.launchConfig, + launchToken, + draftPrompt: startupDraftPrompt + } + // Why: the composer was ready on the failed PTY, so retry it directly; + // a replacement PTY must pass the normal readiness gate first. + queuePendingAgentStartupDelivery({ + worktreeId: deps.worktreeId, + tabId: deps.tabId, + launchToken, + startup: retryStartup, + deliver: async (retryTabId, retryPtyId): Promise => { + const outcome = + retryPtyId === failedPtyId + ? await sendAgentDraftPasteContentWithOutcome( + getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), deps.worktreeId), + retryPtyId, + startupDraftPrompt + ) + : await pasteDraftToAgentPtyWhenReadyWithOutcome({ + tabId: retryTabId, + ptyId: retryPtyId, + content: startupDraftPrompt, + agent: startupDraftAgent, + forcePaste: true + }) + return outcome === 'delivered' + ? { kind: 'delivered' } + : outcome === 'not-written' + ? { kind: 'retryable', startup: retryStartup } + : { kind: 'delivery-uncertain' } + } + }) + } const sendStartupDraftPaste = (): void => { if ( !startupDraftPrompt || @@ -3977,15 +4039,32 @@ export function connectPanePty( return } startupDraftPasteInFlight = true - startupDraftPasteSettled = true - startupDraftPasteAttempted = true - cleanupStartupDraftPasteTimers() const settings = getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), deps.worktreeId) - void sendAgentDraftPasteContent(settings, ptyId, startupDraftPrompt) - .catch(() => false) - .finally(() => { + void sendAgentDraftPasteContentWithOutcome(settings, ptyId, startupDraftPrompt).then( + (outcome) => { startupDraftPasteInFlight = false - }) + if (outcome === 'delivered') { + startupDraftPasteDelivered = true + startupDraftPasteSettled = true + cleanupStartupDraftPasteTimers() + } else if (outcome === 'not-written') { + handOffFailedStartupDraftPaste(ptyId) + } else { + // Why: an acknowledgement can be lost after bytes reached the PTY. + // Consume the launch instead of duplicating an ambiguous draft. + startupDraftPasteDeliveryUncertain = true + startupDraftPasteSettled = true + cleanupStartupDraftPasteTimers() + } + }, + (error) => { + startupDraftPasteInFlight = false + startupDraftPasteDeliveryUncertain = true + startupDraftPasteSettled = true + cleanupStartupDraftPasteTimers() + console.warn('Startup draft paste delivery failed', error) + } + ) } const deliverStartupDraftIfAgentOwnsPty = async (): Promise => { if (!startupDraftAgentConfig || startupDraftPasteSettled) { @@ -4248,7 +4327,7 @@ export function connectPanePty( if (submitted) { armStartupDraftReadinessObservation() } else { - releaseUnattemptedStartupDraftPasteDelivery() + releaseUndeliveredStartupDraftPasteDelivery() } pendingStartupCommand = null })() @@ -7434,7 +7513,7 @@ export function connectPanePty( sshShellReadyFallbackTimer = null } cleanupStartupDraftPasteTimers() - releaseUnattemptedStartupDraftPasteDelivery() + releaseUndeliveredStartupDraftPasteDelivery() unregisterAgentHookTerminalLifecycle() clearSuppressedTitleSideEffects() clearPendingAgentTaskCompleteNotification() diff --git a/src/renderer/src/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index b1019b7bc6e..426dd0e8314 100644 --- a/src/renderer/src/lib/agent-draft-paste-content.ts +++ b/src/renderer/src/lib/agent-draft-paste-content.ts @@ -16,33 +16,52 @@ const AGENT_DRAFT_PASTE_ESCAPE_CODE_POINT = 0x1b const AGENT_DRAFT_PASTE_INERT_ESCAPE_CODE_POINT = 0x241b const AGENT_DRAFT_PASTE_INERT_ESCAPE = '\u241b' +export type AgentDraftPasteContentOutcome = 'delivered' | 'not-written' | 'delivery-uncertain' + export async function sendAgentDraftPasteContent( settings: Pick | null | undefined, ptyId: string, content: string ): Promise { + return (await sendAgentDraftPasteContentWithOutcome(settings, ptyId, content)) === 'delivered' +} + +export async function sendAgentDraftPasteContentWithOutcome( + settings: Pick | null | undefined, + ptyId: string, + content: string +): Promise { if (content.length > AGENT_DRAFT_PASTE_MAX_BYTES) { - return false + return 'not-written' } const directMeasurement = measureSanitizedUtf8ByteLength(content, { stopAfterBytes: AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES }) if (!directMeasurement.exceededLimit) { - return await sendRuntimePtyInputVerified( - settings, - ptyId, - [BRACKETED_PASTE_START, sanitizeTerminalPasteText(content), BRACKETED_PASTE_END].join('') - ) + try { + return (await sendRuntimePtyInputVerified( + settings, + ptyId, + [BRACKETED_PASTE_START, sanitizeTerminalPasteText(content), BRACKETED_PASTE_END].join('') + )) + ? 'delivered' + : 'not-written' + } catch { + // Why: a remote timeout can lose only the acknowledgement; replaying the + // same prompt could duplicate bytes that already reached the terminal. + return 'delivery-uncertain' + } } // Why: generated prompts can be paste-sized; yield during accepted-size // preflight before starting any PTY writes so the renderer is not pinned. if (await isSanitizedDraftPasteOverLimit(content, AGENT_DRAFT_PASTE_MAX_BYTES)) { - return false + return 'not-written' } let bracketedPasteOpen = false + let deliveryStarted = false for (const chunk of iterateAgentDraftPasteContentChunks(content)) { let accepted = false try { @@ -51,21 +70,22 @@ export async function sendAgentDraftPasteContent( if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) { await closeAgentDraftBracketedPaste(settings, ptyId) } - return false + return 'delivery-uncertain' } if (!accepted) { if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) { await closeAgentDraftBracketedPaste(settings, ptyId) } - return false + return deliveryStarted ? 'delivery-uncertain' : 'not-written' } + deliveryStarted = true if (chunk === BRACKETED_PASTE_START) { bracketedPasteOpen = true } else if (chunk === BRACKETED_PASTE_END) { bracketedPasteOpen = false } } - return true + return 'delivered' } export function chunkAgentDraftPasteContent( diff --git a/src/renderer/src/lib/agent-followup-delivery.ts b/src/renderer/src/lib/agent-followup-delivery.ts index 0042cb0f2e4..8ae27eed91d 100644 --- a/src/renderer/src/lib/agent-followup-delivery.ts +++ b/src/renderer/src/lib/agent-followup-delivery.ts @@ -11,20 +11,34 @@ import type { GlobalSettings } from '../../../shared/types' type RuntimeOwnerSettings = Pick | null | undefined +export type AgentFollowupDeliveryOutcome = 'delivered' | 'not-written' | 'delivery-uncertain' + export async function sendFollowupPromptWhenAgentReady(args: { ptyId: string expectedProcess: string prompt: string settings: RuntimeOwnerSettings }): Promise { + return (await sendFollowupPromptWhenAgentReadyWithOutcome(args)) === 'delivered' +} + +export async function sendFollowupPromptWhenAgentReadyWithOutcome(args: { + ptyId: string + expectedProcess: string + prompt: string + settings: RuntimeOwnerSettings +}): Promise { const { ptyId, expectedProcess, prompt, settings } = args if (!(await waitForAgentForeground(ptyId, expectedProcess, settings))) { - return false + return 'not-written' } try { - return await sendRuntimePtyInputVerified(settings, ptyId, `${prompt}\r`) + return (await sendRuntimePtyInputVerified(settings, ptyId, `${prompt}\r`)) + ? 'delivered' + : 'not-written' } catch { - return false + // A timed-out acknowledgement cannot prove that the PTY did not receive it. + return 'delivery-uncertain' } } diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index 318823c6ab6..12337b7102c 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -9,6 +9,7 @@ import { pasteDraftToAgentPtyWhenReady, pasteDraftWhenAgentReady, sendAgentDraftPasteContent, + sendAgentDraftPasteContentWithOutcome, sendBracketedPasteToRunningAgent, submitPromptToAgentPty } from './agent-paste-draft' @@ -96,7 +97,7 @@ describe('pasteDraftWhenAgentReady', () => { vi.useRealTimers() }) - it('pastes into Codex as soon as its composer prompt renders after bracketed paste is enabled', async () => { + it('pastes into Codex only after its glyph and idle placeholder render after bracketed paste', async () => { const promise = pasteDraftWhenAgentReady({ tabId: 'tab-1', content: ISSUE_URL, @@ -112,6 +113,10 @@ describe('pasteDraftWhenAgentReady', () => { await flushMicrotasks() expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() + testState.ptyObserver?.('›') + await flushMicrotasks() + expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() + testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER) await expect(promise).resolves.toBe(true) @@ -351,7 +356,7 @@ describe('pasteDraftWhenAgentReady', () => { 'pty-1', PASTED_ISSUE_URL ) - await vi.advanceTimersByTimeAsync(49) + await vi.advanceTimersByTimeAsync(499) expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(1) await expect(promise).resolves.toBe(true) @@ -532,7 +537,7 @@ describe('pasteDraftWhenAgentReady', () => { ) await flushMicrotasks() - await vi.advanceTimersByTimeAsync(49) + await vi.advanceTimersByTimeAsync(499) expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(1) @@ -558,7 +563,7 @@ describe('pasteDraftWhenAgentReady', () => { }) await flushMicrotasks() - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await expect(promise).resolves.toBe(true) expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith( @@ -599,7 +604,7 @@ describe('pasteDraftWhenAgentReady', () => { expect((call[2] as string).length).toBeLessThanOrEqual(AGENT_DRAFT_PASTE_CHUNK_MAX_BYTES) } - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await expect(promise).resolves.toBe(true) expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith({}, 'pty-1', '\r') @@ -629,6 +634,30 @@ describe('pasteDraftWhenAgentReady', () => { ) }) + it('marks a rejected chunk after the opener as delivery-uncertain', async () => { + testState.sendRuntimePtyInputVerified + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + const content = 'x'.repeat( + AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + AGENT_DRAFT_PASTE_CHUNK_MAX_BYTES + 7 + ) + + await expect(sendAgentDraftPasteContentWithOutcome({}, 'pty-1', content)).resolves.toBe( + 'delivery-uncertain' + ) + }) + + it('marks a lost direct-write acknowledgement as delivery-uncertain', async () => { + testState.sendRuntimePtyInputVerified.mockRejectedValueOnce( + new Error('runtime acknowledgement lost') + ) + + await expect(sendAgentDraftPasteContentWithOutcome({}, 'pty-1', 'linked draft')).resolves.toBe( + 'delivery-uncertain' + ) + }) + it('sanitizes escape bytes inside chunked agent draft paste content', () => { const chunks = chunkAgentDraftPasteContent('before\x1b[201~after😀', 6) diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index e9246310881..9e6e4ae2877 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -13,17 +13,22 @@ import { import { waitForAgentReady } from './agent-ready-wait' import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner' import type { GlobalSettings } from '../../../shared/types' -import { sendAgentDraftPasteContent } from './agent-draft-paste-content' +import { + sendAgentDraftPasteContentWithOutcome, + type AgentDraftPasteContentOutcome +} from './agent-draft-paste-content' import { agentDeliversDraftViaNativePrefill } from './agent-native-draft-prefill' import { waitForAgentDraftInputReady } from './agent-draft-readiness' import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' +import { AGENT_PROMPT_SUBMIT_DELAY_MS } from '../../../shared/agent-prompt-injection' export { AGENT_DRAFT_PASTE_CHUNK_MAX_BYTES, AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES, AGENT_DRAFT_PASTE_MAX_BYTES, chunkAgentDraftPasteContent, iterateAgentDraftPasteContentChunks, - sendAgentDraftPasteContent + sendAgentDraftPasteContent, + sendAgentDraftPasteContentWithOutcome } from './agent-draft-paste-content' // Why: bracketed paste markers let modern TUIs (Claude Code / Codex / Pi / @@ -32,7 +37,8 @@ export { // line-edit shortcuts. Callers choose whether to append Enter after the paste. export const BRACKETED_PASTE_BEGIN = BRACKETED_PASTE_START export { BRACKETED_PASTE_END } -export const POST_PASTE_SUBMIT_DELAY_MS = 50 +// Why: let the TUI leave bracketed-paste mode before Enter is interpreted. +export const POST_PASTE_SUBMIT_DELAY_MS = AGENT_PROMPT_SUBMIT_DELAY_MS export function sanitizeBracketedPasteContent(content: string): string { return sanitizeTerminalPasteText(content) @@ -138,11 +144,24 @@ export async function pasteDraftToAgentPtyWhenReady(args: { timeoutMs?: number onTimeout?: () => void }): Promise { + return (await pasteDraftToAgentPtyWhenReadyWithOutcome(args)) === 'delivered' +} + +export async function pasteDraftToAgentPtyWhenReadyWithOutcome(args: { + tabId: string + ptyId: string + content: string + agent?: TuiAgent + submit?: boolean + forcePaste?: boolean + timeoutMs?: number + onTimeout?: () => void +}): Promise { const { tabId, ptyId, content, agent, submit, forcePaste, timeoutMs, onTimeout } = args const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null if (agentDeliversDraftViaNativePrefill(agent, forcePaste)) { - return false + return 'not-written' } const budget = timeoutMs ?? READINESS_TIMEOUT_MS @@ -155,11 +174,11 @@ export async function pasteDraftToAgentPtyWhenReady(args: { : false if (!fallbackReady) { onTimeout?.() - return false + return 'not-written' } } - return await sendBracketedPasteToAgent({ + return await sendBracketedPasteToAgentWithOutcome({ settings, ptyId, content, @@ -211,23 +230,34 @@ async function sendBracketedPasteToAgent(args: { content: string submit: boolean }): Promise { + return (await sendBracketedPasteToAgentWithOutcome(args)) === 'delivered' +} + +async function sendBracketedPasteToAgentWithOutcome(args: { + settings?: Pick | null + ptyId: string + content: string + submit: boolean +}): Promise { const { settings = useAppStore.getState().settings, ptyId, content, submit } = args - try { - const pasted = await sendAgentDraftPasteContent(settings, ptyId, content) - if (!pasted) { - return false - } - if (!submit) { - return true - } + const pasted = await sendAgentDraftPasteContentWithOutcome(settings, ptyId, content) + if (pasted !== 'delivered') { + return pasted + } + if (!submit) { + return 'delivered' + } + try { // Why: Claude Code can leave a prompt as editable text when paste-end and // Enter arrive in the same PTY write. Split the submit into the next turn so // the TUI processes bracketed-paste termination before handling Enter. await new Promise((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) - return await sendRuntimePtyInputVerified(settings, ptyId, '\r') + return (await sendRuntimePtyInputVerified(settings, ptyId, '\r')) + ? 'delivered' + : 'delivery-uncertain' } catch { - return false + return 'delivery-uncertain' } } diff --git a/src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts b/src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts index 3ea4a168b7c..c880f257570 100644 --- a/src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts +++ b/src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts @@ -108,7 +108,7 @@ describe('delayed agent startup subscription', () => { it('delivers when launch registration, PTY ownership, and layout binding arrive', () => { seedPendingState() - const deliver = vi.fn().mockResolvedValue(undefined) + const deliver = vi.fn().mockResolvedValue({ kind: 'delivered' }) const startup = {} as never queuePendingAgentStartupDelivery({ worktreeId: 'wt-background', @@ -123,9 +123,65 @@ describe('delayed agent startup subscription', () => { expect(deliver).toHaveBeenCalledWith('tab-background', 'pty-background', startup) }) + it('requeues a failed delivery for the next relevant startup-state change', async () => { + seedPendingState() + const startup = {} as never + const deliver = vi + .fn() + .mockResolvedValueOnce({ kind: 'retryable', startup }) + .mockResolvedValueOnce({ kind: 'delivered' }) + queuePendingAgentStartupDelivery({ + worktreeId: 'wt-background', + tabId: 'tab-background', + launchToken: 'target-launch', + startup, + deliver + }) + + bindPendingPty() + await Promise.resolve() + await Promise.resolve() + expect(deliver).toHaveBeenCalledTimes(1) + + useAppStore.setState({ ptyIdsByTabId: { 'tab-background': ['pty-background'] } } as never) + await Promise.resolve() + + expect(deliver).toHaveBeenCalledTimes(2) + }) + + it('does not requeue a failed delivery after its startup tab is removed', async () => { + seedPendingState() + let resolveDelivery!: (outcome: { kind: 'retryable'; startup: never }) => void + const startup = {} as never + const deliver = vi.fn( + () => + new Promise<{ kind: 'retryable'; startup: never }>((resolve) => { + resolveDelivery = resolve + }) + ) + queuePendingAgentStartupDelivery({ + worktreeId: 'wt-background', + tabId: 'tab-background', + launchToken: 'target-launch', + startup, + deliver + }) + + bindPendingPty() + expect(deliver).toHaveBeenCalledTimes(1) + useAppStore.setState({ tabsByWorktree: {} }) + resolveDelivery({ kind: 'retryable', startup }) + await Promise.resolve() + await Promise.resolve() + + seedPendingState() + bindPendingPty() + expect(deliver).toHaveBeenCalledTimes(1) + }) + it('drops a delivery when its tab is removed before PTY binding', () => { seedPendingState() - const deliver = vi.fn().mockResolvedValue(undefined) + const deliver = vi.fn().mockResolvedValue({ kind: 'delivered' }) queuePendingAgentStartupDelivery({ worktreeId: 'wt-background', tabId: 'tab-background', @@ -143,7 +199,7 @@ describe('delayed agent startup subscription', () => { it('drops a delivery when a newer pending launch token replaces it', () => { seedPendingState() - const deliver = vi.fn().mockResolvedValue(undefined) + const deliver = vi.fn().mockResolvedValue({ kind: 'delivered' }) queuePendingAgentStartupDelivery({ worktreeId: 'wt-background', tabId: 'tab-background', diff --git a/src/renderer/src/lib/agent-startup-delayed-delivery.ts b/src/renderer/src/lib/agent-startup-delayed-delivery.ts index 2e38fad41d1..adb1e1ba4b4 100644 --- a/src/renderer/src/lib/agent-startup-delayed-delivery.ts +++ b/src/renderer/src/lib/agent-startup-delayed-delivery.ts @@ -11,12 +11,21 @@ import { type AppStoreSnapshot = ReturnType +export type AgentStartupDeliveryOutcome = + | { kind: 'delivered' } + | { kind: 'retryable'; startup: AgentStartupPlan } + | { kind: 'delivery-uncertain' } + type PendingAgentStartupDelivery = { worktreeId: string tabId: string launchToken: string startup: AgentStartupPlan - deliver: (tabId: string, ptyId: string, startup: AgentStartupPlan) => Promise + deliver: ( + tabId: string, + ptyId: string, + startup: AgentStartupPlan + ) => Promise } const pendingAgentStartupDeliveries = new Map() @@ -173,6 +182,28 @@ export function releaseAgentStartupDeliveryAttempt(args: { releaseAgentStartupDeliveryConsumed(deliveryKey(args)) } +function requeueFailedAgentStartupDelivery( + key: string, + delivery: PendingAgentStartupDelivery, + retryStartup: AgentStartupPlan +): void { + releaseAgentStartupDeliveryAttempt(delivery) + const retryDelivery = { ...delivery, startup: retryStartup } + const state = useAppStore.getState() + const queuedLaunchToken = getPendingStartupLaunchToken(state, delivery.tabId) + const launchRegistered = hasRegisteredStartupLaunch(state, delivery.tabId, delivery.launchToken) + // Why: the attempt removed this entry; keep it pending so a later + // launch/PTY state change can retry instead of silently losing the draft. + if ( + worktreeStillOwnsStartupTab(state, delivery.worktreeId, delivery.tabId) && + (queuedLaunchToken === delivery.launchToken || launchRegistered) && + !isAgentStartupDeliveryConsumed(key) + ) { + pendingAgentStartupDeliveries.set(key, retryDelivery) + ensurePendingAgentStartupSubscription() + } +} + function flushPendingAgentStartupDeliveries(): void { const state = useAppStore.getState() for (const [key, delivery] of pendingAgentStartupDeliveries) { @@ -198,11 +229,21 @@ function flushPendingAgentStartupDeliveries(): void { } // Why: once the launch-bound PTY exists, the bounded readiness/paste path // owns success or failure. Consume before awaiting so store churn cannot - // duplicate a linked-work-item draft. + // duplicate a linked-work-item draft. Only an explicit no-write outcome + // can requeue; a rejected or partial write has ambiguous delivery. if (beginAgentStartupDeliveryAttempt(delivery)) { - void delivery.deliver(tabId, ptyId, delivery.startup).catch((error) => { - console.warn('Queued agent startup delivery failed', error) - }) + void delivery + .deliver(tabId, ptyId, delivery.startup) + .then((outcome) => { + if (outcome.kind === 'retryable') { + requeueFailedAgentStartupDelivery(key, delivery, outcome.startup) + } + }) + .catch((error) => { + // Why: rejection cannot prove whether a remote write took effect. + // Keep the launch consumed instead of replaying user/task text. + console.warn('Queued agent startup delivery failed', error) + }) } } stopPendingAgentStartupSubscriptionIfIdle() diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 652fddb9bcf..866851cde0e 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -620,12 +620,12 @@ describe('launchAgentBackgroundSession', () => { } }) - it('waits for shell-ready for SSH background Codex native prefill commands without a hint', async () => { + it('keeps empty Codex override launches on the fast SSH path', async () => { vi.useFakeTimers() try { state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] state.settings = { - agentCmdOverrides: { codex: "codex --prefill 'draft from override'" }, + agentCmdOverrides: { codex: 'codex --model gpt-5' }, activeRuntimeEnvironmentId: null, terminalMainSideEffectAuthority: undefined } @@ -639,22 +639,17 @@ describe('launchAgentBackgroundSession', () => { expect(mockSpawn.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ - command: - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'" + command: "codex --model gpt-5 '--dangerously-bypass-approvals-and-sandbox'" }) ) expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('startupCommandDelivery') const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void dataSidecar('user@remote repo % ') vi.advanceTimersByTime(50) - expect(mockWrite).not.toHaveBeenCalled() - - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r" + "codex --model gpt-5 '--dangerously-bypass-approvals-and-sandbox'\r" ) } finally { vi.useRealTimers() diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index dc70d24841f..8c1826ef84a 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockInspectRuntimeTerminalProcess, mockSendRuntimePtyInputVerified, - mockPasteDraftToAgentPtyWhenReady, + mockPasteDraftToAgentPtyWhenReadyWithOutcome, mockShowAutomationPromptNotSentToast, mockTrack, store, @@ -12,7 +12,7 @@ const { } = vi.hoisted(() => ({ mockInspectRuntimeTerminalProcess: vi.fn(), mockSendRuntimePtyInputVerified: vi.fn(), - mockPasteDraftToAgentPtyWhenReady: vi.fn(), + mockPasteDraftToAgentPtyWhenReadyWithOutcome: vi.fn(), mockShowAutomationPromptNotSentToast: vi.fn(), mockTrack: vi.fn(), storeListeners: new Set<(state: unknown, previousState: unknown) => void>(), @@ -79,7 +79,7 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ vi.mock('@/lib/agent-paste-draft', () => ({ getSettingsForAgentTabRuntimeOwner: () => store.settings, - pasteDraftToAgentPtyWhenReady: mockPasteDraftToAgentPtyWhenReady + pasteDraftToAgentPtyWhenReadyWithOutcome: mockPasteDraftToAgentPtyWhenReadyWithOutcome })) vi.mock('@/lib/browser-uuid', () => ({ @@ -263,7 +263,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { hasChildProcesses: true }) mockSendRuntimePtyInputVerified.mockResolvedValue(true) - mockPasteDraftToAgentPtyWhenReady.mockResolvedValue(true) + mockPasteDraftToAgentPtyWhenReadyWithOutcome.mockResolvedValue('delivered') }) afterEach(() => { @@ -355,7 +355,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { } }) - const call = mockPasteDraftToAgentPtyWhenReady.mock.calls.at(-1)?.[0] as + const call = mockPasteDraftToAgentPtyWhenReadyWithOutcome.mock.calls.at(-1)?.[0] as | { onTimeout?: () => void } | undefined expect(call?.onTimeout).toBeTypeOf('function') @@ -380,6 +380,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { ).resolves.toBeUndefined() expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) + expect(mockShowAutomationPromptNotSentToast).not.toHaveBeenCalled() }) it('does not track draft prompt delivery as a sent prompt', async () => { @@ -395,7 +396,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { } }) - expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ + expect(mockPasteDraftToAgentPtyWhenReadyWithOutcome).toHaveBeenCalledWith({ tabId: 'tab-1', ptyId: 'pty-1', content: 'review this before sending', @@ -439,7 +440,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { } }) - expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ + expect(mockPasteDraftToAgentPtyWhenReadyWithOutcome).toHaveBeenCalledWith({ tabId: 'agent-tab', ptyId: 'agent-pty', content: 'Linear context draft', @@ -472,7 +473,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { await vi.advanceTimersByTimeAsync(5_000) await delivery - expect(mockPasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled() + expect(mockPasteDraftToAgentPtyWhenReadyWithOutcome).not.toHaveBeenCalled() store.ptyIdsByTabId = { 'tab-1': ['pty-delayed'] } store.pendingStartupByTabId = {} @@ -495,8 +496,8 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { listener(store, store) } - expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) - expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ + expect(mockPasteDraftToAgentPtyWhenReadyWithOutcome).toHaveBeenCalledTimes(1) + expect(mockPasteDraftToAgentPtyWhenReadyWithOutcome).toHaveBeenCalledWith({ tabId: 'tab-1', ptyId: 'pty-delayed', content: 'https://github.com/stablyai/orca/pull/2051', @@ -550,7 +551,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { listener(store, store) } - expect(mockPasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled() + expect(mockPasteDraftToAgentPtyWhenReadyWithOutcome).not.toHaveBeenCalled() store.ptyIdsByTabId = { 'tab-1': ['split-pty', 'startup-pty'] } store.terminalLayoutsByTabId = { @@ -577,8 +578,8 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { listener(store, store) } - expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) - expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ + expect(mockPasteDraftToAgentPtyWhenReadyWithOutcome).toHaveBeenCalledTimes(1) + expect(mockPasteDraftToAgentPtyWhenReadyWithOutcome).toHaveBeenCalledWith({ tabId: 'tab-1', ptyId: 'startup-pty', content: 'linked draft', @@ -633,7 +634,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { listener(store, store) } - expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + expect(mockPasteDraftToAgentPtyWhenReadyWithOutcome).toHaveBeenCalledTimes(1) }) it('does not duplicate immediate delivery for the same launch token', async () => { @@ -658,7 +659,57 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { startup }) - expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + expect(mockPasteDraftToAgentPtyWhenReadyWithOutcome).toHaveBeenCalledTimes(1) + }) + + it('automatically requeues a failed immediate delivery for the same launch', async () => { + mockPasteDraftToAgentPtyWhenReadyWithOutcome + .mockResolvedValueOnce('not-written') + .mockResolvedValueOnce('delivered') + const startup = { + agent: 'codex' as const, + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'linked draft', + launchToken: 'launch-token-1' + } + + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup + }) + await Promise.resolve() + await Promise.resolve() + + expect(mockPasteDraftToAgentPtyWhenReadyWithOutcome).toHaveBeenCalledTimes(2) + }) + + it('does not resend a delivered follow-up when only the draft is retryable', async () => { + mockPasteDraftToAgentPtyWhenReadyWithOutcome + .mockResolvedValueOnce('not-written') + .mockResolvedValueOnce('delivered') + + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup: { + agent: 'aider', + launchCommand: 'aider', + expectedProcess: 'aider', + followupPrompt: 'fix the spinner', + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'linked draft', + launchToken: 'launch-token-1' + } + }) + await Promise.resolve() + await Promise.resolve() + + expect(mockSendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + expect(mockPasteDraftToAgentPtyWhenReadyWithOutcome).toHaveBeenCalledTimes(2) }) it('keeps one delayed subscription and tears it down after delivery drains', async () => { @@ -714,7 +765,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { listener(store, store) } - expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + expect(mockPasteDraftToAgentPtyWhenReadyWithOutcome).toHaveBeenCalledTimes(1) expect(storeListeners.size).toBe(0) }) @@ -768,7 +819,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { listener(store, store) } - expect(mockPasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled() + expect(mockPasteDraftToAgentPtyWhenReadyWithOutcome).not.toHaveBeenCalled() }) it('does not write a delayed follow-up prompt on readiness timeout', async () => { diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 0a7505762d0..aed9b8efce7 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -1,9 +1,9 @@ import { useAppStore } from '@/store' import { getSettingsForAgentTabRuntimeOwner, - pasteDraftToAgentPtyWhenReady + pasteDraftToAgentPtyWhenReadyWithOutcome } from '@/lib/agent-paste-draft' -import { sendFollowupPromptWhenAgentReady } from '@/lib/agent-followup-delivery' +import { sendFollowupPromptWhenAgentReadyWithOutcome } from '@/lib/agent-followup-delivery' import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import type { LinkedWorkItemContext } from '@/lib/linked-work-item-context' @@ -11,7 +11,9 @@ import { beginAgentStartupDeliveryAttempt, getAgentStartupTabPtyId, queuePendingAgentStartupDelivery, - resolveAgentStartupTabId + releaseAgentStartupDeliveryAttempt, + resolveAgentStartupTabId, + type AgentStartupDeliveryOutcome } from '@/lib/agent-startup-delayed-delivery' import type { FolderWorkspaceLinkedTask, OrcaHooks, TaskViewPresetId } from '../../../shared/types' import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy' @@ -288,7 +290,27 @@ export async function ensureAgentStartupInTerminal(args: { } if (beginAgentStartupDeliveryAttempt({ worktreeId, tabId, launchToken })) { - await deliverAgentStartupToTerminal(tabId, ptyId, startup) + let outcome: AgentStartupDeliveryOutcome + try { + outcome = await deliverAgentStartupToTerminal(tabId, ptyId, startup) + } catch (error) { + // A rejected remote write has ambiguous delivery; do not replay it. + console.warn('Immediate agent startup delivery failed', error) + return + } + if (outcome.kind === 'retryable') { + // Why: a readiness/write failure is not final delivery. Requeue once + // through the launch-token gate; repeated failures then wait for the + // next relevant startup-state change instead of spinning. + releaseAgentStartupDeliveryAttempt({ worktreeId, tabId, launchToken }) + queuePendingAgentStartupDelivery({ + worktreeId, + tabId, + launchToken, + startup: outcome.startup, + deliver: deliverAgentStartupToTerminal + }) + } } } @@ -296,34 +318,40 @@ async function deliverAgentStartupToTerminal( tabId: string, ptyId: string, startup: AgentStartupPlan -): Promise { - const draftPrompt = startup.draftPrompt ?? null +): Promise { const runtimeSettings = getSettingsForAgentTabRuntimeOwner(tabId) + let remainingStartup = startup // 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 delivered = await sendFollowupPromptWhenAgentReady({ + const followupOutcome = await sendFollowupPromptWhenAgentReadyWithOutcome({ ptyId, expectedProcess: startup.expectedProcess, prompt: startup.followupPrompt, settings: runtimeSettings }) - // Why: a dropped follow-up is otherwise silent — surface the same toast the - // draft path uses so the user knows to open the workspace and paste it. - if (!delivered) { - showAutomationPromptNotSentToast(startup.agent) + if (followupOutcome !== 'delivered') { + if (followupOutcome === 'not-written') { + // Why: only a confirmed no-write can invite retry; a lost ACK may mean delivery succeeded. + showAutomationPromptNotSentToast(startup.agent) + return { kind: 'retryable', startup } + } + return { kind: 'delivery-uncertain' } } + // Why: a later draft retry must not resubmit a follow-up that already + // reached the live agent. + remainingStartup = { ...startup, followupPrompt: null } } // Why: draftPrompt uses bracketed-paste so the URL lands atomically in the // agent's input buffer (no per-char echo, no auto-submit). Shared with the // launch-work-item-direct flow so both behave identically. - if (draftPrompt) { - await pasteDraftToAgentPtyWhenReady({ + if (startup.draftPrompt) { + const draftOutcome = await pasteDraftToAgentPtyWhenReadyWithOutcome({ tabId, ptyId, - content: draftPrompt, + content: startup.draftPrompt, agent: startup.agent, // Why: startup.draftPrompt is only attached after native draft launch // planning is unavailable, so this paste is the first delivery attempt. @@ -331,7 +359,13 @@ async function deliverAgentStartupToTerminal( // Why: surface a dropped draft instead of silently losing it. onTimeout: () => showAutomationPromptNotSentToast(startup.agent) }) + if (draftOutcome !== 'delivered') { + return draftOutcome === 'not-written' + ? { kind: 'retryable', startup: remainingStartup } + : { kind: 'delivery-uncertain' } + } } + return { kind: 'delivered' } } function ensureStartupLaunchToken(startup: AgentStartupPlan): string { diff --git a/src/shared/agent-draft-platform-limit.ts b/src/shared/agent-draft-platform-limit.ts index 7b94b5624e3..8e256b1a21c 100644 --- a/src/shared/agent-draft-platform-limit.ts +++ b/src/shared/agent-draft-platform-limit.ts @@ -1,9 +1,13 @@ +import type { AgentStartupShell } from './tui-agent-startup-shell' + const WIN32_INLINE_DRAFT_LIMIT_CHARS = 24_000 +const WIN32_CMD_INLINE_DRAFT_LIMIT_CHARS = 7_500 export function inlineAgentDraftFitsPlatform(args: { command: string env?: Record platform: NodeJS.Platform + shell?: AgentStartupShell }): boolean { if (args.platform !== 'win32') { return true @@ -12,7 +16,9 @@ export function inlineAgentDraftFitsPlatform(args: { (total, [key, value]) => total + key.length + value.length, 0 ) - // Why: Windows CreateProcess/env blocks have tight length ceilings. Large - // generated drafts should use the existing post-ready paste fallback. - return args.command.length + envChars <= WIN32_INLINE_DRAFT_LIMIT_CHARS + // Why: cmd.exe caps command lines at 8191 chars; other Windows launch paths + // still need headroom for CreateProcess and its environment block. + const limit = + args.shell === 'cmd' ? WIN32_CMD_INLINE_DRAFT_LIMIT_CHARS : WIN32_INLINE_DRAFT_LIMIT_CHARS + return args.command.length + envChars <= limit } diff --git a/src/shared/codex-startup-delivery.test.ts b/src/shared/codex-startup-delivery.test.ts index e7788f778fe..4ebd8e1d040 100644 --- a/src/shared/codex-startup-delivery.test.ts +++ b/src/shared/codex-startup-delivery.test.ts @@ -1,28 +1,45 @@ import { describe, expect, it } from 'vitest' -import { hasCodexNativeDraftFlag } from './codex-startup-delivery' +import { shouldUseShellReadyStartupDelivery } from './codex-startup-delivery' -describe('hasCodexNativeDraftFlag', () => { - it('matches Codex --prefill option tokens', () => { - expect(hasCodexNativeDraftFlag("codex --prefill 'linked issue context'")).toBe(true) - expect(hasCodexNativeDraftFlag("codex --model gpt-5 --prefill 'draft'")).toBe(true) +describe('shouldUseShellReadyStartupDelivery', () => { + it('honors explicit shell-ready startup plans', () => { + expect( + shouldUseShellReadyStartupDelivery({ + command: "codex 'fix it'", + startupCommandDelivery: 'shell-ready' + }) + ).toBe(true) }) - it('matches Codex --prefill=value option tokens', () => { - expect(hasCodexNativeDraftFlag('codex --prefill=review')).toBe(true) - expect(hasCodexNativeDraftFlag("codex --prefill='linked issue context'")).toBe(true) + it('stays on the fast path without an explicit shell-ready hint', () => { + expect(shouldUseShellReadyStartupDelivery({ command: 'codex' })).toBe(false) + expect( + shouldUseShellReadyStartupDelivery({ + command: "codex 'please compare --prefill behavior'" + }) + ).toBe(false) }) - it('does not match quoted prompt text mentioning prefill', () => { - expect(hasCodexNativeDraftFlag("codex 'please compare --prefill behavior'")).toBe(false) - expect(hasCodexNativeDraftFlag("codex '--prefill=not-an-option'")).toBe(false) + it('does not treat mythical Codex --prefill tokens as native draft delivery', () => { + // Why: Codex has no --prefill flag. Only a startup plan that actually + // carries positional PROMPT may opt into shell-ready command delivery. + expect( + shouldUseShellReadyStartupDelivery({ + command: "codex --prefill 'linked issue context'" + }) + ).toBe(false) + expect( + shouldUseShellReadyStartupDelivery({ + command: 'codex --prefill=review' + }) + ).toBe(false) }) - it('does not match non-Codex commands', () => { - expect(hasCodexNativeDraftFlag("claude --prefill 'review this'")).toBe(false) - }) - - it('leaves plain Codex and normal Codex arguments on the fast path', () => { - expect(hasCodexNativeDraftFlag('codex')).toBe(false) - expect(hasCodexNativeDraftFlag('codex --model gpt-5')).toBe(false) + it('does not force shell-ready for Claude native prefill', () => { + expect( + shouldUseShellReadyStartupDelivery({ + command: "claude --prefill 'review this'" + }) + ).toBe(false) }) }) diff --git a/src/shared/codex-startup-delivery.ts b/src/shared/codex-startup-delivery.ts index a45337dd0be..3c9147e0f6a 100644 --- a/src/shared/codex-startup-delivery.ts +++ b/src/shared/codex-startup-delivery.ts @@ -1,82 +1,11 @@ -import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' - export type StartupCommandDelivery = 'fast' | 'shell-ready' -type CommandToken = { - value: string - startsQuoted: boolean -} - -function tokenizeCommandWithQuoteMetadata(command: string): CommandToken[] { - const tokens: CommandToken[] = [] - let current = '' - let inToken = false - let startsQuoted = false - let quote: '"' | "'" | null = null - let escaped = false - - for (let index = 0; index < command.length; index += 1) { - const char = command[index] - if (escaped) { - current += char - escaped = false - continue - } - if (char === '\\' && quote !== "'") { - const next = command[index + 1] - if (next && (/\s/.test(next) || next === '"' || next === "'" || next === '\\')) { - escaped = true - inToken = true - continue - } - } - if ((char === '"' || char === "'") && quote === null) { - if (!inToken) { - startsQuoted = true - } - quote = char - inToken = true - continue - } - if (quote === char) { - quote = null - continue - } - if (/\s/.test(char) && quote === null) { - if (inToken) { - tokens.push({ value: current, startsQuoted }) - current = '' - inToken = false - startsQuoted = false - } - continue - } - current += char - inToken = true - } - - if (inToken) { - tokens.push({ value: current, startsQuoted }) - } - return tokens -} - -export function hasCodexNativeDraftFlag(command: string | null | undefined): boolean { - if (recognizeAgentProcessFromCommandLine(command)?.agent !== 'codex' || !command) { - return false - } - const tokens = tokenizeCommandWithQuoteMetadata(command) - return tokens.some( - (token, index) => - index > 0 && - !token.startsQuoted && - (token.value === '--prefill' || token.value.startsWith('--prefill=')) - ) -} - export function shouldUseShellReadyStartupDelivery(args: { - command: string | null | undefined + command?: string | null | undefined startupCommandDelivery?: StartupCommandDelivery }): boolean { - return args.startupCommandDelivery === 'shell-ready' || hasCodexNativeDraftFlag(args.command) + // Why: Codex has no native draft-prefill flag. Only startup plans carrying + // positional PROMPT explicitly opt in after the shell startup files run. + void args.command + return args.startupCommandDelivery === 'shell-ready' } diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index 3af02f06795..998ff0a4dcd 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -4,7 +4,9 @@ import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner' const DECSET_BRACKETED_PASTE = '\x1b[?2004h' const SHOW_CURSOR = '\x1b[?25h' const HIDE_CURSOR = '\x1b[?25l' -const CODEX_PROMPT = '\x1b[1m›\x1b[0m Ask Codex to do anything' +const CODEX_GLYPH = '›' +const CODEX_PLACEHOLDER = 'Ask Codex to do anything' +const CODEX_PROMPT = `\x1b[1m${CODEX_GLYPH}\x1b[0m ${CODEX_PLACEHOLDER}` describe('createDraftPasteReadyScanner', () => { describe('render-cursor-after-bracketed-paste (opencode / mimo-code)', () => { @@ -92,23 +94,39 @@ describe('createDraftPasteReadyScanner', () => { }) }) - describe('codex-composer-prompt (unchanged behavior)', () => { - it('is ready on the composer glyph after bracketed paste and never arms the quiet timer', () => { + describe('codex-composer-prompt (multi-signal gate)', () => { + it('is ready only when glyph and idle placeholder both render after bracketed paste', () => { const scanner = createDraftPasteReadyScanner('codex-composer-prompt') expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({ ready: false, armQuietTimer: false }) - expect(scanner.observe(CODEX_PROMPT)).toEqual({ ready: true, armQuietTimer: false }) + // Why: Codex can render a bare glyph during hooks review before its idle + // composer owns input, so that glyph must not unlock a linked draft. + expect(scanner.observe(CODEX_GLYPH)).toEqual({ ready: false, armQuietTimer: false }) + expect(scanner.observe(CODEX_PLACEHOLDER)).toEqual({ ready: true, armQuietTimer: false }) }) - it('detects the composer glyph inside a large first render chunk', () => { + it('detects the full idle composer prompt inside a large first render chunk', () => { const scanner = createDraftPasteReadyScanner('codex-composer-prompt') expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${CODEX_PROMPT}${'x'.repeat(900)}`)).toEqual( { ready: true, armQuietTimer: false } ) }) + it('does not fire on glyph alone or placeholder alone', () => { + const glyphOnly = createDraftPasteReadyScanner('codex-composer-prompt') + glyphOnly.observe(DECSET_BRACKETED_PASTE) + expect(glyphOnly.observe(CODEX_GLYPH)).toEqual({ ready: false, armQuietTimer: false }) + + const placeholderOnly = createDraftPasteReadyScanner('codex-composer-prompt') + placeholderOnly.observe(DECSET_BRACKETED_PASTE) + expect(placeholderOnly.observe(CODEX_PLACEHOLDER)).toEqual({ + ready: false, + armQuietTimer: false + }) + }) + it('never arms the quiet-window fallback', () => { const scanner = createDraftPasteReadyScanner('codex-composer-prompt') expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({ diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index da491dc2d93..490685f880e 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -4,7 +4,8 @@ import type { DraftPasteReadySignal } from './tui-agent-config' // actually mounted/focused. These markers let the scanner detect the real // "input is ready" moment per agent instead of guessing from output silence. const DECSET_BRACKETED_PASTE = '\x1b[?2004h' -const CODEX_COMPOSER_PROMPT = '›' +const CODEX_COMPOSER_GLYPH = '›' +const CODEX_COMPOSER_PLACEHOLDER = 'Ask Codex' // Why: opencode emits the DECTCEM show-cursor only once the composer row is // mounted and the text cursor is placed in it — a "composer ready" signal, // analogous to Codex's prompt glyph. It fires ~2s after bracketed paste is @@ -28,8 +29,8 @@ export type DraftPasteReadyScanResult = { * and return types differ. * * Per agent signal: - * - `codex-composer-prompt`: ready when the `›` glyph renders after DECSET - * 2004; never arms the quiet window (`armQuietTimer` stays false). + * - `codex-composer-prompt`: ready when the `›` glyph and idle "Ask Codex" + * placeholder render after DECSET 2004; never arms the quiet window. * - `render-cursor-after-bracketed-paste`: ready when DECTCEM show-cursor * (`\x1b[?25h`) renders after DECSET 2004. Like Codex it does NOT arm the * quiet window: opencode stays silent for ~1.5-2s between enabling @@ -49,13 +50,21 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) let recent = '' let postHandshakeRecent = '' let saw2004 = false + let sawCodexGlyph = false + let sawCodexPlaceholder = false - const signalMarker = - readySignal === 'codex-composer-prompt' - ? CODEX_COMPOSER_PROMPT - : readySignal === 'render-cursor-after-bracketed-paste' - ? DECTCEM_SHOW_CURSOR - : null + const usesCursorMarker = readySignal === 'render-cursor-after-bracketed-paste' + const usesCodexComposer = readySignal === 'codex-composer-prompt' + const usesMarker = usesCursorMarker || usesCodexComposer + + const observeCodexMarkers = (chunk: string): void => { + if (!sawCodexGlyph && chunk.includes(CODEX_COMPOSER_GLYPH)) { + sawCodexGlyph = true + } + if (!sawCodexPlaceholder && chunk.includes(CODEX_COMPOSER_PLACEHOLDER)) { + sawCodexPlaceholder = true + } + } return { observe(data: string): DraftPasteReadyScanResult { @@ -68,27 +77,34 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) } saw2004 = true const postHandshakeChunk = combined.slice(markerIndex + DECSET_BRACKETED_PASTE.length) - if (signalMarker !== null && postHandshakeChunk.includes(signalMarker)) { + if (usesCodexComposer) { + observeCodexMarkers(postHandshakeChunk) + if (sawCodexGlyph && sawCodexPlaceholder) { + return { ready: true, armQuietTimer: false } + } + } else if (usesCursorMarker && postHandshakeChunk.includes(DECTCEM_SHOW_CURSOR)) { return { ready: true, armQuietTimer: false } } postHandshakeRecent = postHandshakeChunk.slice(-512) } else { - if ( - signalMarker !== null && - (data.includes(signalMarker) || (postHandshakeRecent + data).includes(signalMarker)) + if (usesCodexComposer) { + observeCodexMarkers(data) + observeCodexMarkers(postHandshakeRecent + data) + if (sawCodexGlyph && sawCodexPlaceholder) { + return { ready: true, armQuietTimer: false } + } + } else if ( + usesCursorMarker && + (data.includes(DECTCEM_SHOW_CURSOR) || + (postHandshakeRecent + data).includes(DECTCEM_SHOW_CURSOR)) ) { return { ready: true, armQuietTimer: false } } postHandshakeRecent = (postHandshakeRecent + data).slice(-512) } - // Why: marker-based signals (Codex glyph, opencode show-cursor) must NOT - // arm the quiet window. opencode goes silent for ~1.5-2s between enabling - // bracketed paste and mounting its composer, so a quiet window would fire - // during that gap — before the composer exists — and pre-empt the marker. - // These signals wait for their marker, bounded only by the caller's hard - // timeout (and the caller's best-effort process-ownership paste after it). - // Only the default signal, which has no marker, uses the quiet window. - return { ready: false, armQuietTimer: signalMarker === null && saw2004 } + // Why: marker-gated agents can be quiet before their composer mounts; + // only the default signal may use that quiet period as readiness. + return { ready: false, armQuietTimer: !usesMarker && saw2004 } } } } diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 6e5f5d8295a..a95a06c8b14 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -59,10 +59,9 @@ export type TuiAgentConfig = { * agent spawns. */ preflightTrust?: 'cursor' | 'copilot' | 'codex' /** Why: most TUIs need both bracketed-paste enablement and a quiet render - * window before pasted bytes reliably land in the composer. Codex can use - * a stronger signal from its own renderer: chat_composer.rs writes the - * `›` prompt only when the composer row exists, so Orca can paste as soon - * as that prompt appears after bracketed paste is enabled. */ + * window before pasted bytes reliably land in the composer. Codex waits for + * both the `›` glyph and idle "Ask Codex" placeholder because hooks review + * can render a bare glyph before the composer owns input. */ draftPasteReadySignal?: DraftPasteReadySignal /** Windows Shift+Enter override. Omitted agents keep the legacy Esc+CR path * because the renderer cannot infer every local or remote TUI's decoder. */ diff --git a/src/shared/tui-agent-launch-command.ts b/src/shared/tui-agent-launch-command.ts new file mode 100644 index 00000000000..8c098f6012f --- /dev/null +++ b/src/shared/tui-agent-launch-command.ts @@ -0,0 +1,26 @@ +import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' +import { planAgentCliArgsSuffix, type AgentStartupShell } from './tui-agent-startup-shell' +import type { TuiAgent } from './types' + +export function resolveTuiAgentBaseCommand(args: { + agent: TuiAgent + cmdOverrides: Partial> + platform: NodeJS.Platform + shell: AgentStartupShell + agentArgs?: string | null + isRemote?: boolean +}): { ok: true; command: string } | { ok: false; error: string } { + const override = args.cmdOverrides[args.agent] + const command = + override || + getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[args.agent], args.platform, { + isRemote: args.isRemote + }) + const suffix = planAgentCliArgsSuffix(args.agentArgs, args.shell) + if (!suffix.ok) { + return suffix + } + // Why: Codex status hooks live in Orca's runtime CODEX_HOME; a second + // profile representation would duplicate hooks and emit a warning. + return { ok: true, command: suffix.suffix ? `${command} ${suffix.suffix}` : command } +} diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 22ffdcdc15f..8bba303005b 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -419,6 +419,35 @@ describe('tui agent startup plans', () => { expect(plan?.startupCommandDelivery).toBe('shell-ready') }) + it('falls back to post-ready delivery when a Codex prompt exceeds the Windows argv budget', () => { + const prompt = 'x'.repeat(25_000) + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt, + cmdOverrides: {}, + platform: 'win32' + }) + + expect(plan?.launchCommand).toBe('codex') + expect(plan?.followupPrompt).toBe(prompt) + expect(plan?.startupCommandDelivery).toBeUndefined() + }) + + it('uses the lower cmd.exe argv budget for Codex startup prompts', () => { + const prompt = 'x'.repeat(7_600) + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt, + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd' + }) + + expect(plan?.launchCommand).toBe('codex') + expect(plan?.followupPrompt).toBe(prompt) + expect(plan?.startupCommandDelivery).toBeUndefined() + }) + it('keeps plain empty Codex startup on the fast delivery path', () => { const plan = buildAgentStartupPlan({ agent: 'codex', @@ -801,6 +830,18 @@ describe('tui agent startup plans', () => { ).toBeNull() }) + it('uses the lower cmd.exe argv budget for native draft flags', () => { + expect( + buildAgentDraftLaunchPlan({ + agent: 'claude', + draft: 'x'.repeat(7_600), + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd' + }) + ).toBeNull() + }) + it('returns null for oversized Windows env-var drafts so callers paste after ready', () => { expect( buildAgentDraftLaunchPlan({ diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 6e3a334dbc1..582dc738f28 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -8,16 +8,16 @@ import { import { clearEnvCommand, commandSeparator, - planAgentCliArgsSuffix, quoteStartupArg, resolveStartupShell, type AgentStartupShell } from './tui-agent-startup-shell' -import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' +import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { StartupCommandDelivery } from './codex-startup-delivery' import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config' import { planHermesStartupQuery } from './hermes-startup-query' import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' +import { resolveTuiAgentBaseCommand } from './tui-agent-launch-command' import type { TuiAgent } from './types' export type AgentStartupPlan = { @@ -32,29 +32,6 @@ export type AgentStartupPlan = { startupCommandDelivery?: StartupCommandDelivery } -function resolveBaseCommand(args: { - agent: TuiAgent - cmdOverrides: Partial> - platform: NodeJS.Platform - shell: AgentStartupShell - agentArgs?: string | null - isRemote?: boolean -}): { ok: true; command: string } | { ok: false; error: string } { - const override = args.cmdOverrides[args.agent] - const command = - override || - getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[args.agent], args.platform, { - isRemote: args.isRemote - }) - const suffix = planAgentCliArgsSuffix(args.agentArgs, args.shell) - if (!suffix.ok) { - return suffix - } - // Why: Codex status hooks live in Orca's runtime CODEX_HOME; adding - // --profile-v2 makes Codex load a second hook representation and warn. - return { ok: true, command: suffix.suffix ? `${command} ${suffix.suffix}` : command } -} - export function buildAgentStartupPlan(args: { agent: TuiAgent prompt: string @@ -73,7 +50,7 @@ export function buildAgentStartupPlan(args: { const trimmedPrompt = prompt.trim() const config = TUI_AGENT_CONFIG[agent] const usesQuery = config.promptInjectionMode === 'hermes-query' && Boolean(trimmedPrompt) - const baseCommand = resolveBaseCommand({ + const baseCommand = resolveTuiAgentBaseCommand({ agent, cmdOverrides, platform, @@ -107,9 +84,29 @@ export function buildAgentStartupPlan(args: { if (config.promptInjectionMode === 'argv') { const promptSeparator = config.argvPromptSeparator ? ` ${config.argvPromptSeparator}` : '' + const launchCommand = `${baseCommand.command}${promptSeparator} ${quotedPrompt}` + if ( + !inlineAgentDraftFitsPlatform({ + command: launchCommand, + env: args.agentEnv ?? undefined, + platform, + shell + }) + ) { + // Why: oversized Windows argv must fall back to post-ready stdin rather + // than being truncated or rejected by CreateProcess/cmd.exe. + return { + agent, + launchCommand: baseCommand.command, + expectedProcess: config.expectedProcess, + followupPrompt: trimmedPrompt, + launchConfig, + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + } + } return { agent, - launchCommand: `${baseCommand.command}${promptSeparator} ${quotedPrompt}`, + launchCommand, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, @@ -207,7 +204,7 @@ export function buildAgentResumeStartupPlan(args: { const resolvedAgentCommand = args.agentCommand?.trim() const baseCommand = resolvedAgentCommand ? ({ ok: true, command: resolvedAgentCommand } as const) - : resolveBaseCommand({ + : resolveTuiAgentBaseCommand({ agent: args.agent, cmdOverrides: args.cmdOverrides, platform: args.platform, @@ -264,7 +261,7 @@ export function buildAgentDraftLaunchPlan(args: { if (!trimmed) { return null } - const baseCommand = resolveBaseCommand({ + const baseCommand = resolveTuiAgentBaseCommand({ agent, cmdOverrides, platform, @@ -303,7 +300,7 @@ export function buildAgentDraftLaunchPlan(args: { } if ( !plan || - !inlineAgentDraftFitsPlatform({ command: plan.launchCommand, env: plan.env, platform }) + !inlineAgentDraftFitsPlatform({ command: plan.launchCommand, env: plan.env, platform, shell }) ) { return null }