Skip to content

Commit a7e4808

Browse files
committed
Wait for goal notifications in ACP client
1 parent 37bcbdc commit a7e4808

3 files changed

Lines changed: 359 additions & 45 deletions

File tree

src/CodexAcpClient.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ export class CodexAcpClient {
343343
}
344344

345345
async setGoalStatus(sessionId: string, status: ThreadGoalStatus): Promise<void> {
346-
await this.codexClient.threadGoalSet({
346+
await this.codexClient.runGoalSet({
347347
threadId: sessionId,
348348
status,
349349
});
@@ -360,7 +360,7 @@ export class CodexAcpClient {
360360
}
361361

362362
async clearGoal(sessionId: string): Promise<void> {
363-
await this.codexClient.threadGoalClear({threadId: sessionId});
363+
await this.codexClient.runGoalClear({threadId: sessionId});
364364
}
365365

366366
async awaitMcpServerStartup(serverNames: Array<string>, afterVersion: number): Promise<McpStartupResult> {

src/CodexAppServerClient.ts

Lines changed: 189 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import type {
3434
ThreadArchiveResponse,
3535
ThreadCompactStartParams,
3636
ThreadCompactStartResponse,
37+
ThreadGoalClearedNotification,
3738
ThreadGoalClearParams,
3839
ThreadGoalClearResponse,
3940
ThreadGoalSetParams,
@@ -85,6 +86,11 @@ export type McpStartupResult = {
8586
cancelled: Array<string>;
8687
};
8788

89+
type ServerNotificationItemType = Extract<
90+
ServerNotification,
91+
{ params: { item: { type: unknown } } }
92+
>["params"]["item"]["type"];
93+
8894
const CommandExecutionApprovalRequest = new RequestType<
8995
CommandExecutionRequestApprovalParams,
9096
CommandExecutionRequestApprovalResponse,
@@ -110,7 +116,45 @@ const McpServerElicitationRequest = new RequestType<
110116
>('mcpServer/elicitation/request');
111117

112118
const GOAL_RUNTIME_EFFECTS_GRACE_MS = 1_000;
113-
const GOAL_TURN_COMPLETION_GRACE_MS = 250;
119+
const VISIBLE_TURN_ACTIVITY_METHODS: ReadonlySet<ServerNotification["method"]> = new Set([
120+
"error",
121+
"turn/plan/updated",
122+
"item/autoApprovalReview/started",
123+
"item/autoApprovalReview/completed",
124+
"item/agentMessage/delta",
125+
"item/commandExecution/outputDelta",
126+
"item/commandExecution/terminalInteraction",
127+
"item/mcpToolCall/progress",
128+
"item/reasoning/summaryTextDelta",
129+
"item/reasoning/summaryPartAdded",
130+
"item/reasoning/textDelta",
131+
"model/rerouted",
132+
"warning",
133+
"configWarning",
134+
"fuzzyFileSearch/sessionUpdated",
135+
"fuzzyFileSearch/sessionCompleted",
136+
]);
137+
const VISIBLE_STARTED_ITEM_TYPES: ReadonlySet<ServerNotificationItemType> = new Set([
138+
"fileChange",
139+
"commandExecution",
140+
"mcpToolCall",
141+
"dynamicToolCall",
142+
"webSearch",
143+
"imageView",
144+
"imageGeneration",
145+
"collabAgentToolCall",
146+
]);
147+
const VISIBLE_COMPLETED_ITEM_TYPES: ReadonlySet<ServerNotificationItemType> = new Set([
148+
"fileChange",
149+
"commandExecution",
150+
"mcpToolCall",
151+
"dynamicToolCall",
152+
"webSearch",
153+
"imageView",
154+
"imageGeneration",
155+
"collabAgentToolCall",
156+
"contextCompaction",
157+
]);
114158

115159
/**
116160
* A type-safe client over the Codex App Server's JSON-RPC API.
@@ -127,8 +171,10 @@ export class CodexAppServerClient {
127171
private readonly pendingCompactionCompletionResolvers = new Map<string, Set<(event: CompactionCompletedNotification) => void>>();
128172
private readonly turnCompletionCaptures = new Map<string, Set<(event: TurnCompletedNotification) => void>>();
129173
private readonly turnRoutingCaptures = new Map<string, Set<(turnId: string) => void>>();
174+
private readonly turnActivityCaptures = new Map<string, Set<(turnId: string) => void>>();
130175
private readonly threadStatusCaptures = new Map<string, Set<(status: ThreadStatus) => void>>();
131176
private readonly threadGoalUpdateCaptures = new Map<string, Set<(event: ThreadGoalUpdatedNotification) => void>>();
177+
private readonly threadGoalClearedCaptures = new Map<string, Set<() => void>>();
132178
private readonly staleTurnIds = new Map<string, Set<string>>();
133179

134180
constructor(connection: MessageConnection) {
@@ -156,6 +202,9 @@ export class CodexAppServerClient {
156202
if (isThreadGoalUpdatedNotification(serverNotification)) {
157203
this.recordThreadGoalUpdated(serverNotification.params);
158204
}
205+
if (isThreadGoalClearedNotification(serverNotification)) {
206+
this.recordThreadGoalCleared(serverNotification.params);
207+
}
159208
const routing = extractTurnRouting(serverNotification);
160209
if (this.handleStaleTurnNotification(serverNotification, routing)) {
161210
return;
@@ -164,6 +213,7 @@ export class CodexAppServerClient {
164213
if (this.handleStaleTurnNotification(serverNotification, routing)) {
165214
return;
166215
}
216+
this.recordVisibleTurnActivity(serverNotification, routing);
167217
this.notify(serverNotification);
168218
for (const callback of this.codexEventHandlers) {
169219
callback({ eventType: "notification", ...serverNotification });
@@ -286,7 +336,6 @@ export class CodexAppServerClient {
286336
params: ThreadGoalSetParams,
287337
onTurnStarted?: (turnId: string) => void,
288338
runtimeEffectsGraceMs = GOAL_RUNTIME_EFFECTS_GRACE_MS,
289-
turnCompletionGraceMs = GOAL_TURN_COMPLETION_GRACE_MS,
290339
): Promise<TurnCompletedNotification | null> {
291340
let goalTurnId: string | null = null;
292341
const capturedCompletions: Array<TurnCompletedNotification> = [];
@@ -304,6 +353,14 @@ export class CodexAppServerClient {
304353
const goalTurnStarted = new Promise<string>((resolve) => {
305354
resolveGoalTurnStarted = resolve;
306355
});
356+
let resolveGoalUpdateHandled: () => void = () => {};
357+
const matchingGoalUpdateHandled = new Promise<null>((resolve) => {
358+
resolveGoalUpdateHandled = () => resolve(null);
359+
});
360+
let resolveGoalTurnActivity: () => void = () => {};
361+
const goalTurnActivity = new Promise<null>((resolve) => {
362+
resolveGoalTurnActivity = () => resolve(null);
363+
});
307364
let goalUpdateHandled = false;
308365
let expectedGoal: ThreadGoal | null = null;
309366
const noGoalTurnStarted = this.createNoGoalTurnStartedPromise(runtimeEffectsGraceMs);
@@ -316,10 +373,17 @@ export class CodexAppServerClient {
316373
onTurnStarted?.(turnId);
317374
resolveGoalTurnStarted(turnId);
318375
});
376+
const releaseActivityCapture = this.captureTurnActivities(params.threadId, (turnId) => {
377+
if (!goalUpdateHandled || goalTurnId !== turnId) {
378+
return;
379+
}
380+
resolveGoalTurnActivity();
381+
});
319382
const releaseGoalUpdateCapture = this.captureThreadGoalUpdates(params.threadId, (event) => {
320383
capturedGoalUpdates.push(event);
321384
if (expectedGoal !== null && goalsMatch(event.goal, expectedGoal)) {
322385
goalUpdateHandled = true;
386+
resolveGoalUpdateHandled();
323387
noGoalTurnStarted.goalUpdated();
324388
}
325389
});
@@ -335,35 +399,60 @@ export class CodexAppServerClient {
335399
expectedGoal = goalSetResponse.goal;
336400
if (capturedGoalUpdates.some(event => goalsMatch(event.goal, expectedGoal!))) {
337401
goalUpdateHandled = true;
402+
resolveGoalUpdateHandled();
338403
noGoalTurnStarted.goalUpdated();
339404
}
405+
if (expectedGoal.status !== "active") {
406+
await matchingGoalUpdateHandled;
407+
return null;
408+
}
340409
const turnId = goalTurnId ?? await Promise.race([goalTurnStarted, noGoalTurnStarted.promise]);
341410
noGoalTurnStarted.release();
342411
releaseRoutingCapture();
343412
releaseStatusCapture();
344413
releaseGoalUpdateCapture();
345414
if (turnId === null) {
415+
releaseActivityCapture();
346416
return null;
347417
}
348418
const earlyCompletion = capturedCompletions.find(event => event.turn.id === turnId);
349419
if (earlyCompletion) {
420+
releaseActivityCapture();
350421
return earlyCompletion;
351422
}
352-
const completionGrace = this.createGoalTurnCompletionGracePromise(turnCompletionGraceMs);
353-
try {
354-
return await Promise.race([goalTurnCompleted, completionGrace.promise]);
355-
} finally {
356-
completionGrace.release();
357-
}
423+
return await Promise.race([goalTurnCompleted, goalTurnActivity]);
358424
} finally {
359425
noGoalTurnStarted.release();
360426
releaseCompletionCapture();
361427
releaseRoutingCapture();
428+
releaseActivityCapture();
362429
releaseStatusCapture();
363430
releaseGoalUpdateCapture();
364431
}
365432
}
366433

434+
async runGoalClear(params: ThreadGoalClearParams): Promise<void> {
435+
let goalClearedHandled = false;
436+
let resolveGoalClearedHandled: () => void = () => {};
437+
const matchingGoalClearedHandled = new Promise<void>((resolve) => {
438+
resolveGoalClearedHandled = () => resolve();
439+
});
440+
const releaseGoalClearedCapture = this.captureThreadGoalClears(params.threadId, () => {
441+
goalClearedHandled = true;
442+
resolveGoalClearedHandled();
443+
});
444+
445+
try {
446+
const response = await this.threadGoalClear(params);
447+
if (!response.cleared || goalClearedHandled) {
448+
return;
449+
}
450+
await matchingGoalClearedHandled;
451+
} finally {
452+
releaseGoalClearedCapture();
453+
}
454+
}
455+
367456
private createNoGoalTurnStartedPromise(
368457
runtimeEffectsGraceMs: number,
369458
): {
@@ -435,30 +524,6 @@ export class CodexAppServerClient {
435524
};
436525
}
437526

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-
462527
async runCompact(params: ThreadCompactStartParams): Promise<CompactionCompletedNotification> {
463528
const compactionCompleted = this.awaitCompactionCompleted(params.threadId);
464529
await this.threadCompactStart(params);
@@ -691,6 +756,16 @@ export class CodexAppServerClient {
691756
}
692757
}
693758

759+
private recordThreadGoalCleared(event: ThreadGoalClearedNotification): void {
760+
const captures = this.threadGoalClearedCaptures.get(event.threadId);
761+
if (!captures) {
762+
return;
763+
}
764+
for (const capture of captures) {
765+
capture();
766+
}
767+
}
768+
694769
private recordTurnRouting(routing: { threadId: string | null, turnId: string | null }): void {
695770
if (routing.threadId === null || routing.turnId === null) {
696771
return;
@@ -704,6 +779,22 @@ export class CodexAppServerClient {
704779
}
705780
}
706781

782+
private recordVisibleTurnActivity(
783+
notification: ServerNotification,
784+
routing: { threadId: string | null, turnId: string | null },
785+
): void {
786+
if (routing.threadId === null || routing.turnId === null || !isVisibleTurnActivityNotification(notification)) {
787+
return;
788+
}
789+
const captures = this.turnActivityCaptures.get(routing.threadId);
790+
if (!captures) {
791+
return;
792+
}
793+
for (const capture of captures) {
794+
capture(routing.turnId);
795+
}
796+
}
797+
707798
private handleStaleTurnNotification(
708799
notification: ServerNotification,
709800
routing: { threadId: string | null, turnId: string | null },
@@ -782,6 +873,23 @@ export class CodexAppServerClient {
782873
};
783874
}
784875

876+
private captureTurnActivities(threadId: string, capture: (turnId: string) => void): () => void {
877+
const captures = this.turnActivityCaptures.get(threadId) ?? new Set<(turnId: string) => void>();
878+
captures.add(capture);
879+
this.turnActivityCaptures.set(threadId, captures);
880+
let released = false;
881+
return () => {
882+
if (released) {
883+
return;
884+
}
885+
released = true;
886+
captures.delete(capture);
887+
if (captures.size === 0) {
888+
this.turnActivityCaptures.delete(threadId);
889+
}
890+
};
891+
}
892+
785893
private captureThreadStatuses(threadId: string, capture: (status: ThreadStatus) => void): () => void {
786894
const captures = this.threadStatusCaptures.get(threadId) ?? new Set<(status: ThreadStatus) => void>();
787895
captures.add(capture);
@@ -816,6 +924,23 @@ export class CodexAppServerClient {
816924
};
817925
}
818926

927+
private captureThreadGoalClears(threadId: string, capture: () => void): () => void {
928+
const captures = this.threadGoalClearedCaptures.get(threadId) ?? new Set<() => void>();
929+
captures.add(capture);
930+
this.threadGoalClearedCaptures.set(threadId, captures);
931+
let released = false;
932+
return () => {
933+
if (released) {
934+
return;
935+
}
936+
released = true;
937+
captures.delete(capture);
938+
if (captures.size === 0) {
939+
this.threadGoalClearedCaptures.delete(threadId);
940+
}
941+
};
942+
}
943+
819944
private resolveMcpServerStartupResolvers(): void {
820945
const pendingResolvers: Array<McpServerStartupResolver> = [];
821946
for (const resolver of this.mcpServerStartupResolvers) {
@@ -934,6 +1059,13 @@ function isThreadGoalUpdatedNotification(notification: ServerNotification): noti
9341059
return notification.method === "thread/goal/updated";
9351060
}
9361061

1062+
function isThreadGoalClearedNotification(notification: ServerNotification): notification is {
1063+
method: "thread/goal/cleared";
1064+
params: ThreadGoalClearedNotification;
1065+
} {
1066+
return notification.method === "thread/goal/cleared";
1067+
}
1068+
9371069
function isCompactionCompletedNotification(notification: ServerNotification): notification is CompactionCompletedNotification {
9381070
if (notification.method === "thread/compacted") {
9391071
return true;
@@ -949,6 +1081,31 @@ function goalsMatch(left: ThreadGoal, right: ThreadGoal): boolean {
9491081
&& left.updatedAt === right.updatedAt;
9501082
}
9511083

1084+
function isVisibleTurnActivityNotification(notification: ServerNotification): boolean {
1085+
if (notification.method === "item/started") {
1086+
return isVisibleStartedItemType(notification.params.item.type);
1087+
}
1088+
if (notification.method === "item/completed") {
1089+
const item = notification.params.item;
1090+
if (item.type === "reasoning") {
1091+
return item.summary.length > 0 || item.content.length > 0;
1092+
}
1093+
if (item.type === "exitedReviewMode") {
1094+
return item.review.trim().length > 0;
1095+
}
1096+
return isVisibleCompletedItemType(item.type);
1097+
}
1098+
return VISIBLE_TURN_ACTIVITY_METHODS.has(notification.method);
1099+
}
1100+
1101+
function isVisibleStartedItemType(itemType: ServerNotificationItemType): boolean {
1102+
return VISIBLE_STARTED_ITEM_TYPES.has(itemType);
1103+
}
1104+
1105+
function isVisibleCompletedItemType(itemType: ServerNotificationItemType): boolean {
1106+
return VISIBLE_COMPLETED_ITEM_TYPES.has(itemType);
1107+
}
1108+
9521109
function extractThreadId(notification: ServerNotification): string | null {
9531110
const params = notification.params as { threadId?: unknown } | undefined;
9541111
if (params && typeof params.threadId === "string") {

0 commit comments

Comments
 (0)