mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
refactor(terminal): submit agent prompts once
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user