diff --git a/src/assistant-request-manager.ts b/src/assistant-request-manager.ts index e371f8e23..58e2eb46d 100644 --- a/src/assistant-request-manager.ts +++ b/src/assistant-request-manager.ts @@ -14,7 +14,7 @@ import { observeOpenCode, type NativeCursor, } from './assistant-native.js'; -import { observeClaudeAssistantTurn, observeAssistantTurn } from './assistant-turn-observer.js'; +import { codexLifecycleReady, observeClaudeAssistantTurn, observeAssistantTurn } from './assistant-turn-observer.js'; import type { AssistantDefinition, AssistantBinding } from './types/assistant.js'; import type { AssistantRecovery } from './assistant-recovery.js'; @@ -70,7 +70,7 @@ export class AssistantRequestManager { conversationReady(path: string, binding?: AssistantBinding): Promise { return binding && assistantBindingMode(binding) !== 'codex' ? otherNativeReady(binding, path) - : this.nativeReady(path); + : this.nativeReady(path, binding?.conversationId); } private handoffPending = new Set(); private takingControl = new Set(); @@ -93,7 +93,7 @@ export class AssistantRequestManager { .map((r) => r.binding.sessionId) ); } - private async nativeReady(path: string): Promise { + private async nativeReady(path: string, conversationId?: string): Promise { const file = await open(path, 'r'); try { const info = await file.stat(); @@ -121,7 +121,13 @@ export class AssistantRequestManager { r.payload?.type ?? '' ) ); - return !malformed && (!events.length || events.at(-1)?.payload?.type === 'task_complete'); + return ( + !malformed && + codexLifecycleReady( + events.map((event) => event!.payload!), + conversationId + ) + ); } finally { await file.close(); } diff --git a/src/assistant-turn-observer.ts b/src/assistant-turn-observer.ts index ffd75ccf7..935483737 100644 --- a/src/assistant-turn-observer.ts +++ b/src/assistant-turn-observer.ts @@ -21,6 +21,28 @@ function isEarlierCodexTurn(candidate: unknown, current: string): boolean { ); } +/** A completed turn remains idle when only its background command results arrive later. */ +export function codexLifecycleReady(events: ReadonlyArray>, conversationId?: string): boolean { + if (!events.length) return true; + let completedIndex = events.length - 1; + while (completedIndex >= 0 && events[completedIndex].type !== 'task_complete') completedIndex--; + if (completedIndex < 0) return false; + const completed = events[completedIndex]; + return events + .slice(completedIndex + 1) + .every( + (event) => + event.type === 'item_completed' && + (event.item as { type?: string } | undefined)?.type === 'CommandExecution' && + typeof completed.turn_id === 'string' && + completed.turn_id.length > 0 && + typeof event.thread_id === 'string' && + typeof conversationId === 'string' && + event.thread_id === conversationId && + (event.turn_id === completed.turn_id || isEarlierCodexTurn(event.turn_id, completed.turn_id)) + ); +} + export function observeAssistantTurn( jsonl: string, conversationId: string, diff --git a/test/assistant-requests.test.ts b/test/assistant-requests.test.ts index b2fe9a7b3..022cfda23 100644 --- a/test/assistant-requests.test.ts +++ b/test/assistant-requests.test.ts @@ -128,6 +128,48 @@ afterEach(async () => { rmSync(root, { recursive: true, force: true }); }); describe('durable assistant requests', () => { + it.each(['01a1a0ff-0bd4-71b2-b26f-33504a365079', '01a1a070-9999-70a1-b838-7e4162d35c0f'])( + 'hands off after a completed turn receives background result from %s', + async (backgroundTurn) => { + const id = '01a1a0ff-0bd4-71b2-b26f-33504a365079'; + appendFileSync( + file, + turn('earlier prompt', 'finished', id) + + row({ + type: 'item_completed', + thread_id: conversationId, + turn_id: backgroundTurn, + item: { type: 'CommandExecution' }, + }) + ); + const a = await ready(); + expect(manager.availability(a).requestExecution).toBe(true); + expect(writes).toEqual([]); + } + ); + + it.each(['task_started', 'UserMessage', 'foreign-thread', 'newer-turn', 'missing-turn'])( + 'does not hand off after completion followed by %s', + async (kind) => { + const id = '01a1a0ff-0bd4-71b2-b26f-33504a365079'; + const later = + kind === 'task_started' + ? row({ type: 'task_started', turn_id: 'new-turn' }) + : row({ + type: 'item_completed', + thread_id: kind === 'foreign-thread' ? randomUUID() : conversationId, + turn_id: + kind === 'missing-turn' + ? undefined + : kind === 'newer-turn' + ? '01a1a1ff-0bd4-71b2-b26f-33504a365079' + : id, + item: { type: kind === 'UserMessage' ? 'UserMessage' : 'CommandExecution' }, + }); + appendFileSync(file, turn('earlier prompt', 'finished', id) + later); + await expect(ready()).rejects.toThrow(/unfinished turn/); + } + ); it('completes once when an older background command finishes during this request (#42)', async () => { const a = await ready(); const request = manager.submit(a, 'hello', 'late-background-command-42');