refactor(terminal): submit agent prompts once

This commit is contained in:
Jinwoo-H
2026-08-16 23:25:56 -04:00
parent 805c60dc7c
commit 57cfdda5b4
8 changed files with 368 additions and 663 deletions
-16
View File
@@ -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')
-17
View File
@@ -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)))
@@ -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<string>
readVisibleTerminalState: (ptyId: string) => Promise<unknown>
}
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<void>((resolve) => {
firstWriteReached = resolve
})
const firstGate = new Promise<void>((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()
@@ -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<AgentPromptActivity> = {}): AgentPromptActi
return {
generation: 1,
lifecycleSequence: 4,
outputSequence: 20,
status: 'idle',
...overrides
}
@@ -19,307 +17,117 @@ function activity(overrides: Partial<AgentPromptActivity> = {}): 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<string | null>>()
.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<string | null>>()
.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 two<ESC>x',
'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)
})
})
@@ -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<string | null>
retrySubmit: (expected: AgentPromptActivity) => Promise<'retried' | 'activity'>
signal?: AbortSignal
}
type AgentPromptEffect = 'working' | 'permission' | 'activity' | 'none'
export async function verifyAgentPromptSubmission(
options: AgentPromptVerificationOptions
): Promise<{ retried: boolean }> {
): Promise<void> {
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<AgentPromptEffect> {
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(
+88 -97
View File
@@ -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<string, number>()
private agentPromptLifecycleByPtyId = new Map<
string,
{ status: AgentStatus | null; sequence: number }
{ status: AgentStatus | null; sequence: number; updatedAt: number }
>()
private agentPromptExplicitStatusFloorByPtyId = new Map<string, number>()
private agentPromptSubmissionTailByPtyId = new Map<string, Promise<void>>()
private providerSequenceInitializedPtys = new Set<string>()
private providerSequenceOffsetByPtyId = new Map<string, number>()
@@ -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<Pick<RuntimeVisibleTerminalState, 'lines' | 'textBeforeCursor'>> {
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<string[]> {
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<string[]> {
@@ -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<void>
@@ -18547,10 +18551,10 @@ export class OrcaRuntimeService {
} = {}
): Promise<number> {
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<T>(
ptyId: string,
generation: number,
submit: () => Promise<T>
): Promise<T> {
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<void>
@@ -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
})
+52 -16
View File
@@ -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
}