Skip to content

Commit 6807a4b

Browse files
committed
Handle goal commands without continuation turns
1 parent 9b0a563 commit 6807a4b

4 files changed

Lines changed: 89 additions & 27 deletions

File tree

src/CodexAcpClient.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ export class CodexAcpClient {
327327
sessionId: string,
328328
objective: string,
329329
onTurnStarted?: (turnId: string) => void,
330-
): Promise<TurnCompletedNotification> {
330+
): Promise<TurnCompletedNotification | null> {
331331
return await this.codexClient.runGoalSet({
332332
threadId: sessionId,
333333
objective,
@@ -345,7 +345,7 @@ export class CodexAcpClient {
345345
async resumeGoal(
346346
sessionId: string,
347347
onTurnStarted?: (turnId: string) => void,
348-
): Promise<TurnCompletedNotification> {
348+
): Promise<TurnCompletedNotification | null> {
349349
return await this.codexClient.runGoalSet({
350350
threadId: sessionId,
351351
status: "active",

src/CodexAppServerClient.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ const McpServerElicitationRequest = new RequestType<
105105
void
106106
>('mcpServer/elicitation/request');
107107

108+
const GOAL_TURN_START_GRACE_MS = 1_000;
109+
108110
/**
109111
* A type-safe client over the Codex App Server's JSON-RPC API.
110112
* Maps each request to its expected response and exposes clear, typed methods for supported JSON-RPC operations.
@@ -268,14 +270,18 @@ export class CodexAppServerClient {
268270
}
269271
}
270272

271-
async runGoalSet(params: ThreadGoalSetParams, onTurnStarted?: (turnId: string) => void): Promise<TurnCompletedNotification> {
273+
async runGoalSet(
274+
params: ThreadGoalSetParams,
275+
onTurnStarted?: (turnId: string) => void,
276+
turnStartGraceMs = GOAL_TURN_START_GRACE_MS,
277+
): Promise<TurnCompletedNotification | null> {
272278
const capturedCompletions: Array<TurnCompletedNotification> = [];
273279
const releaseCompletionCapture = this.captureTurnCompletions(params.threadId, (event) => {
274280
capturedCompletions.push(event);
275281
});
276282
let goalTurnId: string | null = null;
277-
let resolveGoalTurnStarted: () => void = () => {};
278-
const goalTurnStarted = new Promise<void>((resolve) => {
283+
let resolveGoalTurnStarted: (turnId: string) => void = () => {};
284+
const goalTurnStarted = new Promise<string>((resolve) => {
279285
resolveGoalTurnStarted = resolve;
280286
});
281287
const releaseRoutingCapture = this.captureTurnRoutings(params.threadId, (turnId) => {
@@ -284,17 +290,14 @@ export class CodexAppServerClient {
284290
}
285291
goalTurnId = turnId;
286292
onTurnStarted?.(turnId);
287-
resolveGoalTurnStarted();
293+
resolveGoalTurnStarted(turnId);
288294
});
289295

290296
try {
291297
await this.threadGoalSet(params);
292-
if (goalTurnId === null) {
293-
await goalTurnStarted;
294-
}
295-
const turnId = goalTurnId;
298+
const turnId = goalTurnId ?? await this.waitForGoalTurnStarted(goalTurnStarted, turnStartGraceMs);
296299
if (turnId === null) {
297-
throw new Error("Goal command did not start a turn");
300+
return null;
298301
}
299302
const earlyCompletion = capturedCompletions.find(event => event.turn.id === turnId);
300303
releaseCompletionCapture();
@@ -309,6 +312,20 @@ export class CodexAppServerClient {
309312
}
310313
}
311314

315+
private async waitForGoalTurnStarted(goalTurnStarted: Promise<string>, timeoutMs: number): Promise<string | null> {
316+
let timeout: ReturnType<typeof setTimeout> | null = null;
317+
const timeoutPromise = new Promise<null>((resolve) => {
318+
timeout = setTimeout(() => resolve(null), timeoutMs);
319+
});
320+
try {
321+
return await Promise.race([goalTurnStarted, timeoutPromise]);
322+
} finally {
323+
if (timeout !== null) {
324+
clearTimeout(timeout);
325+
}
326+
}
327+
}
328+
312329
async runCompact(params: ThreadCompactStartParams): Promise<CompactionCompletedNotification> {
313330
const compactionCompleted = this.awaitCompactionCompleted(params.threadId);
314331
await this.threadCompactStart(params);

src/CodexCommands.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -272,15 +272,12 @@ export class CodexCommands {
272272
await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionId, "paused"));
273273
return { handled: true };
274274
case "resume":
275-
return {
276-
handled: true,
277-
turnCompleted: await this.runWithProcessCheck(() => this.codexAcpClient.resumeGoal(
278-
sessionId,
279-
(turnId) => {
280-
sessionState.currentTurnId = turnId;
281-
},
282-
)),
283-
};
275+
return this.createGoalCommandResult(await this.runWithProcessCheck(() => this.codexAcpClient.resumeGoal(
276+
sessionId,
277+
(turnId) => {
278+
sessionState.currentTurnId = turnId;
279+
},
280+
)));
284281
case "clear":
285282
await this.runWithProcessCheck(() => this.codexAcpClient.clearGoal(sessionId));
286283
return { handled: true };
@@ -298,15 +295,22 @@ export class CodexCommands {
298295
return { handled: true };
299296
}
300297

298+
return this.createGoalCommandResult(await this.runWithProcessCheck(() => this.codexAcpClient.setGoal(
299+
sessionId,
300+
argument,
301+
(turnId) => {
302+
sessionState.currentTurnId = turnId;
303+
},
304+
)));
305+
}
306+
307+
private createGoalCommandResult(turnCompleted: TurnCompletedNotification | null): CommandHandleResult {
308+
if (turnCompleted === null) {
309+
return { handled: true };
310+
}
301311
return {
302312
handled: true,
303-
turnCompleted: await this.runWithProcessCheck(() => this.codexAcpClient.setGoal(
304-
sessionId,
305-
argument,
306-
(turnId) => {
307-
sessionState.currentTurnId = turnId;
308-
},
309-
)),
313+
turnCompleted,
310314
};
311315
}
312316

src/__tests__/CodexACPAgent/CodexAcpClient.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,6 +1299,47 @@ describe('ACP server test', { timeout: 40_000 }, () => {
12991299
expect(promptResolved).toBe(true);
13001300
});
13011301

1302+
it('does not hang when goal set starts no continuation turn', async () => {
1303+
const mockFixture = createCodexMockTestFixture();
1304+
const codexAppServerClient = mockFixture.getCodexAppServerClient();
1305+
const threadGoalSetSpy = vi.spyOn(codexAppServerClient, "threadGoalSet")
1306+
.mockResolvedValue({ goal: createThreadGoal() });
1307+
const awaitTurnCompletedSpy = vi.spyOn(codexAppServerClient, "awaitTurnCompleted");
1308+
1309+
const result = await codexAppServerClient.runGoalSet({
1310+
threadId: "session-id",
1311+
objective: "Ship the migration and keep tests green",
1312+
status: "active",
1313+
}, undefined, 0);
1314+
1315+
expect(result).toBeNull();
1316+
expect(threadGoalSetSpy).toHaveBeenCalledWith({
1317+
threadId: "session-id",
1318+
objective: "Ship the migration and keep tests green",
1319+
status: "active",
1320+
});
1321+
expect(awaitTurnCompletedSpy).not.toHaveBeenCalled();
1322+
});
1323+
1324+
it('completes goal slash command when app server starts no continuation turn', async () => {
1325+
const { mockFixture, turnStartSpy } = setupPromptFixture();
1326+
const goalRunSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "runGoalSet")
1327+
.mockResolvedValue(null);
1328+
1329+
const response = await mockFixture.getCodexAcpAgent().prompt({
1330+
sessionId: "session-id",
1331+
prompt: [{ type: "text", text: "/goal Ship the migration and keep tests green" }],
1332+
});
1333+
1334+
expect(response.stopReason).toBe("end_turn");
1335+
expect(goalRunSpy).toHaveBeenCalledWith({
1336+
threadId: "session-id",
1337+
objective: "Ship the migration and keep tests green",
1338+
status: "active",
1339+
}, expect.any(Function));
1340+
expect(turnStartSpy).not.toHaveBeenCalled();
1341+
});
1342+
13021343
it('reports missing goal slash command input', async () => {
13031344
const { mockFixture } = setupPromptFixture();
13041345
const goalSetSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadGoalSet")

0 commit comments

Comments
 (0)