From fbd7890f788c650c053c6c4c533e27c3f3ec7ebf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 21:03:31 +0100 Subject: [PATCH] fix(server): a profile is stored in the current option keys, converted for the adapter its provider extends Profiles in config.json are old data entering, so the store converts them (stored value and incoming patch) with the same conversion the agent manager uses. Storage and clients now share one vocabulary: resetting a legacy Codex profile's speed removes what was shown, and a malformed fast_mode rejects the write instead of breaking later config reads. The config API goes back to copying the field shape only. The extends chain is followed by one function over either the registry's definitions or config.json's providers. --- .../server/src/server/agent/agent-manager.ts | 15 ++- .../src/server/agent/option-controls.ts | 39 ++++++-- .../codex-app-server-agent.features.test.ts | 27 ------ .../src/server/daemon-config-store.test.ts | 81 +++++++++++++++- .../server/src/server/daemon-config-store.ts | 92 +++++++++++-------- packages/server/src/server/session.ts | 16 +--- .../server/src/server/websocket-server.ts | 4 +- 7 files changed, 174 insertions(+), 100 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index af776d7811..23ad5be23e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -66,6 +66,7 @@ import { applyProviderOptionsPatch, exposeFeatureValuesToHook, convertLegacyOptions, + resolveAdapter, foldHookFeatureValues, type ProviderOptionsPatch, } from "./option-controls.js"; @@ -5691,21 +5692,17 @@ export class AgentManager { /** The adapter a provider id runs on: a custom provider is named by the one it extends. */ private adapterOf(provider: AgentProvider): AgentProvider { - const seen = new Set(); - let current = provider; - for (;;) { - seen.add(current); - const base = this.providerDefinitions.get(current)?.derivedFromProviderId; - if (!base || seen.has(base)) return current; - current = base; - } + return resolveAdapter({ + provider, + extendsOf: (id) => this.providerDefinitions.get(id)?.derivedFromProviderId, + }); } /** * 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. */ - convertLegacyOptions( + private convertLegacyOptions( provider: AgentProvider, options: T, ): T { diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts index 9b82b113cb..9e843f6fe2 100644 --- a/packages/server/src/server/agent/option-controls.ts +++ b/packages/server/src/server/agent/option-controls.ts @@ -65,6 +65,25 @@ export function convertLegacyOptions( return { ...rest, service_tier: legacy ? "fast" : "default" }; } +/** + * The built-in adapter a provider id runs on: a custom provider is named by the one it + * `extends`, followed until a provider that extends nothing. `extendsOf` is where the caller + * reads that link (the registry's definitions, or `config.json`'s `providers` section). + */ +export function resolveAdapter(input: { + provider: string; + extendsOf: (provider: string) => string | null | undefined; +}): string { + const seen = new Set(); + let current = input.provider; + for (;;) { + seen.add(current); + const base = input.extendsOf(current); + if (!base || seen.has(base)) return current; + current = base; + } +} + /** * COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. * Older clients, stored records, profiles, and plugin hooks carry composer values in @@ -131,6 +150,8 @@ export function foldHookFeatureValues< }; } +type ConvertedOptions = Record | undefined; + interface ProfileOptionFields { provider?: string; options?: Record; @@ -153,16 +174,22 @@ export function foldProfileFeatureValues(input: { profile: T; stored: ProfileOptionFields | undefined; source: "stored" | "client write"; + /** Renames old option keys for the adapter the profile's provider runs on. */ + convert: (provider: string, options: Record | undefined) => ConvertedOptions; }): Omit { - const { profile, stored, source } = input; + const { profile, stored, source, convert } = input; const { featureValues: legacyValues, ...rest } = profile; - // Keys pass through unchanged: renaming an old key needs the adapter the provider runs on, which - // only the agent manager knows. - const featureValues = legacyValues; + // A profile is stored in the current keys, so removal and merging below see one vocabulary. + const provider = rest.provider ?? ""; + const featureValues = convert(provider, legacyValues); + const ownOptions = convert(provider, rest.options); if (source === "stored") { - return featureValues ? { ...rest, options: { ...featureValues, ...rest.options } } : rest; + if (featureValues) return { ...rest, options: { ...featureValues, ...ownOptions } }; + return ownOptions ? { ...rest, options: ownOptions } : rest; } - const base = rest.options ?? (stored?.provider === rest.provider ? stored?.options : undefined); + const base = + ownOptions ?? + (stored?.provider === rest.provider ? convert(provider, stored?.options) : undefined); // The form sends no `featureValues` when no control is set, which is still an edit of a // profile that may hold other options. if (!featureValues && !base) return rest; 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 e4bcca79e4..4009acc3be 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 @@ -7,11 +7,6 @@ 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, @@ -895,28 +890,6 @@ 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], diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 90b77c8070..cf5677e744 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -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(), (_provider, options) => options).agentProfiles).toEqual([ + expect(toWireDaemonConfig(store.get()).agentProfiles).toEqual([ { id: "p", name: "P", @@ -355,7 +355,7 @@ describe("DaemonConfigStore", () => { ]); }); - test("keeps a profile's legacy fast_mode key; the agent manager renames it", async () => { + test("converts a Codex profile's legacy fast_mode to service_tier", 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, fast_mode: true }, + options: { sandbox_mode: "read-only", plan_mode: false, service_tier: "fast" }, }, ]); }); @@ -416,6 +416,81 @@ describe("DaemonConfigStore", () => { ]); }); + describe("a profile holding Codex's legacy fast_mode", () => { + const legacyProfiles = ["codex", "my-codex"].map((provider) => ({ + id: provider, + name: provider, + provider, + options: { fast_mode: true }, + })); + + function storeWithLegacyProfiles(agentProfiles: unknown[] = legacyProfiles): DaemonConfigStore { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + return new DaemonConfigStore(paseoHome, { + relay: { enabled: false }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: { "my-codex": { extends: "codex" } }, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + agentProfiles, + } as MutableDaemonConfig); + } + + test("reads the same for codex and a provider that extends it", () => { + const store = storeWithLegacyProfiles(); + + expect(store.get().agentProfiles).toEqual([ + { id: "codex", name: "codex", provider: "codex", options: { service_tier: "fast" } }, + { + id: "my-codex", + name: "my-codex", + provider: "my-codex", + options: { service_tier: "fast" }, + }, + ]); + expect(toWireDaemonConfig(store.get()).agentProfiles).toEqual([ + expect.objectContaining({ featureValues: { service_tier: "fast" } }), + expect.objectContaining({ featureValues: { service_tier: "fast" } }), + ]); + }); + + test.each(["codex", "my-codex"])("resetting %s's speed removes what was shown", async (id) => { + const store = storeWithLegacyProfiles(); + + await store.patch({ + agentProfiles: [{ id, name: id, provider: id, featureValues: { service_tier: null } }], + }); + + expect(store.get().agentProfiles).toEqual([{ id, name: id, provider: id, options: {} }]); + }); + + test("a malformed value rejects the write and leaves config reads working", async () => { + const store = storeWithLegacyProfiles(); + + await expect( + store.patch({ + agentProfiles: [ + { id: "p", name: "P", provider: "my-codex", options: { fast_mode: "false" } }, + ], + }), + ).rejects.toThrow(/fast_mode must be true, false or null/); + + expect(toWireDaemonConfig(store.get()).agentProfiles).toHaveLength(2); + }); + + test("a malformed value in a stored profile does not stop the config from loading", () => { + const store = storeWithLegacyProfiles([ + { id: "p", name: "P", provider: "codex", options: { fast_mode: "false" } }, + ]); + + expect(store.get().agentProfiles?.[0]?.options).toEqual({ fast_mode: "false" }); + }); + }); + test("patch replaces the whole agent profile list rather than merging entries", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 483e91d9a2..55b7cd0caa 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -6,7 +6,9 @@ import { } from "./persisted-config.js"; import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; import { + convertLegacyOptions, foldProfileFeatureValues, + resolveAdapter, withLegacyProfileFeatureValues, } from "./agent/option-controls.js"; import type { AgentProfile } from "@getpaseo/protocol/agent-profile"; @@ -173,49 +175,52 @@ function omitProvidersFromOverrides( return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined; } -/** Profiles are held and persisted with `options` only. */ -function foldConfigProfiles( - config: T, - source: "stored" | "client write", - held: AgentProfile[] = [], -): T { +/** + * Profiles are held and persisted with `options` only, in the current keys: a profile is old + * data entering, so it is converted for the adapter its provider runs on, which `config.json`'s + * `providers` section names through `extends`. + */ +function foldConfigProfiles(input: { + config: T; + providers: Record | undefined; + source: "stored" | "client write"; + held?: AgentProfile[]; +}): T { + const { config, providers, source, held = [] } = input; if (!config.agentProfiles) return config; + const convert = (provider: string, options: Record | undefined) => + convertLegacyOptions({ + provider: resolveAdapter({ provider, extendsOf: (id) => providers?.[id]?.extends }), + options, + }); + const identity = (_provider: string, options: Record | undefined) => options; + const foldWith = (profile: AgentProfile, convertWith: typeof convert) => + foldProfileFeatureValues({ + profile, + stored: held.find((candidate) => candidate.id === profile.id), + source, + convert: convertWith, + }); return { ...config, - agentProfiles: config.agentProfiles.map((profile) => - foldProfileFeatureValues({ - profile, - stored: held.find((candidate) => candidate.id === profile.id), - source, - }), - ), + agentProfiles: config.agentProfiles.map((profile) => { + // A malformed value rejects a client write before anything is committed. + if (source === "client write") return foldWith(profile, convert); + try { + return foldWith(profile, convert); + } catch { + // A hand-edited `config.json` value that cannot convert must not stop the daemon from + // starting; it keeps its keys and is rejected when an agent is created from it. + return foldWith(profile, identity); + } + }), }; } -/** Renames old option keys for the adapter a provider runs on (the agent manager's conversion). */ -export type ConvertProfileOptions = ( - provider: string, - options: Record | undefined, -) => Record | 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 { +/** The config API boundary: the unchanged app reads profile options from `featureValues`. */ +export function toWireDaemonConfig(config: MutableDaemonConfig): MutableDaemonConfig { if (!config.agentProfiles) return config; - return { - ...config, - agentProfiles: config.agentProfiles.map((profile) => - withLegacyProfileFeatureValues({ - ...profile, - options: convertOptions(profile.provider, profile.options), - }), - ), - }; + return { ...config, agentProfiles: config.agentProfiles.map(withLegacyProfileFeatureValues) }; } function getValueAtPath(config: MutableDaemonConfig, path: string): unknown { @@ -430,7 +435,7 @@ export class DaemonConfigStore { this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger); this.lastKnownPersisted = this.startupPersisted; this.current = MutableDaemonConfigSchema.parse({ - ...foldConfigProfiles(initial, "stored"), + ...foldConfigProfiles({ config: initial, providers: initial.providers, source: "stored" }), relay: initial.relay ?? { enabled: true }, ...this.readSpeech(this.startupPersisted), }); @@ -469,7 +474,12 @@ export class DaemonConfigStore { ); } const { removeProviders = [], ...submittedPatch } = parsedPatch; - const patch = foldConfigProfiles(submittedPatch, "client write", this.current.agentProfiles); + const patch = foldConfigProfiles({ + config: submittedPatch, + providers: { ...this.current.providers, ...submittedPatch.providers }, + source: "client write", + held: this.current.agentProfiles, + }); const { features: speechPatch, ...configPatch } = patch; const removedProviders = Array.from(new Set(removeProviders)); const merged = deepMerge(this.current, configPatch); @@ -555,7 +565,11 @@ export class DaemonConfigStore { // Plugin source changes require the plugin lifecycle operation or a daemon // restart. The global switch is independently reloadable. const desired = MutableDaemonConfigSchema.parse({ - ...foldConfigProfiles(resolved.mutable, "stored"), + ...foldConfigProfiles({ + config: resolved.mutable, + providers: resolved.mutable.providers, + source: "stored", + }), plugins: this.current.plugins, ...this.readSpeech(persisted), }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 46c28c065e..7c4f3405a2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -80,11 +80,7 @@ import { createWorkspaceScriptsService, type WorkspaceScriptsService, } from "./session/workspace-scripts/workspace-scripts-service.js"; -import { - toWireDaemonConfig, - type DaemonConfigStore, - type MutableDaemonConfig, -} from "./daemon-config-store.js"; +import { toWireDaemonConfig, type DaemonConfigStore } 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"; @@ -2955,7 +2951,7 @@ export class Session { type: "get_daemon_config_response", payload: { requestId: msg.requestId, - config: this.toWireDaemonConfig(this.daemonConfigStore.get()), + config: toWireDaemonConfig(this.daemonConfigStore.get()), }, }); return undefined; @@ -2980,7 +2976,7 @@ export class Session { type: "set_daemon_config_response", payload: { requestId: msg.requestId, - config: this.toWireDaemonConfig(config), + config: toWireDaemonConfig(config), }, }); }); @@ -8627,12 +8623,6 @@ 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") { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 8f23cda3f4..2c3828353e 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2201,9 +2201,7 @@ export class VoiceAssistantWebSocketServer { type: "status", payload: { status: "daemon_config_changed", - config: toWireDaemonConfig(config, (provider, options) => - this.agentManager.convertLegacyOptions(provider, options), - ), + config: toWireDaemonConfig(config), }, }); }