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.
This commit is contained in:
Mohamed Boudra
2026-11-01 07:53:29 +01:00
parent 8e83d9346a
commit 72d488f17a
2 changed files with 108 additions and 2 deletions
@@ -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";
@@ -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();