Skip to content

Commit c6fb600

Browse files
committed
fix(agent): cancel active Codex goal turns
1 parent a6ec9ca commit c6fb600

2 files changed

Lines changed: 55 additions & 5 deletions

File tree

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,32 @@ describe("CodexAppServerAgent", () => {
499499
});
500500
});
501501

502+
it("interrupts a native goal turn that started before it was paused", async () => {
503+
const stub = makeStubRpc({
504+
"thread/start": { thread: { id: "thr_1" } },
505+
"thread/goal/set": {
506+
goal: { objective: "Ship the fix", status: "paused" },
507+
},
508+
});
509+
const { client } = makeFakeClient();
510+
const agent = new CodexAppServerAgent(client, {
511+
processOptions: { binaryPath: "/bundle/codex" },
512+
rpcFactory: stub.factory,
513+
});
514+
await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest);
515+
stub.emit("turn/started", { turn: { id: "goal_tick_1" } });
516+
517+
await agent.prompt({
518+
sessionId: "thr_1",
519+
prompt: [{ type: "text", text: "/goal pause" }],
520+
} as unknown as PromptRequest);
521+
522+
expect(stub.requests).toContainEqual({
523+
method: "turn/interrupt",
524+
params: { threadId: "thr_1", turnId: "goal_tick_1" },
525+
});
526+
});
527+
502528
it("retries queued goal cancellation after an interrupt failure", async () => {
503529
let interruptAttempts = 0;
504530
const stub = makeStubRpc({

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ export class CodexAppServerAgent extends BaseAcpAgent {
222222
private readonly usage = new UsageTracker();
223223
/** Pause/clear can race a goal continuation already queued by app-server. */
224224
private cancelNextGoalTurn = false;
225+
/** Native goal ticks start outside prompt(), so TurnController does not own them. */
226+
private nativeGoalTurnId?: string;
225227

226228
constructor(
227229
client: AgentSideConnection,
@@ -427,6 +429,8 @@ export class CodexAppServerAgent extends BaseAcpAgent {
427429
additionalDirectories?: string[];
428430
},
429431
): Promise<{ threadId: string; thread: AppServerThread | undefined }> {
432+
this.cancelNextGoalTurn = false;
433+
this.nativeGoalTurnId = undefined;
430434
this.jsonSchema = params.meta?.jsonSchema ?? undefined;
431435
this.taskRunId = params.meta?.taskRunId;
432436
this.environment = params.meta?.environment;
@@ -807,23 +811,36 @@ export class CodexAppServerAgent extends BaseAcpAgent {
807811
}
808812

809813
private async cancelRunningGoalTurn(): Promise<void> {
810-
if (!this.turns.isRunning) return;
811-
this.cancelNextGoalTurn = false;
812-
await this.interrupt();
814+
if (this.turns.isRunning) {
815+
this.cancelNextGoalTurn = false;
816+
await this.interrupt();
817+
return;
818+
}
819+
if (this.nativeGoalTurnId) {
820+
await this.interruptNativeGoalTurn(this.nativeGoalTurnId);
821+
}
813822
}
814823

815824
private interruptQueuedGoalTurn(turnId: string | undefined): void {
816825
if (!this.cancelNextGoalTurn || !this.threadId || !turnId) return;
817-
void this.rpc
826+
void this.interruptNativeGoalTurn(turnId);
827+
}
828+
829+
private async interruptNativeGoalTurn(turnId: string): Promise<void> {
830+
if (!this.threadId) return;
831+
await this.rpc
818832
.request(APP_SERVER_METHODS.TURN_INTERRUPT, {
819833
threadId: this.threadId,
820834
turnId,
821835
})
822836
.then(() => {
823837
this.cancelNextGoalTurn = false;
838+
if (this.nativeGoalTurnId === turnId) {
839+
this.nativeGoalTurnId = undefined;
840+
}
824841
})
825842
.catch((error) =>
826-
this.logger.warn("Queued goal turn interrupt failed", error),
843+
this.logger.warn("Native goal turn interrupt failed", error),
827844
);
828845
}
829846

@@ -1106,6 +1123,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
11061123

11071124
async closeSession(): Promise<void> {
11081125
this.commandOutputs.clear();
1126+
this.nativeGoalTurnId = undefined;
11091127
this.session.abortController.abort();
11101128
this.session.cancelled = true;
11111129
this.planHandoffCancel?.();
@@ -1156,6 +1174,9 @@ export class CodexAppServerAgent extends BaseAcpAgent {
11561174
if (method === APP_SERVER_NOTIFICATIONS.TURN_STARTED) {
11571175
// Capture the active turn id (steer precondition / interrupt target).
11581176
const turnId = (params as { turn?: { id?: string } })?.turn?.id;
1177+
if (!this.turns.isPending && turnId) {
1178+
this.nativeGoalTurnId = turnId;
1179+
}
11591180
this.turns.onStarted(turnId);
11601181
this.interruptQueuedGoalTurn(turnId);
11611182
}
@@ -1200,6 +1221,9 @@ export class CodexAppServerAgent extends BaseAcpAgent {
12001221
this.commandOutputs.clear();
12011222
const turn = (params as { turn?: { id?: string; status?: string } })
12021223
?.turn;
1224+
if (turn?.id === this.nativeGoalTurnId) {
1225+
this.nativeGoalTurnId = undefined;
1226+
}
12031227
// Drop the late completion of an already-interrupted turn (else it cancels the follow-up).
12041228
if (this.turns.shouldDropCompletion(turn?.id)) return;
12051229
void this.finalizeTurn(mapTurnStopReason(turn?.status));

0 commit comments

Comments
 (0)