From 72d488f17a9f7b5648f48663cba8d7591e9936ba Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 07:36:42 +0100 Subject: [PATCH] fix(server): read a pre-v0.10 Codex fast_mode record as service_tier "fast" and store it that way A record holding `fast_mode: true` without `service_tier` ran Fast but the speed control showed Normal, and picking Normal then wrote "default". The Codex session now normalizes the old boolean at construction and reports the new shape through its existing options_changed event. --- .../codex-app-server-agent.features.test.ts | 92 ++++++++++++++++++- .../agent/providers/codex-app-server-agent.ts | 18 ++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index 3ff31807e7..0422fc907a 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -1,8 +1,14 @@ import { featuresFromControls } from "../option-controls.js"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import pino from "pino"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; -import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js"; +import { AgentManager } from "../agent-manager.js"; +import { toAgentPayload } from "../agent-projections.js"; +import { AgentStorage } from "../agent-storage.js"; +import type { AgentClient, AgentSession, AgentSessionConfig } from "../agent-sdk-types.js"; import { CodexAppServerAgentSession } from "./codex-app-server-agent.js"; import { createFakeCodexAppServer, @@ -546,6 +552,88 @@ describe("Codex app-server provider controls", () => { await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" }); }); + test("a pre-v0.10 fast_mode record shows Fast, is stored as service_tier, and can be turned off and on", async () => { + const appServer = createFakeCodexAppServer({ + "collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }), + "model/list": () => ({ data: TEST_SPEED_MODELS }), + }); + const logger = createTestLogger(); + const client: AgentClient = { + provider: CODEX_PROVIDER, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + isAvailable: async () => true, + fetchCatalog: async () => ({ models: [], modes: [] }), + resumeSession: async () => { + throw new Error("not used"); + }, + createSession: async (config) => { + const session = new CodexAppServerAgentSession( + { ...config, provider: CODEX_PROVIDER }, + null, + logger, + async () => appServer.child, + ); + await session.connect(); + return session; + }, + }; + const turnStarts = () => + appServer + .requests() + .filter((message) => message.method === "turn/start") + .map((message) => message.params); + const workdir = mkdtempSync(join(tmpdir(), "codex-legacy-fast-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + + const created = await manager.createAgent( + { + provider: CODEX_PROVIDER, + cwd: workdir, + model: "gpt-5.4", + providerOptions: { fast_mode: true }, + }, + undefined, + { workspaceId: undefined }, + ); + const agent = manager.getAgent(created.id)!; + + // The old boolean is stored in the new shape, and the control shows Fast. + expect(agent.config.providerOptions).toEqual({ service_tier: "fast" }); + const payload = toAgentPayload(agent); + expect(payload.options).toMatchObject({ service_tier: "fast" }); + expect(payload.options).not.toHaveProperty("fast_mode"); + expect(payload.features?.find((feature) => feature.id === "service_tier")).toMatchObject({ + value: "priority", + }); + await manager.flush(); + expect((await storage.get(created.id))?.config?.providerOptions).toEqual({ + service_tier: "fast", + }); + + await manager.setAgentOptions(created.id, { service_tier: "default" }); + expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ + service_tier: "default", + }); + await manager.getAgent(created.id)!.session!.startTurn("hello"); + const normalTurn = await appServer.waitForTurnStart(); + expect(normalTurn).toMatchObject({ serviceTier: "default" }); + appServer.completeTurn({ threadId: String(normalTurn.threadId) }); + await vi.waitFor(() => expect(manager.getAgent(created.id)?.lifecycle).toBe("idle")); + + await manager.setAgentOptions(created.id, { service_tier: "priority" }); + await manager.getAgent(created.id)!.session!.startTurn("hello again"); + await vi.waitFor(() => expect(turnStarts()).toHaveLength(2)); + expect(turnStarts()[1]).toMatchObject({ serviceTier: "priority" }); + }); + test("startTurn logs a sanitized turn/start summary for fast mode observability", async () => { const capture = createCapturedLogger(); const prompt = "secret prompt text should not be logged"; 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 ba5d9fe9a0..d3343077d7 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 @@ -3570,6 +3570,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions); this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env); this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); + this.migrateLegacyFastMode(); this.requestedServiceTier = readCodexServiceTier(this.providerOptions); this.planModeEnabled = this.providerOptions.plan_mode === true; @@ -4673,6 +4674,23 @@ export class CodexAppServerAgentSession implements AgentSession { this.cachedRuntimeInfo = null; } + /** + * COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated. + * Records from before v0.10 hold Fast as `fast_mode: true`. The session reads it as the + * `service_tier: "fast"` alias and tells the daemon to store that shape instead. + */ + private migrateLegacyFastMode(): void { + const { fast_mode: legacyFast, ...rest } = this.providerOptions; + if (legacyFast !== true || rest.service_tier !== undefined) return; + this.providerOptions = { ...rest, service_tier: "fast" }; + this.startupEvents ??= []; + this.notifySubscribers({ + type: "options_changed", + provider: CODEX_PROVIDER, + patch: { service_tier: "fast", fast_mode: null }, + }); + } + private isServiceTierAvailable(tier: string | null): boolean { if (tier === null || tier === "default") return true; const tiers = this.currentServiceTiers();