diff --git a/src/main/runtime/agent-prompt-composer-pending.test.ts b/src/main/runtime/agent-prompt-composer-pending.test.ts new file mode 100644 index 000000000000..792ec4ce7e79 --- /dev/null +++ b/src/main/runtime/agent-prompt-composer-pending.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import { + buildAgentPromptFragment, + detectAgentPromptComposerVerdict +} from './agent-prompt-composer-pending' + +const PROMPT = [ + 'You are working on task task_123 for dispatch ctx_456.', + '', + 'Send worker_done when finished.' +].join('\n') + +describe('detectAgentPromptComposerVerdict', () => { + it('is unknown when no rendered screen exists', () => { + expect(detectAgentPromptComposerVerdict(null, PROMPT)).toBe('unknown') + expect(detectAgentPromptComposerVerdict({ lines: [] }, PROMPT)).toBe('unknown') + }) + + it('is pending when the emulator extracted a draft holding the payload', () => { + expect( + detectAgentPromptComposerVerdict( + { lines: ['›'], draft: '[Pasted Content 5033 chars]' }, + PROMPT + ) + ).toBe('pending') + expect( + detectAgentPromptComposerVerdict( + { lines: ['›'], draft: 'You are working on task task_123 for dispatch ctx_456.' }, + PROMPT + ) + ).toBe('pending') + }) + + it('is clear when the extracted draft is something else entirely', () => { + expect( + detectAgentPromptComposerVerdict({ lines: ['›'], draft: 'notes the operator typed' }, PROMPT) + ).toBe('clear') + }) + + it('is pending for a Codex pasted-content placeholder on the prompt line', () => { + const lines = [ + ' >_ OpenAI Codex (v0.152.0)', + ' model: gpt-5.6-sol medium', + '› [Pasted Content 5033 chars]', + ' tab to queue message', + ' gpt-5.6-sol · 100% context left' + ] + expect(detectAgentPromptComposerVerdict({ lines }, PROMPT)).toBe('pending') + }) + + it.each([ + '❯ [Pasted text #1 +91 lines]', + '❯ [Pasted: 91 lines]', + '> [[ You are working.. [91 lines] .. ]]' + ])('is pending for the placeholder %s', (composerLine) => { + expect(detectAgentPromptComposerVerdict({ lines: ['history', composerLine] }, PROMPT)).toBe( + 'pending' + ) + }) + + it('is pending when the prompt text itself sits after the prompt glyph', () => { + const lines = ['› You are working on task task_123 for dispatch ctx_456.', 'Send worker_done'] + expect(detectAgentPromptComposerVerdict({ lines }, PROMPT)).toBe('pending') + }) + + it('is clear when the last prompt line is empty even if history echoes the prompt', () => { + const lines = [ + '> [Pasted text #1 +2 lines]', + '✻ Thinking…', + '────────', + '❯', + '────────', + '? for shortcuts' + ] + expect(detectAgentPromptComposerVerdict({ lines }, PROMPT)).toBe('clear') + }) + + it('is clear when the prompt line holds unrelated text', () => { + expect( + detectAgentPromptComposerVerdict({ lines: ['› something the operator typed'] }, PROMPT) + ).toBe('clear') + }) + + it('is unknown when no prompt glyph line exists on screen', () => { + expect(detectAgentPromptComposerVerdict({ lines: ['plain output', 'more'] }, PROMPT)).toBe( + 'unknown' + ) + }) + + it('ignores a prompt glyph line that scrolled far above the bottom of the screen', () => { + const lines = [ + '› [Pasted Content 5033 chars]', + ...Array.from({ length: 20 }, (_, i) => `l${i}`) + ] + expect(detectAgentPromptComposerVerdict({ lines }, PROMPT)).toBe('unknown') + }) + + it('does not mistake the Codex banner for a composer line', () => { + expect( + detectAgentPromptComposerVerdict( + { lines: [' >_ OpenAI Codex (v0.152.0)', 'model: gpt'] }, + PROMPT + ) + ).toBe('unknown') + }) + + it('does not treat a shell prompt ending in > as a composer', () => { + expect(detectAgentPromptComposerVerdict({ lines: ['PS C:\\repo> codex'] }, PROMPT)).toBe( + 'unknown' + ) + }) +}) + +describe('buildAgentPromptFragment', () => { + it('uses the first non-empty line, collapsed and capped', () => { + expect(buildAgentPromptFragment('\n\n You are working\ton task\nrest')).toBe( + 'You are working on task' + ) + expect(buildAgentPromptFragment('x'.repeat(100))?.length).toBe(48) + }) + + it('refuses fragments too short to be distinctive', () => { + expect(buildAgentPromptFragment('ok')).toBeNull() + expect(buildAgentPromptFragment(' \n\n')).toBeNull() + }) +}) diff --git a/src/main/runtime/agent-prompt-composer-pending.ts b/src/main/runtime/agent-prompt-composer-pending.ts new file mode 100644 index 000000000000..d19d83322e6e --- /dev/null +++ b/src/main/runtime/agent-prompt-composer-pending.ts @@ -0,0 +1,76 @@ +/** What the rendered screen says about an injected prompt: `pending` = the composer still holds + * it, `clear` = a recognized composer is empty (or holds something else), `unknown` = no rendered + * screen, or no composer line this detector recognizes — never grounds for another Enter. */ +export type AgentPromptComposerVerdict = 'pending' | 'clear' | 'unknown' + +export type AgentPromptComposerScreen = { + lines: readonly string[] + /** Composer text the emulator extracted from the cursor rows, when it recognized one. */ + draft?: string +} + +// Why: multi-line pastes collapse to a placeholder whose shape differs per TUI — Codex +// `[Pasted Content 5033 chars]`, Claude `[Pasted text #1 +91 lines]`, Grok `[Pasted: 91 lines]`, +// Hermes `[[ … [91 lines] .. ]]` — so match the shared "pasted"/"N lines" bracket, not a vendor. +const PASTE_PLACEHOLDER_RE = /\[+\s*pasted\b[^\]]*\]+|\[\d+\s+lines\]/i +// Why: composer prompts start the line with their glyph; a shell prompt like `PS C:\repo>` does not, +// and a bare `>` only counts when spaced — Codex's own banner opens with `>_ OpenAI Codex`. +const COMPOSER_PROMPT_LINE_RE = /^\s*(?:[❯›»]|>(?=\s|$))\s?(.*)$/ +// Why: the composer lives at the bottom of the frame; a glyph line further up is transcript. +const COMPOSER_SCAN_LINES = 12 +const PROMPT_FRAGMENT_MIN_CHARS = 6 +const PROMPT_FRAGMENT_MAX_CHARS = 48 + +/** The prompt's first non-empty line, normalized, or null when too short to be distinctive. */ +export function buildAgentPromptFragment(prompt: string): string | null { + for (const line of prompt.split('\n')) { + const collapsed = line.replace(/\s+/g, ' ').trim() + if (!collapsed) { + continue + } + return collapsed.length >= PROMPT_FRAGMENT_MIN_CHARS + ? collapsed.slice(0, PROMPT_FRAGMENT_MAX_CHARS) + : null + } + return null +} + +export function detectAgentPromptComposerVerdict( + screen: AgentPromptComposerScreen | null | undefined, + prompt: string +): AgentPromptComposerVerdict { + if (!screen || screen.lines.length === 0) { + return 'unknown' + } + const fragment = buildAgentPromptFragment(prompt) + // Why: a draft the operator typed is not the payload; another Enter would submit it, not ours. + const draft = screen.draft?.trim() + if (draft) { + return holdsInjectedPayload(draft, fragment) ? 'pending' : 'clear' + } + const composerText = findLastComposerLineText(screen.lines) + if (composerText === null) { + return 'unknown' + } + return holdsInjectedPayload(composerText, fragment) ? 'pending' : 'clear' +} + +function holdsInjectedPayload(composerText: string, fragment: string | null): boolean { + return ( + PASTE_PLACEHOLDER_RE.test(composerText) || + (fragment !== null && composerText.includes(fragment)) + ) +} + +// Why the last glyph line only: Claude keeps `> [Pasted text …]` in its transcript after submit, +// while the empty `❯` below it is the composer that actually answers "is anything parked?". +function findLastComposerLineText(lines: readonly string[]): string | null { + const tail = lines.filter((line) => line.trim().length > 0).slice(-COMPOSER_SCAN_LINES) + for (let index = tail.length - 1; index >= 0; index -= 1) { + const match = tail[index]!.match(COMPOSER_PROMPT_LINE_RE) + if (match) { + return match[1]!.trim() + } + } + return null +} diff --git a/src/main/runtime/agent-prompt-composer-submission.test.ts b/src/main/runtime/agent-prompt-composer-submission.test.ts new file mode 100644 index 000000000000..c1f92d9c64c7 --- /dev/null +++ b/src/main/runtime/agent-prompt-composer-submission.test.ts @@ -0,0 +1,268 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AGENT_PROMPT_BRACKETED_PASTE_END } from '../../shared/agent-prompt-injection' +import { + AGENT_PROMPT_COMPOSER_READY_TIMEOUT_MS, + AGENT_PROMPT_EFFECT_TIMEOUT_MS, + AGENT_PROMPT_PENDING_COMPOSER_GRACE_MS +} from '../../shared/orchestration-timing-budgets' +import { AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS } from './agent-prompt-submission-verification' +import { + AGENT_PROMPT_TEST_WORKTREE_PATH, + createAgentPromptSubmissionRuntime +} from './agent-prompt-submission-runtime-test-fixture' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/prompt-verification', + isBare: false, + isMainWorktree: false + } + ]), + listWorktreesStrict: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/prompt-verification', + isBare: false, + isMainWorktree: false + } + ]) +})) + +const PTY = 'pty-prompt' +const PROMPT = + 'You are working on task task_123 for dispatch ctx_456.\n\nSend worker_done when done.' +const CLEAR_SCREEN = '\x1b[2J\x1b[H' +const BRACKETED_PASTE_ON = '\x1b[?2004h' + +// Why: the shape Codex paints once its model is loaded — header, composer glyph, footer. +function codexFrame(composer: string, model = 'gpt-5.6-sol medium'): string { + return ( + `${CLEAR_SCREEN}\x1b[?25h >_ OpenAI Codex (v0.152.0)\r\n` + + ` model: ${model}\r\n` + + ` directory: ${AGENT_PROMPT_TEST_WORKTREE_PATH}\r\n\r\n` + + `› ${composer}\r\n\r\n` + + ` gpt-5.6-sol · 100% context left\r\n` + ) +} + +function enters(writes: string[]): number { + return writes.filter((data) => data === '\r').length +} + +describe('agent prompt submission observes the composer', () => { + afterEach(() => vi.useRealTimers()) + + it('re-sends Enter while the pasted payload stays parked, then accepts the turn start', async () => { + vi.useFakeTimers() + let enterCount = 0 + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData(PTY, codexFrame('[Pasted Content 5033 chars]'), Date.now()) + } else if (data === '\r') { + enterCount += 1 + // Why: the first Enter is absorbed into the paste burst and only repaints the placeholder. + runtime.onPtyData( + PTY, + enterCount === 1 + ? codexFrame('[Pasted Content 5033 chars]') + : `${codexFrame('')}\x1b]0;Codex working\x07`, + Date.now() + ) + } + }, + 'codex', + { seedReadyHeader: false } + ) + runtime.onPtyData(PTY, `${BRACKETED_PASTE_ON}${codexFrame('')}`, Date.now()) + + const submission = runtime.sendTerminalAgentPrompt(handle, PROMPT) + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + expect(enters(writes)).toBe(2) + }) + + it('accepts a visible payload that disappears after Enter, with no hook or title evidence', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData(PTY, codexFrame(PROMPT.split('\n')[0]!), Date.now()) + } else if (data === '\r') { + runtime.onPtyData(PTY, codexFrame(''), Date.now()) + } + }, + 'codex', + { seedReadyHeader: false } + ) + runtime.onPtyData(PTY, `${BRACKETED_PASTE_ON}${codexFrame('')}`, Date.now()) + + const submission = runtime.sendTerminalAgentPrompt(handle, PROMPT) + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + expect(enters(writes)).toBe(1) + }) + + // Why (field, 2026-09-01): two Codex dispatches came back input_accepted while the payload sat + // as `[Pasted Content N chars]` for four hours — the agent was already `working` from its session + // hook and kept painting, so output-after-Enter passed as delivery evidence. + it('does not accept a busy agent painting around a parked payload; re-sends Enter until it clears', async () => { + vi.useFakeTimers() + let enterCount = 0 + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData(PTY, codexFrame('[Pasted Content 4087 chars]'), Date.now()) + } else if (data === '\r') { + enterCount += 1 + runtime.onPtyData( + PTY, + enterCount === 1 ? codexFrame('[Pasted Content 4087 chars]') : codexFrame(''), + Date.now() + ) + } + }, + 'codex', + { seedReadyHeader: false } + ) + runtime.onPtyData( + PTY, + `${BRACKETED_PASTE_ON}${codexFrame('')}\x1b]0;Codex working\x07`, + Date.now() + ) + + const submission = runtime.sendTerminalAgentPrompt(handle, PROMPT) + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + expect(enters(writes)).toBe(2) + }) + + it('keeps re-sending Enter with backoff and only then reports a parked payload as stalled', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END) || data === '\r') { + runtime.onPtyData(PTY, codexFrame('[Pasted Content 5033 chars]'), Date.now()) + } + }, + 'codex', + { seedReadyHeader: false } + ) + runtime.onPtyData(PTY, `${BRACKETED_PASTE_ON}${codexFrame('')}`, Date.now()) + + const submission = runtime.sendTerminalAgentPrompt(handle, PROMPT) + const rejected = expect(submission).rejects.toMatchObject({ + message: 'agent_prompt_stalled', + composer: 'pending', + enterRetries: AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS.length + }) + await vi.advanceTimersByTimeAsync( + AGENT_PROMPT_EFFECT_TIMEOUT_MS + AGENT_PROMPT_PENDING_COMPOSER_GRACE_MS + 15_000 + ) + + await rejected + expect(enters(writes)).toBe(1 + AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS.length) + }) + + it('reports an empty composer with no activity as stalled after exactly one Enter', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END) || data === '\r') { + runtime.onPtyData(PTY, codexFrame(''), Date.now()) + } + }, + 'codex', + { seedReadyHeader: false } + ) + runtime.onPtyData(PTY, `${BRACKETED_PASTE_ON}${codexFrame('')}`, Date.now()) + + const submission = runtime.sendTerminalAgentPrompt(handle, PROMPT) + const rejected = expect(submission).rejects.toMatchObject({ + message: 'agent_prompt_stalled', + composer: 'clear', + enterRetries: 0 + }) + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS + 15_000) + + await rejected + expect(enters(writes)).toBe(1) + }) + + it('holds the paste until a booting TUI shows its composer', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData(PTY, codexFrame('[Pasted Content 5033 chars]'), Date.now()) + } else if (data === '\r') { + runtime.onPtyData(PTY, `${codexFrame('')}\x1b]0;Codex working\x07`, Date.now()) + } + }, + 'codex', + { seedReadyHeader: false } + ) + // Why: a splash with no composer yet; the header-based readiness is covered elsewhere. + runtime.onPtyData(PTY, `${CLEAR_SCREEN}Starting MCP servers (1/2)\r\n`, Date.now()) + + const submission = runtime.sendTerminalAgentPrompt(handle, PROMPT) + await vi.advanceTimersByTimeAsync(5_000) + expect(writes).toEqual([]) + + runtime.onPtyData(PTY, `${BRACKETED_PASTE_ON}${codexFrame('')}`, Date.now()) + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + expect(writes.length).toBeGreaterThan(0) + }) + + it('pastes anyway once the composer readiness budget runs out', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data === '\r') { + runtime.onPtyData(PTY, '\x1b]0;Codex working\x07', Date.now()) + } + }, + 'codex', + { seedReadyHeader: false } + ) + + const submission = runtime.sendTerminalAgentPrompt(handle, PROMPT) + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_COMPOSER_READY_TIMEOUT_MS - 1_000) + expect(writes).toEqual([]) + await vi.advanceTimersByTimeAsync(1_100) + expect(writes.length).toBeGreaterThan(0) + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + }) + + it('pastes immediately into an agent that already reports a status', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data === '\r') { + runtime.onPtyData(PTY, '\x1b]0;Codex working\x07', Date.now()) + } + }, + 'codex', + { seedReadyHeader: false } + ) + runtime.onPtyData(PTY, '\x1b]0;Codex idle\x07', Date.now()) + + const submission = runtime.sendTerminalAgentPrompt(handle, PROMPT) + await vi.advanceTimersByTimeAsync(10) + expect(writes.length).toBeGreaterThan(0) + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + }) +}) diff --git a/src/main/runtime/agent-prompt-submission-runtime-test-fixture.ts b/src/main/runtime/agent-prompt-submission-runtime-test-fixture.ts index 69470ac61487..873fbae36a07 100644 --- a/src/main/runtime/agent-prompt-submission-runtime-test-fixture.ts +++ b/src/main/runtime/agent-prompt-submission-runtime-test-fixture.ts @@ -5,9 +5,15 @@ import { makeStore } from './runtime-rpc-worktree-store-fixtures' export const AGENT_PROMPT_TEST_WORKTREE_PATH = '/tmp/worktree-a' export const AGENT_PROMPT_TEST_WORKTREE_ID = 'repo-1::/tmp/worktree-a' +// Why: the paste path first waits for composer readiness; a settled ready header is the cheapest +// evidence and leaves status, permission and title state untouched for the scenario under test. +export const AGENT_PROMPT_TEST_READY_HEADER = + ' >_ OpenAI Codex (v0.152.0)\n model: gpt-5.5 high\n directory: /tmp/worktree-a\n' + export async function createAgentPromptSubmissionRuntime( onWrite: (runtime: OrcaRuntimeService, data: string, writeIndex: number) => void, - launchAgent: TuiAgent = 'aider' + launchAgent: TuiAgent = 'aider', + options: { seedReadyHeader?: boolean } = {} ): Promise<{ runtime: OrcaRuntimeService; handle: string; writes: string[] }> { const runtime = new OrcaRuntimeService(makeStore() as never) const writes: string[] = [] @@ -24,5 +30,8 @@ export async function createAgentPromptSubmissionRuntime( const terminal = await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, { launchAgent }) + if (options.seedReadyHeader !== false) { + runtime.onPtyData('pty-prompt', AGENT_PROMPT_TEST_READY_HEADER, Date.now()) + } return { runtime, handle: terminal.handle, writes } } diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 14ac4cf4fb92..e48fa99c1491 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -8,6 +8,7 @@ import { AGENT_PROMPT_TEST_WORKTREE_PATH, createAgentPromptSubmissionRuntime } from './agent-prompt-submission-runtime-test-fixture' +import { AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS } from './agent-prompt-submission-verification' import { OrcaRuntimeService } from './orca-runtime' import { makeStore } from './runtime-rpc-worktree-store-fixtures' @@ -70,7 +71,7 @@ describe('agent prompt submission runtime', () => { expect(writes.filter((data) => data === '\r')).toHaveLength(1) }) - it('reports redraw-only activity as stalled without retrying Enter', async () => { + it('re-sends Enter while a redraw keeps the prompt parked, then reports it stalled', async () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { @@ -80,7 +81,32 @@ describe('agent prompt submission runtime', () => { } }) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') - const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') + const rejected = expect(submission).rejects.toMatchObject({ + message: 'agent_prompt_stalled', + composer: 'pending' + }) + + await vi.runAllTimersAsync() + + await rejected + expect(writes.filter((data) => data === '\r')).toHaveLength( + 1 + AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS.length + ) + }) + + it('reports a redraw with an empty composer as stalled without retrying Enter', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END) || data === '\r') { + runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› ', Date.now()) + } + }) + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const rejected = expect(submission).rejects.toMatchObject({ + message: 'agent_prompt_stalled', + composer: 'clear', + enterRetries: 0 + }) await vi.runAllTimersAsync() diff --git a/src/main/runtime/agent-prompt-submission-verification.test.ts b/src/main/runtime/agent-prompt-submission-verification.test.ts index ec7c2b853784..fb1e5d23a944 100644 --- a/src/main/runtime/agent-prompt-submission-verification.test.ts +++ b/src/main/runtime/agent-prompt-submission-verification.test.ts @@ -1,13 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { + AGENT_PROMPT_COMPOSER_CLEAR_CONFIRM_MS, AGENT_PROMPT_EFFECT_TIMEOUT_MS, AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS, + AGENT_PROMPT_PENDING_COMPOSER_GRACE_MS, + AGENT_PROMPT_STALLED_ERROR, + AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS, type AgentPromptActivity, isAgentPromptStalledError, readAgentPromptWaitText, resolveAgentPromptEffectTimeoutMs, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' +import type { AgentPromptComposerVerdict } from './agent-prompt-composer-pending' function activity(overrides: Partial = {}): AgentPromptActivity { return { @@ -46,7 +51,7 @@ describe('agent prompt submission verification', () => { current = activity({ workingSequence: 5, status: 'working' }) await vi.advanceTimersByTimeAsync(50) - await expect(verification).resolves.toBeUndefined() + await expect(verification).resolves.toMatchObject({ evidence: 'activity' }) }) it('accepts a completed lifecycle transition between polls', async () => { @@ -60,7 +65,7 @@ describe('agent prompt submission verification', () => { current = activity({ workingSequence: 5 }) await vi.advanceTimersByTimeAsync(50) - await expect(verification).resolves.toBeUndefined() + await expect(verification).resolves.toMatchObject({ evidence: 'activity' }) }) it('does not accept an unrelated transition to a neutral title', async () => { @@ -104,7 +109,7 @@ describe('agent prompt submission verification', () => { current = activity({ workingSequence: 5, status: 'working' }) await vi.advanceTimersByTimeAsync(50) - await expect(verification).resolves.toBeUndefined() + await expect(verification).resolves.toMatchObject({ evidence: 'activity' }) }) it('blocks when permission appears after submit', async () => { @@ -171,7 +176,7 @@ describe('agent prompt submission verification', () => { current = activity({ explicitWorkingStartedAt: 2_000, status: 'working' }) await vi.advanceTimersByTimeAsync(50) - await expect(verification).resolves.toBeUndefined() + await expect(verification).resolves.toMatchObject({ evidence: 'activity' }) }) it('does not accept a hook working status that predates the baseline', async () => { @@ -216,7 +221,7 @@ describe('agent prompt submission verification', () => { current = activity({ status: 'working', outputSequence: 8 }) await vi.advanceTimersByTimeAsync(50) - await expect(verification).resolves.toBeUndefined() + await expect(verification).resolves.toMatchObject({ evidence: 'activity' }) }) it('does not accept pane output when the agent was idle at submit', async () => { @@ -247,7 +252,7 @@ describe('agent prompt submission verification', () => { current = activity({ explicitWorkingStartedAt: 9_000, status: 'working' }) await vi.advanceTimersByTimeAsync(50) - await expect(verification).resolves.toBeUndefined() + await expect(verification).resolves.toMatchObject({ evidence: 'activity' }) }) it('gives hook-observed agents the longer effect window', () => { @@ -290,3 +295,222 @@ describe('agent prompt submission verification', () => { await expect(verification).rejects.toThrow('request_aborted') }) }) + +describe('agent prompt submission verification with a composer observer', () => { + afterEach(() => vi.useRealTimers()) + + function composerObserver( + verdicts: AgentPromptComposerVerdict[], + beforeSubmit: AgentPromptComposerVerdict = 'pending' + ) { + // Why: the last verdict repeats, the way a screen keeps showing the same frame. + const read = vi.fn(async () => (verdicts.length > 1 ? verdicts.shift()! : verdicts[0]!)) + const resubmit = vi.fn() + return { observer: { beforeSubmit, read, resubmit }, read, resubmit } + } + + it('re-sends Enter with backoff while the composer keeps the payload, then accepts activity', async () => { + vi.useFakeTimers() + let current = activity() + const { observer, resubmit } = composerObserver(['pending', 'pending', 'clear']) + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current, + composer: observer + }) + + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS[0]! + 100) + expect(resubmit).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync( + AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS[1]! - AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS[0]! + ) + expect(resubmit).toHaveBeenCalledTimes(2) + + current = activity({ workingSequence: 5, status: 'working' }) + // Why: the payload was last seen parked, so the activity only counts after a composer re-read. + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_COMPOSER_CLEAR_CONFIRM_MS + 100) + + await expect(verification).resolves.toEqual({ evidence: 'activity', enterRetries: 2 }) + }) + + it('accepts a payload that was visible before Enter and stays cleared afterwards', async () => { + vi.useFakeTimers() + const current = activity() + const { observer, read, resubmit } = composerObserver(['clear', 'clear']) + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current, + composer: observer + }) + + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS[0]! + 100) + expect(read).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_COMPOSER_CLEAR_CONFIRM_MS + 100) + + await expect(verification).resolves.toEqual({ evidence: 'composer-cleared', enterRetries: 0 }) + expect(read).toHaveBeenCalledTimes(2) + expect(resubmit).not.toHaveBeenCalled() + }) + + it('does not accept one cleared read that the confirmation read contradicts', async () => { + vi.useFakeTimers() + const current = activity() + const { observer, resubmit } = composerObserver(['clear', 'pending', 'pending', 'pending']) + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current, + composer: observer, + timeoutMs: 10_000 + }) + const rejected = expect(verification).rejects.toThrow(AGENT_PROMPT_STALLED_ERROR) + + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS[1]! + 100) + expect(resubmit).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(60_000) + + await rejected + }) + + it('accepts a cleared composer after a retry made the payload visible first', async () => { + vi.useFakeTimers() + const current = activity() + const { observer, resubmit } = composerObserver(['pending', 'clear', 'clear'], 'clear') + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current, + composer: observer + }) + + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS[1]! + 100) + expect(resubmit).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_COMPOSER_CLEAR_CONFIRM_MS + 100) + + await expect(verification).resolves.toEqual({ evidence: 'composer-cleared', enterRetries: 1 }) + }) + + it('never re-sends Enter while the composer reads clear or unknown', async () => { + vi.useFakeTimers() + for (const verdict of ['clear', 'unknown'] as const) { + const current = activity() + const { observer, resubmit } = composerObserver([verdict], verdict) + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current, + composer: observer + }) + const rejected = expect(verification).rejects.toThrow(AGENT_PROMPT_STALLED_ERROR) + + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS + 100) + + await rejected + expect(resubmit).not.toHaveBeenCalled() + } + }) + + it('extends the deadline once while the payload is still parked and reports the verdict', async () => { + vi.useFakeTimers() + const current = activity() + const { observer, resubmit } = composerObserver(['pending']) + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current, + composer: observer + }) + let settled: unknown = null + verification.then( + () => (settled = 'resolved'), + (error: unknown) => (settled = error) + ) + + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS + 100) + expect(settled).toBeNull() + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_PENDING_COMPOSER_GRACE_MS + 100) + + expect(settled).toBeInstanceOf(Error) + expect(isAgentPromptStalledError(settled)).toBe(true) + expect(settled).toMatchObject({ + composer: 'pending', + enterRetries: AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS.length + }) + expect(resubmit).toHaveBeenCalledTimes(AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS.length) + }) + + // Why: a busy agent keeps printing after Enter whether or not it took the paste; the parked + // payload on screen outranks that activity until the composer is seen to let go of it. + it('does not accept activity while the payload still reads pending, then accepts once it clears', async () => { + vi.useFakeTimers() + // Reads: 0ms, 500ms, 1000ms (activity-triggered, one per confirm window), 1500ms (checkpoint, + // still pending → Enter again), 2000ms (cleared → the activity finally counts). + const { observer, read, resubmit } = composerObserver([ + 'pending', + 'pending', + 'pending', + 'pending', + 'clear' + ]) + const baseline = activity({ status: 'working' }) + const verification = verifyAgentPromptSubmission({ + baseline, + readActivity: () => activity({ status: 'working', outputSequence: 9 }), + composer: observer + }) + let settled = false + verification.then( + () => (settled = true), + () => (settled = true) + ) + + await vi.advanceTimersByTimeAsync(100) + expect(read).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS[0]!) + expect(resubmit).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_COMPOSER_CLEAR_CONFIRM_MS + 100) + + await expect(verification).resolves.toEqual({ evidence: 'activity', enterRetries: 1 }) + expect(read).toHaveBeenCalledTimes(5) + }) + + it('rejects request_aborted when the signal fires during a deferred composer read', async () => { + vi.useFakeTimers() + const controller = new AbortController() + let releaseRead!: (verdict: AgentPromptComposerVerdict) => void + const read = vi.fn( + () => new Promise((resolve) => (releaseRead = resolve)) + ) + const baseline = activity({ status: 'working' }) + const verification = verifyAgentPromptSubmission({ + baseline, + readActivity: () => activity({ status: 'working', outputSequence: 9 }), + composer: { beforeSubmit: 'pending', read, resubmit: vi.fn() }, + signal: controller.signal + }) + const rejected = expect(verification).rejects.toThrow('request_aborted') + + await vi.advanceTimersByTimeAsync(100) + expect(read).toHaveBeenCalledTimes(1) + controller.abort() + releaseRead('clear') + + await rejected + }) + + it('does not re-send Enter once a permission state appears', async () => { + vi.useFakeTimers() + let current = activity() + const { observer, resubmit } = composerObserver(['pending']) + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current, + composer: observer + }) + const rejected = expect(verification).rejects.toThrow('agent_prompt_blocked') + + current = activity({ status: 'permission' }) + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS[0]! + 100) + + await rejected + expect(resubmit).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index 5bd1a6223216..f216fff951a8 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -1,15 +1,43 @@ -export { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' -import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' +export { + AGENT_PROMPT_EFFECT_TIMEOUT_MS, + AGENT_PROMPT_PENDING_COMPOSER_GRACE_MS +} from '../../shared/orchestration-timing-budgets' +import { + AGENT_PROMPT_EFFECT_TIMEOUT_MS, + AGENT_PROMPT_PENDING_COMPOSER_GRACE_MS, + AGENT_PROMPT_VERIFICATION_MAX_MS +} from '../../shared/orchestration-timing-budgets' import type { TuiAgent } from '../../shared/tui-agent' +import type { AgentPromptComposerVerdict } from './agent-prompt-composer-pending' export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS const AGENT_PROMPT_EFFECT_POLL_MS = 50 +/** When, after Enter, the composer is re-read and a still-parked payload gets Enter again. + * Backs off so a slow first turn is not hammered, yet reaches a TUI that ate the first Enter + * while it was still absorbing the paste (Codex over Windows console input records). */ +export const AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS: readonly number[] = [ + 1_500, 4_000, 9_000, 18_000, 36_000 +] +/** A cleared composer must hold for this long before it counts as the payload being consumed. */ +export const AGENT_PROMPT_COMPOSER_CLEAR_CONFIRM_MS = 500 const HOOK_OBSERVED_TURN_START_AGENTS = new Set(['codex', 'kimi']) /** The prompt bytes are written before verification, so this only ever means "not observed". */ export const AGENT_PROMPT_STALLED_ERROR = 'agent_prompt_stalled' +export class AgentPromptStalledError extends Error { + constructor( + /** What the rendered composer showed when the verdict was reached. */ + readonly composer: AgentPromptComposerVerdict, + /** Extra Enters written after the first one. */ + readonly enterRetries: number + ) { + super(AGENT_PROMPT_STALLED_ERROR) + this.name = 'AgentPromptStalledError' + } +} + export type AgentPromptActivity = Readonly<{ generation: number permissionSequence: number @@ -27,11 +55,29 @@ export type AgentPromptWaitTextCache = { waitText?: string } +/** Rendered-screen view of the composer, for callers that can read one. */ +export type AgentPromptComposerObserver = { + /** Verdict read after the paste settled and before the first Enter. */ + beforeSubmit: AgentPromptComposerVerdict + read: () => Promise + /** Writes one more Enter; only invoked while the payload is visibly parked and nothing blocks. */ + resubmit: () => Promise | void + retryDelaysMs?: readonly number[] + pendingGraceMs?: number +} + +export type AgentPromptSubmissionOutcome = { + /** `activity`: a turn-start/hook/output proof; `composer-cleared`: the parked payload vanished. */ + evidence: 'activity' | 'composer-cleared' + enterRetries: number +} + type AgentPromptVerificationOptions = { baseline: AgentPromptActivity readActivity: () => AgentPromptActivity timeoutMs?: number signal?: AbortSignal + composer?: AgentPromptComposerObserver } export function resolveAgentPromptEffectTimeoutMs(agent: TuiAgent | null | undefined): number { @@ -68,28 +114,108 @@ export function readAgentPromptWaitText( export async function verifyAgentPromptSubmission( options: AgentPromptVerificationOptions -): Promise { +): Promise { throwIfAgentPromptAborted(options.signal) assertPromptNotBlocked(options.baseline, options.baseline) - const deadline = Date.now() + (options.timeoutMs ?? AGENT_PROMPT_EFFECT_TIMEOUT_MS) - while (Date.now() < deadline) { + const startedAt = Date.now() + let deadline = startedAt + (options.timeoutMs ?? AGENT_PROMPT_EFFECT_TIMEOUT_MS) + const composer = options.composer + const retryDelaysMs = composer?.retryDelaysMs ?? AGENT_PROMPT_SUBMIT_RETRY_DELAYS_MS + let nextCheckpoint = 0 + let enterRetries = 0 + let lastVerdict: AgentPromptComposerVerdict = composer?.beforeSubmit ?? 'unknown' + let sawPending = lastVerdict === 'pending' + let clearObservedAt: number | null = null + let graceApplied = false + + const activityObserved = (): boolean => { const current = options.readActivity() assertSamePromptGeneration(options.baseline, current) assertPromptNotBlocked(options.baseline, current) - if (agentPromptEffectObserved(options.baseline, current)) { - return + return agentPromptEffectObserved(options.baseline, current) + } + // Why: a busy agent keeps painting after Enter whether or not it took the paste, and its session + // hook can already read `working`. Activity therefore proves nothing while the payload is still on + // screen; the composer's last verdict has to release it first. While that holds, the composer is + // re-read at most once per confirm window so a retry that lands is noticed without hammering. + let lastComposerReadAt = -Infinity + const activityProvesSubmission = async (): Promise => { + if (!composer || !sawPending || lastVerdict !== 'pending') { + return true + } + if (Date.now() - lastComposerReadAt < AGENT_PROMPT_COMPOSER_CLEAR_CONFIRM_MS) { + return false + } + lastComposerReadAt = Date.now() + lastVerdict = await composer.read() + throwIfAgentPromptAborted(options.signal) + return lastVerdict !== 'pending' && activityObserved() + } + + while (Date.now() < deadline) { + const elapsed = Date.now() - startedAt + const checkpointDue = + composer !== undefined && + nextCheckpoint < retryDelaysMs.length && + elapsed >= retryDelaysMs[nextCheckpoint]! + const confirmDue = + clearObservedAt !== null && + Date.now() - clearObservedAt >= AGENT_PROMPT_COMPOSER_CLEAR_CONFIRM_MS + // Why: a due checkpoint owns this iteration's read, so a parked payload gets its Enter before + // the activity path can spend the read and swallow the retry. + if (activityObserved() && !checkpointDue && !confirmDue && (await activityProvesSubmission())) { + return { evidence: 'activity', enterRetries } + } + if (composer) { + if (checkpointDue || confirmDue) { + if (checkpointDue) { + nextCheckpoint += 1 + } + throwIfAgentPromptAborted(options.signal) + lastComposerReadAt = Date.now() + lastVerdict = await composer.read() + throwIfAgentPromptAborted(options.signal) + // Why: the read is asynchronous; a turn start or a permission dialog may have landed meanwhile. + if (lastVerdict !== 'pending' && activityObserved()) { + return { evidence: 'activity', enterRetries } + } + if (lastVerdict === 'pending') { + sawPending = true + clearObservedAt = null + if (checkpointDue) { + throwIfAgentPromptAborted(options.signal) + await composer.resubmit() + enterRetries += 1 + if (!graceApplied) { + // Why the cap: budgets downstream are derived from the verification ceiling. + deadline = Math.min( + deadline + (composer.pendingGraceMs ?? AGENT_PROMPT_PENDING_COMPOSER_GRACE_MS), + startedAt + AGENT_PROMPT_VERIFICATION_MAX_MS + ) + graceApplied = true + } + } + } else if (lastVerdict === 'clear' && sawPending) { + // Why two reads: a frame caught mid-redraw can look empty; a payload that was on screen + // and stays gone across the confirm window was consumed by the TUI. + if (clearObservedAt === null) { + clearObservedAt = Date.now() + } else if (confirmDue) { + return { evidence: 'composer-cleared', enterRetries } + } + } else { + clearObservedAt = null + } + } } await waitForAgentPromptPoll(options.signal) } - const current = options.readActivity() - assertSamePromptGeneration(options.baseline, current) - assertPromptNotBlocked(options.baseline, current) - if (agentPromptEffectObserved(options.baseline, current)) { - return + if (activityObserved() && (await activityProvesSubmission())) { + return { evidence: 'activity', enterRetries } } - throw new Error(AGENT_PROMPT_STALLED_ERROR) + throw new AgentPromptStalledError(lastVerdict, enterRetries) } function agentPromptEffectObserved( diff --git a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts index f6298df8b175..73529705fa91 100644 --- a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts +++ b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts @@ -5,6 +5,7 @@ import { getTerminalPasteIngestMs } from '../../shared/agent-prompt-injection' import { setSshTargetRegistryHandlers } from '../ssh/ssh-target-registry' +import { AGENT_PROMPT_TEST_READY_HEADER } from './agent-prompt-submission-runtime-test-fixture' import { OrcaRuntimeService } from './orca-runtime' import { makeStore } from './runtime-rpc-worktree-store-fixtures' @@ -65,6 +66,7 @@ async function createPromptRuntime(): Promise<{ const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, { launchAgent: 'aider' }) + runtime.onPtyData(PTY_ID, AGENT_PROMPT_TEST_READY_HEADER, Date.now()) return { runtime, handle: terminal.handle, writes, submitTimes } } @@ -334,6 +336,7 @@ describe('agent prompt render gate on a ConPTY host', () => { const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, { launchAgent: 'claude' }) + runtime.onPtyData(PTY_ID, AGENT_PROMPT_TEST_READY_HEADER, Date.now()) return { runtime, handle: terminal.handle, writes, submitTimes } } diff --git a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts index 5b37dcff3ea4..c0545d8e1ddf 100644 --- a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts +++ b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts @@ -128,6 +128,8 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso beforeWrite?: (ptyId: string) => void | Promise suffixFailureError?: string signal?: AbortSignal + /** Bound on waiting for a booting TUI's composer before the paste; agent default otherwise. */ + composerReadyTimeoutMs?: number } = {} ): Promise { const payload = buildAgentPromptPasteBytes(prompt) @@ -148,6 +150,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso handle, pty.pty.ptyId, generation, + prompt, payload, options ) @@ -171,7 +174,14 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso const submits = await this.serializeAgentPromptSubmission(leaf.ptyId, generation, async () => { this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId!) this.assertAgentPromptGeneration(leaf.ptyId!, generation) - return await this.writeTerminalAgentPrompt(handle, leaf.ptyId!, generation, payload, options) + return await this.writeTerminalAgentPrompt( + handle, + leaf.ptyId!, + generation, + prompt, + payload, + options + ) }) const bytesWritten = Buffer.byteLength(payload, 'utf8') + submits return { handle, accepted: true, bytesWritten } diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts index e829be07d9f3..eb8a730d91ff 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts @@ -8,6 +8,7 @@ import { import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import type { TuiAgent } from '../../../shared/tui-agent' import { OrcaRuntimeService } from '../orca-runtime' +import { AGENT_PROMPT_TEST_READY_HEADER } from '../agent-prompt-submission-runtime-test-fixture' import { acknowledgeAgentPromptSubmit } from '../orca-runtime-test-mocks.spec' import { TEST_WORKTREE_PATH, @@ -557,6 +558,9 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { launchAgent: agent }) + // Why: the paste path first waits for composer readiness; this suite measures the submit + // timing after the paste, so give it the cheapest readiness evidence up front. + runtime.onPtyData('pty-bg', AGENT_PROMPT_TEST_READY_HEADER, Date.now()) const assertAuthority = vi.fn() const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { @@ -605,6 +609,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { launchAgent: agent }) + runtime.onPtyData('pty-bg', AGENT_PROMPT_TEST_READY_HEADER, Date.now()) const submitDelayMs = getAgentPromptSubmitDelayMs( process.platform, diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts index 8b27ce9bb5da..3e175df27bfc 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { AGENT_PROMPT_TEST_READY_HEADER } from '../agent-prompt-submission-runtime-test-fixture' import { AGENT_PROMPT_BRACKETED_PASTE_END, AGENT_PROMPT_BRACKETED_PASTE_START, @@ -44,6 +45,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) await expect(runtime.isTerminalRunningSettledPromptAgent(handle)).resolves.toBe(true) + runtime.onPtyData('pty-bg', AGENT_PROMPT_TEST_READY_HEADER, Date.now()) const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') await vi.advanceTimersByTimeAsync(1_199) expect(writes).not.toContain('\r') @@ -78,6 +80,7 @@ describe('OrcaRuntimeService', () => { launchAgent: 'claude' }) + runtime.onPtyData('pty-bg', AGENT_PROMPT_TEST_READY_HEADER, Date.now()) const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') await vi.advanceTimersByTimeAsync(renderGateCapMs('review this change') - 1) expect(writes).not.toContain('\r') @@ -117,6 +120,7 @@ describe('OrcaRuntimeService', () => { launchAgent: 'codex' }) + runtime.onPtyData('pty-bg', AGENT_PROMPT_TEST_READY_HEADER, Date.now()) const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') await vi.advanceTimersByTimeAsync(8_000) expect(writes).not.toContain('\r') @@ -159,6 +163,7 @@ describe('OrcaRuntimeService', () => { launchAgent: 'claude' }) + runtime.onPtyData('pty-bg', AGENT_PROMPT_TEST_READY_HEADER, Date.now()) const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') // The marker at 100 ms re-arms the cap, but the ingest term is absolute: a prompt this // small is already ingested by then, so the fallback is one flat render timeout later. diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index a2c40a08bdc4..e1ee74faedfc 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -6,26 +6,64 @@ import { waitForAgentPromptDelay, waitForAgentPromptPromise } from './orca-runtime-core' +import { TUI_IDLE_QUIESCENCE_MS } from './orca-runtime-postlude' import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import { AGENT_PROMPT_SUBMIT, getAgentPromptSubmitDelayMs, getTerminalPasteIngestMs } from '../../shared/agent-prompt-injection' +import { AGENT_PROMPT_COMPOSER_READY_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' +import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout' import type { AgentPromptWaitTextCache } from './agent-prompt-submission-verification' import { resolveAgentPromptEffectTimeoutMs, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' +import { + type AgentPromptComposerVerdict, + detectAgentPromptComposerVerdict +} from './agent-prompt-composer-pending' +import { + resolveAgentDraftPasteReadySignal, + waitForDraftPasteReadySignal +} from './runtime-worktree-startup-readiness' +import { isKnownReadyPromptPreview } from './terminal-wait-detection' + +export type AgentPromptWriteOptions = RuntimeTerminalWriteOptions & { + /** Bound on waiting for a booting TUI's composer before the paste; agent default otherwise. */ + composerReadyTimeoutMs?: number +} + +// Why: once the paste has settled, the composer gets this long to show the payload before Enter — +// a TUI still folding input into the paste would otherwise eat the submit. +const AGENT_PROMPT_COMPOSER_ECHO_WAIT_MS = 1_500 +const AGENT_PROMPT_COMPOSER_ECHO_POLL_MS = 250 export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission { protected async writeTerminalAgentPrompt( handle: string, ptyId: string, generation: number, + prompt: string, pastePayload: string, - options: RuntimeTerminalWriteOptions = {} + options: AgentPromptWriteOptions = {} ): Promise { + assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) + const initialActivity = this.getAgentPromptActivity(handle, ptyId) + this.assertAgentPromptPermissionSafe(initialActivity, initialActivity) + // Why: a paste into a TUI that is still booting lands scrambled on its splash screen or parks + // unsubmitted, whoever the caller is — the composer is observed before the first byte. + const readiness = await this.waitForAgentPromptComposerReady(handle, ptyId, { + timeoutMs: options.composerReadyTimeoutMs, + signal: options.signal + }) + if (readiness === 'timeout') { + console.warn( + `[agent-prompt] ${handle}: no composer readiness signal within budget; pasting anyway` + ) + } assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) const permissionBaseline = this.getAgentPromptActivity(handle, ptyId) @@ -72,29 +110,127 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) - try { - await options.beforeWrite?.(ptyId) - } catch (error) { - if (options.suffixFailureError) { - throw new Error(options.suffixFailureError) + // Why: Enter is only worth writing once the screen shows the paste was absorbed; until then the + // TUI may still be folding input into it (Codex reads Windows console records) and eats the submit. + const composerBeforeSubmit = await this.observeAgentPromptComposerBeforeSubmit( + ptyId, + generation, + prompt, + options.signal + ) + const waitTextCache: AgentPromptWaitTextCache = {} + // Why: every Enter — the first and each retry — re-runs the caller hook and re-checks that no + // permission dialog took the pane meanwhile; a retry must never confirm a dialog. + const guardSubmit = async (): Promise => { + assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + try { + await options.beforeWrite?.(ptyId) + } catch (error) { + if (options.suffixFailureError) { + throw new Error(options.suffixFailureError) + } + throw error } - throw error + assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) + this.assertAgentPromptPermissionSafe( + permissionBaseline, + this.getAgentPromptActivity(handle, ptyId, waitTextCache) + ) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) } - assertAgentPromptRequestActive(options.signal) - this.assertAgentPromptGeneration(ptyId, generation) - const waitTextCache: AgentPromptWaitTextCache = {} - const baseline = this.getAgentPromptActivity(handle, ptyId, waitTextCache) - this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) - if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT)) { - throw new Error(options.suffixFailureError ?? 'terminal_not_writable') + const writeSubmit = (): void => { + if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT)) { + throw new Error(options.suffixFailureError ?? 'terminal_not_writable') + } } - await verifyAgentPromptSubmission({ + await guardSubmit() + const baseline = this.getAgentPromptActivity(handle, ptyId, waitTextCache) + writeSubmit() + const outcome = await verifyAgentPromptSubmission({ baseline, readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), timeoutMs: resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId)), - signal: options.signal + signal: options.signal, + composer: { + beforeSubmit: composerBeforeSubmit, + read: () => this.readAgentPromptComposerVerdict(ptyId, generation, prompt), + resubmit: async () => { + await guardSubmit() + writeSubmit() + } + } }) - return 1 + if (outcome.enterRetries > 0 || outcome.evidence === 'composer-cleared') { + console.warn( + `[agent-prompt] ${handle}: submission proven by ${outcome.evidence} after ${outcome.enterRetries} extra Enter(s)` + ) + } + return 1 + outcome.enterRetries + } + + /** Why: `terminal wait --for tui-idle` is the caller's gate; this is the paste's own. Any agent + * status, a known ready header, or a stream that has gone quiet means the composer exists; only + * a pane with none of those waits for the agent's composer signal, bounded by the budget. */ + protected async waitForAgentPromptComposerReady( + handle: string, + ptyId: string, + options: { timeoutMs?: number; signal?: AbortSignal } + ): Promise<'evidence' | 'ready' | 'timeout'> { + const status = this.getAgentPromptActivity(handle, ptyId).status + if (status === 'idle' || status === 'working') { + return 'evidence' + } + if (isKnownReadyPromptPreview(this.getTerminalAgentStatusSnapshot(handle, ptyId).waitText)) { + return 'evidence' + } + const lastOutputAt = this.ptysById.get(ptyId)?.lastOutputAt + if (lastOutputAt && Date.now() - lastOutputAt >= TUI_IDLE_QUIESCENCE_MS) { + return 'evidence' + } + const agent = this.getPtyAgent(ptyId) + const ready = await waitForDraftPasteReadySignal( + this.getWorktreeStartupReadinessHost(), + ptyId, + resolveAgentDraftPasteReadySignal(agent), + options.timeoutMs ?? + Math.max( + AGENT_PROMPT_COMPOSER_READY_TIMEOUT_MS, + resolveDraftPasteReadyTimeoutMs(agent ?? undefined) + ), + options.signal + ) + return ready ? 'ready' : 'timeout' + } + + protected async readAgentPromptComposerVerdict( + ptyId: string, + generation: number, + prompt: string + ): Promise { + const state = await this.readVisibleTerminalState(ptyId) + this.assertAgentPromptGeneration(ptyId, generation) + return detectAgentPromptComposerVerdict( + state ? { lines: state.lines, draft: state.draft } : null, + prompt + ) + } + + protected async observeAgentPromptComposerBeforeSubmit( + ptyId: string, + generation: number, + prompt: string, + signal?: AbortSignal + ): Promise { + const deadline = Date.now() + AGENT_PROMPT_COMPOSER_ECHO_WAIT_MS + let verdict = await this.readAgentPromptComposerVerdict(ptyId, generation, prompt) + // Why: only a readable, still-empty composer is worth waiting on; an unreadable screen stays unknown. + while (verdict === 'clear' && Date.now() < deadline) { + await waitForAgentPromptDelay(AGENT_PROMPT_COMPOSER_ECHO_POLL_MS, signal) + verdict = await this.readAgentPromptComposerVerdict(ptyId, generation, prompt) + } + return verdict } } diff --git a/src/main/runtime/runtime-worktree-startup-readiness.ts b/src/main/runtime/runtime-worktree-startup-readiness.ts index 77b4e1315db7..d7e4b60fbd9f 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.ts @@ -2,7 +2,7 @@ import { isShellProcess } from '../../shared/agent-detection' import { isExpectedAgentProcess } from '../../shared/agent-process-recognition' import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout' -import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { TUI_AGENT_CONFIG, type DraftPasteReadySignal } from '../../shared/tui-agent-config' import type { TuiAgent } from '../../shared/tui-agent' import type { WorktreeStartupDraftPaste, @@ -95,15 +95,43 @@ export function waitForWorktreeStartupDraft( if (!ptyId) { return Promise.resolve(null) } - const signal = - TUI_AGENT_CONFIG[agent].draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' + return waitForDraftPasteReadySignal( + host, + ptyId, + resolveAgentDraftPasteReadySignal(agent), + resolveDraftPasteReadyTimeoutMs(agent) + ).then((ready) => (ready ? ptyId : null)) +} + +export function resolveAgentDraftPasteReadySignal(agent: TuiAgent | null): DraftPasteReadySignal { + return ( + (agent ? TUI_AGENT_CONFIG[agent].draftPasteReadySignal : undefined) ?? + 'render-quiet-after-bracketed-paste' + ) +} + +export type DraftPasteReadyHost = Pick< + WorktreeStartupReadinessHost, + 'subscribeToData' | 'readRecentOutput' +> + +/** Composer readiness from the agent's marker (or quiet window) over the replayed recent output + * plus the live stream; false on the hard timeout or abort. Shared by the startup draft and the + * agent-prompt paste so the two delivery paths cannot drift. */ +export function waitForDraftPasteReadySignal( + host: DraftPasteReadyHost, + ptyId: string, + readySignal: DraftPasteReadySignal, + hardTimeoutMs: number, + signal?: AbortSignal +): Promise { return new Promise((resolve) => { let settled = false - const scanner = createDraftPasteReadyScanner(signal) + const scanner = createDraftPasteReadyScanner(readySignal) let quietTimer: NodeJS.Timeout | null = null let hardTimer: NodeJS.Timeout | null = null let unsubscribe: (() => void) | null = null - const finish = (value: string | null): void => { + const finish = (value: boolean): void => { if (settled) { return } @@ -114,19 +142,21 @@ export function waitForWorktreeStartupDraft( if (hardTimer) { clearTimeout(hardTimer) } + signal?.removeEventListener('abort', onAbort) unsubscribe?.() resolve(value) } + const onAbort = (): void => finish(false) const observe = (data: string): void => { const result = scanner.observe(data) if (result.ready) { - return finish(ptyId) + return finish(true) } if (result.armQuietTimer) { if (quietTimer) { clearTimeout(quietTimer) } - quietTimer = setTimeout(() => finish(ptyId), BRACKETED_PASTE_QUIET_MS) + quietTimer = setTimeout(() => finish(true), BRACKETED_PASTE_QUIET_MS) } } unsubscribe = host.subscribeToData(ptyId, observe) @@ -134,6 +164,14 @@ export function waitForWorktreeStartupDraft( if (replay) { observe(replay) } - hardTimer = setTimeout(() => finish(null), resolveDraftPasteReadyTimeoutMs(agent)) + if (settled) { + return + } + if (signal?.aborted) { + finish(false) + return + } + signal?.addEventListener('abort', onAbort, { once: true }) + hardTimer = setTimeout(() => finish(false), hardTimeoutMs) }) } diff --git a/src/shared/orchestration-timing-budgets.ts b/src/shared/orchestration-timing-budgets.ts index cc6890bf0f0f..bbe38badffaa 100644 --- a/src/shared/orchestration-timing-budgets.ts +++ b/src/shared/orchestration-timing-budgets.ts @@ -2,11 +2,22 @@ import { MAX_TIMER_DELAY_MS } from './timer-delay' export const AGENT_PROMPT_EFFECT_TIMEOUT_MS = 30_000 +/** Extra verification time granted once when the injected payload is still visible in the + * composer after the effect window: a parked prompt is being re-submitted, not lost. */ +export const AGENT_PROMPT_PENDING_COMPOSER_GRACE_MS = 30_000 +/** Ceiling on one prompt submission's verification, grace included. */ +export const AGENT_PROMPT_VERIFICATION_MAX_MS = + AGENT_PROMPT_EFFECT_TIMEOUT_MS + AGENT_PROMPT_PENDING_COMPOSER_GRACE_MS +/** Bound on waiting for a booting TUI's composer before the paste is written. */ +export const AGENT_PROMPT_COMPOSER_READY_TIMEOUT_MS = 20_000 +/** Everything one sendTerminalAgentPrompt can spend before it answers. */ +export const AGENT_PROMPT_SUBMISSION_MAX_MS = + AGENT_PROMPT_COMPOSER_READY_TIMEOUT_MS + AGENT_PROMPT_VERIFICATION_MAX_MS export const ORCHESTRATION_CONTRACT_PREFLIGHT_TIMEOUT_MS = 5_000 export const ORCHESTRATION_READINESS_TIMEOUT_MS = 60_000 -export const ORCHESTRATION_FEDERATION_ATTACH_GRACE_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS + 10_000 -export const ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS + 20_000 -export const SWALLOWED_ENTER_FIXTURE_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS + 30_000 +export const ORCHESTRATION_FEDERATION_ATTACH_GRACE_MS = AGENT_PROMPT_SUBMISSION_MAX_MS + 10_000 +export const ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS = AGENT_PROMPT_SUBMISSION_MAX_MS + 20_000 +export const SWALLOWED_ENTER_FIXTURE_TIMEOUT_MS = AGENT_PROMPT_SUBMISSION_MAX_MS + 30_000 export function resolveWorkerStartReadinessTimeoutMs(timeoutMs: number | undefined): number { return typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0 diff --git a/tests/e2e/terminal-send-agent-prompt-submit.spec.ts b/tests/e2e/terminal-send-agent-prompt-submit.spec.ts index 3567cb1d8a2d..674b5cb3160e 100644 --- a/tests/e2e/terminal-send-agent-prompt-submit.spec.ts +++ b/tests/e2e/terminal-send-agent-prompt-submit.spec.ts @@ -134,7 +134,9 @@ test('CLI text plus Enter waits for a slow agent composer before submitting', as }) }) -test('CLI reports a swallowed Enter without submitting a second Enter', async ({ +// Why: the composer visibly keeps the payload after the first Enter, which is the one condition +// under which a second Enter is safe — and the manual recovery every field report ended with. +test('CLI re-sends Enter when the composer still shows the payload after the first one', async ({ electronApp, orcaPage, testRepoPath @@ -162,7 +164,7 @@ test('CLI reports a swallowed Enter without submitting a second Enter', async ({ terminal, '--timeout-ms', String(swallowedEnterFixtureTimeoutMs), - '--expect-stalled', + '--expect-swallowed-enter', '--report', fixtureReport, '--marker', @@ -183,11 +185,11 @@ test('CLI reports a swallowed Enter without submitting a second Enter', async ({ expect(JSON.parse(stdout)).toMatchObject({ rescueSent: false, - sendErrorCode: 'agent_prompt_stalled', + sendErrorCode: null, contractOk: true, - submitted: false, + submitted: true, prematureEnters: 0, - receivedEnters: 1, + receivedEnters: 2, swallowedEnters: 1, configuredTimeoutMs: swallowedEnterFixtureTimeoutMs, markerReceived: true diff --git a/tests/tools/repro-terminal-send-submit.mjs b/tests/tools/repro-terminal-send-submit.mjs index 7e1c0152012b..88be6d8c6718 100644 --- a/tests/tools/repro-terminal-send-submit.mjs +++ b/tests/tools/repro-terminal-send-submit.mjs @@ -182,7 +182,9 @@ async function parentMain() { const reportPath = path.resolve(argValue('report', path.join(tempDir, 'report.json'))) const marker = argValue('marker', `ORCA_TERMINAL_SEND_${process.pid}_${Date.now()}`) const prompt = `${marker} ${'slow composer payload '.repeat(24)}` - const expectStalled = hasFlag('expect-stalled') + // Why: the fake agent eats the first Enter while its composer keeps the payload; the CLI is + // expected to observe that and submit with a second Enter instead of reporting a stall. + const expectSwallowedEnter = hasFlag('expect-swallowed-enter') const expectBlocked = hasFlag('expect-blocked') const providedHandle = argValue('terminal') await mkdir(tempDir, { recursive: true }) @@ -200,7 +202,7 @@ async function parentMain() { shellQuote(marker), '--timeout-ms', String(timeoutMs), - ...(expectStalled ? ['--swallow-first-enter'] : []), + ...(expectSwallowedEnter ? ['--swallow-first-enter'] : []), ...(expectBlocked ? ['--permission-before-send'] : []), ...(process.platform === 'win32' ? ['--allow-unframed-paste'] : []) ])) @@ -252,9 +254,7 @@ async function parentMain() { cwd ) } catch (error) { - const expectedError = - (expectStalled && error?.code === 'agent_prompt_stalled') || - (expectBlocked && error?.code === 'agent_prompt_blocked') + const expectedError = expectBlocked && error?.code === 'agent_prompt_blocked' if (!expectedError) { throw error } @@ -262,7 +262,7 @@ async function parentMain() { } let report = await readReport(reportPath, 1_000) let rescueSent = false - if (!report && !expectStalled && !expectBlocked) { + if (!report && !expectBlocked) { rescueSent = true await callOrca(cli, ['terminal', 'send', '--terminal', handle, '--enter'], cwd) report = await readReport(reportPath, timeoutMs) @@ -278,10 +278,10 @@ async function parentMain() { ...report } console.log(JSON.stringify(summary, null, 2)) - const expectedStallObserved = - sendErrorCode === 'agent_prompt_stalled' && - report.submitted === false && - report.receivedEnters === 1 && + const expectedSwallowRecovered = + sendErrorCode === null && + report.submitted === true && + report.receivedEnters === 2 && report.swallowedEnters === 1 const expectedBlockObserved = sendErrorCode === 'agent_prompt_blocked' && @@ -290,7 +290,7 @@ async function parentMain() { if ( !report.contractOk || rescueSent || - (expectStalled && !expectedStallObserved) || + (expectSwallowedEnter && !expectedSwallowRecovered) || (expectBlocked && !expectedBlockObserved) ) { process.exitCode = 1