From 1f8ed151f76824a4c7d1da401d5519ff11b32c4c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 17 Aug 2026 01:40:32 -0400 Subject: [PATCH] fix(terminal): preserve split prompt status order --- .../agent-prompt-submission-runtime.test.ts | 68 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 69 ++++++++++++++----- src/shared/agent-status-osc.test.ts | 2 + src/shared/agent-status-osc.ts | 5 +- 4 files changed, 124 insertions(+), 20 deletions(-) diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 4563f98cc57..ce19f22a3ca 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -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) => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d660d091bd3..363dd983c91 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { diff --git a/src/shared/agent-status-osc.test.ts b/src/shared/agent-status-osc.test.ts index ac14c495786..4a0711cc426 100644 --- a/src/shared/agent-status-osc.test.ts +++ b/src/shared/agent-status-osc.test.ts @@ -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) }) }) diff --git a/src/shared/agent-status-osc.ts b/src/shared/agent-status-osc.ts index 95908805907..39f9703bf85 100644 --- a/src/shared/agent-status-osc.ts +++ b/src/shared/agent-status-osc.ts @@ -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 } } }