Skip to content

Commit a6ec9ca

Browse files
committed
fix(agent): harden Codex goal recovery
1 parent 45dff94 commit a6ec9ca

4 files changed

Lines changed: 69 additions & 4 deletions

File tree

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

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@ function makeStubRpc(responses: Record<string, unknown>) {
4646
message: `Invalid request: missing field \`${missing}\``,
4747
};
4848
}
49-
return (responses[method] ?? {}) as T;
49+
const response = responses[method];
50+
return (
51+
typeof response === "function"
52+
? await response(params)
53+
: (response ?? {})
54+
) as T;
5055
},
5156
notify() {},
5257
async close() {},
@@ -494,6 +499,52 @@ describe("CodexAppServerAgent", () => {
494499
});
495500
});
496501

502+
it("retries queued goal cancellation after an interrupt failure", async () => {
503+
let interruptAttempts = 0;
504+
const stub = makeStubRpc({
505+
"thread/start": { thread: { id: "thr_1" } },
506+
"thread/goal/set": {
507+
goal: { objective: "Ship the fix", status: "paused" },
508+
},
509+
"turn/interrupt": () => {
510+
interruptAttempts++;
511+
if (interruptAttempts === 1) {
512+
throw new Error("interrupt failed");
513+
}
514+
return {};
515+
},
516+
});
517+
const { client } = makeFakeClient();
518+
const agent = new CodexAppServerAgent(client, {
519+
processOptions: { binaryPath: "/bundle/codex" },
520+
rpcFactory: stub.factory,
521+
});
522+
await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest);
523+
524+
await agent.prompt({
525+
sessionId: "thr_1",
526+
prompt: [{ type: "text", text: "/goal pause" }],
527+
} as unknown as PromptRequest);
528+
stub.emit("turn/started", { turn: { id: "goal_tick_1" } });
529+
await Promise.resolve();
530+
await Promise.resolve();
531+
stub.emit("turn/started", { turn: { id: "goal_tick_2" } });
532+
await Promise.resolve();
533+
534+
expect(
535+
stub.requests.filter((request) => request.method === "turn/interrupt"),
536+
).toEqual([
537+
{
538+
method: "turn/interrupt",
539+
params: { threadId: "thr_1", turnId: "goal_tick_1" },
540+
},
541+
{
542+
method: "turn/interrupt",
543+
params: { threadId: "thr_1", turnId: "goal_tick_2" },
544+
},
545+
]);
546+
});
547+
497548
it("includes buffered command output when completion omits aggregatedOutput", async () => {
498549
const stub = makeStubRpc({
499550
initialize: {},

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -814,12 +814,14 @@ export class CodexAppServerAgent extends BaseAcpAgent {
814814

815815
private interruptQueuedGoalTurn(turnId: string | undefined): void {
816816
if (!this.cancelNextGoalTurn || !this.threadId || !turnId) return;
817-
this.cancelNextGoalTurn = false;
818817
void this.rpc
819818
.request(APP_SERVER_METHODS.TURN_INTERRUPT, {
820819
threadId: this.threadId,
821820
turnId,
822821
})
822+
.then(() => {
823+
this.cancelNextGoalTurn = false;
824+
})
823825
.catch((error) =>
824826
this.logger.warn("Queued goal turn interrupt failed", error),
825827
);

packages/agent/src/sagas/resume-saga.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,19 @@ describe("ResumeSaga", () => {
641641
],
642642
expected: null,
643643
},
644+
{
645+
name: "skips malformed newer goal notifications",
646+
entries: [
647+
createNotification(POSTHOG_NOTIFICATIONS.CODEX_GOAL, {
648+
goal: { objective: "Ship the fix", status: "paused" },
649+
}),
650+
createNotification(POSTHOG_NOTIFICATIONS.CODEX_GOAL, {
651+
goal: { objective: "Invalid goal", status: "unknown" },
652+
}),
653+
createNotification(POSTHOG_NOTIFICATIONS.CODEX_GOAL, {}),
654+
],
655+
expected: { objective: "Ship the fix", status: "paused" },
656+
},
644657
{
645658
name: "leaves goal state undefined when no notification exists",
646659
entries: [createUserMessage("Hello")],

packages/agent/src/sagas/resume-saga.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
146146
const goal = (notification?.params as { goal?: unknown } | undefined)
147147
?.goal;
148148
if (goal === null) return null;
149-
if (!goal || typeof goal !== "object") return undefined;
149+
if (!goal || typeof goal !== "object") continue;
150150
const value = goal as Record<string, unknown>;
151151
if (
152152
typeof value.objective === "string" &&
@@ -158,7 +158,6 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
158158
status: value.status as NativeGoalState["status"],
159159
};
160160
}
161-
return undefined;
162161
}
163162
return undefined;
164163
}

0 commit comments

Comments
 (0)