diff --git a/packages/app/e2e/codex-plan-approval.spec.ts b/packages/app/e2e/codex-plan-approval.spec.ts new file mode 100644 index 000000000..61769afef --- /dev/null +++ b/packages/app/e2e/codex-plan-approval.spec.ts @@ -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(); + } + }); +}); diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts index 8b11d55e6..696cb19b2 100644 --- a/packages/app/e2e/helpers/terminal-perf.ts +++ b/packages/app/e2e/helpers/terminal-perf.ts @@ -24,6 +24,10 @@ export interface TerminalPerfDaemonClient { cwd: string; title?: string; modeId?: string; + model?: string; + thinkingOptionId?: string; + featureValues?: Record; + initialPrompt?: string; }): Promise<{ id: string; status: string }>; sendAgentMessage(agentId: string, text: string): Promise; subscribeTerminal( diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 754c3fa61..40d4bf6f1 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -1129,6 +1129,7 @@ function PermissionRequestCard({ description={description} text={planMarkdown} footer={footer} + testID="permission-plan-card" disableOuterSpacing /> ); @@ -1141,7 +1142,12 @@ function PermissionRequestCard({ {description ? {description} : null} {planMarkdown ? ( - + ) : null} {!isPlanRequest ? ( diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index b6146286f..87cc9d4a8 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -2973,6 +2973,7 @@ export const ToolCall = memo(function ToolCall({ ); diff --git a/packages/app/src/components/plan-card.tsx b/packages/app/src/components/plan-card.tsx index f21aa5bb4..c512220e8 100644 --- a/packages/app/src/components/plan-card.tsx +++ b/packages/app/src/components/plan-card.tsx @@ -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 ( - + {title} {description ? {description} : null} diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 2b7eaf3f1..d4e53ae3c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -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[] = []; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index ed203dc50..9d5f65b3d 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -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); }