Skip to content

Commit 476f879

Browse files
committed
Wait for thread idle when no goal turn starts
1 parent f6aa545 commit 476f879

2 files changed

Lines changed: 121 additions & 21 deletions

File tree

src/CodexAppServerClient.ts

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import type {
2626
SkillsExtraRootsSetParams,
2727
SkillsListParams,
2828
SkillsListResponse,
29+
ThreadStatus,
30+
ThreadStatusChangedNotification,
2931
ThreadArchiveParams,
3032
ThreadArchiveResponse,
3133
ThreadCompactStartParams,
@@ -105,8 +107,6 @@ const McpServerElicitationRequest = new RequestType<
105107
void
106108
>('mcpServer/elicitation/request');
107109

108-
const GOAL_TURN_START_GRACE_MS = 1_000;
109-
110110
/**
111111
* A type-safe client over the Codex App Server's JSON-RPC API.
112112
* Maps each request to its expected response and exposes clear, typed methods for supported JSON-RPC operations.
@@ -122,6 +122,7 @@ export class CodexAppServerClient {
122122
private readonly pendingCompactionCompletionResolvers = new Map<string, Set<(event: CompactionCompletedNotification) => void>>();
123123
private readonly turnCompletionCaptures = new Map<string, Set<(event: TurnCompletedNotification) => void>>();
124124
private readonly turnRoutingCaptures = new Map<string, Set<(turnId: string) => void>>();
125+
private readonly threadStatusCaptures = new Map<string, Set<(status: ThreadStatus) => void>>();
125126
private readonly staleTurnIds = new Map<string, Set<string>>();
126127

127128
constructor(connection: MessageConnection) {
@@ -143,6 +144,9 @@ export class CodexAppServerClient {
143144
if (isCompactionCompletedNotification(serverNotification)) {
144145
this.recordCompactionCompleted(serverNotification);
145146
}
147+
if (isThreadStatusChangedNotification(serverNotification)) {
148+
this.recordThreadStatusChanged(serverNotification.params);
149+
}
146150
const routing = extractTurnRouting(serverNotification);
147151
if (this.handleStaleTurnNotification(serverNotification, routing)) {
148152
return;
@@ -272,7 +276,6 @@ export class CodexAppServerClient {
272276
async runGoalSet(
273277
params: ThreadGoalSetParams,
274278
onTurnStarted?: (turnId: string) => void,
275-
turnStartGraceMs = GOAL_TURN_START_GRACE_MS,
276279
): Promise<TurnCompletedNotification | null> {
277280
const capturedCompletions: Array<TurnCompletedNotification> = [];
278281
const releaseCompletionCapture = this.captureTurnCompletions(params.threadId, (event) => {
@@ -283,6 +286,10 @@ export class CodexAppServerClient {
283286
const goalTurnStarted = new Promise<string>((resolve) => {
284287
resolveGoalTurnStarted = resolve;
285288
});
289+
let resolveNoGoalTurnStarted: () => void = () => {};
290+
const noGoalTurnStarted = new Promise<null>((resolve) => {
291+
resolveNoGoalTurnStarted = () => resolve(null);
292+
});
286293
const releaseRoutingCapture = this.captureTurnRoutings(params.threadId, (turnId) => {
287294
if (goalTurnId !== null) {
288295
return;
@@ -291,37 +298,31 @@ export class CodexAppServerClient {
291298
onTurnStarted?.(turnId);
292299
resolveGoalTurnStarted(turnId);
293300
});
301+
const releaseStatusCapture = this.captureThreadStatuses(params.threadId, (status) => {
302+
if (goalTurnId !== null || status.type === "active") {
303+
return;
304+
}
305+
resolveNoGoalTurnStarted();
306+
});
294307

295308
try {
296309
await this.threadGoalSet(params);
297-
const turnId = goalTurnId ?? await this.waitForGoalTurnStarted(goalTurnStarted, turnStartGraceMs);
310+
const turnId = goalTurnId ?? await Promise.race([goalTurnStarted, noGoalTurnStarted]);
298311
if (turnId === null) {
299312
return null;
300313
}
301314
const earlyCompletion = capturedCompletions.find(event => event.turn.id === turnId);
302315
releaseCompletionCapture();
303316
releaseRoutingCapture();
317+
releaseStatusCapture();
304318
if (earlyCompletion) {
305319
return earlyCompletion;
306320
}
307321
return await this.awaitTurnCompleted(params.threadId, turnId);
308322
} finally {
309323
releaseCompletionCapture();
310324
releaseRoutingCapture();
311-
}
312-
}
313-
314-
private async waitForGoalTurnStarted(goalTurnStarted: Promise<string>, timeoutMs: number): Promise<string | null> {
315-
let timeout: ReturnType<typeof setTimeout> | null = null;
316-
const timeoutPromise = new Promise<null>((resolve) => {
317-
timeout = setTimeout(() => resolve(null), timeoutMs);
318-
});
319-
try {
320-
return await Promise.race([goalTurnStarted, timeoutPromise]);
321-
} finally {
322-
if (timeout !== null) {
323-
clearTimeout(timeout);
324-
}
325+
releaseStatusCapture();
325326
}
326327
}
327328

@@ -537,6 +538,16 @@ export class CodexAppServerClient {
537538
}
538539
}
539540

541+
private recordThreadStatusChanged(event: ThreadStatusChangedNotification): void {
542+
const captures = this.threadStatusCaptures.get(event.threadId);
543+
if (!captures) {
544+
return;
545+
}
546+
for (const capture of captures) {
547+
capture(event.status);
548+
}
549+
}
550+
540551
private recordTurnRouting(routing: { threadId: string | null, turnId: string | null }): void {
541552
if (routing.threadId === null || routing.turnId === null) {
542553
return;
@@ -628,6 +639,23 @@ export class CodexAppServerClient {
628639
};
629640
}
630641

642+
private captureThreadStatuses(threadId: string, capture: (status: ThreadStatus) => void): () => void {
643+
const captures = this.threadStatusCaptures.get(threadId) ?? new Set<(status: ThreadStatus) => void>();
644+
captures.add(capture);
645+
this.threadStatusCaptures.set(threadId, captures);
646+
let released = false;
647+
return () => {
648+
if (released) {
649+
return;
650+
}
651+
released = true;
652+
captures.delete(capture);
653+
if (captures.size === 0) {
654+
this.threadStatusCaptures.delete(threadId);
655+
}
656+
};
657+
}
658+
631659
private resolveMcpServerStartupResolvers(): void {
632660
const pendingResolvers: Array<McpServerStartupResolver> = [];
633661
for (const resolver of this.mcpServerStartupResolvers) {
@@ -732,6 +760,13 @@ function isTurnCompletedNotification(notification: ServerNotification): notifica
732760
return notification.method === "turn/completed";
733761
}
734762

763+
function isThreadStatusChangedNotification(notification: ServerNotification): notification is {
764+
method: "thread/status/changed";
765+
params: ThreadStatusChangedNotification;
766+
} {
767+
return notification.method === "thread/status/changed";
768+
}
769+
735770
function isCompactionCompletedNotification(notification: ServerNotification): notification is CompactionCompletedNotification {
736771
if (notification.method === "thread/compacted") {
737772
return true;

src/__tests__/CodexACPAgent/CodexAcpClient.test.ts

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1588,14 +1588,28 @@ describe('ACP server test', { timeout: 40_000 }, () => {
15881588
const threadGoalSetSpy = vi.spyOn(codexAppServerClient, "threadGoalSet")
15891589
.mockResolvedValue({ goal: createThreadGoal() });
15901590
const awaitTurnCompletedSpy = vi.spyOn(codexAppServerClient, "awaitTurnCompleted");
1591+
let resultSettled = false;
15911592

1592-
const result = await codexAppServerClient.runGoalSet({
1593+
const resultPromise = codexAppServerClient.runGoalSet({
15931594
threadId: "session-id",
15941595
objective: "Ship the migration and keep tests green",
15951596
status: "active",
1596-
}, undefined, 0);
1597+
}).finally(() => {
1598+
resultSettled = true;
1599+
});
1600+
1601+
await Promise.resolve();
1602+
expect(resultSettled).toBe(false);
1603+
1604+
mockFixture.sendServerNotification({
1605+
method: "thread/status/changed",
1606+
params: {
1607+
threadId: "session-id",
1608+
status: { type: "idle" },
1609+
},
1610+
});
15971611

1598-
expect(result).toBeNull();
1612+
await expect(resultPromise).resolves.toBeNull();
15991613
expect(threadGoalSetSpy).toHaveBeenCalledWith({
16001614
threadId: "session-id",
16011615
objective: "Ship the migration and keep tests green",
@@ -1604,6 +1618,57 @@ describe('ACP server test', { timeout: 40_000 }, () => {
16041618
expect(awaitTurnCompletedSpy).not.toHaveBeenCalled();
16051619
});
16061620

1621+
it('keeps goal set pending after elapsed startup time until a turn is routed', async () => {
1622+
vi.useFakeTimers();
1623+
try {
1624+
const mockFixture = createCodexMockTestFixture();
1625+
const codexAppServerClient = mockFixture.getCodexAppServerClient();
1626+
vi.spyOn(codexAppServerClient, "threadGoalSet")
1627+
.mockResolvedValue({ goal: createThreadGoal() });
1628+
const goalCompleted = deferred<TurnCompletedNotification>();
1629+
const awaitTurnCompletedSpy = vi.spyOn(codexAppServerClient, "awaitTurnCompleted")
1630+
.mockReturnValue(goalCompleted.promise);
1631+
let resultSettled = false;
1632+
1633+
const resultPromise = codexAppServerClient.runGoalSet({
1634+
threadId: "session-id",
1635+
objective: "Ship the migration and keep tests green",
1636+
status: "active",
1637+
}).finally(() => {
1638+
resultSettled = true;
1639+
});
1640+
1641+
await Promise.resolve();
1642+
await vi.advanceTimersByTimeAsync(10_000);
1643+
await Promise.resolve();
1644+
expect(resultSettled).toBe(false);
1645+
1646+
mockFixture.sendServerNotification({
1647+
method: "item/agentMessage/delta",
1648+
params: {
1649+
threadId: "session-id",
1650+
turnId: "goal-turn-id",
1651+
itemId: "goal-message-id",
1652+
delta: "late goal output",
1653+
},
1654+
});
1655+
1656+
await Promise.resolve();
1657+
await Promise.resolve();
1658+
expect(awaitTurnCompletedSpy).toHaveBeenCalledWith("session-id", "goal-turn-id");
1659+
1660+
goalCompleted.resolve({
1661+
threadId: "session-id",
1662+
turn: createTurn("goal-turn-id", "completed"),
1663+
});
1664+
await expect(resultPromise).resolves.toMatchObject({
1665+
turn: createTurn("goal-turn-id", "completed"),
1666+
});
1667+
} finally {
1668+
vi.useRealTimers();
1669+
}
1670+
});
1671+
16071672
it('completes goal slash command when app server starts no continuation turn', async () => {
16081673
const { mockFixture, turnStartSpy } = setupPromptFixture();
16091674
const goalRunSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "runGoalSet")

0 commit comments

Comments
 (0)