diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 58fa5b2aad3..9ee6918113f 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -53,22 +53,6 @@ describe('HeadlessEmulator', () => { expect(snapshot.snapshotAnsi).toContain('red text') }) - it('reads visible wrapped text only through the live cursor', async () => { - emulator = new HeadlessEmulator({ cols: 8, rows: 4 }) - await emulator.write('header\r\nreview this now') - - expect(emulator.getVisibleTextBeforeCursor()).toBe('header\nreview this now') - await emulator.write('\r\nfooter') - expect(emulator.getVisibleTextBeforeCursor()).toBe('header\nreview this now\nfooter') - }) - - it('excludes text after the live cursor', async () => { - emulator = new HeadlessEmulator({ cols: 20, rows: 4 }) - await emulator.write('review this suffix\x1b[6D') - - expect(emulator.getVisibleTextBeforeCursor()).toBe('review this ') - }) - it('captures OSC 8 link ranges in snapshot metadata', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) await emulator.write('\x1b]8;;https://news.ycombinator.com\x07Hacker News\x1b]8;;\x07') diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index a8dea6ffbd6..e26ce0d3094 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -302,23 +302,6 @@ export class HeadlessEmulator { return lines } - getVisibleTextBeforeCursor(): string { - const buffer = this.terminal.buffer.active - const cursorRow = buffer.baseY + buffer.cursorY - const startRow = buffer.viewportY - let text = '' - for (let row = startRow; row <= cursorRow; row += 1) { - const line = buffer.getLine(row) - if (!line) { - continue - } - const end = row === cursorRow ? buffer.cursorX : undefined - const rowText = line.translateToString(true, 0, end) - text += row > startRow && !line.isWrapped ? `\n${rowText}` : rowText - } - return text - } - getBufferTailLines(limit: number): string[] { const buffer = this.terminal.buffer.active const start = Math.max(0, buffer.length - Math.max(0, Math.floor(limit))) diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index fbb494d3bc8..bd9762ec3d9 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -1,9 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -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 { AGENT_PROMPT_BRACKETED_PASTE_END } from '../../shared/agent-prompt-injection' import { OrcaRuntimeService } from './orca-runtime' import { makeStore } from './runtime-rpc-worktree-store-fixtures' @@ -54,32 +50,28 @@ async function createPromptRuntime( describe('agent prompt submission runtime', () => { afterEach(() => vi.useRealTimers()) - it('retries Enter once when the exact prompt remains at the cursor', async () => { + it('submits exactly once after an observed lifecycle transition', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data === '\r') { + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + } + }) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + it('reports redraw-only activity as stalled without retrying Enter', async () => { vi.useFakeTimers() - let enters = 0 const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now()) } else if (data === '\r') { - enters += 1 - if (enters === 2) { - runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) - } - } - }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') - - await vi.runAllTimersAsync() - - await expect(submission).resolves.toMatchObject({ accepted: true }) - expect(writes.filter((data) => data === '\r')).toHaveLength(2) - }) - - it('does not retry when the prompt is visible only in history', async () => { - vi.useFakeTimers() - const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { - if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { - runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[Hreview this\r\n› ', Date.now()) + runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now()) } }) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') @@ -91,18 +83,20 @@ describe('agent prompt submission runtime', () => { expect(writes.filter((data) => data === '\r')).toHaveLength(1) }) - it('accepts output after the first Enter without retrying', async () => { + it('reports a neutral title transition as stalled without retrying Enter', async () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { if (data === '\r') { - runtime.onPtyData('pty-prompt', 'agent started\r\n', Date.now()) + runtime.onPtyData('pty-prompt', '\x1b]0;plain shell\x07', Date.now()) } }) + runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() - await expect(submission).resolves.toMatchObject({ accepted: true }) + await rejected expect(writes.filter((data) => data === '\r')).toHaveLength(1) }) @@ -123,7 +117,6 @@ describe('agent prompt submission runtime', () => { }) it('does not paste into an existing permission prompt', async () => { - vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) @@ -134,7 +127,6 @@ describe('agent prompt submission runtime', () => { }) 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', @@ -149,135 +141,81 @@ describe('agent prompt submission runtime', () => { expect(writes).toEqual([]) }) - it('does not retry from a composer snapshot older than observed output', async () => { + it('prefers a later working title over an earlier explicit idle status', 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 { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data === '\r') { + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + } }) - const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, { - launchAgent: 'aider' - }) - runtime.synchronizePtyOutputSequenceFromProvider( + runtime.onPtyData( 'pty-prompt', - { value: 900, generation: 'continued' }, - 0 + '\x1b]9999;{"state":"done","agentType":"aider"}\x07', + Date.now() ) - 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' - }) + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) expect(writes.filter((data) => data === '\r')).toHaveLength(1) }) - it('does not reuse a provider composer snapshot captured before the current paste', async () => { + it('does not write Enter after the PTY generation changes during settlement', async () => { vi.useFakeTimers() - const runtime = new OrcaRuntimeService(makeStore() as never) - const writes: string[] = [] - const serializeProviderBuffer = vi - .fn() - .mockResolvedValueOnce({ - data: '\x1b[2J\x1b[H› review this', - cols: 80, - rows: 24, - seq: 900, - source: 'headless' as const, - alternateScreen: true - }) - .mockResolvedValueOnce({ - data: '\x1b[2J\x1b[H› ', - cols: 80, - rows: 24, - seq: 900, - source: 'headless' as const, - alternateScreen: true - }) - runtime.setPtyController({ - spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), - write: (_ptyId, data) => { - writes.push(data) - return true - }, - kill: () => true, - getForegroundProcess: async () => null, - serializeProviderBuffer, - hasRendererSerializer: () => false - }) - const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, { - launchAgent: 'aider' - }) + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const rejected = expect(submission).rejects.toThrow('terminal_handle_stale') + + await vi.advanceTimersByTimeAsync(0) + expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true) runtime.synchronizePtyOutputSequenceFromProvider( 'pty-prompt', - { value: 900, generation: 'continued' }, - 0 + { value: 0, generation: 'reset' }, + runtime.getPtyOutputSequence('pty-prompt') ) - const internal = runtime as unknown as { - providerSnapshotPreferredPtys: Set - readVisibleTerminalState: (ptyId: string) => Promise - } - internal.providerSnapshotPreferredPtys.add('pty-prompt') - await internal.readVisibleTerminalState('pty-prompt') - expect(serializeProviderBuffer).toHaveBeenCalledOnce() - - const submission = runtime.sendTerminalAgentPrompt(terminal.handle, 'review this') - const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() await rejected - expect(serializeProviderBuffer).toHaveBeenCalledTimes(2) - expect(writes.filter((data) => data === '\r')).toHaveLength(1) + expect(writes).not.toContain('\r') }) - it('blocks a retry when permission appears after the first Enter', async () => { + it('does not reuse explicit permission status across a provider generation reset', async () => { + vi.useFakeTimers() + const controller = new AbortController() + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'continued' }, + 0 + ) + runtime.onPtyData( + 'pty-prompt', + '\x1b]9999;{"state":"waiting","agentType":"aider"}\x07', + Date.now() + ) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'reset' }, + 0 + ) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + signal: controller.signal + }) + const rejected = expect(submission).rejects.toThrow('request_aborted') + await vi.advanceTimersByTimeAsync(0) + + expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true) + controller.abort() + await vi.runAllTimersAsync() + await rejected + }) + + it('reports permission reached after the first Enter as blocked', async () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { - if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { - runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now()) - } else if (data === '\r') { + if (data === '\r') { runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) } }) @@ -290,7 +228,7 @@ describe('agent prompt submission runtime', () => { expect(writes.filter((data) => data === '\r')).toHaveLength(1) }) - it('serializes concurrent prompt submissions to one PTY', async () => { + it('serializes concurrent prompt submissions within one PTY generation', async () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { if (data === '\r') { @@ -313,20 +251,93 @@ describe('agent prompt submission runtime', () => { expect(secondEnter).toBeGreaterThan(secondPaste) }) - it('does not retry after the request is cancelled', async () => { + it('does not queue a replacement generation behind an obsolete submission', async () => { vi.useFakeTimers() - const controller = new AbortController() + let releaseFirst!: () => void + let firstWriteReached!: () => void + const firstWrite = new Promise((resolve) => { + firstWriteReached = resolve + }) + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { - if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { - runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now()) + if (data === '\r') { + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) } }) + + const first = runtime.sendTerminalAgentPrompt(handle, 'obsolete prompt', { + beforeWrite: async () => { + firstWriteReached() + await firstGate + } + }) + await firstWrite + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'reset' }, + 0 + ) + + const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt') + await vi.runAllTimersAsync() + await expect(replacement).resolves.toMatchObject({ accepted: true }) + expect(writes.some((data) => data.includes('replacement prompt'))).toBe(true) + + releaseFirst() + await expect(first).rejects.toThrow('terminal_handle_stale') + }) + + it('does not close a partial paste after the PTY generation changes', async () => { + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + let writeChecks = 0 + + const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + beforeWrite: () => { + writeChecks += 1 + if (writeChecks === 2) { + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'reset' }, + runtime.getPtyOutputSequence('pty-prompt') + ) + } + } + }) + + await expect(submission).rejects.toThrow('terminal_handle_stale') + expect(writes).toHaveLength(1) + expect(writes[0]).not.toContain(AGENT_PROMPT_BRACKETED_PASTE_END) + }) + + it('does not send delayed Enter after cancellation during settlement', async () => { + vi.useFakeTimers() + const controller = new AbortController() + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') - await vi.advanceTimersByTimeAsync(AGENT_PROMPT_SUBMIT_DELAY_MS) + await vi.advanceTimersByTimeAsync(0) + controller.abort() + await vi.runAllTimersAsync() + + await rejected + expect(writes.filter((data) => data === '\r')).toHaveLength(0) + }) + + it('does not send another Enter after cancellation during verification', async () => { + vi.useFakeTimers() + const controller = new AbortController() + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + signal: controller.signal + }) + const rejected = expect(submission).rejects.toThrow('request_aborted') + + await vi.advanceTimersByTimeAsync(500) controller.abort() 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 056dde11351..318d63e3a42 100644 --- a/src/main/runtime/agent-prompt-submission-verification.test.ts +++ b/src/main/runtime/agent-prompt-submission-verification.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AGENT_PROMPT_EFFECT_TIMEOUT_MS, type AgentPromptActivity, - textBeforeCursorEndsWithPrompt, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' @@ -10,7 +9,6 @@ function activity(overrides: Partial = {}): AgentPromptActi return { generation: 1, lifecycleSequence: 4, - outputSequence: 20, status: 'idle', ...overrides } @@ -19,307 +17,117 @@ function activity(overrides: Partial = {}): AgentPromptActi describe('agent prompt submission verification', () => { afterEach(() => vi.useRealTimers()) - it('accepts an observed lifecycle transition without retrying', async () => { + it('accepts an observed working transition', async () => { vi.useFakeTimers() let current = activity() - const retrySubmit = vi.fn() const verification = verifyAgentPromptSubmission({ baseline: current, - prompt: 'review this', - readActivity: () => current, - readTextBeforeCursor: vi.fn(), - retrySubmit + readActivity: () => current }) current = activity({ lifecycleSequence: 5, status: 'working' }) await vi.advanceTimersByTimeAsync(50) - await expect(verification).resolves.toEqual({ retried: false }) - expect(retrySubmit).not.toHaveBeenCalled() + await expect(verification).resolves.toBeUndefined() }) - it('retries once when the full prompt remains immediately before the cursor', async () => { + it('accepts a completed lifecycle transition between polls', async () => { vi.useFakeTimers() let current = activity() - const retrySubmit = vi.fn(async () => { - current = activity({ lifecycleSequence: 5, outputSequence: 21, status: 'working' }) - return 'retried' as const - }) const verification = verifyAgentPromptSubmission({ baseline: current, - prompt: 'line one\nline two', - readActivity: () => current, - readTextBeforeCursor: async () => 'header\n› line one\nline two', - retrySubmit + readActivity: () => current }) - await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS + 50) + current = activity({ lifecycleSequence: 6 }) + await vi.advanceTimersByTimeAsync(50) - await expect(verification).resolves.toEqual({ retried: true }) - expect(retrySubmit).toHaveBeenCalledOnce() + await expect(verification).resolves.toBeUndefined() }) - it('fails without retrying when the prompt is not at the cursor', async () => { + it('does not accept an unrelated transition to a neutral title', async () => { vi.useFakeTimers() - const current = activity() - const retrySubmit = vi.fn() + let current = activity() const verification = verifyAgentPromptSubmission({ baseline: current, - prompt: 'review this', - readActivity: () => current, - readTextBeforeCursor: async () => 'review this\n›', - retrySubmit + readActivity: () => current + }) + const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled') + + current = activity({ lifecycleSequence: 5, status: null }) + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS) + + await rejected + }) + + it('reports stalled when no lifecycle transition occurs', async () => { + vi.useFakeTimers() + const current = activity() + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current }) const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS) await rejected - expect(retrySubmit).not.toHaveBeenCalled() }) - it('fails closed when only a large prompt suffix is visible', async () => { - vi.useFakeTimers() - const current = activity() - const retrySubmit = vi.fn() - const verification = verifyAgentPromptSubmission({ - baseline: current, - prompt: `${'hidden '.repeat(200)}visible tail`, - readActivity: () => current, - readTextBeforeCursor: async () => 'visible tail', - retrySubmit - }) - const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled') - - await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS) - - await rejected - expect(retrySubmit).not.toHaveBeenCalled() - }) - - it('fails after one ineffective retry', async () => { - vi.useFakeTimers() - const current = activity() - const retrySubmit = vi.fn().mockResolvedValue('retried') - const verification = verifyAgentPromptSubmission({ - baseline: current, - prompt: 'review this', - readActivity: () => current, - readTextBeforeCursor: async () => '› review this', - retrySubmit - }) - const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled') - - await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS * 2) - - await rejected - expect(retrySubmit).toHaveBeenCalledOnce() - }) - - it('retries after redraw-only output leaves the exact prompt at the cursor', async () => { + it('blocks when permission appears after submit', async () => { vi.useFakeTimers() let current = activity() - const retrySubmit = vi.fn(async () => { - current = activity({ lifecycleSequence: 5, status: 'working' }) - return 'retried' as const - }) const verification = verifyAgentPromptSubmission({ baseline: current, - prompt: 'review this', - readActivity: () => current, - readTextBeforeCursor: async () => '› review this', - retrySubmit - }) - - current = activity({ outputSequence: 21 }) - await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS + 50) - - await expect(verification).resolves.toEqual({ retried: true }) - expect(retrySubmit).toHaveBeenCalledOnce() - }) - - it('blocks a retry when permission appears after the first submit', async () => { - vi.useFakeTimers() - let current = activity() - const retrySubmit = vi.fn() - const verification = verifyAgentPromptSubmission({ - baseline: current, - prompt: 'review this', - readActivity: () => current, - readTextBeforeCursor: async () => '› review this', - retrySubmit + readActivity: () => current }) const rejected = expect(verification).rejects.toThrow('agent_prompt_blocked') - current = activity({ status: 'permission' }) + current = activity({ lifecycleSequence: 5, status: 'permission' }) await vi.advanceTimersByTimeAsync(50) await rejected - expect(retrySubmit).not.toHaveBeenCalled() }) - it('fails closed when redraw activity races the retry and leaves the prompt stranded', async () => { - vi.useFakeTimers() - let current = activity() - const retrySubmit = vi.fn(async () => { - current = activity({ outputSequence: 21 }) - return 'activity' 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_stalled') + it('rejects an existing permission state', async () => { + const current = activity({ status: 'permission' }) - await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS) - - await rejected - expect(retrySubmit).toHaveBeenCalledOnce() + await expect( + verifyAgentPromptSubmission({ baseline: current, readActivity: () => current }) + ).rejects.toThrow('agent_prompt_blocked') }) - it('accepts activity racing the retry after fresh proof shows the prompt cleared', async () => { - vi.useFakeTimers() - let current = activity() - const readTextBeforeCursor = vi - .fn<() => Promise>() - .mockResolvedValueOnce('› review this') - .mockResolvedValueOnce('›') - const retrySubmit = vi.fn(async () => { - current = activity({ outputSequence: 21 }) - return 'activity' as const - }) - const verification = verifyAgentPromptSubmission({ - baseline: current, - prompt: 'review this', - readActivity: () => current, - readTextBeforeCursor, - retrySubmit - }) + it('does not wait when the agent is already working', async () => { + const current = activity({ status: 'working' }) - await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS) - - await expect(verification).resolves.toEqual({ retried: false }) - expect(retrySubmit).toHaveBeenCalledOnce() - expect(readTextBeforeCursor).toHaveBeenCalledTimes(2) + await expect( + verifyAgentPromptSubmission({ baseline: current, readActivity: () => current }) + ).resolves.toBeUndefined() }) - it('fails closed when redraw activity races the retry and fresh proof is unavailable', async () => { - vi.useFakeTimers() - let current = activity() - const readTextBeforeCursor = vi - .fn<() => Promise>() - .mockResolvedValueOnce('› review this') - .mockResolvedValueOnce(null) - const retrySubmit = vi.fn(async () => { - current = activity({ outputSequence: 21 }) - return 'activity' as const - }) - const verification = verifyAgentPromptSubmission({ - baseline: current, - prompt: 'review this', - readActivity: () => current, - readTextBeforeCursor, - retrySubmit - }) - const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled') + it('rejects a replaced terminal generation', async () => { + const baseline = activity() - await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS) - - await rejected - expect(retrySubmit).toHaveBeenCalledOnce() - expect(readTextBeforeCursor).toHaveBeenCalledTimes(2) + await expect( + verifyAgentPromptSubmission({ + baseline, + readActivity: () => activity({ generation: 2 }) + }) + ).rejects.toThrow('terminal_handle_stale') }) - 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 () => { + it('cancels while waiting for activity', async () => { vi.useFakeTimers() const controller = new AbortController() const current = activity() - const retrySubmit = vi.fn() const verification = verifyAgentPromptSubmission({ baseline: current, - prompt: 'review this', readActivity: () => current, - readTextBeforeCursor: async () => '› review this', - retrySubmit, signal: controller.signal }) controller.abort() await expect(verification).rejects.toThrow('request_aborted') - expect(retrySubmit).not.toHaveBeenCalled() - }) - - it('does not retry prompts sent while an agent is already working', async () => { - const current = activity({ status: 'working' }) - const retrySubmit = vi.fn() - - await expect( - verifyAgentPromptSubmission({ - baseline: current, - prompt: 'queue this', - readActivity: () => current, - readTextBeforeCursor: vi.fn(), - retrySubmit - }) - ).resolves.toEqual({ retried: false }) - expect(retrySubmit).not.toHaveBeenCalled() - }) - - it('rejects a replaced terminal generation', async () => { - vi.useFakeTimers() - const baseline = activity() - const verification = verifyAgentPromptSubmission({ - baseline, - prompt: 'review this', - readActivity: () => activity({ generation: 2 }), - readTextBeforeCursor: vi.fn(), - retrySubmit: vi.fn() - }) - - await expect(verification).rejects.toThrow('terminal_handle_stale') - }) - - it('matches sanitized multiline text only at the cursor', () => { - expect( - textBeforeCursorEndsWithPrompt( - 'header\n› line one\nline twox', - 'line one\nline two\x1bx' - ) - ).toBe(true) - expect(textBeforeCursorEndsWithPrompt('line one\nline two\n›', 'line one\nline two')).toBe( - false - ) - }) - - it('preserves meaningful whitespace and hard cursor boundaries', () => { - expect(textBeforeCursorEndsWithPrompt('› review this', 'review this')).toBe(false) - expect(textBeforeCursorEndsWithPrompt('› review this', 'review\tthis')).toBe(false) - expect(textBeforeCursorEndsWithPrompt('› review this\n', 'review this')).toBe(false) }) }) diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index 842e4b4fdb5..b18967cc46a 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -1,165 +1,55 @@ -import { sanitizeAgentPromptText } from '../../shared/agent-prompt-injection' - export const AGENT_PROMPT_EFFECT_TIMEOUT_MS = 5_000 const AGENT_PROMPT_EFFECT_POLL_MS = 50 export type AgentPromptActivity = Readonly<{ generation: number lifecycleSequence: number - outputSequence: number status: 'working' | 'permission' | 'idle' | null }> type AgentPromptVerificationOptions = { baseline: AgentPromptActivity - prompt: string readActivity: () => AgentPromptActivity - readTextBeforeCursor: () => Promise - retrySubmit: (expected: AgentPromptActivity) => Promise<'retried' | 'activity'> signal?: AbortSignal } -type AgentPromptEffect = 'working' | 'permission' | 'activity' | 'none' - export async function verifyAgentPromptSubmission( options: AgentPromptVerificationOptions -): Promise<{ retried: boolean }> { +): Promise { throwIfAgentPromptAborted(options.signal) - if (options.baseline.status === 'permission') { - throw new Error('agent_prompt_blocked') - } + assertPromptNotBlocked(options.baseline) if (options.baseline.status === 'working') { - return { retried: false } - } - const firstEffect = await waitForAgentPromptEffect( - options.baseline, - options.readActivity, - options.signal - ) - if (firstEffect === 'permission') { - throw new Error('agent_prompt_blocked') - } - if (firstEffect === 'working') { - return { retried: false } + return } - const proofActivity = options.readActivity() - assertSamePromptGeneration(options.baseline, proofActivity) - assertPromptNotBlocked(proofActivity) - const textBeforeCursor = await options.readTextBeforeCursor() - const activityAfterProof = options.readActivity() - assertSamePromptGeneration(options.baseline, activityAfterProof) - assertPromptNotBlocked(activityAfterProof) - if (agentPromptActivityChanged(options.baseline, activityAfterProof)) { - if (activityAfterProof.status === 'working') { - return { retried: false } + const deadline = Date.now() + AGENT_PROMPT_EFFECT_TIMEOUT_MS + while (Date.now() < deadline) { + const current = options.readActivity() + assertSamePromptGeneration(options.baseline, current) + assertPromptNotBlocked(current) + if (agentPromptLifecycleChanged(options.baseline, current)) { + return } - if (!textBeforeCursorEndsWithPrompt(textBeforeCursor, options.prompt)) { - return { retried: false } - } - } - if (!textBeforeCursorEndsWithPrompt(textBeforeCursor, options.prompt)) { - throw new Error('agent_prompt_stalled') + await waitForAgentPromptPoll(options.signal) } - throwIfAgentPromptAborted(options.signal) - const retry = await options.retrySubmit(activityAfterProof) - if (retry === 'activity') { - const activityBeforeFinalProof = options.readActivity() - assertSamePromptGeneration(options.baseline, activityBeforeFinalProof) - assertPromptNotBlocked(activityBeforeFinalProof) - if (activityBeforeFinalProof.status === 'working') { - return { retried: false } - } - const finalTextBeforeCursor = await options.readTextBeforeCursor() - const activityAfterFinalProof = options.readActivity() - assertSamePromptGeneration(options.baseline, activityAfterFinalProof) - assertPromptNotBlocked(activityAfterFinalProof) - if (activityAfterFinalProof.status === 'working') { - return { retried: false } - } - if (finalTextBeforeCursor === null) { - throw new Error('agent_prompt_stalled') - } - if (!textBeforeCursorEndsWithPrompt(finalTextBeforeCursor, options.prompt)) { - return { retried: false } - } - throw new Error('agent_prompt_stalled') + const current = options.readActivity() + assertSamePromptGeneration(options.baseline, current) + assertPromptNotBlocked(current) + if (agentPromptLifecycleChanged(options.baseline, current)) { + return } - const retryEffect = await waitForAgentPromptEffect( - activityAfterProof, - options.readActivity, - options.signal - ) - if (retryEffect === 'permission') { - throw new Error('agent_prompt_blocked') - } - if (retryEffect === 'working') { - return { retried: true } - } - if (retryEffect === 'none') { - throw new Error('agent_prompt_stalled') - } - const retryTextBeforeCursor = await options.readTextBeforeCursor() - if (textBeforeCursorEndsWithPrompt(retryTextBeforeCursor, options.prompt)) { - throw new Error('agent_prompt_stalled') - } - return { retried: true } + throw new Error('agent_prompt_stalled') } -export function agentPromptActivityChanged( +function agentPromptLifecycleChanged( baseline: AgentPromptActivity, current: AgentPromptActivity ): boolean { - return ( - current.lifecycleSequence !== baseline.lifecycleSequence || - current.outputSequence !== baseline.outputSequence || - current.status !== baseline.status - ) -} - -export function textBeforeCursorEndsWithPrompt( - textBeforeCursor: string | null, - prompt: string -): boolean { - if (!textBeforeCursor) { - return false - } - const expected = sanitizeAgentPromptText(prompt) - return expected.length > 0 && textBeforeCursor.endsWith(expected) -} - -async function waitForAgentPromptEffect( - baseline: AgentPromptActivity, - readActivity: () => AgentPromptActivity, - signal?: AbortSignal -): Promise { - const deadline = Date.now() + AGENT_PROMPT_EFFECT_TIMEOUT_MS - let activityObserved = false - while (Date.now() < deadline) { - throwIfAgentPromptAborted(signal) - const current = readActivity() - assertSamePromptGeneration(baseline, current) - if (current.status === 'permission') { - return 'permission' - } - if (current.status === 'working') { - return 'working' - } - if (agentPromptActivityChanged(baseline, current)) { - activityObserved = true - } - await waitForAgentPromptPoll(signal) - } - const current = readActivity() - assertSamePromptGeneration(baseline, current) - if (current.status === 'permission') { - return 'permission' - } if (current.status === 'working') { - return 'working' + return true } - return activityObserved || agentPromptActivityChanged(baseline, current) ? 'activity' : 'none' + return current.status === 'idle' && current.lifecycleSequence >= baseline.lifecycleSequence + 2 } function assertSamePromptGeneration( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9c32dd16a37..6f62b80c8a0 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -101,7 +101,6 @@ import { buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection' import { - agentPromptActivityChanged, type AgentPromptActivity, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' @@ -1731,7 +1730,6 @@ export type RuntimeTerminalDataMeta = Readonly<{ type RuntimeVisibleTerminalState = { lines: string[] - textBeforeCursor: string isAlternateScreen: boolean sequence: number generation: number @@ -3198,8 +3196,9 @@ export class OrcaRuntimeService { private ptyOutputSequenceById = new Map() private agentPromptLifecycleByPtyId = new Map< string, - { status: AgentStatus | null; sequence: number } + { status: AgentStatus | null; sequence: number; updatedAt: number } >() + private agentPromptExplicitStatusFloorByPtyId = new Map() private agentPromptSubmissionTailByPtyId = new Map>() private providerSequenceInitializedPtys = new Set() private providerSequenceOffsetByPtyId = new Map() @@ -11902,14 +11901,16 @@ export class OrcaRuntimeService { private recordAgentPromptLifecycleState(ptyId: string, status: AgentStatus | null): void { const current = this.agentPromptLifecycleByPtyId.get(ptyId) + const updatedAt = Date.now() if (!current) { - this.agentPromptLifecycleByPtyId.set(ptyId, { status, sequence: 0 }) - } else if (current.status !== status) { - this.agentPromptLifecycleByPtyId.set(ptyId, { - status, - sequence: current.sequence + 1 - }) + this.agentPromptLifecycleByPtyId.set(ptyId, { status, sequence: 0, updatedAt }) + return } + this.agentPromptLifecycleByPtyId.set(ptyId, { + status, + sequence: current.sequence + (current.status === status ? 0 : 1), + updatedAt + }) } private getPtyLifecycleGeneration(ptyId: string): number { @@ -11925,6 +11926,7 @@ export class OrcaRuntimeService { private advancePtyLifecycleGeneration(ptyId: string): void { this.ptyLifecycleGenerationById.set(ptyId, this.nextPtyLifecycleGeneration++) this.agentPromptLifecycleByPtyId.delete(ptyId) + this.agentPromptExplicitStatusFloorByPtyId.set(ptyId, Date.now()) this.legacyWorkerRecoveredPtys.delete(ptyId) // Why: a respawn under the same session id needs its own subscriber-driven attach. this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) @@ -13139,7 +13141,6 @@ export class OrcaRuntimeService { ? { ...headlessState, isAlternateScreen: false } : { lines: [], - textBeforeCursor: '', isAlternateScreen: false, sequence: outputSequence, generation @@ -13167,12 +13168,12 @@ export class OrcaRuntimeService { return liveState } } - const parsed = await this.parseVisibleSnapshotContent(snapshot) + const lines = await this.parseVisibleSnapshotLines(snapshot) if (this.getPtyLifecycleGeneration(ptyId) !== generation) { return null } const visibleState: RuntimeVisibleTerminalState = { - ...parsed, + lines, isAlternateScreen: snapshot.alternateScreen ?? false, sequence: snapshot.seq, generation @@ -13200,43 +13201,31 @@ export class OrcaRuntimeService { } return { lines: visibleNonBlankTerminalLines(state.emulator.getVisibleLines()), - textBeforeCursor: state.emulator.getVisibleTextBeforeCursor(), isAlternateScreen: state.emulator.isAlternateScreen, sequence: state.outputSequence, generation } } - private async parseVisibleSnapshotContent(snapshot: { - data: string - cols: number - rows: number - }): Promise> { - if (snapshot.data.length === 0) { - return { lines: [], textBeforeCursor: '' } - } - const emulator = new HeadlessEmulator({ - cols: snapshot.cols, - rows: snapshot.rows, - scrollback: 0 - }) - try { - await emulator.write(`\x1b[2J\x1b[3J\x1b[H${snapshot.data}`) - return { - lines: visibleNonBlankTerminalLines(emulator.getVisibleLines()), - textBeforeCursor: emulator.getVisibleTextBeforeCursor() - } - } finally { - emulator.dispose() - } - } - private async parseVisibleSnapshotLines(snapshot: { data: string cols: number rows: number }): Promise { - return (await this.parseVisibleSnapshotContent(snapshot)).lines + if (snapshot.data.length === 0) { + return [] + } + const emulator = new HeadlessEmulator({ + cols: snapshot.cols, + rows: snapshot.rows, + scrollback: 0 + }) + try { + await emulator.write(`\x1b[2J\x1b[3J\x1b[H${snapshot.data}`) + return visibleNonBlankTerminalLines(emulator.getVisibleLines()) + } finally { + emulator.dispose() + } } private async readRendererVisibleSnapshotLines(ptyId: string): Promise { @@ -14869,6 +14858,7 @@ export class OrcaRuntimeService { this.providerBufferAcquisitionsByPtyId.delete(ptyId) this.providerVisibleStateByPtyId.delete(ptyId) this.providerVisibleRetryAtByPtyId.delete(ptyId) + this.agentPromptExplicitStatusFloorByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) @@ -17958,10 +17948,22 @@ export class OrcaRuntimeService { throw new Error('terminal_not_writable') } await assertTerminalInputWithinLimitWithYield(payload) - const submits = await this.serializeAgentPromptSubmission(pty.pty.ptyId, async () => { - this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) - return await this.writeTerminalAgentPrompt(handle, pty.pty.ptyId, prompt, payload, options) - }) + const generation = this.getPtyLifecycleGeneration(pty.pty.ptyId) + const submits = await this.serializeAgentPromptSubmission( + pty.pty.ptyId, + generation, + async () => { + this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) + this.assertAgentPromptGeneration(pty.pty.ptyId, generation) + return await this.writeTerminalAgentPrompt( + handle, + pty.pty.ptyId, + generation, + payload, + options + ) + } + ) const bytesWritten = Buffer.byteLength(payload, 'utf8') + submits return { handle, accepted: true, bytesWritten } } @@ -17976,9 +17978,11 @@ export class OrcaRuntimeService { if (await this.isLeafPtyProvenAbsent(leaf.ptyId)) { throw new Error('terminal_not_writable') } - const submits = await this.serializeAgentPromptSubmission(leaf.ptyId, async () => { + const generation = this.getPtyLifecycleGeneration(leaf.ptyId) + const submits = await this.serializeAgentPromptSubmission(leaf.ptyId, generation, async () => { this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId!) - return await this.writeTerminalAgentPrompt(handle, leaf.ptyId!, prompt, payload, options) + this.assertAgentPromptGeneration(leaf.ptyId!, generation) + return await this.writeTerminalAgentPrompt(handle, leaf.ptyId!, generation, payload, options) }) const bytesWritten = Buffer.byteLength(payload, 'utf8') + submits return { handle, accepted: true, bytesWritten } @@ -18538,7 +18542,7 @@ export class OrcaRuntimeService { private async writeTerminalAgentPrompt( handle: string, ptyId: string, - prompt: string, + generation: number, pastePayload: string, options: { beforeWrite?: (ptyId: string) => void | Promise @@ -18547,10 +18551,10 @@ export class OrcaRuntimeService { } = {} ): Promise { assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) if (this.getAgentPromptActivity(handle, ptyId).status === 'permission') { throw new Error('agent_prompt_blocked') } - this.providerVisibleStateByPtyId.delete(ptyId) const renderGate = this.createAgentPromptRenderGate(ptyId) let wrotePasteBytes = false let completedPaste = false @@ -18560,8 +18564,10 @@ export class OrcaRuntimeService { while (!chunk.done) { const nextChunk = chunks.next() assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) await options.beforeWrite?.(ptyId) assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) if (nextChunk.done) { renderGate?.arm() } @@ -18577,7 +18583,11 @@ export class OrcaRuntimeService { } completedPaste = true } catch (error) { - if (wrotePasteBytes && !completedPaste) { + if ( + wrotePasteBytes && + !completedPaste && + this.getPtyLifecycleGeneration(ptyId) === generation + ) { this.ptyController?.write(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END) } renderGate?.dispose() @@ -18594,6 +18604,7 @@ export class OrcaRuntimeService { await waitForAgentPromptDelay(AGENT_PROMPT_SUBMIT_DELAY_MS, options.signal) } assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) try { await options.beforeWrite?.(ptyId) } catch (error) { @@ -18603,6 +18614,7 @@ export class OrcaRuntimeService { throw error } assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) const baseline = this.getAgentPromptActivity(handle, ptyId) if (baseline.status === 'permission') { throw new Error('agent_prompt_blocked') @@ -18611,90 +18623,69 @@ export class OrcaRuntimeService { if (!suffixWrote) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } - const verification = await verifyAgentPromptSubmission({ + await verifyAgentPromptSubmission({ baseline, - prompt, readActivity: () => this.getAgentPromptActivity(handle, ptyId), - readTextBeforeCursor: async () => { - const visible = await this.readVisibleTerminalState(ptyId) - const current = this.getAgentPromptActivity(handle, ptyId) - if (current.generation !== baseline.generation) { - throw new Error('terminal_handle_stale') - } - return visible?.generation === baseline.generation && - visible.sequence >= current.outputSequence - ? visible.textBeforeCursor - : null - }, - retrySubmit: async (expected) => { - assertAgentPromptRequestActive(options.signal) - try { - await options.beforeWrite?.(ptyId) - } catch (error) { - if (options.suffixFailureError) { - throw new Error(options.suffixFailureError) - } - throw error - } - assertAgentPromptRequestActive(options.signal) - const current = this.getAgentPromptActivity(handle, ptyId) - if (current.generation !== expected.generation) { - throw new Error('terminal_handle_stale') - } - if (current.status === 'permission') { - throw new Error('agent_prompt_blocked') - } - if (agentPromptActivityChanged(expected, current)) { - return 'activity' - } - const retryWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false - if (!retryWrote) { - throw new Error(options.suffixFailureError ?? 'terminal_not_writable') - } - return 'retried' - }, signal: options.signal }) - return verification.retried ? 2 : 1 + return 1 } private async serializeAgentPromptSubmission( ptyId: string, + generation: number, submit: () => Promise ): Promise { - const previous = this.agentPromptSubmissionTailByPtyId.get(ptyId) ?? Promise.resolve() + const queueKey = `${ptyId}\u0000${generation}` + const previous = this.agentPromptSubmissionTailByPtyId.get(queueKey) ?? Promise.resolve() const submission = previous.catch(() => undefined).then(submit) const tail = submission.then( () => undefined, () => undefined ) - this.agentPromptSubmissionTailByPtyId.set(ptyId, tail) + this.agentPromptSubmissionTailByPtyId.set(queueKey, tail) try { return await submission } finally { - if (this.agentPromptSubmissionTailByPtyId.get(ptyId) === tail) { - this.agentPromptSubmissionTailByPtyId.delete(ptyId) + if (this.agentPromptSubmissionTailByPtyId.get(queueKey) === tail) { + this.agentPromptSubmissionTailByPtyId.delete(queueKey) } } } private getAgentPromptActivity(handle: string, ptyId: string): AgentPromptActivity { this.assertLiveTerminalHandleTargetsPty(handle, ptyId) - const explicit = this.getFreshExplicitAgentStatusForHandle(handle)?.status - const ptyStatus = this.ptysById.get(ptyId)?.lastAgentStatus ?? null + const explicitCandidate = this.getFreshExplicitAgentStatusForHandle(handle) + const explicitFloor = this.agentPromptExplicitStatusFloorByPtyId.get(ptyId) + const explicit = + explicitCandidate && + (explicitFloor === undefined || explicitCandidate.updatedAt > explicitFloor) + ? explicitCandidate + : null const lifecycle = this.agentPromptLifecycleByPtyId.get(ptyId) + const ptyStatus = + lifecycle || explicitFloor === undefined + ? (this.ptysById.get(ptyId)?.lastAgentStatus ?? null) + : null const status = - lifecycle?.status === 'permission' + lifecycle?.status === 'permission' || explicit?.status === 'permission' ? 'permission' - : (explicit ?? ptyStatus ?? lifecycle?.status ?? null) + : lifecycle && (!explicit || lifecycle.updatedAt >= explicit.updatedAt) + ? lifecycle.status + : (explicit?.status ?? ptyStatus ?? null) return { generation: this.getPtyLifecycleGeneration(ptyId), lifecycleSequence: lifecycle?.sequence ?? 0, - outputSequence: this.getPtyOutputSequence(ptyId), status } } + private assertAgentPromptGeneration(ptyId: string, expected: number): void { + if (this.getPtyLifecycleGeneration(ptyId) !== expected) { + throw new Error('terminal_handle_stale') + } + } + private createAgentPromptRenderGate(ptyId: string): { arm: () => void wait: () => Promise diff --git a/tests/e2e/terminal-send-agent-prompt-submit.spec.ts b/tests/e2e/terminal-send-agent-prompt-submit.spec.ts index 5351ba70706..5a1893da9d1 100644 --- a/tests/e2e/terminal-send-agent-prompt-submit.spec.ts +++ b/tests/e2e/terminal-send-agent-prompt-submit.spec.ts @@ -89,7 +89,7 @@ test('CLI text plus Enter waits for a slow agent composer before submitting', as }) }) -test('CLI retries one swallowed Enter only while the exact prompt remains in the composer', async ({ +test('CLI reports a swallowed Enter without submitting a second Enter', async ({ electronApp, orcaPage, testRepoPath @@ -110,6 +110,7 @@ test('CLI retries one swallowed Enter only while the exact prompt remains in the testRepoPath, '--agent-command', 'codex --swallow-first-enter', + '--expect-stalled', '--report', fixtureReport, '--marker', @@ -130,10 +131,11 @@ test('CLI retries one swallowed Enter only while the exact prompt remains in the expect(JSON.parse(stdout)).toMatchObject({ rescueSent: false, + sendErrorCode: 'agent_prompt_stalled', contractOk: true, - submitted: true, + submitted: false, prematureEnters: 0, - receivedEnters: 2, + receivedEnters: 1, swallowedEnters: 1, markerReceived: true }) diff --git a/tests/tools/repro-terminal-send-submit.mjs b/tests/tools/repro-terminal-send-submit.mjs index 057f1ec4577..be4c1fdee37 100644 --- a/tests/tools/repro-terminal-send-submit.mjs +++ b/tests/tools/repro-terminal-send-submit.mjs @@ -66,7 +66,7 @@ function runCommand(command, args, options = {}) { reject( Object.assign( new Error(`${command} ${args.join(' ')} exited ${code}${detail ? `: ${detail}` : ''}`), - { stderr } + { stdout, stderr } ) ) }) @@ -76,10 +76,24 @@ function runCommand(command, args, options = {}) { async function callOrca(cli, args, cwd) { const command = cli.endsWith('.mjs') ? process.execPath : cli const prefixArgs = cli.endsWith('.mjs') ? [cli] : [] - const { stdout } = await runCommand(command, [...prefixArgs, ...args, '--json'], { cwd }) + let stdout + try { + const result = await runCommand(command, [...prefixArgs, ...args, '--json'], { cwd }) + stdout = result.stdout + } catch (error) { + const parsed = JSON.parse(error?.stdout?.trim() ?? 'null') + if (parsed?.ok === false) { + throw Object.assign(new Error(parsed.error?.message ?? JSON.stringify(parsed.error)), { + code: parsed.error?.code + }) + } + throw error + } const parsed = JSON.parse(stdout.trim()) if (parsed.ok === false) { - throw new Error(parsed.error?.message ?? JSON.stringify(parsed.error)) + throw Object.assign(new Error(parsed.error?.message ?? JSON.stringify(parsed.error)), { + code: parsed.error?.code + }) } return parsed.result ?? parsed } @@ -146,6 +160,7 @@ 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') await mkdir(tempDir, { recursive: true }) const command = @@ -191,14 +206,22 @@ async function parentMain() { ['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', '10000'], cwd ) - await callOrca( - cli, - ['terminal', 'send', '--terminal', handle, '--text', prompt, '--enter'], - cwd - ) + let sendErrorCode = null + try { + await callOrca( + cli, + ['terminal', 'send', '--terminal', handle, '--text', prompt, '--enter'], + cwd + ) + } catch (error) { + if (!expectStalled || error?.code !== 'agent_prompt_stalled') { + throw error + } + sendErrorCode = error.code + } let report = await readReport(reportPath, 1_000) let rescueSent = false - if (!report) { + if (!report && !expectStalled) { rescueSent = true await callOrca(cli, ['terminal', 'send', '--terminal', handle, '--enter'], cwd) report = await readReport(reportPath, timeoutMs) @@ -210,10 +233,16 @@ async function parentMain() { handle, promptBytes: Buffer.byteLength(prompt, 'utf8'), rescueSent, + sendErrorCode, ...report } console.log(JSON.stringify(summary, null, 2)) - if (!report.contractOk || rescueSent) { + const expectedStallObserved = + sendErrorCode === 'agent_prompt_stalled' && + report.submitted === false && + report.receivedEnters === 1 && + report.swallowedEnters === 1 + if (!report.contractOk || rescueSent || (expectStalled && !expectedStallObserved)) { process.exitCode = 1 } } finally { @@ -250,15 +279,11 @@ async function fakeAgentMain() { let renderScheduled = false let finished = false - const finish = async () => { - if (finished) { - return - } - finished = true + const writeReport = async (submitted) => { const hasBracketedPasteFrame = input.includes(BEGIN) && input.includes(END) const report = { contractOk: prematureEnters === 0 && (!pasteFramingRequired || hasBracketedPasteFrame), - submitted: true, + submitted, prematureEnters, receivedEnters, swallowedEnters, @@ -268,6 +293,15 @@ async function fakeAgentMain() { receivedBytes: Buffer.byteLength(input, 'utf8') } await writeFile(reportPath, JSON.stringify(report, null, 2)) + return report + } + + const finish = async () => { + if (finished) { + return + } + finished = true + const report = await writeReport(true) process.stdout.write(`\nORCA_TERMINAL_SEND_REPORT ${report.contractOk ? 'ok' : 'rescued'}\n`) process.exit(report.contractOk ? 0 : 7) } @@ -295,10 +329,12 @@ async function fakeAgentMain() { receivedEnters += 1 if (swallowFirstEnter && swallowedEnters === 0) { swallowedEnters += 1 + void writeReport(false) nextCarriage = input.indexOf('\r', countedCarriages) continue } clearTimeout(timeout) + process.stdout.write('\x1b]0;Codex working\x07') void finish() return }