From 2cd5627fe85c3fd2785319084d4565e3fadcb24c Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Tue, 3 Nov 2026 15:16:04 +0400 Subject: [PATCH] fix(assistants): ignore older background command completions (#42) --- src/assistant-turn-observer.ts | 28 ++++++++++++++++++++- test/assistant-requests.test.ts | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/assistant-turn-observer.ts b/src/assistant-turn-observer.ts index 7419cd6ef..ffd75ccf7 100644 --- a/src/assistant-turn-observer.ts +++ b/src/assistant-turn-observer.ts @@ -7,6 +7,20 @@ export type TurnObservation = | { state: 'failed'; turnId: string; reason: string } | { state: 'unknown'; reason: string }; +/** Codex turn IDs encode their creation time as UUIDv7's first 48 bits. + * Only a strictly earlier timestamp proves a late background completion; + * unversioned IDs and IDs from the same millisecond remain ambiguous. + */ +function isEarlierCodexTurn(candidate: unknown, current: string): boolean { + const uuid7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return ( + typeof candidate === 'string' && + uuid7.test(candidate) && + uuid7.test(current) && + candidate.slice(0, 13).toLowerCase() < current.slice(0, 13).toLowerCase() + ); +} + export function observeAssistantTurn( jsonl: string, conversationId: string, @@ -30,7 +44,19 @@ export function observeAssistantTurn( : { state: 'waiting' }; const turnId = starts[0].turn_id; if (typeof turnId !== 'string' || !turnId) return { state: 'unknown', reason: 'Unsupported native turn identity' }; - const items = events.filter((p) => p.type === 'item_completed'); + const startIndex = events.indexOf(starts[0]); + const items = events.filter((p, index) => { + if (p.type !== 'item_completed') return false; + // Background commands can finish after their parent turn completed, even + // during the next request. They are neither current-turn results nor + // evidence that a different conversation took control (#42). + const lateBackgroundCommand = + p.thread_id === conversationId && + (p.item as { type?: string } | undefined)?.type === 'CommandExecution' && + index > startIndex && + isEarlierCodexTurn(p.turn_id, turnId); + return !lateBackgroundCommand; + }); if (items.some((p) => p.turn_id !== turnId || p.thread_id !== conversationId)) return { state: 'unknown', reason: 'Transcript identity changed' }; const item = (p: Record) => diff --git a/test/assistant-requests.test.ts b/test/assistant-requests.test.ts index 97a56b0be..b2fe9a7b3 100644 --- a/test/assistant-requests.test.ts +++ b/test/assistant-requests.test.ts @@ -128,6 +128,49 @@ afterEach(async () => { rmSync(root, { recursive: true, force: true }); }); describe('durable assistant requests', () => { + 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'); + await manager.tick(); + const id = '01a1a0ff-0bd4-71b2-b26f-33504a365079'; + const records = turn('hello', 'current answer', id).trimEnd().split('\n'); + const old = row({ + type: 'item_completed', + thread_id: conversationId, + turn_id: '01a1a070-9999-70a1-b838-7e4162d35c0f', + item: { type: 'CommandExecution' }, + }); + appendFileSync(file, records.slice(0, 2).join('\n') + '\n' + old); + await manager.tick(); + expect(manager.store.get(request.id)?.status).toBe('running'); + appendFileSync(file, records.slice(2).join('\n') + '\n'); + await manager.tick(); + expect(manager.store.get(request.id)).toMatchObject({ status: 'completed', result: { text: 'current answer' } }); + await manager.tick(); + expect(writes).toEqual(['hello\r']); + }); + + it.each([ + ['future command', '01a1a1ff-0bd4-71b2-b26f-33504a365079', 'CommandExecution', false], + ['same-millisecond command', '01a1a0ff-0bd4-71b2-b26f-33504a365078', 'CommandExecution', false], + ['unversioned command', 'older-turn', 'CommandExecution', false], + ['missing turn', undefined, 'CommandExecution', false], + ['old foreign conversation', '01a1a070-9999-70a1-b838-7e4162d35c0f', 'CommandExecution', true], + ['old user message', '01a1a070-9999-70a1-b838-7e4162d35c0f', 'UserMessage', false], + ['old final answer', '01a1a070-9999-70a1-b838-7e4162d35c0f', 'AgentMessage', false], + ])('still fails closed for %s', (_label, otherId, type, foreign) => { + const id = '01a1a0ff-0bd4-71b2-b26f-33504a365079'; + const data = + turn('hello', 'current answer', id) + + row({ + type: 'item_completed', + turn_id: otherId, + thread_id: foreign ? randomUUID() : conversationId, + item: { type }, + }); + expect(observeAssistantTurn(data, conversationId, 'hello')).toMatchObject({ state: 'unknown' }); + }); + it('treats an exact empty native final as a stopped response failure, never unknown or task success', async () => { const a = await ready(); const r = manager.submit(a, 'silent task', 'empty-native-123456789');