fix(terminal): isolate prompt permission generations

This commit is contained in:
Jinwoo-H
2026-08-17 01:25:50 -04:00
parent 6fa102251d
commit d276cfc96f
2 changed files with 202 additions and 12 deletions
@@ -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) => {
+65 -12
View File
@@ -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<boolean> {
@@ -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