Skip to content

Commit e9bc724

Browse files
committed
Wait on turns
1 parent 947d2eb commit e9bc724

4 files changed

Lines changed: 98 additions & 10 deletions

File tree

src/CodexAcpClient.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,10 @@ export class CodexAcpClient {
325325
await this.codexClient.threadGoalClear({threadId: sessionId});
326326
}
327327

328+
async awaitTurnCompleted(params: { threadId: string, turnId: string }): Promise<TurnCompletedNotification> {
329+
return await this.codexClient.awaitTurnCompleted(params.threadId, params.turnId);
330+
}
331+
328332
async awaitMcpServerStartup(serverNames: Array<string>, afterVersion: number): Promise<McpStartupResult> {
329333
return await this.codexClient.awaitMcpServerStartup(serverNames, afterVersion);
330334
}

src/CodexAcpServer.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
ReasoningEffortOption,
1616
Thread,
1717
ThreadItem,
18+
TurnCompletedNotification,
1819
UserInput
1920
} from "./app-server/v2";
2021
import type {RateLimitsMap} from "./RateLimitsMap";
@@ -1279,6 +1280,30 @@ export class CodexAcpServer {
12791280
return turnId;
12801281
}
12811282

1283+
private async awaitCurrentCommandTurnCompletion(
1284+
sessionState: SessionState,
1285+
activePrompt: ActivePrompt,
1286+
): Promise<TurnCompletedNotification | null | undefined> {
1287+
const turnId = sessionState.currentTurnId;
1288+
if (!turnId) {
1289+
return undefined;
1290+
}
1291+
1292+
const turnCompleted = await Promise.race([
1293+
this.runWithProcessCheck(() => this.codexAcpClient.awaitTurnCompleted({
1294+
threadId: sessionState.sessionId,
1295+
turnId,
1296+
})),
1297+
activePrompt.closeSignal,
1298+
]);
1299+
if (turnCompleted === null) {
1300+
return null;
1301+
}
1302+
1303+
await this.codexAcpClient.waitForSessionNotifications(sessionState.sessionId);
1304+
return turnCompleted;
1305+
}
1306+
12821307
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
12831308
logger.log("Prompt received", {
12841309
sessionId: params.sessionId,
@@ -1306,7 +1331,18 @@ export class CodexAcpServer {
13061331
if (commandResult.handled) {
13071332
logger.log("Prompt handled by a command");
13081333
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
1309-
if (commandResult.turnCompleted?.turn.status === "interrupted") {
1334+
let turnCompleted: TurnCompletedNotification | null | undefined = commandResult.turnCompleted;
1335+
if (!turnCompleted && commandResult.waitForCurrentTurnCompletion) {
1336+
turnCompleted = await this.awaitCurrentCommandTurnCompletion(sessionState, activePrompt);
1337+
if (turnCompleted === null) {
1338+
return {
1339+
stopReason: "cancelled",
1340+
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
1341+
_meta: this.buildQuotaMeta(sessionState),
1342+
};
1343+
}
1344+
}
1345+
if (turnCompleted?.turn.status === "interrupted") {
13101346
if (!this.sessionIsClosing(params.sessionId) && this.sessions.has(params.sessionId)) {
13111347
await this.connection.notify(acp.methods.client.session.update, {
13121348
sessionId: params.sessionId,

src/CodexCommands.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type ParsedSlashCommand = {
1515

1616
export type CommandHandleResult =
1717
| { handled: false }
18-
| { handled: true, turnCompleted?: TurnCompletedNotification };
18+
| { handled: true, turnCompleted?: TurnCompletedNotification, waitForCurrentTurnCompletion?: boolean };
1919

2020
export class CodexCommands {
2121
private readonly connection: AcpClientConnection;
@@ -157,8 +157,7 @@ export class CodexCommands {
157157
return { handled: true };
158158
}
159159
case "goal": {
160-
await this.runGoalCommand(sessionId, command.rest);
161-
return { handled: true };
160+
return await this.runGoalCommand(sessionId, command.rest);
162161
}
163162
case "review": {
164163
const target = this.buildReviewTarget(command.rest);
@@ -260,23 +259,23 @@ export class CodexCommands {
260259
));
261260
}
262261

263-
private async runGoalCommand(sessionId: string, rest: string): Promise<void> {
262+
private async runGoalCommand(sessionId: string, rest: string): Promise<CommandHandleResult> {
264263
const argument = rest.trim();
265264
if (argument.length === 0) {
266265
await this.sendCommandUsageMessage("goal", "[<objective>|clear|edit|pause|resume]", sessionId);
267-
return;
266+
return { handled: true };
268267
}
269268

270269
switch (argument.toLowerCase()) {
271270
case "pause":
272271
await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionId, "paused"));
273-
return;
272+
return { handled: true };
274273
case "resume":
275274
await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionId, "active"));
276-
return;
275+
return { handled: true, waitForCurrentTurnCompletion: true };
277276
case "clear":
278277
await this.runWithProcessCheck(() => this.codexAcpClient.clearGoal(sessionId));
279-
return;
278+
return { handled: true };
280279
}
281280

282281
if (argument.length > 4000) {
@@ -288,10 +287,11 @@ export class CodexCommands {
288287
text: 'Command "/goal" requires goal text of at most 4000 characters.'
289288
}
290289
});
291-
return;
290+
return { handled: true };
292291
}
293292

294293
await this.runWithProcessCheck(() => this.codexAcpClient.setGoal(sessionId, argument));
294+
return { handled: true, waitForCurrentTurnCompletion: true };
295295
}
296296

297297
private buildReviewTarget(instructions: string): ReviewTarget {

src/__tests__/CodexACPAgent/CodexAcpClient.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,6 +1141,54 @@ describe('ACP server test', { timeout: 40_000 }, () => {
11411141
expect(turnStartSpy).not.toHaveBeenCalled();
11421142
});
11431143

1144+
it('waits for goal slash command turn completion', async () => {
1145+
const { mockFixture } = setupPromptFixture();
1146+
vi.spyOn(mockFixture.getCodexAppServerClient(), "threadGoalSet")
1147+
.mockImplementation(async () => {
1148+
mockFixture.sendServerNotification({
1149+
method: "turn/started",
1150+
params: {
1151+
threadId: "session-id",
1152+
turn: createTurn("goal-turn-id", "inProgress"),
1153+
},
1154+
});
1155+
return { goal: createThreadGoal() };
1156+
});
1157+
let completeGoal: (value: TurnCompletedNotification) => void = () => {};
1158+
const goalCompletedPromise = new Promise<TurnCompletedNotification>((resolve) => {
1159+
completeGoal = resolve;
1160+
});
1161+
vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted")
1162+
.mockReturnValue(goalCompletedPromise);
1163+
1164+
let promptResolved = false;
1165+
const promptPromise = mockFixture.getCodexAcpAgent().prompt({
1166+
sessionId: "session-id",
1167+
prompt: [{ type: "text", text: "/goal Ship the migration and keep tests green" }],
1168+
}).then((response) => {
1169+
promptResolved = true;
1170+
return response;
1171+
});
1172+
1173+
await vi.waitFor(() => {
1174+
expect(mockFixture.getCodexAppServerClient().awaitTurnCompleted).toHaveBeenCalledWith(
1175+
"session-id",
1176+
"goal-turn-id",
1177+
);
1178+
});
1179+
await Promise.resolve();
1180+
expect(promptResolved).toBe(false);
1181+
1182+
completeGoal({
1183+
threadId: "session-id",
1184+
turn: createTurn("goal-turn-id", "completed"),
1185+
});
1186+
await expect(promptPromise).resolves.toEqual(expect.objectContaining({
1187+
stopReason: "end_turn",
1188+
}));
1189+
expect(promptResolved).toBe(true);
1190+
});
1191+
11441192
it('reports missing goal slash command input', async () => {
11451193
const { mockFixture } = setupPromptFixture();
11461194
const goalSetSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadGoalSet")

0 commit comments

Comments
 (0)