Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/assistant-turn-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, unknown>) =>
Expand Down
43 changes: 43 additions & 0 deletions test/assistant-requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading