Fix duplicate Codex plan approval panels

This commit is contained in:
Mohamed Boudra
2026-05-01 11:42:30 +07:00
parent eee4a772d7
commit fa0af97aa6
7 changed files with 142 additions and 2 deletions

View File

@@ -0,0 +1,63 @@
import { expect, test } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { allowPermission, waitForPermissionPrompt } from "./helpers/app";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
test.describe("Codex plan approval", () => {
test("shows a single actionable plan panel and removes it after implementation starts", async ({
page,
}) => {
test.setTimeout(180_000);
const repo = await createTempGitRepo("codex-plan-approval-");
const client = await connectTerminalClient();
try {
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const agent = await client.createAgent({
provider: "codex",
cwd: repo.path,
title: "Codex plan approval e2e",
model: "gpt-5.4-mini",
thinkingOptionId: "low",
featureValues: { plan_mode: true, fast_mode: true },
initialPrompt:
"Create a concise plan to add a README note. Present the plan only and wait for approval.",
});
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
repo.path,
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await waitForPermissionPrompt(page, 120_000);
await expect(page.getByTestId("permission-plan-card")).toHaveCount(1);
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
await allowPermission(page);
await expect(page.getByTestId("permission-plan-card")).toHaveCount(0, {
timeout: 30_000,
});
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
} finally {
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -24,6 +24,10 @@ export interface TerminalPerfDaemonClient {
cwd: string;
title?: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
initialPrompt?: string;
}): Promise<{ id: string; status: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
subscribeTerminal(

View File

@@ -1129,6 +1129,7 @@ function PermissionRequestCard({
description={description}
text={planMarkdown}
footer={footer}
testID="permission-plan-card"
disableOuterSpacing
/>
);
@@ -1141,7 +1142,12 @@ function PermissionRequestCard({
{description ? <Text style={permissionStyles.description}>{description}</Text> : null}
{planMarkdown ? (
<PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing />
<PlanCard
title="Proposed plan"
text={planMarkdown}
testID="permission-plan-card"
disableOuterSpacing
/>
) : null}
{!isPlanRequest ? (

View File

@@ -2973,6 +2973,7 @@ export const ToolCall = memo(function ToolCall({
<PlanCard
title="Plan"
text={effectiveDetail.text}
testID="timeline-plan-card"
disableOuterSpacing={disableOuterSpacing}
/>
);

View File

@@ -199,12 +199,14 @@ export function PlanCard({
text,
footer,
disableOuterSpacing = false,
testID,
}: {
title?: string;
description?: string;
text: string;
footer?: ReactNode;
disableOuterSpacing?: boolean;
testID?: string;
}) {
const { theme } = useUnistyles();
const markdownStyles = createMarkdownStyles(theme);
@@ -231,7 +233,7 @@ export function PlanCard({
);
return (
<View style={containerStyle}>
<View testID={testID} style={containerStyle}>
<Text style={titleStyle}>{title}</Text>
{description ? <Text style={descriptionStyle}>{description}</Text> : null}
<Markdown style={markdownStyles} rules={markdownRules}>

View File

@@ -770,6 +770,14 @@ describe("Codex app-server provider", () => {
turn: { status: "completed", error: null },
});
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.detail.type === "plan",
),
).toBe(false);
expect(events.at(-2)).toEqual({
type: "permission_requested",
provider: "codex",
@@ -804,6 +812,51 @@ describe("Codex app-server provider", () => {
});
});
test("does not emit Codex plan thread items as timeline cards while plan approval is pending", () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-thread-item" },
});
asInternals(session).handleNotification("item/completed", {
item: {
id: "plan-item-1",
type: "plan",
text: "- Inspect README\n- Add a short note",
},
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
expect(events).not.toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
type: "tool_call",
detail: expect.objectContaining({ type: "plan" }),
}),
}),
);
expect(events.at(-2)).toEqual({
type: "permission_requested",
provider: "codex",
turnId: "test-turn",
request: expect.objectContaining({
provider: "codex",
name: "CodexPlanApproval",
kind: "plan",
input: {
plan: "- Inspect README\n- Add a short note",
},
}),
});
});
test("emits usage_updated on token usage updates and keeps usage on turn completion", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];

View File

@@ -3724,6 +3724,12 @@ class CodexAppServerAgentSession implements AgentSession {
});
if (timelineItem) {
this.rememberPlanResult(timelineItem);
// In plan mode, the same plan is rendered through the synthetic approval
// permission. Keep the remembered text for that card, but do not also
// emit a static timeline plan panel.
if (this.planModeEnabled) {
return;
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
}
@@ -3883,6 +3889,11 @@ class CodexAppServerAgentSession implements AgentSession {
if (timelineItem.type === "tool_call") {
if (timelineItem.detail.type === "plan") {
this.rememberPlanResult(timelineItem);
// Codex can surface plans both as turn/plan updates and as completed
// thread items. In plan mode, approval owns the visible plan card.
if (this.planModeEnabled) {
return;
}
}
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
}