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