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
14 changes: 10 additions & 4 deletions src/assistant-request-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -70,7 +70,7 @@ export class AssistantRequestManager {
conversationReady(path: string, binding?: AssistantBinding): Promise<boolean> {
return binding && assistantBindingMode(binding) !== 'codex'
? otherNativeReady(binding, path)
: this.nativeReady(path);
: this.nativeReady(path, binding?.conversationId);
}
private handoffPending = new Set<string>();
private takingControl = new Set<string>();
Expand All @@ -93,7 +93,7 @@ export class AssistantRequestManager {
.map((r) => r.binding.sessionId)
);
}
private async nativeReady(path: string): Promise<boolean> {
private async nativeReady(path: string, conversationId?: string): Promise<boolean> {
const file = await open(path, 'r');
try {
const info = await file.stat();
Expand Down Expand Up @@ -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();
}
Expand Down
22 changes: 22 additions & 0 deletions src/assistant-turn-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>, 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,
Expand Down
42 changes: 42 additions & 0 deletions test/assistant-requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading