fix(server): a malformed Codex fast_mode is rejected, and profile options are renamed for clients by the agent manager's conversion

convertLegacyOptions read any truthy fast_mode as Fast, so the string "false" ran priority. It now accepts only true, false or null. The profile store no longer renames keys: a literal codex profile was renamed in storage while an extends-codex profile was not. The config API now converts each profile's options with the manager's adapter-aware conversion, so codex and my-codex profiles read the same.
This commit is contained in:
Mohamed Boudra
2026-11-01 20:53:09 +01:00
parent 9df005f602
commit 83ae20ff06
9 changed files with 107 additions and 21 deletions
@@ -5705,7 +5705,7 @@ export class AgentManager {
* Options older data carries are converted for the adapter the provider runs on, before
* defaults merge and before anything is stored; nothing past the manager sees the old keys.
*/
private convertLegacyOptions<T extends ProviderOptions | undefined>(
convertLegacyOptions<T extends ProviderOptions | undefined>(
provider: AgentProvider,
options: T,
): T {
@@ -1,6 +1,14 @@
import { describe, expect, test } from "vitest";
import { convertLegacyOptions, foldHookFeatureValues } from "./option-controls.js";
describe("convertLegacyOptions rejects a malformed Codex fast_mode", () => {
test.each(["false", 1, {}])("%j", (value) => {
expect(() =>
convertLegacyOptions({ provider: "codex", options: { fast_mode: value } }),
).toThrow(/fast_mode must be true, false or null/);
});
});
describe("convertLegacyOptions", () => {
test.each([
{ legacy: true, tier: "fast" },
@@ -57,6 +57,9 @@ export function convertLegacyOptions(
const { provider, options } = input;
if (provider !== "codex" || !options || !("fast_mode" in options)) return options;
const { fast_mode: legacy, ...rest } = options;
if (legacy !== undefined && legacy !== null && typeof legacy !== "boolean") {
throw new Error("Codex fast_mode must be true, false or null");
}
if ("service_tier" in rest || legacy === undefined) return rest;
if (legacy === null) return { ...rest, service_tier: null };
return { ...rest, service_tier: legacy ? "fast" : "default" };
@@ -153,9 +156,9 @@ export function foldProfileFeatureValues<T extends ProfileOptionFields>(input: {
}): Omit<T, "featureValues"> {
const { profile, stored, source } = input;
const { featureValues: legacyValues, ...rest } = profile;
// The app reads a profile's speed from its controls, so the old key is converted here for the
// provider id the profile names; a profile on a custom provider is converted when it is applied.
const featureValues = convertLegacyOptions({ provider: profile.provider, options: legacyValues });
// Keys pass through unchanged: renaming an old key needs the adapter the provider runs on, which
// only the agent manager knows.
const featureValues = legacyValues;
if (source === "stored") {
return featureValues ? { ...rest, options: { ...featureValues, ...rest.options } } : rest;
}
@@ -499,12 +499,12 @@ function wrapClientProvider(
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id);
const providerOptions = convertLegacyOptions({
provider: inner.provider,
options: configuredOptions,
});
// Converted where it is used, so a malformed configured default fails the call that reads it
// rather than the registry that holds every provider.
const configuredDefaults = () =>
convertLegacyOptions({ provider: inner.provider, options: configuredOptions });
const withDefaults = (own: ProviderOptions | undefined) =>
mergeProviderOptions(providerOptions, own);
mergeProviderOptions(configuredDefaults(), own);
return {
provider,
@@ -543,7 +543,10 @@ function wrapClientProvider(
),
),
fetchCatalog: async (options, context) => {
const catalog = await inner.fetchCatalog({ ...options, providerOptions }, context);
const catalog = await inner.fetchCatalog(
{ ...options, providerOptions: configuredDefaults() },
context,
);
return {
...catalog,
models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
@@ -577,7 +580,8 @@ function wrapClientProvider(
})
: undefined,
listImportableSessions: listImportableSessions
? async (options) => await listImportableSessions({ ...options, providerOptions })
? async (options) =>
await listImportableSessions({ ...options, providerOptions: configuredDefaults() })
: undefined,
importSession: importSession
? async (input, context) => {
@@ -7,6 +7,11 @@ import { describe, expect, test, vi } from "vitest";
import { AgentManager } from "../agent-manager.js";
import { toAgentPayload } from "../agent-projections.js";
import { AgentStorage } from "../agent-storage.js";
import {
toWireDaemonConfig,
type ConvertProfileOptions,
type MutableDaemonConfig,
} from "../../daemon-config-store.js";
import { convertLegacyOptions } from "../option-controls.js";
import type {
AgentClient,
@@ -890,6 +895,40 @@ describe("Codex app-server provider controls", () => {
},
);
test.each(["codex", "my-codex"])(
"the config API shows a %s profile's legacy fast_mode as service_tier",
(providerId) => {
const { manager } = createLegacyFastHarness({}, providerId);
const stored: MutableDaemonConfig = {
agentProfiles: [
{ id: "p", name: "P", provider: providerId, options: { fast_mode: true } },
],
} as MutableDaemonConfig;
const convert: ConvertProfileOptions = (provider, options) =>
manager.convertLegacyOptions(provider, options);
const wire = toWireDaemonConfig(stored, convert);
expect(wire.agentProfiles?.[0]).toMatchObject({
options: { service_tier: "fast" },
featureValues: { service_tier: "fast" },
});
},
);
test.each([
["a string", "false"],
["a number", 1],
])("a legacy fast_mode that is %s is rejected, not read as Fast", async (_name, value) => {
const { manager, create } = createLegacyFastHarness();
const agentId = await create({});
await expect(manager.setAgentOptions(agentId, { fast_mode: value })).rejects.toThrow(
/fast_mode must be true, false or null/,
);
});
test("an older client turning fast_mode off selects Normal", async () => {
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
const agentId = await create({ service_tier: "priority" });
@@ -239,7 +239,7 @@ describe("DaemonConfigStore", () => {
expect(store.get().agentProfiles).toEqual([
{ id: "p", name: "P", provider: "claude", options: { fast_mode: true } },
]);
expect(toWireDaemonConfig(store.get()).agentProfiles).toEqual([
expect(toWireDaemonConfig(store.get(), (_provider, options) => options).agentProfiles).toEqual([
{
id: "p",
name: "P",
@@ -355,7 +355,7 @@ describe("DaemonConfigStore", () => {
]);
});
test("converts a Codex profile's legacy fast_mode to service_tier", async () => {
test("keeps a profile's legacy fast_mode key; the agent manager renames it", async () => {
const store = storeWithProfile();
await store.patch({
@@ -369,7 +369,7 @@ describe("DaemonConfigStore", () => {
id: "p",
name: "P",
provider: "codex",
options: { sandbox_mode: "read-only", plan_mode: false, service_tier: "fast" },
options: { sandbox_mode: "read-only", plan_mode: false, fast_mode: true },
},
]);
});
@@ -192,10 +192,30 @@ function foldConfigProfiles<T extends { agentProfiles?: AgentProfile[] }>(
};
}
/** The config API boundary: the unchanged app reads profile options from `featureValues`. */
export function toWireDaemonConfig(config: MutableDaemonConfig): MutableDaemonConfig {
/** Renames old option keys for the adapter a provider runs on (the agent manager's conversion). */
export type ConvertProfileOptions = (
provider: string,
options: Record<string, unknown> | undefined,
) => Record<string, unknown> | undefined;
/**
* The config API boundary: clients see each profile's options in their current keys, and the
* unchanged app reads them from `featureValues`.
*/
export function toWireDaemonConfig(
config: MutableDaemonConfig,
convertOptions: ConvertProfileOptions,
): MutableDaemonConfig {
if (!config.agentProfiles) return config;
return { ...config, agentProfiles: config.agentProfiles.map(withLegacyProfileFeatureValues) };
return {
...config,
agentProfiles: config.agentProfiles.map((profile) =>
withLegacyProfileFeatureValues({
...profile,
options: convertOptions(profile.provider, profile.options),
}),
),
};
}
function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
+13 -3
View File
@@ -80,7 +80,11 @@ import {
createWorkspaceScriptsService,
type WorkspaceScriptsService,
} from "./session/workspace-scripts/workspace-scripts-service.js";
import { toWireDaemonConfig, type DaemonConfigStore } from "./daemon-config-store.js";
import {
toWireDaemonConfig,
type DaemonConfigStore,
type MutableDaemonConfig,
} from "./daemon-config-store.js";
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
@@ -2951,7 +2955,7 @@ export class Session {
type: "get_daemon_config_response",
payload: {
requestId: msg.requestId,
config: toWireDaemonConfig(this.daemonConfigStore.get()),
config: this.toWireDaemonConfig(this.daemonConfigStore.get()),
},
});
return undefined;
@@ -2976,7 +2980,7 @@ export class Session {
type: "set_daemon_config_response",
payload: {
requestId: msg.requestId,
config: toWireDaemonConfig(config),
config: this.toWireDaemonConfig(config),
},
});
});
@@ -8623,6 +8627,12 @@ export class Session {
return true;
}
private toWireDaemonConfig(config: MutableDaemonConfig): MutableDaemonConfig {
return toWireDaemonConfig(config, (provider, options) =>
this.agentManager.convertLegacyOptions(provider, options),
);
}
private emit(msg: SessionOutboundMessage): void {
if (!this.authorization.allowsOutbound(msg)) return;
if (msg.type === "workspace_setup_progress") {
@@ -2201,7 +2201,9 @@ export class VoiceAssistantWebSocketServer {
type: "status",
payload: {
status: "daemon_config_changed",
config: toWireDaemonConfig(config),
config: toWireDaemonConfig(config, (provider, options) =>
this.agentManager.convertLegacyOptions(provider, options),
),
},
});
}