Skip to content

Commit 5b5e747

Browse files
committed
fix(codex): harden plan approval lifecycle
Generated-By: PostHog Code Task-Id: 2cd7c8b3-ddf5-4e4c-994d-1f5bbafabff0
1 parent 9f16aa4 commit 5b5e747

6 files changed

Lines changed: 244 additions & 84 deletions

File tree

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

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2759,7 +2759,10 @@ describe("CodexAppServerAgent", () => {
27592759
// permissionOutcome may be a value or a function (per-call, e.g. a pending promise).
27602760
function makePlanAgent(
27612761
permissionOutcome: unknown,
2762-
options: { rejectPlanToolUpdates?: boolean } = {},
2762+
options: {
2763+
rejectPlanToolUpdates?: boolean;
2764+
stallPlanToolUpdates?: boolean;
2765+
} = {},
27632766
) {
27642767
const stub = makeStubRpc({
27652768
"thread/start": { thread: { id: "t" } },
@@ -2775,11 +2778,15 @@ describe("CodexAppServerAgent", () => {
27752778
sessionUpdate: async (n: unknown) => {
27762779
const notification = n as { update?: Record<string, unknown> };
27772780
if (
2778-
options.rejectPlanToolUpdates &&
2779-
(notification.update?.sessionUpdate === "tool_call" ||
2780-
notification.update?.sessionUpdate === "tool_call_update")
2781+
notification.update?.sessionUpdate === "tool_call" ||
2782+
notification.update?.sessionUpdate === "tool_call_update"
27812783
) {
2782-
throw new Error("renderer disconnected");
2784+
if (options.rejectPlanToolUpdates) {
2785+
throw new Error("renderer disconnected");
2786+
}
2787+
if (options.stallPlanToolUpdates) {
2788+
return new Promise(() => {});
2789+
}
27832790
}
27842791
sessionUpdates.push(notification);
27852792
},
@@ -2916,14 +2923,29 @@ describe("CodexAppServerAgent", () => {
29162923
toolCallId: "p1:implement",
29172924
title: "Ready to code?",
29182925
kind: "switch_mode",
2926+
content: [
2927+
{
2928+
type: "content",
2929+
content: { type: "text", text: "# The plan\n\n" },
2930+
},
2931+
],
2932+
rawInput: { plan: "# The plan\n\n" },
2933+
status: "in_progress",
2934+
},
2935+
});
2936+
expect(sessionUpdates).toContainEqual({
2937+
sessionId: "t",
2938+
update: {
2939+
sessionUpdate: "tool_call_update",
2940+
toolCallId: "p1:implement",
2941+
status: "in_progress",
29192942
content: [
29202943
{
29212944
type: "content",
29222945
content: { type: "text", text: "# The plan\n\n1. do it" },
29232946
},
29242947
],
29252948
rawInput: { plan: "# The plan\n\n1. do it" },
2926-
status: "in_progress",
29272949
},
29282950
});
29292951
expect(sessionUpdates).toContainEqual({
@@ -3022,6 +3044,27 @@ describe("CodexAppServerAgent", () => {
30223044
);
30233045
});
30243046

3047+
it("does not block the handoff when plan tool-call updates never settle", async () => {
3048+
const { agent, stub, permissionRequests } = makePlanAgent(
3049+
{
3050+
outcome: { outcome: "selected", optionId: "reject_with_feedback" },
3051+
},
3052+
{ stallPlanToolUpdates: true },
3053+
);
3054+
const { done } = await startPlanTurn(agent, stub);
3055+
3056+
stub.emit("item/plan/delta", { itemId: "p1", delta: "# The plan" });
3057+
stub.emit("turn/completed", {
3058+
turn: { id: "turn_1", status: "completed" },
3059+
});
3060+
3061+
expect((await done).stopReason).toBe("end_turn");
3062+
expect(permissionRequests).toHaveLength(1);
3063+
expect(stub.requests.filter((r) => r.method === "turn/start")).toHaveLength(
3064+
1,
3065+
);
3066+
});
3067+
30253068
it("feeds handoff feedback into another plan turn", async () => {
30263069
const { agent, stub, permissionRequests } = makePlanAgent({
30273070
outcome: { outcome: "selected", optionId: "reject_with_feedback" },

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

Lines changed: 100 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ export class CodexAppServerAgent extends BaseAcpAgent {
242242
private workspaceDirectory?: string;
243243
/** The in-flight turn's <proposed_plan>, streamed or completed (drives the implement handoff). */
244244
private planProposal?: { itemId: string; text: string };
245+
/** Structured plan tool call already emitted while the proposal streams. */
246+
private streamedPlanToolCallId?: string;
245247
/** Idle signal deferred while the plan handoff keeps this prompt busy. */
246248
private deferredTurnComplete?: { usage: PromptResponse["usage"] };
247249
/** Settles the pending plan-approval race on cancel/close/preempting prompt. */
@@ -879,6 +881,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
879881
this.lastAgentMessage = "";
880882
this.resetUsage();
881883
this.planProposal = undefined;
884+
this.streamedPlanToolCallId = undefined;
882885
// A new turn owns the idle boundary; its own completion emits the signal.
883886
this.deferredTurnComplete = undefined;
884887
const { completion, turn } = this.turns.begin();
@@ -936,13 +939,22 @@ export class CodexAppServerAgent extends BaseAcpAgent {
936939
// Re-check after the await: a cancel that raced the response wins, so a
937940
// late accept can never start implementation on a cancelled prompt.
938941
if (this.session.cancelled) {
942+
if (outcome.kind === "implement") {
943+
this.completePlanApprovalToolCall(outcome.toolCallId, "failed");
944+
}
939945
result = { ...result, stopReason: "cancelled" };
940946
break;
941947
}
942948
// A picker change while approval was open owns the mode. Never let a
943949
// stale approval overwrite it with a broader implementation mode.
944-
if (this.config.mode !== "plan") break;
950+
if (this.config.mode !== "plan") {
951+
if (outcome.kind === "implement") {
952+
this.completePlanApprovalToolCall(outcome.toolCallId, "failed");
953+
}
954+
break;
955+
}
945956
if (outcome.kind === "implement") {
957+
this.completePlanApprovalToolCall(outcome.toolCallId, "completed");
946958
this.config.setOption("mode", outcome.mode);
947959
this.emitCurrentMode(outcome.mode);
948960
this.emitConfigOptions();
@@ -994,28 +1006,17 @@ export class CodexAppServerAgent extends BaseAcpAgent {
9941006
itemId: string;
9951007
text: string;
9961008
}): Promise<
997-
| { kind: "implement"; mode: "auto" | "full-access" }
1009+
| {
1010+
kind: "implement";
1011+
mode: "auto" | "full-access";
1012+
toolCallId: string;
1013+
}
9981014
| { kind: "feedback"; feedback: string }
9991015
| { kind: "stay" }
10001016
> {
10011017
const toolCallId = `${proposal.itemId}:implement`;
1002-
const toolCall = {
1003-
toolCallId,
1004-
title: "Ready to code?",
1005-
kind: "switch_mode",
1006-
content: [
1007-
{
1008-
type: "content" as const,
1009-
content: { type: "text" as const, text: proposal.text },
1010-
},
1011-
],
1012-
rawInput: { plan: proposal.text },
1013-
};
1014-
await this.emitPlanApprovalToolCall({
1015-
sessionUpdate: "tool_call",
1016-
...toolCall,
1017-
status: "in_progress",
1018-
});
1018+
const toolCall = this.buildPlanApprovalToolCall(proposal);
1019+
this.emitPlanProposal(toolCall, proposal.text);
10191020
const options = [
10201021
{
10211022
optionId: "auto",
@@ -1059,11 +1060,11 @@ export class CodexAppServerAgent extends BaseAcpAgent {
10591060
const settled = await Promise.race([permission, cancelled]);
10601061
this.planHandoffCancel = undefined;
10611062
if (!settled) {
1062-
await this.completePlanApprovalToolCall(toolCallId, "failed");
1063+
this.completePlanApprovalToolCall(toolCallId, "failed");
10631064
return { kind: "stay" };
10641065
}
10651066
if (settled.failed) {
1066-
await this.completePlanApprovalToolCall(toolCallId, "failed");
1067+
this.completePlanApprovalToolCall(toolCallId, "failed");
10671068
this.logger.warn("plan implementation prompt failed; staying in plan", {
10681069
error: String(settled.err),
10691070
});
@@ -1075,61 +1076,106 @@ export class CodexAppServerAgent extends BaseAcpAgent {
10751076
}
10761077
const response = settled.res;
10771078
if (this.session.cancelled || response.outcome.outcome !== "selected") {
1078-
await this.completePlanApprovalToolCall(toolCallId, "failed");
1079+
this.completePlanApprovalToolCall(toolCallId, "failed");
10791080
return { kind: "stay" };
10801081
}
10811082
const optionId = response.outcome.optionId;
10821083
if (!offered.has(optionId)) {
1083-
await this.completePlanApprovalToolCall(toolCallId, "failed");
1084+
this.completePlanApprovalToolCall(toolCallId, "failed");
10841085
return { kind: "stay" };
10851086
}
10861087
if (optionId === "auto") {
1087-
await this.completePlanApprovalToolCall(toolCallId, "completed");
1088-
return { kind: "implement", mode: "auto" };
1088+
return { kind: "implement", mode: "auto", toolCallId };
10891089
}
10901090
// Double-gated: only ever offered under ALLOW_BYPASS, and re-checked here.
10911091
if (optionId === "full-access" && ALLOW_BYPASS) {
1092-
await this.completePlanApprovalToolCall(toolCallId, "completed");
1093-
return { kind: "implement", mode: "full-access" };
1092+
return { kind: "implement", mode: "full-access", toolCallId };
10941093
}
10951094
if (optionId === "reject_with_feedback") {
10961095
const feedback = (response as { _meta?: { customInput?: unknown } })._meta
10971096
?.customInput;
10981097
if (typeof feedback === "string" && feedback.trim()) {
1099-
await this.completePlanApprovalToolCall(toolCallId, "failed");
1098+
this.completePlanApprovalToolCall(toolCallId, "failed");
11001099
return { kind: "feedback", feedback: feedback.trim() };
11011100
}
11021101
}
1103-
await this.completePlanApprovalToolCall(toolCallId, "failed");
1102+
this.completePlanApprovalToolCall(toolCallId, "failed");
11041103
return { kind: "stay" };
11051104
}
11061105

1107-
private async completePlanApprovalToolCall(
1106+
private buildPlanApprovalToolCall(proposal: {
1107+
itemId: string;
1108+
text: string;
1109+
}): {
1110+
toolCallId: string;
1111+
title: string;
1112+
kind: "switch_mode";
1113+
content: Array<{
1114+
type: "content";
1115+
content: { type: "text"; text: string };
1116+
}>;
1117+
rawInput: { plan: string };
1118+
} {
1119+
return {
1120+
toolCallId: `${proposal.itemId}:implement`,
1121+
title: "Ready to code?",
1122+
kind: "switch_mode",
1123+
content: [
1124+
{
1125+
type: "content",
1126+
content: { type: "text", text: proposal.text },
1127+
},
1128+
],
1129+
rawInput: { plan: proposal.text },
1130+
};
1131+
}
1132+
1133+
private emitPlanProposal(
1134+
toolCall: ReturnType<CodexAppServerAgent["buildPlanApprovalToolCall"]>,
1135+
text: string,
1136+
): void {
1137+
if (this.streamedPlanToolCallId === toolCall.toolCallId) {
1138+
this.emitPlanApprovalToolCall({
1139+
sessionUpdate: "tool_call_update",
1140+
toolCallId: toolCall.toolCallId,
1141+
status: "in_progress",
1142+
content: [{ type: "content", content: { type: "text", text } }],
1143+
rawInput: { plan: text },
1144+
});
1145+
return;
1146+
}
1147+
this.streamedPlanToolCallId = toolCall.toolCallId;
1148+
this.emitPlanApprovalToolCall({
1149+
sessionUpdate: "tool_call",
1150+
...toolCall,
1151+
status: "in_progress",
1152+
});
1153+
}
1154+
1155+
private completePlanApprovalToolCall(
11081156
toolCallId: string,
11091157
status: "completed" | "failed",
1110-
): Promise<void> {
1111-
await this.emitPlanApprovalToolCall({
1158+
): void {
1159+
this.emitPlanApprovalToolCall({
11121160
sessionUpdate: "tool_call_update",
11131161
toolCallId,
11141162
status,
11151163
});
11161164
}
11171165

1118-
private async emitPlanApprovalToolCall(
1119-
update: Record<string, unknown>,
1120-
): Promise<void> {
1121-
try {
1122-
await this.client.sessionUpdate({
1123-
sessionId: this.sessionId,
1124-
update,
1125-
} as unknown as Parameters<AgentSideConnection["sessionUpdate"]>[0]);
1126-
} catch (error) {
1166+
private emitPlanApprovalToolCall(update: Record<string, unknown>): void {
1167+
const notification = {
1168+
sessionId: this.sessionId,
1169+
update,
1170+
} as unknown as Parameters<AgentSideConnection["sessionUpdate"]>[0];
1171+
this.appendNotification(this.sessionId, notification);
1172+
void this.client.sessionUpdate(notification).catch((error) => {
11271173
this.logger.warn("Failed to emit plan approval tool call update", {
11281174
error: String(error),
11291175
sessionUpdate: update.sessionUpdate,
11301176
toolCallId: update.toolCallId,
11311177
});
1132-
}
1178+
});
11331179
}
11341180

11351181
/** Emit a plain agent message (user-facing status the model didn't produce). */
@@ -1414,6 +1460,12 @@ export class CodexAppServerAgent extends BaseAcpAgent {
14141460
)?.item;
14151461
if (item?.type === "plan" && typeof item.text === "string" && item.text) {
14161462
this.planProposal = { itemId: item.id ?? "codex-plan", text: item.text };
1463+
if (this.config.mode === "plan" && this.streamedPlanToolCallId) {
1464+
this.emitPlanProposal(
1465+
this.buildPlanApprovalToolCall(this.planProposal),
1466+
this.planProposal.text,
1467+
);
1468+
}
14171469
}
14181470
}
14191471

@@ -1431,6 +1483,12 @@ export class CodexAppServerAgent extends BaseAcpAgent {
14311483
const previousText =
14321484
this.planProposal?.itemId === proposalId ? this.planProposal.text : "";
14331485
this.planProposal = { itemId: proposalId, text: previousText + delta };
1486+
if (this.config.mode === "plan") {
1487+
this.emitPlanProposal(
1488+
this.buildPlanApprovalToolCall(this.planProposal),
1489+
this.planProposal.text,
1490+
);
1491+
}
14341492
}
14351493

14361494
/** Compaction started: emit `_posthog/status` so the host sets `isCompacting` (gates steer/queue). */

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -603,15 +603,25 @@ describe("mapHistoryItem", () => {
603603
]);
604604
});
605605

606-
it("replays a persisted plan item as an agent_message_chunk", () => {
606+
it("replays a persisted plan item as a historical plan tool call", () => {
607607
expect(
608608
mapHistoryItem("s-1", { type: "plan", id: "p1", text: "# The plan" }),
609609
).toEqual([
610610
{
611611
sessionId: "s-1",
612612
update: {
613-
sessionUpdate: "agent_message_chunk",
614-
content: { type: "text", text: "# The plan" },
613+
sessionUpdate: "tool_call",
614+
toolCallId: "p1:implement",
615+
title: "Plan",
616+
kind: "switch_mode",
617+
status: "completed",
618+
content: [
619+
{
620+
type: "content",
621+
content: { type: "text", text: "# The plan" },
622+
},
623+
],
624+
rawInput: { plan: "# The plan", historical: true },
615625
},
616626
},
617627
]);

0 commit comments

Comments
 (0)