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), }, }); }