diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 2ada409e50d..7b79b890d00 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -3,6 +3,7 @@ import { AGENT_PROMPT_BRACKETED_PASTE_END, AGENT_PROMPT_SUBMIT_DELAY_MS } from '../../shared/agent-prompt-injection' +import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from './agent-prompt-submission-verification' import { OrcaRuntimeService } from './orca-runtime' import { makeStore } from './runtime-rpc-worktree-store-fixtures' @@ -132,6 +133,87 @@ describe('agent prompt submission runtime', () => { expect(writes).toEqual([]) }) + it('prefers a later permission title over an earlier explicit idle status', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + runtime.onPtyData( + 'pty-prompt', + '\x1b]9999;{"state":"done","agentType":"aider"}\x07', + Date.now() + ) + runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) + + await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( + 'agent_prompt_blocked' + ) + expect(writes).toEqual([]) + }) + + it('does not retry from a composer snapshot older than observed output', async () => { + vi.useFakeTimers() + let resolveSnapshot!: (snapshot: { + data: string + cols: number + rows: number + seq: number + source: 'headless' + }) => void + let snapshotSequence = 0 + const runtime = new OrcaRuntimeService(makeStore() as never) + const writes: string[] = [] + const serializeProviderBuffer = vi.fn( + () => + new Promise<{ + data: string + cols: number + rows: number + seq: number + source: 'headless' + }>((resolve) => { + snapshotSequence = runtime.getPtyOutputSequence('pty-prompt') + resolveSnapshot = resolve + }) + ) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now()) + } + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, { + launchAgent: 'aider' + }) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 900, generation: 'continued' }, + 0 + ) + const submission = runtime.sendTerminalAgentPrompt(terminal.handle, 'review this') + + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS + 500) + await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledOnce()) + runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[Hagent accepted', Date.now()) + resolveSnapshot({ + data: '\x1b[2J\x1b[H› review this', + cols: 80, + rows: 24, + seq: snapshotSequence, + source: 'headless' + }) + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + it('blocks a retry when permission appears after the first Enter', async () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { diff --git a/src/main/runtime/agent-prompt-submission-verification.test.ts b/src/main/runtime/agent-prompt-submission-verification.test.ts index c6557bb1844..7afc6dab203 100644 --- a/src/main/runtime/agent-prompt-submission-verification.test.ts +++ b/src/main/runtime/agent-prompt-submission-verification.test.ts @@ -158,6 +158,28 @@ describe('agent prompt submission verification', () => { expect(retrySubmit).not.toHaveBeenCalled() }) + it('reports permission reached after the retry as blocked', async () => { + vi.useFakeTimers() + let current = activity() + const retrySubmit = vi.fn(async () => { + current = activity({ lifecycleSequence: 5, status: 'permission' }) + return 'retried' as const + }) + const verification = verifyAgentPromptSubmission({ + baseline: current, + prompt: 'review this', + readActivity: () => current, + readTextBeforeCursor: async () => '› review this', + retrySubmit + }) + const rejected = expect(verification).rejects.toThrow('agent_prompt_blocked') + + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS + 50) + + await rejected + expect(retrySubmit).toHaveBeenCalledOnce() + }) + it('cancels before retrying', async () => { vi.useFakeTimers() const controller = new AbortController() diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index c09219bdf65..14df0d10a3a 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -72,7 +72,10 @@ export async function verifyAgentPromptSubmission( options.readActivity, options.signal ) - if (retryEffect === 'working' || retryEffect === 'permission') { + if (retryEffect === 'permission') { + throw new Error('agent_prompt_blocked') + } + if (retryEffect === 'working') { return { retried: true } } if (retryEffect === 'none') { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e1a48ae94b5..08bfca901ef 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -18620,7 +18620,10 @@ export class OrcaRuntimeService { if (current.generation !== baseline.generation) { throw new Error('terminal_handle_stale') } - return visible?.generation === baseline.generation ? visible.textBeforeCursor : null + return visible?.generation === baseline.generation && + visible.sequence >= current.outputSequence + ? visible.textBeforeCursor + : null }, retrySubmit: async (expected) => { assertAgentPromptRequestActive(options.signal) @@ -18679,11 +18682,15 @@ export class OrcaRuntimeService { const explicit = this.getFreshExplicitAgentStatusForHandle(handle)?.status const ptyStatus = this.ptysById.get(ptyId)?.lastAgentStatus ?? null const lifecycle = this.agentPromptLifecycleByPtyId.get(ptyId) + const status = + lifecycle?.status === 'permission' + ? 'permission' + : (explicit ?? ptyStatus ?? lifecycle?.status ?? null) return { generation: this.getPtyLifecycleGeneration(ptyId), lifecycleSequence: lifecycle?.sequence ?? 0, outputSequence: this.getPtyOutputSequence(ptyId), - status: explicit ?? ptyStatus ?? lifecycle?.status ?? null + status } }