From 8df6d3aad8944d2ca632827d2cfe1d3d57fa9d96 Mon Sep 17 00:00:00 2001 From: Tauri-EPO Date: Tue, 1 Sep 2026 18:42:14 -0300 Subject: [PATCH 1/3] fix(agent-prompt): observe the composer before and after Enter instead of a blind submit clock worker-start, dispatch --inject and terminal send --text --enter all reach writeTerminalAgentPrompt, which pasted at once, wrote Enter on a timer and then declared agent_prompt_stalled after a fixed window without ever looking at the composer. In the field that produces a paste that lands on a TUI still booting, a payload parked as `[Pasted Content N chars]` because the TUI folded Enter into the paste burst (Codex reads Windows console input records, so bracketed-paste frames never reach it), and a stall verdict while the agent is visibly working (#15581, #16680, #17066, #16660, #16902, #16377 family). - Before the paste, the prompt path waits for composer readiness with the scanner the startup draft already uses (per-agent marker or quiet window, replayed from recent output), bounded by a 20s budget. Any agent status, a known ready header or a quiet stream passes at once, so idle and busy agents are not delayed. - After the paste, the rendered screen (headless emulator or provider snapshot) is classified as pending / clear / unknown from the emulator's composer draft or the last prompt-glyph line. Enter waits for the payload to show; after Enter, a payload that stays parked gets Enter again on a 1.5s/4s/9s/18s/36s backoff, a payload that was visible and stays cleared for 500ms counts as submission proof with no hook or title needed, and the effect window grows once by 30s while the composer is pending. Only a recognized composer line or an emulator draft ever triggers a retry; an unknown screen never does, and a permission dialog still aborts. - The stall error carries the last composer verdict and the retry count; the vocabulary is unchanged. - Budgets derive from the new maximum so client, attach and fixture deadlines stay nested; the swallowed-Enter e2e now expects the second Enter to submit instead of a stall. Co-Authored-By: Claude Fable 5.1 --- .../agent-prompt-composer-pending.test.ts | 114 +++++++++ .../runtime/agent-prompt-composer-pending.ts | 70 ++++++ .../agent-prompt-composer-submission.test.ts | 233 ++++++++++++++++++ ...-prompt-submission-runtime-test-fixture.ts | 11 +- .../agent-prompt-submission-runtime.test.ts | 30 ++- ...ent-prompt-submission-verification.test.ts | 174 ++++++++++++- .../agent-prompt-submission-verification.ts | 124 +++++++++- ...pt-submission-windows-submit-delay.test.ts | 3 + ...ca-runtime-controller-knows-pty-is-live.ts | 12 +- ...nal-creation-and-readiness-part-07.spec.ts | 5 + ...nal-creation-and-readiness-part-08.spec.ts | 5 + ...rca-runtime-write-terminal-agent-prompt.ts | 172 +++++++++++-- .../runtime-worktree-startup-readiness.ts | 54 +++- src/shared/orchestration-timing-budgets.ts | 17 +- .../terminal-send-agent-prompt-submit.spec.ts | 12 +- tests/tools/repro-terminal-send-submit.mjs | 22 +- 16 files changed, 990 insertions(+), 68 deletions(-) create mode 100644 src/main/runtime/agent-prompt-composer-pending.test.ts create mode 100644 src/main/runtime/agent-prompt-composer-pending.ts create mode 100644 src/main/runtime/agent-prompt-composer-submission.test.ts 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..8eae72733e37 --- /dev/null +++ b/src/main/runtime/agent-prompt-composer-pending.test.ts @@ -0,0 +1,114 @@ +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 non-empty composer draft', () => { + expect( + detectAgentPromptComposerVerdict( + { lines: ['›'], draft: '[Pasted Content 5033 chars]' }, + PROMPT + ) + ).toBe('pending') + }) + + 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..e08575d719ea --- /dev/null +++ b/src/main/runtime/agent-prompt-composer-pending.ts @@ -0,0 +1,70 @@ +/** 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' + } + if (screen.draft?.trim()) { + return 'pending' + } + const composerText = findLastComposerLineText(screen.lines) + if (composerText === null) { + return 'unknown' + } + if (PASTE_PLACEHOLDER_RE.test(composerText)) { + return 'pending' + } + const fragment = buildAgentPromptFragment(prompt) + return fragment !== null && composerText.includes(fragment) ? 'pending' : 'clear' +} + +// 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..33a920645b6d --- /dev/null +++ b/src/main/runtime/agent-prompt-composer-submission.test.ts @@ -0,0 +1,233 @@ +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) + }) + + 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..7866896ab70d 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,160 @@ 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' }) + await vi.advanceTimersByTimeAsync(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) + }) + + 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..961c00570165 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -1,15 +1,42 @@ -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 +} 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 +54,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 +113,81 @@ 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) + } + + while (Date.now() < deadline) { + if (activityObserved()) { + return { evidence: 'activity', enterRetries } + } + if (composer) { + const elapsed = Date.now() - startedAt + const checkpointDue = + nextCheckpoint < retryDelaysMs.length && elapsed >= retryDelaysMs[nextCheckpoint]! + const confirmDue = + clearObservedAt !== null && + Date.now() - clearObservedAt >= AGENT_PROMPT_COMPOSER_CLEAR_CONFIRM_MS + if (checkpointDue || confirmDue) { + if (checkpointDue) { + nextCheckpoint += 1 + } + throwIfAgentPromptAborted(options.signal) + lastVerdict = await composer.read() + // Why: the read is asynchronous; a turn start or a permission dialog may have landed meanwhile. + if (activityObserved()) { + return { evidence: 'activity', enterRetries } + } + if (lastVerdict === 'pending') { + sawPending = true + clearObservedAt = null + if (checkpointDue) { + throwIfAgentPromptAborted(options.signal) + await composer.resubmit() + enterRetries += 1 + if (!graceApplied) { + deadline += composer.pendingGraceMs ?? AGENT_PROMPT_PENDING_COMPOSER_GRACE_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()) { + 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 From cb6023b45bc7a38c48564c8efc036084f6638026 Mon Sep 17 00:00:00 2001 From: Tauri-EPO Date: Tue, 1 Sep 2026 19:49:20 -0300 Subject: [PATCH 2/3] fix(agent-prompt): let a parked payload outrank activity evidence Field data from the same day (20 fresh Codex worker-starts): two returned input_accepted while `[Pasted Content N chars]` sat in the composer for four hours, dispatch-show stayed `dispatched`, zero heartbeats. The agent was already `working` from its session hook and kept painting, so pane output after Enter passed as delivery evidence with the payload still on screen. Activity now proves submission only while the composer's last verdict is not `pending`. When it is, the composer is re-read at most once per confirm window (500 ms) so a retry that lands is noticed quickly, and a due checkpoint owns its iteration's read so the parked payload still gets its Enter. Covered by a verifier unit case and a headless-emulator runtime case that reproduces the false accept before the change. Co-Authored-By: Claude Fable 5.1 --- .../agent-prompt-composer-submission.test.ts | 35 ++++++++++++++++ ...ent-prompt-submission-verification.test.ts | 40 ++++++++++++++++++- .../agent-prompt-submission-verification.ts | 39 +++++++++++++----- 3 files changed, 104 insertions(+), 10 deletions(-) diff --git a/src/main/runtime/agent-prompt-composer-submission.test.ts b/src/main/runtime/agent-prompt-composer-submission.test.ts index 33a920645b6d..c1f92d9c64c7 100644 --- a/src/main/runtime/agent-prompt-composer-submission.test.ts +++ b/src/main/runtime/agent-prompt-composer-submission.test.ts @@ -109,6 +109,41 @@ describe('agent prompt submission observes the composer', () => { 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( diff --git a/src/main/runtime/agent-prompt-submission-verification.test.ts b/src/main/runtime/agent-prompt-submission-verification.test.ts index 7866896ab70d..56fb5e8dc734 100644 --- a/src/main/runtime/agent-prompt-submission-verification.test.ts +++ b/src/main/runtime/agent-prompt-submission-verification.test.ts @@ -327,7 +327,8 @@ describe('agent prompt submission verification with a composer observer', () => expect(resubmit).toHaveBeenCalledTimes(2) current = activity({ workingSequence: 5, status: 'working' }) - await vi.advanceTimersByTimeAsync(100) + // 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 }) }) @@ -434,6 +435,43 @@ describe('agent prompt submission verification with a composer observer', () => 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('does not re-send Enter once a permission state appears', async () => { vi.useFakeTimers() let current = activity() diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index 961c00570165..a3ac4da8056d 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -134,26 +134,47 @@ export async function verifyAgentPromptSubmission( assertPromptNotBlocked(options.baseline, current) 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() + return lastVerdict !== 'pending' && activityObserved() + } while (Date.now() < deadline) { - if (activityObserved()) { + 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) { - const elapsed = Date.now() - startedAt - const checkpointDue = - nextCheckpoint < retryDelaysMs.length && elapsed >= retryDelaysMs[nextCheckpoint]! - const confirmDue = - clearObservedAt !== null && - Date.now() - clearObservedAt >= AGENT_PROMPT_COMPOSER_CLEAR_CONFIRM_MS if (checkpointDue || confirmDue) { if (checkpointDue) { nextCheckpoint += 1 } throwIfAgentPromptAborted(options.signal) + lastComposerReadAt = Date.now() lastVerdict = await composer.read() // Why: the read is asynchronous; a turn start or a permission dialog may have landed meanwhile. - if (activityObserved()) { + if (lastVerdict !== 'pending' && activityObserved()) { return { evidence: 'activity', enterRetries } } if (lastVerdict === 'pending') { @@ -184,7 +205,7 @@ export async function verifyAgentPromptSubmission( await waitForAgentPromptPoll(options.signal) } - if (activityObserved()) { + if (activityObserved() && (await activityProvesSubmission())) { return { evidence: 'activity', enterRetries } } throw new AgentPromptStalledError(lastVerdict, enterRetries) From accc09da9b30a138373d79be752d764cdadd0e6b Mon Sep 17 00:00:00 2001 From: Tauri-EPO Date: Tue, 1 Sep 2026 20:36:16 -0300 Subject: [PATCH 3/3] fix(agent-prompt): match the extracted draft against the payload, cap the grace, honor abort after reads Review follow-ups on the composer observer: - An emulator-extracted draft only reads `pending` when it holds the paste placeholder or the prompt's first line; an unrelated draft the operator typed reads `clear`, so another Enter never submits it. - The one-time pending grace is capped at AGENT_PROMPT_VERIFICATION_MAX_MS, the ceiling the downstream budgets derive from. - The abort signal is re-checked after every awaited composer read, so a request cancelled mid-read rejects with request_aborted instead of resolving as accepted; covered by a deferred-read test. Co-Authored-By: Claude Fable 5.1 --- .../agent-prompt-composer-pending.test.ts | 14 ++++++++++- .../runtime/agent-prompt-composer-pending.ts | 20 ++++++++++------ ...ent-prompt-submission-verification.test.ts | 24 +++++++++++++++++++ .../agent-prompt-submission-verification.ts | 11 +++++++-- 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/main/runtime/agent-prompt-composer-pending.test.ts b/src/main/runtime/agent-prompt-composer-pending.test.ts index 8eae72733e37..792ec4ce7e79 100644 --- a/src/main/runtime/agent-prompt-composer-pending.test.ts +++ b/src/main/runtime/agent-prompt-composer-pending.test.ts @@ -16,13 +16,25 @@ describe('detectAgentPromptComposerVerdict', () => { expect(detectAgentPromptComposerVerdict({ lines: [] }, PROMPT)).toBe('unknown') }) - it('is pending when the emulator extracted a non-empty composer draft', () => { + 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', () => { diff --git a/src/main/runtime/agent-prompt-composer-pending.ts b/src/main/runtime/agent-prompt-composer-pending.ts index e08575d719ea..d19d83322e6e 100644 --- a/src/main/runtime/agent-prompt-composer-pending.ts +++ b/src/main/runtime/agent-prompt-composer-pending.ts @@ -42,18 +42,24 @@ export function detectAgentPromptComposerVerdict( if (!screen || screen.lines.length === 0) { return 'unknown' } - if (screen.draft?.trim()) { - return 'pending' + 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' } - if (PASTE_PLACEHOLDER_RE.test(composerText)) { - return 'pending' - } - const fragment = buildAgentPromptFragment(prompt) - return fragment !== null && composerText.includes(fragment) ? 'pending' : 'clear' + 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, diff --git a/src/main/runtime/agent-prompt-submission-verification.test.ts b/src/main/runtime/agent-prompt-submission-verification.test.ts index 56fb5e8dc734..fb1e5d23a944 100644 --- a/src/main/runtime/agent-prompt-submission-verification.test.ts +++ b/src/main/runtime/agent-prompt-submission-verification.test.ts @@ -472,6 +472,30 @@ describe('agent prompt submission verification with a composer observer', () => 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() diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index a3ac4da8056d..f216fff951a8 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -4,7 +4,8 @@ export { } from '../../shared/orchestration-timing-budgets' import { AGENT_PROMPT_EFFECT_TIMEOUT_MS, - AGENT_PROMPT_PENDING_COMPOSER_GRACE_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' @@ -148,6 +149,7 @@ export async function verifyAgentPromptSubmission( } lastComposerReadAt = Date.now() lastVerdict = await composer.read() + throwIfAgentPromptAborted(options.signal) return lastVerdict !== 'pending' && activityObserved() } @@ -173,6 +175,7 @@ export async function verifyAgentPromptSubmission( 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 } @@ -185,7 +188,11 @@ export async function verifyAgentPromptSubmission( await composer.resubmit() enterRetries += 1 if (!graceApplied) { - deadline += composer.pendingGraceMs ?? AGENT_PROMPT_PENDING_COMPOSER_GRACE_MS + // 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 } }