mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
fix(terminal): preserve split prompt status order
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user