fix(terminal): fence prompt retry state

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