Skip to content

Commit 9f16aa4

Browse files
committed
fix(codex): render plans in approval UI
Generated-By: PostHog Code Task-Id: 2cd7c8b3-ddf5-4e4c-994d-1f5bbafabff0
1 parent 0b69cb9 commit 9f16aa4

7 files changed

Lines changed: 151 additions & 31 deletions

File tree

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

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2757,7 +2757,10 @@ describe("CodexAppServerAgent", () => {
27572757
}
27582758

27592759
// permissionOutcome may be a value or a function (per-call, e.g. a pending promise).
2760-
function makePlanAgent(permissionOutcome: unknown) {
2760+
function makePlanAgent(
2761+
permissionOutcome: unknown,
2762+
options: { rejectPlanToolUpdates?: boolean } = {},
2763+
) {
27612764
const stub = makeStubRpc({
27622765
"thread/start": { thread: { id: "t" } },
27632766
"turn/start": { turn: { id: "turn_1" } },
@@ -2770,7 +2773,15 @@ describe("CodexAppServerAgent", () => {
27702773
}> = [];
27712774
const client = {
27722775
sessionUpdate: async (n: unknown) => {
2773-
sessionUpdates.push(n as { update?: Record<string, unknown> });
2776+
const notification = n as { update?: Record<string, unknown> };
2777+
if (
2778+
options.rejectPlanToolUpdates &&
2779+
(notification.update?.sessionUpdate === "tool_call" ||
2780+
notification.update?.sessionUpdate === "tool_call_update")
2781+
) {
2782+
throw new Error("renderer disconnected");
2783+
}
2784+
sessionUpdates.push(notification);
27742785
},
27752786
requestPermission: async (params: {
27762787
toolCall: Record<string, unknown>;
@@ -2898,6 +2909,42 @@ describe("CodexAppServerAgent", () => {
28982909
expect(permissionRequests[0].options.map((o) => o.optionId)).toContain(
28992910
"auto",
29002911
);
2912+
expect(sessionUpdates).toContainEqual({
2913+
sessionId: "t",
2914+
update: {
2915+
sessionUpdate: "tool_call",
2916+
toolCallId: "p1:implement",
2917+
title: "Ready to code?",
2918+
kind: "switch_mode",
2919+
content: [
2920+
{
2921+
type: "content",
2922+
content: { type: "text", text: "# The plan\n\n1. do it" },
2923+
},
2924+
],
2925+
rawInput: { plan: "# The plan\n\n1. do it" },
2926+
status: "in_progress",
2927+
},
2928+
});
2929+
expect(sessionUpdates).toContainEqual({
2930+
sessionId: "t",
2931+
update: {
2932+
sessionUpdate: "tool_call_update",
2933+
toolCallId: "p1:implement",
2934+
status: "completed",
2935+
},
2936+
});
2937+
expect(
2938+
sessionUpdates.some((notification) => {
2939+
if (notification.update?.sessionUpdate !== "agent_message_chunk") {
2940+
return false;
2941+
}
2942+
const content = notification.update.content as
2943+
| { type?: string; text?: string }
2944+
| undefined;
2945+
return content?.text?.includes("# The plan") === true;
2946+
}),
2947+
).toBe(false);
29012948

29022949
// Mode flipped to auto and the host was told.
29032950
expect(sessionUpdates).toContainEqual(
@@ -2924,7 +2971,7 @@ describe("CodexAppServerAgent", () => {
29242971
});
29252972

29262973
it("stays in plan mode when the handoff is rejected without feedback", async () => {
2927-
const { agent, stub, permissionRequests } = makePlanAgent({
2974+
const { agent, stub, sessionUpdates, permissionRequests } = makePlanAgent({
29282975
outcome: { outcome: "selected", optionId: "reject_with_feedback" },
29292976
});
29302977
const { done } = await startPlanTurn(agent, stub);
@@ -2938,12 +2985,43 @@ describe("CodexAppServerAgent", () => {
29382985

29392986
expect((await done).stopReason).toBe("end_turn");
29402987
expect(permissionRequests).toHaveLength(1);
2988+
expect(sessionUpdates).toContainEqual({
2989+
sessionId: "t",
2990+
update: {
2991+
sessionUpdate: "tool_call_update",
2992+
toolCallId: "p1:implement",
2993+
status: "failed",
2994+
},
2995+
});
29412996
// No implementation turn started; the picker stays on plan.
29422997
expect(stub.requests.filter((r) => r.method === "turn/start")).toHaveLength(
29432998
1,
29442999
);
29453000
});
29463001

3002+
it("settles the handoff when plan tool-call updates cannot be delivered", async () => {
3003+
const { agent, stub, permissionRequests } = makePlanAgent(
3004+
{
3005+
outcome: { outcome: "selected", optionId: "reject_with_feedback" },
3006+
},
3007+
{ rejectPlanToolUpdates: true },
3008+
);
3009+
const { done } = await startPlanTurn(agent, stub);
3010+
3011+
stub.emit("item/completed", {
3012+
item: { type: "plan", id: "p1", text: "# The plan" },
3013+
});
3014+
stub.emit("turn/completed", {
3015+
turn: { id: "turn_1", status: "completed" },
3016+
});
3017+
3018+
expect((await done).stopReason).toBe("end_turn");
3019+
expect(permissionRequests).toHaveLength(1);
3020+
expect(stub.requests.filter((r) => r.method === "turn/start")).toHaveLength(
3021+
1,
3022+
);
3023+
});
3024+
29473025
it("feeds handoff feedback into another plan turn", async () => {
29483026
const { agent, stub, permissionRequests } = makePlanAgent({
29493027
outcome: { outcome: "selected", optionId: "reject_with_feedback" },

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

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,11 @@ export class CodexAppServerAgent extends BaseAcpAgent {
10111011
],
10121012
rawInput: { plan: proposal.text },
10131013
};
1014+
await this.emitPlanApprovalToolCall({
1015+
sessionUpdate: "tool_call",
1016+
...toolCall,
1017+
status: "in_progress",
1018+
});
10141019
const options = [
10151020
{
10161021
optionId: "auto",
@@ -1053,8 +1058,12 @@ export class CodexAppServerAgent extends BaseAcpAgent {
10531058
});
10541059
const settled = await Promise.race([permission, cancelled]);
10551060
this.planHandoffCancel = undefined;
1056-
if (!settled) return { kind: "stay" };
1061+
if (!settled) {
1062+
await this.completePlanApprovalToolCall(toolCallId, "failed");
1063+
return { kind: "stay" };
1064+
}
10571065
if (settled.failed) {
1066+
await this.completePlanApprovalToolCall(toolCallId, "failed");
10581067
this.logger.warn("plan implementation prompt failed; staying in plan", {
10591068
error: String(settled.err),
10601069
});
@@ -1066,25 +1075,63 @@ export class CodexAppServerAgent extends BaseAcpAgent {
10661075
}
10671076
const response = settled.res;
10681077
if (this.session.cancelled || response.outcome.outcome !== "selected") {
1078+
await this.completePlanApprovalToolCall(toolCallId, "failed");
10691079
return { kind: "stay" };
10701080
}
10711081
const optionId = response.outcome.optionId;
1072-
if (!offered.has(optionId)) return { kind: "stay" };
1073-
if (optionId === "auto") return { kind: "implement", mode: "auto" };
1082+
if (!offered.has(optionId)) {
1083+
await this.completePlanApprovalToolCall(toolCallId, "failed");
1084+
return { kind: "stay" };
1085+
}
1086+
if (optionId === "auto") {
1087+
await this.completePlanApprovalToolCall(toolCallId, "completed");
1088+
return { kind: "implement", mode: "auto" };
1089+
}
10741090
// Double-gated: only ever offered under ALLOW_BYPASS, and re-checked here.
10751091
if (optionId === "full-access" && ALLOW_BYPASS) {
1092+
await this.completePlanApprovalToolCall(toolCallId, "completed");
10761093
return { kind: "implement", mode: "full-access" };
10771094
}
10781095
if (optionId === "reject_with_feedback") {
10791096
const feedback = (response as { _meta?: { customInput?: unknown } })._meta
10801097
?.customInput;
10811098
if (typeof feedback === "string" && feedback.trim()) {
1099+
await this.completePlanApprovalToolCall(toolCallId, "failed");
10821100
return { kind: "feedback", feedback: feedback.trim() };
10831101
}
10841102
}
1103+
await this.completePlanApprovalToolCall(toolCallId, "failed");
10851104
return { kind: "stay" };
10861105
}
10871106

1107+
private async completePlanApprovalToolCall(
1108+
toolCallId: string,
1109+
status: "completed" | "failed",
1110+
): Promise<void> {
1111+
await this.emitPlanApprovalToolCall({
1112+
sessionUpdate: "tool_call_update",
1113+
toolCallId,
1114+
status,
1115+
});
1116+
}
1117+
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) {
1127+
this.logger.warn("Failed to emit plan approval tool call update", {
1128+
error: String(error),
1129+
sessionUpdate: update.sessionUpdate,
1130+
toolCallId: update.toolCallId,
1131+
});
1132+
}
1133+
}
1134+
10881135
/** Emit a plain agent message (user-facing status the model didn't produce). */
10891136
private broadcastAgentText(text: string): void {
10901137
if (!this.sessionId) return;

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

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,14 @@ describe("mapAppServerNotification", () => {
4242
});
4343
});
4444

45-
it("streams a plan delta as an ACP agent_message_chunk", () => {
45+
it("keeps plan deltas out of the agent transcript", () => {
4646
const result = mapAppServerNotification(
4747
"s-1",
4848
APP_SERVER_NOTIFICATIONS.PLAN_DELTA,
4949
{ itemId: "p1", delta: "## Plan\n" },
5050
);
5151

52-
expect(result).toEqual({
53-
sessionId: "s-1",
54-
update: {
55-
sessionUpdate: "agent_message_chunk",
56-
content: { type: "text", text: "## Plan\n" },
57-
},
58-
});
52+
expect(result).toBeNull();
5953
});
6054

6155
it("returns null when the delta is missing or empty", () => {

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

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,9 @@ export function mapAppServerNotification(
4141
},
4242
};
4343
}
44-
// Plan-mode proposal streaming as agent prose (codex strips it from agentMessage deltas).
45-
case APP_SERVER_NOTIFICATIONS.PLAN_DELTA: {
46-
const delta = readStringField(params, "delta");
47-
if (!delta) return null;
48-
return {
49-
sessionId,
50-
update: {
51-
sessionUpdate: "agent_message_chunk",
52-
content: { type: "text", text: delta },
53-
},
54-
};
55-
}
44+
// Plan deltas are buffered by the adapter for the structured approval UI.
45+
case APP_SERVER_NOTIFICATIONS.PLAN_DELTA:
46+
return null;
5647
case APP_SERVER_NOTIFICATIONS.TOKEN_USAGE_UPDATED: {
5748
// Context indicator: renderer reads `used`/`size`; detailed breakdown comes via `_posthog/usage_update`.
5849
const usage = readTokenUsage(params);

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ export const APP_SERVER_NOTIFICATIONS = {
3232
REASONING_TEXT_DELTA: "item/reasoning/textDelta",
3333
// Default reasoning stream for gpt-5 models; raw textDelta is off by default, so without this the host sees no reasoning.
3434
REASONING_SUMMARY_TEXT_DELTA: "item/reasoning/summaryTextDelta",
35-
// Plan-mode <proposed_plan> stream. codex strips the plan from agentMessage deltas,
36-
// so without this the host sees nothing while the plan is written.
35+
// Plan-mode <proposed_plan> stream. The adapter buffers it for the structured
36+
// plan approval UI because codex strips it from agentMessage deltas.
3737
PLAN_DELTA: "item/plan/delta",
3838
TURN_PLAN_UPDATED: "turn/plan/updated",
3939
TURN_COMPLETED: "turn/completed",

packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ describe("PlanApprovalView", () => {
8484
expect(screen.getByText(PLAN_MARKER)).toBeInTheDocument();
8585
});
8686

87+
it("shows the rejected status when the plan tool call fails", () => {
88+
renderView({ toolCall: makeToolCall({ status: "failed" }) });
89+
90+
expect(screen.getByText(/\(plan rejected\)/i)).toBeInTheDocument();
91+
expect(
92+
screen.getByRole("button", { name: /show plan/i }),
93+
).toBeInTheDocument();
94+
});
95+
8796
it("omits the toggle when there is no plan text available", () => {
8897
renderView({
8998
toolCall: makeToolCall({

packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function PlanApprovalView({
1616
turnComplete,
1717
}: ToolViewProps) {
1818
const { content } = toolCall;
19-
const { isComplete, wasCancelled } = useToolCallStatus(
19+
const { isComplete, isFailed, wasCancelled } = useToolCallStatus(
2020
toolCall.status,
2121
turnCancelled,
2222
turnComplete,
@@ -45,7 +45,8 @@ export function PlanApprovalView({
4545
return null;
4646
}, [content, toolCall.rawInput]);
4747

48-
const showResult = isComplete || wasCancelled;
48+
const wasRejected = isFailed || wasCancelled;
49+
const showResult = isComplete || wasRejected;
4950
const canTogglePlan = showResult && !!planText;
5051
const planContentId = `plan-content-${toolCall.toolCallId}`;
5152

@@ -58,7 +59,7 @@ export function PlanApprovalView({
5859
Plan approved — proceeding with implementation
5960
</Text>
6061
</>
61-
) : wasCancelled ? (
62+
) : wasRejected ? (
6263
<Text className="text-[13px] text-gray-10">(Plan rejected)</Text>
6364
) : null;
6465

0 commit comments

Comments
 (0)