From d276cfc96ff025c1972fec8b1d8cdbfd79f0a224 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 17 Aug 2026 01:25:50 -0400 Subject: [PATCH] fix(terminal): isolate prompt permission generations --- .../agent-prompt-submission-runtime.test.ts | 137 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 77 ++++++++-- 2 files changed, 202 insertions(+), 12 deletions(-) diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 44f61708751..4563f98cc57 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -161,6 +161,39 @@ describe('agent prompt submission runtime', () => { 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 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) @@ -225,6 +258,31 @@ describe('agent prompt submission runtime', () => { 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( @@ -240,6 +298,54 @@ describe('agent prompt submission runtime', () => { 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) => { @@ -339,6 +445,37 @@ describe('agent prompt submission runtime', () => { 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('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 1c3a7e082d8..d660d091bd3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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, @@ -10985,6 +10986,7 @@ 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) } 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. @@ -11665,6 +11667,7 @@ export class OrcaRuntimeService { 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 @@ -11678,13 +11681,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) } @@ -11939,6 +11947,28 @@ export class OrcaRuntimeService { ) } + private restoreAgentPromptLifecycleByteOrder(ptyId: string, data: string): void { + const titleStart = findLastCompleteOscTitleStart(data) + if (titleStart <= data.lastIndexOf('\x1b]9999;')) { + return + } + const title = extractLastOscTitle(data) + 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) { @@ -18160,22 +18190,24 @@ export class OrcaRuntimeService { if (!blockedByWaitText) { return false } - if (explicitStatus?.status === 'permission' || lifecycle?.status === 'permission') { - return true - } - const newestClearAt = Math.max( - explicitStatus?.updatedAt ?? -1, - lifecycle?.status ? lifecycle.updatedAt : -1 - ) - if (terminal.waitBlockedAt !== null) { - return terminal.waitBlockedAt >= newestClearAt - } const liveTitleClearsBlockedText = terminal.titleStatusIsLive && terminal.titleStatus !== null && terminal.titleStatus !== 'permission' && !isOpenCodeNativeTitle(terminal.title) - return !liveTitleClearsBlockedText && !explicitStatus + 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 + ) + return newestPermissionAt >= 0 && newestPermissionAt >= newestClearAt } private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise { @@ -37368,7 +37400,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 @@ -39451,6 +39483,27 @@ 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) + if (start === -1) { + return -1 + } + 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 + } + } + searchFrom = start + } + return -1 +} + function terminalTitleBlocksExplicitAgentStatus(title: string | null): boolean { if (!title) { return false