diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts new file mode 100644 index 00000000000..ce19f22a3ca --- /dev/null +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -0,0 +1,683 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AGENT_PROMPT_BRACKETED_PASTE_END } from '../../shared/agent-prompt-injection' +import { OrcaRuntimeService } from './orca-runtime' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' + +const WORKTREE_PATH = '/tmp/worktree-a' + +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 + } + ]) +})) + +async function createPromptRuntime( + onWrite: (runtime: OrcaRuntimeService, data: string, writeIndex: number) => void +): Promise<{ runtime: OrcaRuntimeService; handle: string; writes: string[] }> { + const runtime = new OrcaRuntimeService(makeStore() as never) + const writes: string[] = [] + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + onWrite(runtime, data, writes.length) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, { + launchAgent: 'aider' + }) + return { runtime, handle: terminal.handle, writes } +} + +describe('agent prompt submission runtime', () => { + afterEach(() => vi.useRealTimers()) + + 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('accepts a working-to-idle cycle completed before the first poll', 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()) + runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\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() + 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') { + runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now()) + } + }) + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') + + await vi.runAllTimersAsync() + + await rejected + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + 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', '\x1b]0;plain shell\x07', Date.now()) + runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\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 rejected + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + it('does not send Enter after a permission state appears', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) + } + }) + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') + + await vi.runAllTimersAsync() + + await rejected + expect(writes).not.toContain('\r') + }) + + it('does not paste into an existing permission prompt', async () => { + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + 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 paste into an output-only permission prompt', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) + vi.setSystemTime(2_000) + runtime.onPtyData( + 'pty-prompt', + 'Permission required\nAllow once\nAllow always\nReject\n', + Date.now() + ) + + await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( + 'agent_prompt_blocked' + ) + expect(writes).toEqual([]) + }) + + it('does not paste into a coalesced live permission title', async () => { + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + runtime.onPtyData( + 'pty-prompt', + '\x1b]9999;{"state":"working","agentType":"aider"}\x07' + + '\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 paste when split status stripping completes a permission title', 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()) + } + }) + runtime.onPtyData( + 'pty-prompt', + '\x1b]0;Codex waiting for permission\x1b]9999;{"state":"working","agentType":"aider"', + Date.now() + ) + runtime.onPtyData('pty-prompt', '}\x07\x07', Date.now()) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') + await vi.runAllTimersAsync() + + await rejected + expect(writes).toEqual([]) + }) + + it('preserves hook permission after an earlier live idle title', async () => { + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + runtime.onPtyData( + 'pty-prompt', + 'Permission required\nAllow once\nAllow always\nReject\n', + Date.now() + ) + runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) + runtime.onPtyData( + 'pty-prompt', + '\x1b]9999;{"state":"waiting","agentType":"aider"}\x07', + Date.now() + ) + + await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( + 'agent_prompt_blocked' + ) + expect(writes).toEqual([]) + }) + + it('does not block on permission text restored only as history', 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()) + } + }) + runtime.seedTerminalRestoreTail('pty-prompt', { + text: 'Permission required\r\nAllow once\r\nAllow always\r\nReject\r\n' + }) + + 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 send Enter after output-only permission appears during settlement', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + vi.setSystemTime(2_000) + runtime.onPtyData( + 'pty-prompt', + 'Permission required\nAllow once\nAllow always\nReject\n', + 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_blocked') + + await vi.runAllTimersAsync() + + await rejected + expect(writes).not.toContain('\r') + }) + + it.each([ + '\x1b]0;Codex waiting for permission\x07\x1b]0;Codex idle\x07', + '\x1b]9999;{"state":"working","agentType":"aider"}\x07' + + '\x1b]0;Codex waiting for permission\x07', + '\x1b]0;Codex waiting for permission\x07' + + '\x1b]9999;{"state":"working","agentType":"aider"}\x07' + ])('does not send Enter after coalesced permission activity', async (output) => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData('pty-prompt', output, Date.now()) + } + }) + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') + + await vi.runAllTimersAsync() + + await rejected + expect(writes).not.toContain('\r') + }) + + it('stops a chunked paste when permission appears between chunks', 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.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) + } + } + }) + + await expect(submission).rejects.toThrow('agent_prompt_blocked') + expect(writes).toHaveLength(2) + expect(writes[1]).toBe(AGENT_PROMPT_BRACKETED_PASTE_END) + expect(writes).not.toContain('\r') + }) + + it('stops a chunked paste after transient output-only permission', async () => { + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + runtime.onPtyData('pty-prompt', 'initial output\n', Date.now()) + let writeChecks = 0 + + const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + beforeWrite: () => { + writeChecks += 1 + if (writeChecks === 2) { + runtime.onPtyData( + 'pty-prompt', + 'Permission required\nAllow once\nAllow always\nReject\n', + Date.now() + ) + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + } + } + }) + + await expect(submission).rejects.toThrow('agent_prompt_blocked') + expect(writes).toHaveLength(2) + expect(writes[1]).toBe(AGENT_PROMPT_BRACKETED_PASTE_END) + expect(writes).not.toContain('\r') + }) + + it('prefers a later permission title over an earlier explicit idle status', async () => { + 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('prefers later explicit idle evidence over stale permission output', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + let handle = '' + const writes: string[] = [] + const runtime = new OrcaRuntimeService(makeStore() as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'prompt-pane', + terminalHandle: handle, + state: 'done', + prompt: '', + agentType: 'aider', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now() + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + if (data === '\r') { + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + } + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + handle = (await runtime.createTerminal(`path:${WORKTREE_PATH}`, { launchAgent: 'aider' })) + .handle + runtime.onPtyData( + 'pty-prompt', + 'Permission required\nAllow once\nAllow always\nReject\n' + + '\x1b]0;Codex waiting for permission\x07', + Date.now() + ) + vi.setSystemTime(2_000) + + 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('prefers a later working title over an earlier explicit idle status', 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()) + } + }) + runtime.onPtyData( + 'pty-prompt', + '\x1b]9999;{"state":"done","agentType":"aider"}\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('does not treat an unchanged newer working status as submission evidence', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data === '\r') { + runtime.onPtyData( + 'pty-prompt', + '\x1b]9999;{"state":"working","agentType":"aider"}\x07', + Date.now() + ) + } + }) + runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) + vi.setSystemTime(2_000) + runtime.onPtyData( + 'pty-prompt', + '\x1b]9999;{"state":"working","agentType":"aider"}\x07', + Date.now() + ) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') + await vi.runAllTimersAsync() + + await rejected + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + it('does not write Enter after the PTY generation changes during settlement', async () => { + vi.useFakeTimers() + 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: 0, generation: 'reset' }, + runtime.getPtyOutputSequence('pty-prompt') + ) + await vi.runAllTimersAsync() + + await rejected + expect(writes).not.toContain('\r') + }) + + 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('does not reuse output-only permission across a provider generation reset', 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()) + } + }) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'continued' }, + 0 + ) + runtime.onPtyData( + 'pty-prompt', + 'Permission required\nAllow once\nAllow always\nReject\n', + Date.now() + ) + const sequenceAtSpawnStart = runtime.getPtyOutputSequence('pty-prompt') + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'reset' }, + sequenceAtSpawnStart + ) + + 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('fails closed when new bytes race a reset after old permission output', async () => { + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'continued' }, + 0 + ) + runtime.onPtyData( + 'pty-prompt', + 'Permission required\nAllow once\nAllow always\nReject\n', + Date.now() + ) + const sequenceAtSpawnStart = runtime.getPtyOutputSequence('pty-prompt') + runtime.onPtyData('pty-prompt', 'replacement startup output\n', Date.now()) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'reset' }, + sequenceAtSpawnStart + ) + + await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( + 'agent_prompt_blocked' + ) + expect(writes).toEqual([]) + }) + + it('reports permission reached after the first Enter as blocked', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data === '\r') { + runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) + } + }) + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') + + await vi.runAllTimersAsync() + + await rejected + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + it('serializes concurrent prompt submissions within one PTY generation', async () => { + vi.useFakeTimers() + let enterCount = 0 + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data === '\r') { + enterCount += 1 + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) + } + }) + + const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt') + const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt') + await vi.runAllTimersAsync() + await Promise.all([first, second]) + + const firstPaste = writes.findIndex((data) => data.includes('first prompt')) + const firstEnter = writes.indexOf('\r', firstPaste + 1) + const secondPaste = writes.findIndex((data) => data.includes('second prompt')) + const secondEnter = writes.indexOf('\r', secondPaste + 1) + expect(firstPaste).toBeGreaterThanOrEqual(0) + expect(firstEnter).toBeGreaterThan(firstPaste) + expect(secondPaste).toBeGreaterThan(firstEnter) + expect(secondEnter).toBeGreaterThan(secondPaste) + expect(enterCount).toBe(2) + }) + + it('does not queue a replacement generation behind an obsolete submission', async () => { + vi.useFakeTimers() + 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 === '\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(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() + + await rejected + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) +}) diff --git a/src/main/runtime/agent-prompt-submission-verification.test.ts b/src/main/runtime/agent-prompt-submission-verification.test.ts new file mode 100644 index 00000000000..a0f3e1a5213 --- /dev/null +++ b/src/main/runtime/agent-prompt-submission-verification.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_PROMPT_EFFECT_TIMEOUT_MS, + type AgentPromptActivity, + verifyAgentPromptSubmission +} from './agent-prompt-submission-verification' + +function activity(overrides: Partial = {}): AgentPromptActivity { + return { + generation: 1, + permissionSequence: 2, + workingSequence: 4, + status: 'idle', + ...overrides + } +} + +describe('agent prompt submission verification', () => { + afterEach(() => vi.useRealTimers()) + + it('accepts an observed working transition', async () => { + vi.useFakeTimers() + let current = activity() + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current + }) + + current = activity({ workingSequence: 5, status: 'working' }) + await vi.advanceTimersByTimeAsync(50) + + await expect(verification).resolves.toBeUndefined() + }) + + it('accepts a completed lifecycle transition between polls', async () => { + vi.useFakeTimers() + let current = activity() + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current + }) + + current = activity({ workingSequence: 5 }) + await vi.advanceTimersByTimeAsync(50) + + await expect(verification).resolves.toBeUndefined() + }) + + it('does not accept an unrelated transition to a neutral title', async () => { + vi.useFakeTimers() + let current = activity() + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current + }) + const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled') + + current = activity({ 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 + }) + + it('blocks when permission appears after submit', async () => { + vi.useFakeTimers() + let current = activity() + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current + }) + const rejected = expect(verification).rejects.toThrow('agent_prompt_blocked') + + current = activity({ status: 'permission' }) + await vi.advanceTimersByTimeAsync(50) + + await rejected + }) + + it('blocks when permission appears and clears between polls', async () => { + vi.useFakeTimers() + let current = activity() + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current + }) + const rejected = expect(verification).rejects.toThrow('agent_prompt_blocked') + + current = activity({ permissionSequence: 3 }) + await vi.advanceTimersByTimeAsync(50) + + await rejected + }) + + it('rejects an existing permission state', async () => { + const current = activity({ status: 'permission' }) + + await expect( + verifyAgentPromptSubmission({ baseline: current, readActivity: () => current }) + ).rejects.toThrow('agent_prompt_blocked') + }) + + it('does not accept an unchanged working baseline', async () => { + vi.useFakeTimers() + const current = activity({ status: 'working' }) + 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 + }) + + it('rejects a replaced terminal generation', async () => { + const baseline = activity() + + await expect( + verifyAgentPromptSubmission({ + baseline, + readActivity: () => activity({ generation: 2 }) + }) + ).rejects.toThrow('terminal_handle_stale') + }) + + it('cancels while waiting for activity', async () => { + vi.useFakeTimers() + const controller = new AbortController() + const current = activity() + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current, + signal: controller.signal + }) + + controller.abort() + + await expect(verification).rejects.toThrow('request_aborted') + }) +}) diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts new file mode 100644 index 00000000000..41b5f5b1732 --- /dev/null +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -0,0 +1,90 @@ +export const AGENT_PROMPT_EFFECT_TIMEOUT_MS = 5_000 +const AGENT_PROMPT_EFFECT_POLL_MS = 50 + +export type AgentPromptActivity = Readonly<{ + generation: number + permissionSequence: number + workingSequence: number + status: 'working' | 'permission' | 'idle' | null +}> + +type AgentPromptVerificationOptions = { + baseline: AgentPromptActivity + readActivity: () => AgentPromptActivity + signal?: AbortSignal +} + +export async function verifyAgentPromptSubmission( + options: AgentPromptVerificationOptions +): Promise { + throwIfAgentPromptAborted(options.signal) + assertPromptNotBlocked(options.baseline, options.baseline) + + const deadline = Date.now() + AGENT_PROMPT_EFFECT_TIMEOUT_MS + while (Date.now() < deadline) { + const current = options.readActivity() + assertSamePromptGeneration(options.baseline, current) + assertPromptNotBlocked(options.baseline, current) + if (agentPromptLifecycleChanged(options.baseline, current)) { + return + } + await waitForAgentPromptPoll(options.signal) + } + + const current = options.readActivity() + assertSamePromptGeneration(options.baseline, current) + assertPromptNotBlocked(options.baseline, current) + if (agentPromptLifecycleChanged(options.baseline, current)) { + return + } + throw new Error('agent_prompt_stalled') +} + +function agentPromptLifecycleChanged( + baseline: AgentPromptActivity, + current: AgentPromptActivity +): boolean { + return current.workingSequence > baseline.workingSequence +} + +function assertSamePromptGeneration( + baseline: AgentPromptActivity, + current: AgentPromptActivity +): void { + if (current.generation !== baseline.generation) { + throw new Error('terminal_handle_stale') + } +} + +function assertPromptNotBlocked(baseline: AgentPromptActivity, current: AgentPromptActivity): void { + if (current.status === 'permission' || current.permissionSequence > baseline.permissionSequence) { + throw new Error('agent_prompt_blocked') + } +} + +function throwIfAgentPromptAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new Error('request_aborted') + } +} + +async function waitForAgentPromptPoll(signal?: AbortSignal): Promise { + if (!signal) { + await new Promise((resolve) => setTimeout(resolve, AGENT_PROMPT_EFFECT_POLL_MS)) + return + } + await new Promise((resolve, reject) => { + const onAbort = (): void => { + clearTimeout(timer) + reject(new Error('request_aborted')) + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, AGENT_PROMPT_EFFECT_POLL_MS) + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + } + }) +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 4c2dd2fbdd7..7bd76f0d316 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -166,6 +166,16 @@ function resetPlatform(): void { } } +function acknowledgeAgentPromptSubmit( + runtime: OrcaRuntimeService, + ptyId: string, + data: string +): void { + if (data === '\r') { + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', Date.now()) + } +} + const electronMocks = vi.hoisted(() => { type Listener = (...args: unknown[]) => void const listeners = new Map>() @@ -16762,6 +16772,7 @@ describe('OrcaRuntimeService', () => { spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), write: (_ptyId, data) => { writes.push(data) + acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data) return true }, kill: () => true, @@ -16828,6 +16839,7 @@ describe('OrcaRuntimeService', () => { } else { prematureEnters += 1 } + acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data) } return true }, @@ -16876,6 +16888,7 @@ describe('OrcaRuntimeService', () => { spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), write: (_ptyId, data) => { writes.push(data) + acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data) return true }, kill: () => true, @@ -16913,6 +16926,7 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('pty-bg', '\x1b[?25hcomposer rendered', Date.now()) }, 1_200) } + acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data) return true }, kill: () => true, @@ -16945,6 +16959,7 @@ describe('OrcaRuntimeService', () => { spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), write: (_ptyId, data) => { writes.push(data) + acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data) return true }, kill: () => true, @@ -16983,6 +16998,7 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('pty-bg', 'final slow composer frame', Date.now()) }, 8_100) } + acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data) return true }, kill: () => true, @@ -17024,6 +17040,7 @@ describe('OrcaRuntimeService', () => { ) } } + acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data) return true }, kill: () => true, @@ -17054,6 +17071,7 @@ describe('OrcaRuntimeService', () => { spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), write: (_ptyId, data) => { writes.push(data) + acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data) return true }, kill: () => true, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fc6a0fcc715..9cae4ee4798 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3,6 +3,7 @@ /* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ import { detectAgentStatusFromTitle, + extractLastOscTitle, isClaudeManagementTitle, isCursorNativeAgentTitle, isOpenCodeNativeTitle, @@ -100,6 +101,10 @@ import { AGENT_PROMPT_SUBMIT_DELAY_MS, buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection' +import { + type AgentPromptActivity, + verifyAgentPromptSubmission +} from './agent-prompt-submission-verification' import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync, @@ -1483,6 +1488,14 @@ type RuntimePtyWorktreeRecord = { tailWaitState?: TerminalTailWaitState } +type TerminalAgentStatusSnapshot = { + waitText: string + waitBlockedAt: number | null + title: string | null + titleStatus: AgentStatus | null + titleStatusIsLive: boolean +} + type TerminalCreateOptions = { command?: string claudeAgentTeamsSourceCommand?: string @@ -1962,6 +1975,67 @@ const AGENT_PROMPT_RENDER_TIMEOUT_MS = 8000 const AGENT_PROMPT_RENDER_QUIET_MS = 1500 // Why: Claude and Codex emit show-cursor after accepting bracketed paste. const AGENT_PROMPT_RENDER_MARKER = '\x1b[?25h' + +function assertAgentPromptRequestActive(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new Error('request_aborted') + } +} + +async function waitForAgentPromptPromise(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) { + return await promise + } + assertAgentPromptRequestActive(signal) + return await new Promise((resolve, reject) => { + let settled = false + const finish = (result: { value: T } | { error: unknown }): void => { + if (settled) { + return + } + settled = true + signal.removeEventListener('abort', onAbort) + if ('error' in result) { + reject(result.error) + } else { + resolve(result.value) + } + } + const onAbort = (): void => finish({ error: new Error('request_aborted') }) + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + return + } + promise.then( + (value) => finish({ value }), + (error: unknown) => finish({ error }) + ) + }) +} + +async function waitForAgentPromptDelay(delayMs: number, signal?: AbortSignal): Promise { + if (!signal) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + return + } + assertAgentPromptRequestActive(signal) + await new Promise((resolve, reject) => { + const onAbort = (): void => { + clearTimeout(timer) + reject(new Error('request_aborted')) + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, delayMs) + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + } + }) +} + const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000 // Why: the split already failed; the caller waits on this teardown only to learn whether the // fallback kill is needed, so keep it short — an unreachable host must not stall the rejection. @@ -3136,6 +3210,13 @@ export class OrcaRuntimeService { private titleObservationSequence = 0 private headlessTerminals = new Map() private ptyOutputSequenceById = new Map() + private agentPromptLifecycleByPtyId = new Map< + string, + { status: AgentStatus | null; workingSequence: number; updatedAt: number } + >() + private agentPromptPermissionSequenceByPtyId = new Map() + private agentPromptExplicitStatusFloorByPtyId = new Map() + private agentPromptSubmissionTailByPtyId = new Map>() private providerSequenceInitializedPtys = new Set() private providerSequenceOffsetByPtyId = new Map() private providerSnapshotPreferredPtys = new Set() @@ -10957,6 +11038,11 @@ export class OrcaRuntimeService { // bell — the chunk's agentStatus:set events must reach the renderer // before its pty:sideEffect batch. retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + const lastPayloadTitleOffset = + agentStatusChunk.lastPayloadCleanOffset === null + ? null + : (previousTitleScanTail?.length ?? 0) + agentStatusChunk.lastPayloadCleanOffset + this.restoreAgentPromptLifecycleByteOrder(ptyId, titleInput, lastPayloadTitleOffset) } finally { // Why: flushed in the finally so a throwing tracker callback cannot // strand this chunk's facts to be emitted under the next chunk's seq. @@ -11052,6 +11138,7 @@ export class OrcaRuntimeService { } if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, state.appended)) { pty.waitBlockedAt = at + this.recordAgentPromptPermissionObservation(ptyId) } state.lastAt = at state.lastWaitState = nextWaitState @@ -11518,6 +11605,7 @@ export class OrcaRuntimeService { const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) const recordedTitle = identityOnlyTitle ? null : normalizedTitle const agentStatus = identityOnlyTitle ? null : detectAgentStatusFromTitle(rawTitle) + this.recordAgentPromptLifecycleState(ptyId, agentStatus) let ptyRecordChanged = false const pty = this.ptysById.get(ptyId) if (pty) { @@ -11633,6 +11721,9 @@ export class OrcaRuntimeService { this.oscTitleScanTailByPtyId.delete(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.agentPromptLifecycleByPtyId.delete(ptyId) + this.agentPromptPermissionSequenceByPtyId.delete(ptyId) + this.clearWaitBlockedCheckState(ptyId) const pty = this.ptysById.get(ptyId) if (pty) { pty.lastOscTitle = null @@ -11646,13 +11737,18 @@ export class OrcaRuntimeService { pty.lastAgentStatusRichInvalidatedAtEpochMs = Date.now() pty.managementTitle = null pty.managementTitleAt = null + pty.waitBlockedAt = null + pty.tailWaitState = undefined } for (const leaf of this.getLeavesForPty(ptyId)) { leaf.lastOscTitle = null leaf.lastOscTitleAt = null leaf.lastAgentStatus = null leaf.lastAgentStatusObservedLive = false + leaf.waitBlockedAt = null + leaf.tailWaitState = undefined } + this.primeWaitBlockedBaselineFromSeededTail(ptyId) this.clearAgentRowSnapshotsForPty(ptyId) } @@ -11791,6 +11887,10 @@ export class OrcaRuntimeService { } let retainedChanged = false for (const payload of chunk.payloads) { + this.recordAgentPromptLifecycleState( + ptyId, + mapExplicitAgentStateToRuntimeTerminalStatus(payload.state) + ) for (const target of targets.values()) { retainedChanged = this.retainAgentRowSnapshot( @@ -11876,6 +11976,64 @@ export class OrcaRuntimeService { return this.ptyOutputSequenceById.get(ptyId) ?? 0 } + private recordAgentPromptLifecycleState(ptyId: string, status: AgentStatus | null): void { + if (status === 'permission') { + this.recordAgentPromptPermissionObservation(ptyId) + } + const current = this.agentPromptLifecycleByPtyId.get(ptyId) + const updatedAt = Date.now() + if (!current) { + this.agentPromptLifecycleByPtyId.set(ptyId, { + status, + workingSequence: status === 'working' ? 1 : 0, + updatedAt + }) + return + } + this.agentPromptLifecycleByPtyId.set(ptyId, { + status, + workingSequence: + current.workingSequence + (status === 'working' && current.status !== 'working' ? 1 : 0), + updatedAt + }) + } + + private recordAgentPromptPermissionObservation(ptyId: string): void { + this.agentPromptPermissionSequenceByPtyId.set( + ptyId, + (this.agentPromptPermissionSequenceByPtyId.get(ptyId) ?? 0) + 1 + ) + } + + private restoreAgentPromptLifecycleByteOrder( + ptyId: string, + titleInput: string, + lastPayloadTitleOffset: number | null + ): void { + if (lastPayloadTitleOffset === null) { + return + } + const titleRange = findLastCompleteOscTitleRange(titleInput) + if (!titleRange || titleRange.end <= lastPayloadTitleOffset) { + return + } + const title = extractLastOscTitle(titleInput) + if (title === null) { + return + } + const status = detectAgentStatusFromTitle(title) + const current = this.agentPromptLifecycleByPtyId.get(ptyId) + if (!current || current.status === status) { + return + } + this.agentPromptLifecycleByPtyId.set(ptyId, { + status, + workingSequence: + current.workingSequence + (status === 'working' && current.status !== 'working' ? 1 : 0), + updatedAt: Date.now() + }) + } + private getPtyLifecycleGeneration(ptyId: string): number { const existing = this.ptyLifecycleGenerationById.get(ptyId) if (existing !== undefined) { @@ -11888,6 +12046,9 @@ export class OrcaRuntimeService { private advancePtyLifecycleGeneration(ptyId: string): void { this.ptyLifecycleGenerationById.set(ptyId, this.nextPtyLifecycleGeneration++) + this.agentPromptLifecycleByPtyId.delete(ptyId) + this.agentPromptPermissionSequenceByPtyId.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) @@ -11940,6 +12101,7 @@ export class OrcaRuntimeService { if (pty) { pty.wslDistro = null } + // Why: raced post-spawn bytes may already contain the replacement's permission state. if (replacesExistingRuntimeGeneration && postSpawnSequence === 0) { this.resetTrackedTerminalStateForProviderGeneration(ptyId) } @@ -14819,6 +14981,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) @@ -17950,17 +18113,33 @@ export class OrcaRuntimeService { options: { beforeWrite?: (ptyId: string) => void | Promise suffixFailureError?: string + signal?: AbortSignal } = {} ): Promise { const payload = buildAgentPromptPasteBytes(prompt) - const bytesWritten = Buffer.byteLength(`${payload}${AGENT_PROMPT_SUBMIT}`, 'utf8') const pty = this.getLivePtyForHandle(handle) if (pty) { if (!pty.pty.connected) { throw new Error('terminal_not_writable') } await assertTerminalInputWithinLimitWithYield(payload) - await this.writeTerminalAgentPrompt(pty.pty.ptyId, 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 } } @@ -17974,7 +18153,13 @@ export class OrcaRuntimeService { if (await this.isLeafPtyProvenAbsent(leaf.ptyId)) { throw new Error('terminal_not_writable') } - await this.writeTerminalAgentPrompt(leaf.ptyId, payload, options) + const generation = this.getPtyLifecycleGeneration(leaf.ptyId) + 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) + }) + const bytesWritten = Buffer.byteLength(payload, 'utf8') + submits return { handle, accepted: true, bytesWritten } } @@ -17982,21 +18167,10 @@ export class OrcaRuntimeService { const ptyId = this.getTerminalAgentStatusPtyId(handle) const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) - const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) - const liveTitleClearsBlockedText = - terminal.titleStatusIsLive && - terminal.titleStatus !== null && - terminal.titleStatus !== 'permission' && - !isOpenCodeNativeTitle(terminal.title) - if (terminal.titleStatus === 'permission' && terminal.titleStatusIsLive) { - return { handle, isRunningAgent: true, status: 'permission' } - } + const lifecycle = this.agentPromptLifecycleByPtyId.get(ptyId) if ( - blockedByWaitText && - !liveTitleClearsBlockedText && - (!explicitStatus || - explicitStatus.status === 'permission' || - (terminal.waitBlockedAt !== null && terminal.waitBlockedAt >= explicitStatus.updatedAt)) + (terminal.titleStatus === 'permission' && terminal.titleStatusIsLive) || + this.hasAuthoritativeTerminalWaitPermission(terminal, explicitStatus, lifecycle) ) { return { handle, isRunningAgent: true, status: 'permission' } } @@ -18066,13 +18240,7 @@ export class OrcaRuntimeService { private getTerminalAgentStatusSnapshot( handle: string, expectedPtyId: string - ): { - waitText: string - waitBlockedAt: number | null - title: string | null - titleStatus: AgentStatus | null - titleStatusIsLive: boolean - } { + ): TerminalAgentStatusSnapshot { const pty = this.getLivePtyForHandle(handle) if (pty) { if (!pty.pty.connected || pty.pty.ptyId !== expectedPtyId) { @@ -18131,6 +18299,36 @@ export class OrcaRuntimeService { } } + private hasAuthoritativeTerminalWaitPermission( + terminal: TerminalAgentStatusSnapshot, + explicitStatus: { status: AgentStatus; updatedAt: number } | null, + lifecycle: { status: AgentStatus | null; updatedAt: number } | null | undefined + ): boolean { + const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) + if (!blockedByWaitText) { + return false + } + const liveTitleClearsBlockedText = + terminal.titleStatusIsLive && + terminal.titleStatus !== null && + terminal.titleStatus !== 'permission' && + !isOpenCodeNativeTitle(terminal.title) + if (liveTitleClearsBlockedText && lifecycle?.status !== terminal.titleStatus) { + return false + } + const newestPermissionAt = Math.max( + explicitStatus?.status === 'permission' ? explicitStatus.updatedAt : -1, + lifecycle?.status === 'permission' ? lifecycle.updatedAt : -1, + terminal.waitBlockedAt ?? -1 + ) + const newestClearAt = Math.max( + explicitStatus && explicitStatus.status !== 'permission' ? explicitStatus.updatedAt : -1, + lifecycle?.status && lifecycle.status !== 'permission' ? lifecycle.updatedAt : -1 + ) + // Equal wall-clock observations fail closed because their raw intra-chunk order is unknown. + return newestPermissionAt >= 0 && newestPermissionAt >= newestClearAt + } + private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise { if (!this.ptyController) { return false @@ -18530,13 +18728,20 @@ export class OrcaRuntimeService { } private async writeTerminalAgentPrompt( + handle: string, ptyId: string, + generation: number, pastePayload: string, options: { beforeWrite?: (ptyId: string) => void | Promise suffixFailureError?: string + signal?: AbortSignal } = {} - ): Promise { + ): Promise { + assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) + const permissionBaseline = this.getAgentPromptActivity(handle, ptyId) + this.assertAgentPromptPermissionSafe(permissionBaseline, permissionBaseline) const renderGate = this.createAgentPromptRenderGate(ptyId) let wrotePasteBytes = false let completedPaste = false @@ -18545,7 +18750,15 @@ export class OrcaRuntimeService { let chunk = chunks.next() 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) + this.assertAgentPromptPermissionSafe( + permissionBaseline, + this.getAgentPromptActivity(handle, ptyId) + ) if (nextChunk.done) { renderGate?.arm() } @@ -18561,7 +18774,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() @@ -18569,11 +18786,16 @@ export class OrcaRuntimeService { } if (renderGate) { - await renderGate.wait() - renderGate.dispose() + try { + await waitForAgentPromptPromise(renderGate.wait(), options.signal) + } finally { + renderGate.dispose() + } } else { - await new Promise((resolve) => setTimeout(resolve, AGENT_PROMPT_SUBMIT_DELAY_MS)) + await waitForAgentPromptDelay(AGENT_PROMPT_SUBMIT_DELAY_MS, options.signal) } + assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) try { await options.beforeWrite?.(ptyId) } catch (error) { @@ -18582,10 +18804,93 @@ export class OrcaRuntimeService { } throw error } + assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) + const baseline = this.getAgentPromptActivity(handle, ptyId) + this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) const suffixWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false if (!suffixWrote) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } + await verifyAgentPromptSubmission({ + baseline, + readActivity: () => this.getAgentPromptActivity(handle, ptyId), + signal: options.signal + }) + return 1 + } + + private async serializeAgentPromptSubmission( + ptyId: string, + generation: number, + submit: () => Promise + ): Promise { + 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(queueKey, tail) + try { + return await submission + } finally { + if (this.agentPromptSubmissionTailByPtyId.get(queueKey) === tail) { + this.agentPromptSubmissionTailByPtyId.delete(queueKey) + } + } + } + + private getAgentPromptActivity(handle: string, ptyId: string): AgentPromptActivity { + this.assertLiveTerminalHandleTargetsPty(handle, ptyId) + 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 lifecycleIsNewer = + lifecycle && + (!explicit || + lifecycle.updatedAt > explicit.updatedAt || + (lifecycle.updatedAt === explicit.updatedAt && lifecycle.status === 'permission')) + const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) + const status = this.hasAuthoritativeTerminalWaitPermission(terminal, explicit, lifecycle) + ? 'permission' + : lifecycleIsNewer + ? lifecycle.status + : (explicit?.status ?? ptyStatus ?? null) + return { + generation: this.getPtyLifecycleGeneration(ptyId), + permissionSequence: this.agentPromptPermissionSequenceByPtyId.get(ptyId) ?? 0, + workingSequence: lifecycle?.workingSequence ?? 0, + status + } + } + + private assertAgentPromptPermissionSafe( + baseline: AgentPromptActivity, + current: AgentPromptActivity + ): void { + if ( + current.status === 'permission' || + current.permissionSequence > baseline.permissionSequence + ) { + throw new Error('agent_prompt_blocked') + } + } + + private assertAgentPromptGeneration(ptyId: string, expected: number): void { + if (this.getPtyLifecycleGeneration(ptyId) !== expected) { + throw new Error('terminal_handle_stale') + } } private createAgentPromptRenderGate(ptyId: string): { @@ -37277,7 +37582,7 @@ export class OrcaRuntimeService { const WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS = 50 // Why: chunks that could complete an actionable prompt bypass the throttle so blocked stamps stay immediate; scanned over the new chunk + short carry, never the whole window. const WAIT_BLOCKED_KEYWORD_PATTERN = - /press enter|press t to trust|do you trust|trust this|trusted workspace|update available|choose working directory|codex just got an upgrade|hooks need review/ + /press enter|press t to trust|do you trust|trust this|trusted workspace|permission required|requires permission|allow once|allow always|update available|choose working directory|codex just got an upgrade|hooks need review/ const WAIT_BLOCKED_KEYWORD_CARRY_CHARS = 31 const MAX_TAIL_LINES = 2000 const MAX_TAIL_CHARS = 256 * 1024 @@ -39360,6 +39665,45 @@ function isTerminalSendSettlementAgent( return agent === 'claude' || agent === 'codex' } +function findLastCompleteOscTitleRange(data: string): { start: number; end: number } | null { + // Why: one forward cursor keeps hostile unterminated OSC output linear-time. + let last: { start: number; end: number } | null = null + let searchFrom = 0 + while (searchFrom < data.length) { + const start = data.indexOf('\x1b]', searchFrom) + if (start === -1) { + break + } + const command = data[start + 2] + if ((command !== '0' && command !== '1' && command !== '2') || data[start + 3] !== ';') { + searchFrom = start + 2 + continue + } + let cursor = start + 4 + for (; cursor < data.length; cursor += 1) { + if (data[cursor] === '\x07') { + last = { start, end: cursor + 1 } + searchFrom = cursor + 1 + break + } + if (data[cursor] !== '\x1b') { + continue + } + if (data[cursor + 1] === '\\') { + last = { start, end: cursor + 2 } + searchFrom = cursor + 2 + } else { + searchFrom = cursor + } + break + } + if (cursor === data.length) { + break + } + } + return last +} + function terminalTitleBlocksExplicitAgentStatus(title: string | null): boolean { if (!title) { return false diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 9d0888846d1..190a5b88be5 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -45,6 +45,16 @@ describe('mapRuntimeError', () => { } ) + it.each(['agent_prompt_blocked', 'agent_prompt_stalled', 'request_aborted'])( + 'preserves the agent prompt failure %s', + (code) => { + expect(mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, new Error(code))).toMatchObject({ + ok: false, + error: { code, message: code } + }) + } + ) + it.each([ 'remote_update_manual_required', 'remote_update_not_available', diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 532b28ffa34..5d07ace973d 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -61,10 +61,13 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'terminal_tab_close_timeout', 'terminal_tab_not_found', 'terminal_tab_pinned', + 'agent_prompt_blocked', + 'agent_prompt_stalled', 'no_active_terminal', 'repo_not_found', 'timeout', 'invalid_limit', + 'request_aborted', 'remote_update_manual_required', 'remote_update_not_available', 'remote_update_not_downloaded', diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 4f1925683d2..1b88b4afc73 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1226,7 +1226,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.send', params: TerminalSend, - handler: async (params, { runtime, clientId }) => { + handler: async (params, { runtime, clientId, signal }) => { await assertTerminalSendTextWithinLimit(params.text) await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) const queryReplyClientId = clientId ?? params.client?.id @@ -1374,7 +1374,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let result try { result = useSettledAgentPrompt - ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { beforeWrite }) + ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { + beforeWrite, + signal + }) : await runtime.sendTerminal( params.terminal, { diff --git a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts index a5d71278bb4..eb7624c95e4 100644 --- a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts +++ b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts @@ -45,7 +45,8 @@ describe('terminal agent prompt send RPC', () => { expect(response.ok).toBe(true) expect(runtime.isTerminalRunningSettledPromptAgent).toHaveBeenCalledWith('terminal-1') expect(sendTerminalAgentPrompt).toHaveBeenCalledWith('terminal-1', 'review this change', { - beforeWrite: undefined + beforeWrite: undefined, + signal: undefined }) expect(sendTerminal).not.toHaveBeenCalled() }) diff --git a/src/shared/agent-status-osc.test.ts b/src/shared/agent-status-osc.test.ts index ac14c495786..4a0711cc426 100644 --- a/src/shared/agent-status-osc.test.ts +++ b/src/shared/agent-status-osc.test.ts @@ -17,6 +17,7 @@ describe('createAgentStatusOscProcessor', () => { agentType: 'codex' } ]) + expect(result.lastPayloadCleanOffset).toBe('before'.length) }) it('preserves parser state across split OSC 9999 chunks', () => { @@ -32,5 +33,6 @@ describe('createAgentStatusOscProcessor', () => { prompt: 'ok' } ]) + expect(result.lastPayloadCleanOffset).toBe(0) }) }) diff --git a/src/shared/agent-status-osc.ts b/src/shared/agent-status-osc.ts index 95908805907..39f9703bf85 100644 --- a/src/shared/agent-status-osc.ts +++ b/src/shared/agent-status-osc.ts @@ -6,6 +6,7 @@ const OSC_AGENT_STATUS_PREFIX = '\x1b]9999;' export type ProcessedAgentStatusChunk = { cleanData: string payloads: ParsedAgentStatusPayload[] + lastPayloadCleanOffset: number | null } function findAgentStatusTerminator( @@ -40,6 +41,7 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen pending = '' const payloads: ParsedAgentStatusPayload[] = [] + let lastPayloadCleanOffset: number | null = null let cleanData = '' let cursor = 0 @@ -77,10 +79,11 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen const parsed = parseAgentStatusPayload(combined.slice(payloadStart, terminator.index)) if (parsed) { payloads.push(parsed) + lastPayloadCleanOffset = cleanData.length } cursor = terminator.index + terminator.length } - return { cleanData, payloads } + return { cleanData, payloads, lastPayloadCleanOffset } } } diff --git a/tests/e2e/terminal-send-agent-prompt-submit.spec.ts b/tests/e2e/terminal-send-agent-prompt-submit.spec.ts index dcff9d73352..1ae525d29da 100644 --- a/tests/e2e/terminal-send-agent-prompt-submit.spec.ts +++ b/tests/e2e/terminal-send-agent-prompt-submit.spec.ts @@ -18,8 +18,8 @@ mkdirSync(fixtureBin) writeFileSync( fakeCodex, process.platform === 'win32' - ? `@echo off\r\n"${process.execPath}" "${fixtureScript}" --fake-agent --report "%ORCA_FAKE_AGENT_REPORT%" --marker "%ORCA_FAKE_AGENT_MARKER%" --allow-unframed-paste\r\n` - : `#!/usr/bin/env sh\nexec "${process.execPath}" "${fixtureScript}" --fake-agent --report "$ORCA_FAKE_AGENT_REPORT" --marker "$ORCA_FAKE_AGENT_MARKER"\n`, + ? `@echo off\r\n"${process.execPath}" "${fixtureScript}" --fake-agent --report "%ORCA_FAKE_AGENT_REPORT%" --marker "%ORCA_FAKE_AGENT_MARKER%" --allow-unframed-paste %*\r\n` + : `#!/usr/bin/env sh\nexec "${process.execPath}" "${fixtureScript}" --fake-agent --report "$ORCA_FAKE_AGENT_REPORT" --marker "$ORCA_FAKE_AGENT_MARKER" "$@"\n`, 'utf8' ) if (process.platform !== 'win32') { @@ -27,6 +27,7 @@ if (process.platform !== 'win32') { } test.use({ + seedTestRepo: false, orcaAppExtraEnv: { PATH: `${fixtureBin}${path.delimiter}${process.env.PATH ?? ''}`, ORCA_FAKE_AGENT_REPORT: fixtureReport, @@ -87,3 +88,104 @@ test('CLI text plus Enter waits for a slow agent composer before submitting', as markerReceived: true }) }) + +test('CLI reports a swallowed Enter without submitting a second Enter', async ({ + electronApp, + orcaPage, + testRepoPath +}) => { + test.setTimeout(90_000) + await waitForSessionReady(orcaPage) + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const repoRoot = process.cwd() + let stdout = '' + try { + const result = await execFileAsync( + process.execPath, + [ + path.join(repoRoot, 'tests', 'tools', 'repro-terminal-send-submit.mjs'), + '--cli', + path.join(repoRoot, 'config', 'scripts', 'orca-dev.mjs'), + '--worktree', + testRepoPath, + '--agent-command', + 'codex --swallow-first-enter', + '--expect-stalled', + '--report', + fixtureReport, + '--marker', + fixtureMarker, + '--discard-report' + ], + { + cwd: repoRoot, + env: { ...process.env, ORCA_DEV_USER_DATA_PATH: userDataDir }, + timeout: 60_000 + } + ) + stdout = result.stdout + } catch (error) { + const failed = error as Error & { stdout?: string; stderr?: string } + throw new Error([failed.message, failed.stdout, failed.stderr].filter(Boolean).join('\n')) + } + + expect(JSON.parse(stdout)).toMatchObject({ + rescueSent: false, + sendErrorCode: 'agent_prompt_stalled', + contractOk: true, + submitted: false, + prematureEnters: 0, + receivedEnters: 1, + swallowedEnters: 1, + markerReceived: true + }) +}) + +test('CLI does not write prompt bytes into an active permission dialog', async ({ + electronApp, + orcaPage, + testRepoPath +}) => { + test.setTimeout(90_000) + await waitForSessionReady(orcaPage) + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const repoRoot = process.cwd() + let stdout = '' + try { + const result = await execFileAsync( + process.execPath, + [ + path.join(repoRoot, 'tests', 'tools', 'repro-terminal-send-submit.mjs'), + '--cli', + path.join(repoRoot, 'config', 'scripts', 'orca-dev.mjs'), + '--worktree', + testRepoPath, + '--expect-blocked', + '--report', + fixtureReport, + '--marker', + fixtureMarker, + '--discard-report' + ], + { + cwd: repoRoot, + env: { ...process.env, ORCA_DEV_USER_DATA_PATH: userDataDir }, + timeout: 60_000 + } + ) + stdout = result.stdout + } catch (error) { + const failed = error as Error & { stdout?: string; stderr?: string } + throw new Error([failed.message, failed.stdout, failed.stderr].filter(Boolean).join('\n')) + } + + expect(JSON.parse(stdout)).toMatchObject({ + rescueSent: false, + sendErrorCode: 'agent_prompt_blocked', + contractOk: true, + submitted: false, + receivedBytes: 0, + receivedEnters: 0, + markerReceived: false + }) +}) diff --git a/tests/tools/repro-terminal-send-submit.mjs b/tests/tools/repro-terminal-send-submit.mjs index bed37d9add4..201e51c9364 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,7 +160,10 @@ 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') + const expectBlocked = hasFlag('expect-blocked') await mkdir(tempDir, { recursive: true }) + await rm(reportPath, { force: true }) const command = argValue('agent-command') ?? @@ -158,6 +175,7 @@ async function parentMain() { shellQuote(marker), '--timeout-ms', String(timeoutMs), + ...(expectBlocked ? ['--permission-before-send'] : []), ...(process.platform === 'win32' ? ['--allow-unframed-paste'] : []) ])) const added = await callOrca(cli, ['repo', 'add', '--path', cwd], cwd) @@ -186,19 +204,37 @@ async function parentMain() { } try { - await callOrca( - cli, - ['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', '10000'], - cwd - ) - await callOrca( - cli, - ['terminal', 'send', '--terminal', handle, '--text', prompt, '--enter'], - cwd - ) + if (expectBlocked) { + const setupReport = await readReport(reportPath, 10_000) + if (!setupReport) { + throw new Error('terminal permission prompt did not materialize') + } + } else { + await callOrca( + cli, + ['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', '10000'], + cwd + ) + } + let sendErrorCode = null + try { + await callOrca( + cli, + ['terminal', 'send', '--terminal', handle, '--text', prompt, '--enter'], + cwd + ) + } catch (error) { + const expectedError = + (expectStalled && error?.code === 'agent_prompt_stalled') || + (expectBlocked && error?.code === 'agent_prompt_blocked') + if (!expectedError) { + throw error + } + sendErrorCode = error.code + } let report = await readReport(reportPath, 1_000) let rescueSent = false - if (!report) { + if (!report && !expectStalled && !expectBlocked) { rescueSent = true await callOrca(cli, ['terminal', 'send', '--terminal', handle, '--enter'], cwd) report = await readReport(reportPath, timeoutMs) @@ -210,10 +246,25 @@ 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 + const expectedBlockObserved = + sendErrorCode === 'agent_prompt_blocked' && + report.receivedBytes === 0 && + report.receivedEnters === 0 + if ( + !report.contractOk || + rescueSent || + (expectStalled && !expectedStallObserved) || + (expectBlocked && !expectedBlockObserved) + ) { process.exitCode = 1 } } finally { @@ -231,6 +282,8 @@ async function fakeAgentMain() { const marker = argValue('marker') const timeoutMs = parsePositiveInteger('timeout-ms', DEFAULT_TIMEOUT_MS) const pasteFramingRequired = !hasFlag('allow-unframed-paste') + const swallowFirstEnter = hasFlag('swallow-first-enter') + const permissionBeforeSend = hasFlag('permission-before-send') if (!reportPath || !marker) { throw new Error('--fake-agent requires --report and --marker') } @@ -239,49 +292,80 @@ async function fakeAgentMain() { } process.stdin.resume() process.stdout.write('OpenAI Codex\nmodel: fake\ndirectory: fixture\n> ') + if (permissionBeforeSend) { + process.stdout.write('\nPermission required\nAllow once\nAllow always\nReject\n') + } let input = '' let countedCarriages = 0 let prematureEnters = 0 + let receivedEnters = 0 + let swallowedEnters = 0 let composerReady = false 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, + contractOk: + prematureEnters === 0 && + (permissionBeforeSend || !pasteFramingRequired || hasBracketedPasteFrame), + submitted, prematureEnters, + receivedEnters, + swallowedEnters, pasteFramingRequired, hasBracketedPasteFrame, markerReceived: input.includes(marker), 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) } const timeout = setTimeout(() => process.exit(8), timeoutMs) + if (permissionBeforeSend) { + setTimeout(() => void writeReport(false), 250) + } process.stdin.on('data', (chunk) => { input += chunk.toString('utf8') if (!renderScheduled && input.includes(marker)) { renderScheduled = true setTimeout(() => { composerReady = true - process.stdout.write('\x1b[?25hcomposer rendered') + const pasteStart = input.indexOf(BEGIN) + const pasteEnd = input.indexOf(END, pasteStart + BEGIN.length) + const composer = + pasteStart !== -1 && pasteEnd !== -1 + ? input.slice(pasteStart + BEGIN.length, pasteEnd) + : input + process.stdout.write(`\x1b[?25h\x1b[2J\x1b[H› ${composer}`) }, COMPOSER_RENDER_MS) } let nextCarriage = input.indexOf('\r', countedCarriages) while (nextCarriage !== -1) { countedCarriages = nextCarriage + 1 if (composerReady) { + 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 }