fix(terminal): preserve split prompt status order

This commit is contained in:
Jinwoo-H
2026-08-17 01:40:32 -04:00
parent d276cfc96f
commit 1f8ed151f7
4 changed files with 124 additions and 20 deletions
@@ -176,6 +176,48 @@ describe('agent prompt submission runtime', () => {
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) => {
@@ -476,6 +518,32 @@ describe('agent prompt submission runtime', () => {
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) => {
+50 -19
View File
@@ -10986,7 +10986,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)
this.restoreAgentPromptLifecycleByteOrder(ptyId, data)
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.
@@ -11947,12 +11951,19 @@ export class OrcaRuntimeService {
)
}
private restoreAgentPromptLifecycleByteOrder(ptyId: string, data: string): void {
const titleStart = findLastCompleteOscTitleStart(data)
if (titleStart <= data.lastIndexOf('\x1b]9999;')) {
private restoreAgentPromptLifecycleByteOrder(
ptyId: string,
titleInput: string,
lastPayloadTitleOffset: number | null
): void {
if (lastPayloadTitleOffset === null) {
return
}
const title = extractLastOscTitle(data)
const titleRange = findLastCompleteOscTitleRange(titleInput)
if (!titleRange || titleRange.end <= lastPayloadTitleOffset) {
return
}
const title = extractLastOscTitle(titleInput)
if (title === null) {
return
}
@@ -12036,6 +12047,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)
}
@@ -18207,6 +18219,7 @@ export class OrcaRuntimeService {
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
}
@@ -39483,25 +39496,43 @@ function isTerminalSendSettlementAgent(
return agent === 'claude' || agent === 'codex'
}
function findLastCompleteOscTitleStart(data: string): number {
let searchFrom = data.length
while (searchFrom > 0) {
const start = data.lastIndexOf('\x1b]', searchFrom - 1)
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) {
return -1
break
}
const commandEnd = data.indexOf(';', start + 2)
const command = commandEnd === -1 ? '' : data.slice(start + 2, commandEnd)
if (command === '0' || command === '1' || command === '2') {
const bel = data.indexOf('\x07', commandEnd + 1)
const stringTerminator = data.indexOf('\x1b\\', commandEnd + 1)
if (bel !== -1 || stringTerminator !== -1) {
return start
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
}
searchFrom = start
}
return -1
return last
}
function terminalTitleBlocksExplicitAgentStatus(title: string | null): boolean {
+2
View File
@@ -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)
})
})
+4 -1
View File
@@ -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 }
}
}