Skip to content

Commit 37bcbdc

Browse files
committed
Add grace periods for goal turn routing
1 parent 314768a commit 37bcbdc

2 files changed

Lines changed: 473 additions & 126 deletions

File tree

src/CodexAppServerClient.ts

Lines changed: 134 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ const McpServerElicitationRequest = new RequestType<
109109
void
110110
>('mcpServer/elicitation/request');
111111

112+
const GOAL_RUNTIME_EFFECTS_GRACE_MS = 1_000;
113+
const GOAL_TURN_COMPLETION_GRACE_MS = 250;
114+
112115
/**
113116
* A type-safe client over the Codex App Server's JSON-RPC API.
114117
* Maps each request to its expected response and exposes clear, typed methods for supported JSON-RPC operations.
@@ -282,90 +285,180 @@ export class CodexAppServerClient {
282285
async runGoalSet(
283286
params: ThreadGoalSetParams,
284287
onTurnStarted?: (turnId: string) => void,
288+
runtimeEffectsGraceMs = GOAL_RUNTIME_EFFECTS_GRACE_MS,
289+
turnCompletionGraceMs = GOAL_TURN_COMPLETION_GRACE_MS,
285290
): Promise<TurnCompletedNotification | null> {
291+
let goalTurnId: string | null = null;
286292
const capturedCompletions: Array<TurnCompletedNotification> = [];
293+
let resolveGoalTurnCompleted: (event: TurnCompletedNotification) => void = () => {};
294+
const goalTurnCompleted = new Promise<TurnCompletedNotification>((resolve) => {
295+
resolveGoalTurnCompleted = resolve;
296+
});
287297
const releaseCompletionCapture = this.captureTurnCompletions(params.threadId, (event) => {
288298
capturedCompletions.push(event);
299+
if (goalTurnId === event.turn.id) {
300+
resolveGoalTurnCompleted(event);
301+
}
289302
});
290-
let goalTurnId: string | null = null;
291303
let resolveGoalTurnStarted: (turnId: string) => void = () => {};
292304
const goalTurnStarted = new Promise<string>((resolve) => {
293305
resolveGoalTurnStarted = resolve;
294306
});
295-
let resolveNoGoalTurnStarted: () => void = () => {};
296-
const noGoalTurnStarted = new Promise<null>((resolve) => {
297-
resolveNoGoalTurnStarted = () => resolve(null);
298-
});
299-
let acceptNoGoalTurnStatus = false;
307+
let goalUpdateHandled = false;
300308
let expectedGoal: ThreadGoal | null = null;
301-
let resolveGoalUpdated: () => void = () => {};
302-
const goalUpdated = new Promise<void>((resolve) => {
303-
resolveGoalUpdated = resolve;
304-
});
309+
const noGoalTurnStarted = this.createNoGoalTurnStartedPromise(runtimeEffectsGraceMs);
305310
const capturedGoalUpdates: Array<ThreadGoalUpdatedNotification> = [];
306311
const releaseRoutingCapture = this.captureTurnRoutings(params.threadId, (turnId) => {
307-
if (goalTurnId !== null) {
312+
if (!goalUpdateHandled || goalTurnId !== null) {
308313
return;
309314
}
310315
goalTurnId = turnId;
311316
onTurnStarted?.(turnId);
312317
resolveGoalTurnStarted(turnId);
313318
});
314-
const releaseStatusCapture = this.captureThreadStatuses(params.threadId, (status) => {
315-
if (!acceptNoGoalTurnStatus || goalTurnId !== null || status.type === "active") {
316-
return;
317-
}
318-
resolveNoGoalTurnStarted();
319-
});
320319
const releaseGoalUpdateCapture = this.captureThreadGoalUpdates(params.threadId, (event) => {
321320
capturedGoalUpdates.push(event);
322321
if (expectedGoal !== null && goalsMatch(event.goal, expectedGoal)) {
323-
resolveGoalUpdated();
322+
goalUpdateHandled = true;
323+
noGoalTurnStarted.goalUpdated();
324+
}
325+
});
326+
const releaseStatusCapture = this.captureThreadStatuses(params.threadId, (status) => {
327+
if (!goalUpdateHandled || goalTurnId !== null) {
328+
return;
324329
}
330+
noGoalTurnStarted.threadStatusChanged(status);
325331
});
326332

327333
try {
328334
const goalSetResponse = await this.threadGoalSet(params);
329335
expectedGoal = goalSetResponse.goal;
330336
if (capturedGoalUpdates.some(event => goalsMatch(event.goal, expectedGoal!))) {
331-
resolveGoalUpdated();
332-
}
333-
if (goalTurnId === null) {
334-
const threadResponse = await this.threadRead({ threadId: params.threadId });
335-
if (goalTurnId === null && threadResponse.thread.status.type !== "active") {
336-
await goalUpdated;
337-
if (goalTurnId === null) {
338-
resolveNoGoalTurnStarted();
339-
}
340-
} else {
341-
acceptNoGoalTurnStatus = true;
342-
}
343-
}
344-
let turnId = goalTurnId ?? await Promise.race([goalTurnStarted, noGoalTurnStarted]);
345-
if (turnId === null) {
346-
await goalUpdated;
347-
turnId = goalTurnId;
348-
if (turnId === null) {
349-
return null;
350-
}
337+
goalUpdateHandled = true;
338+
noGoalTurnStarted.goalUpdated();
351339
}
352-
const earlyCompletion = capturedCompletions.find(event => event.turn.id === turnId);
353-
releaseCompletionCapture();
340+
const turnId = goalTurnId ?? await Promise.race([goalTurnStarted, noGoalTurnStarted.promise]);
341+
noGoalTurnStarted.release();
354342
releaseRoutingCapture();
355343
releaseStatusCapture();
356344
releaseGoalUpdateCapture();
345+
if (turnId === null) {
346+
return null;
347+
}
348+
const earlyCompletion = capturedCompletions.find(event => event.turn.id === turnId);
357349
if (earlyCompletion) {
358350
return earlyCompletion;
359351
}
360-
return await this.awaitTurnCompleted(params.threadId, turnId);
352+
const completionGrace = this.createGoalTurnCompletionGracePromise(turnCompletionGraceMs);
353+
try {
354+
return await Promise.race([goalTurnCompleted, completionGrace.promise]);
355+
} finally {
356+
completionGrace.release();
357+
}
361358
} finally {
359+
noGoalTurnStarted.release();
362360
releaseCompletionCapture();
363361
releaseRoutingCapture();
364362
releaseStatusCapture();
365363
releaseGoalUpdateCapture();
366364
}
367365
}
368366

367+
private createNoGoalTurnStartedPromise(
368+
runtimeEffectsGraceMs: number,
369+
): {
370+
promise: Promise<null>,
371+
release: () => void,
372+
goalUpdated: () => void,
373+
threadStatusChanged: (status: ThreadStatus) => void,
374+
} {
375+
let released = false;
376+
let resolved = false;
377+
let goalUpdated = false;
378+
let activeAfterGoalUpdate = false;
379+
let timeout: ReturnType<typeof setTimeout> | null = null;
380+
let resolveNoGoalTurnStarted: () => void = () => {};
381+
const clearTimer = () => {
382+
if (timeout !== null) {
383+
clearTimeout(timeout);
384+
timeout = null;
385+
}
386+
};
387+
const resolveNoTurn = () => {
388+
if (released || resolved) {
389+
return;
390+
}
391+
resolved = true;
392+
clearTimer();
393+
resolveNoGoalTurnStarted();
394+
};
395+
const scheduleNoTurnTimer = () => {
396+
if (released || resolved || !goalUpdated || activeAfterGoalUpdate || timeout !== null) {
397+
return;
398+
}
399+
timeout = setTimeout(resolveNoTurn, runtimeEffectsGraceMs);
400+
};
401+
const release = () => {
402+
if (released) {
403+
return;
404+
}
405+
released = true;
406+
clearTimer();
407+
};
408+
const promise = new Promise<null>((resolve) => {
409+
resolveNoGoalTurnStarted = () => {
410+
resolve(null);
411+
};
412+
});
413+
const handleGoalUpdated = () => {
414+
goalUpdated = true;
415+
scheduleNoTurnTimer();
416+
};
417+
const handleThreadStatusChanged = (status: ThreadStatus) => {
418+
if (!goalUpdated || released || resolved) {
419+
return;
420+
}
421+
if (status.type === "active") {
422+
activeAfterGoalUpdate = true;
423+
clearTimer();
424+
return;
425+
}
426+
if (activeAfterGoalUpdate) {
427+
resolveNoTurn();
428+
}
429+
};
430+
return {
431+
promise,
432+
release,
433+
goalUpdated: handleGoalUpdated,
434+
threadStatusChanged: handleThreadStatusChanged,
435+
};
436+
}
437+
438+
private createGoalTurnCompletionGracePromise(
439+
turnCompletionGraceMs: number,
440+
): { promise: Promise<null>, release: () => void } {
441+
let released = false;
442+
let timeout: ReturnType<typeof setTimeout> | null = null;
443+
const release = () => {
444+
if (released) {
445+
return;
446+
}
447+
released = true;
448+
if (timeout !== null) {
449+
clearTimeout(timeout);
450+
}
451+
};
452+
const promise = new Promise<null>((resolve) => {
453+
const resolveNoGoalTurnCompleted = () => {
454+
timeout = null;
455+
resolve(null);
456+
};
457+
timeout = setTimeout(resolveNoGoalTurnCompleted, turnCompletionGraceMs);
458+
});
459+
return {promise, release};
460+
}
461+
369462
async runCompact(params: ThreadCompactStartParams): Promise<CompactionCompletedNotification> {
370463
const compactionCompleted = this.awaitCompactionCompleted(params.threadId);
371464
await this.threadCompactStart(params);

0 commit comments

Comments
 (0)