From fd05bf8bfff740bac5dd5cbd1deff54b760e0947 Mon Sep 17 00:00:00 2001 From: therainisme Date: Wed, 22 Apr 2026 18:51:57 +0800 Subject: [PATCH] fix(server): preserve Codex fast mode after plan approval Codex was clearing fast mode after approving a plan. This keeps fast mode and plan mode independent. Approving a plan still exits plan collaboration mode, and the follow-up implementation turn now keeps serviceTier=fast when fast mode was already enabled. Verified locally: - node node_modules/vitest/vitest.mjs run packages/server/src/server/agent/providers/codex-app-server-agent.test.ts --bail=1 - node node_modules/vitest/vitest.mjs run packages/server/src/server/agent/agent-manager.test.ts --bail=1 - npm run typecheck --- .../src/server/agent/agent-manager.test.ts | 6 +- .../providers/codex-app-server-agent.test.ts | 119 +++++++++++++++++- .../agent/providers/codex-app-server-agent.ts | 3 +- 3 files changed, 120 insertions(+), 8 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 7c20f7b9c..455666184 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -3719,7 +3719,7 @@ describe("AgentManager", () => { this.modeId = "auto"; this.pending = []; this.featureState = [ - createFeature({ id: "fast_mode", label: "Fast", value: false }), + createFeature({ id: "fast_mode", label: "Fast", value: true }), createFeature({ id: "plan_mode", label: "Plan", value: false }), ]; } @@ -3765,7 +3765,7 @@ describe("AgentManager", () => { const updated = manager.getAgent(snapshot.id); expect(updated?.pendingPermissions.size).toBe(0); expect(updated?.features).toEqual([ - createFeature({ id: "fast_mode", label: "Fast", value: false }), + createFeature({ id: "fast_mode", label: "Fast", value: true }), createFeature({ id: "plan_mode", label: "Plan", value: false }), ]); expect(updated?.runtimeInfo).toMatchObject({ @@ -3775,7 +3775,7 @@ describe("AgentManager", () => { const persisted = await storage.get(snapshot.id); expect(persisted?.features).toEqual([ - createFeature({ id: "fast_mode", label: "Fast", value: false }), + createFeature({ id: "fast_mode", label: "Fast", value: true }), createFeature({ id: "plan_mode", label: "Plan", value: false }), ]); }); 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 283d63e25..66ac169c6 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 @@ -700,7 +700,7 @@ describe("Codex app-server provider", () => { }); }); - test("approving a synthetic Codex plan permission disables plan and fast mode and returns follow-up prompt", async () => { + test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, }); @@ -731,11 +731,11 @@ describe("Codex app-server provider", () => { selectedActionId: "implement", }); - expect((session as any).serviceTier).toBeNull(); + expect((session as any).serviceTier).toBe("fast"); expect((session as any).planModeEnabled).toBe(false); expect((session as any).config.featureValues).toEqual({ plan_mode: false, - fast_mode: false, + fast_mode: true, }); // The session returns the follow-up prompt instead of calling startTurn directly. // The caller (session/agent-manager) is responsible for sending it through streamAgent. @@ -753,4 +753,117 @@ describe("Codex app-server provider", () => { }, }); }); + + test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => { + const session = createSession({ + featureValues: { plan_mode: true, fast_mode: false }, + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + (session as any).handleNotification("turn/started", { + turn: { id: "turn-plan-3" }, + }); + (session as any).handleNotification("turn/plan/updated", { + plan: [{ step: "Implement the safe flow", status: "pending" }], + }); + (session as any).handleNotification("turn/completed", { + turn: { status: "completed", error: null }, + }); + + const request = events.find( + (event): event is Extract => + event.type === "permission_requested" && event.request.kind === "plan", + ); + expect(request).toBeDefined(); + if (!request) { + throw new Error("Expected synthetic plan approval permission"); + } + + const result = await session.respondToPermission(request.request.id, { + behavior: "allow", + selectedActionId: "implement", + }); + + expect((session as any).serviceTier).toBeNull(); + expect((session as any).planModeEnabled).toBe(false); + expect((session as any).config.featureValues).toEqual({ + plan_mode: false, + fast_mode: false, + }); + expect(result?.followUpPrompt).toEqual( + expect.stringContaining("The user approved the plan. Implement it now."), + ); + }); + + test("follow-up implementation turn keeps fast service tier and switches back to code collaboration mode", async () => { + const session = createSession({ + featureValues: { plan_mode: true, fast_mode: true }, + }); + (session as any).collaborationModes = [ + { + name: "Code", + mode: "code", + developer_instructions: "Built-in code mode", + }, + { + name: "Plan", + mode: "plan", + developer_instructions: "Built-in plan mode", + }, + ]; + (session as any).refreshResolvedCollaborationMode(); + const request = vi.fn(async (method: string) => { + if (method === "thread/loaded/list") { + return { data: ["test-thread"] }; + } + if (method === "turn/start") { + return {}; + } + throw new Error(`Unexpected request: ${method}`); + }); + + session.activeForegroundTurnId = null; + session.client = { request } as any; + + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + (session as any).handleNotification("turn/started", { + turn: { id: "turn-plan-4" }, + }); + (session as any).handleNotification("turn/plan/updated", { + plan: [{ step: "Implement the fast flow", status: "pending" }], + }); + (session as any).handleNotification("turn/completed", { + turn: { status: "completed", error: null }, + }); + + const permissionRequest = events.find( + (event): event is Extract => + event.type === "permission_requested" && event.request.kind === "plan", + ); + expect(permissionRequest).toBeDefined(); + if (!permissionRequest) { + throw new Error("Expected synthetic plan approval permission"); + } + + const result = await session.respondToPermission(permissionRequest.request.id, { + behavior: "allow", + selectedActionId: "implement", + }); + expect(result?.followUpPrompt).toEqual(expect.any(String)); + + await session.startTurn(result!.followUpPrompt!); + + const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start"); + expect(turnStartCall?.[1]).toEqual( + expect.objectContaining({ + serviceTier: "fast", + collaborationMode: expect.objectContaining({ + mode: "code", + }), + }), + ); + }); }); 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 0b730458c..7216342a9 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 @@ -2721,7 +2721,7 @@ class CodexAppServerAgentSession implements AgentSession { } /** - * Prepare the session for plan implementation by disabling plan/fast mode + * Prepare the session for plan implementation by disabling plan mode * and returning the implementation prompt. The caller is responsible for * starting the turn through the normal streamAgent path. */ @@ -2730,7 +2730,6 @@ class CodexAppServerAgentSession implements AgentSession { typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : ""; this.applyFeatureValue("plan_mode", false); - this.applyFeatureValue("fast_mode", false); return buildCodexPlanImplementationPrompt(planText); }