diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index c55150b51f..b876a35031 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -13600,6 +13600,27 @@ describe("agent options", () => { ]); }); + test("a stored record with a malformed legacy speed still reads; opening it is what rejects it", () => { + const manager = new AgentManager({ clients: { codex: new OptionsClient() }, logger }); + // An older daemon accepted any value here; this one rejects it on create and on change. + const record = parseStoredAgentRecord({ + id: "agent-legacy-malformed", + provider: "codex", + cwd: "/repo", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + lastStatus: "closed", + config: { providerOptions: { fast_mode: "false" } }, + persistence: { provider: "codex", sessionId: "thread-2" }, + }); + + const payload = buildStoredAgentPayload(record, ["codex"], (provider, own) => + manager.effectiveOptionsFor(provider, own), + ); + + expect(payload.options).toEqual({ service_tier: "default", fast_mode: "false" }); + }); + test("setAgentOptions hands the provider only the keys the patch sets", async () => { const { manager, client, storage, agentId } = await createOptionsAgent({ web_search: true }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 4d2db2024a..880709f152 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -68,6 +68,7 @@ import { applyProviderOptionsPatch, exposeFeatureValuesToHook, convertLegacyOptions, + MalformedLegacyOptionError, resolveAdapter, foldHookFeatureValues, type ProviderOptionsPatch, @@ -2578,14 +2579,28 @@ export class AgentManager { /** * What an agent of this provider runs with, given its own options: the defaults under them. * Stored records are read through here before they are reopened, so the legacy conversion - * runs here too; a record converted on open passes through unchanged. + * runs here too; a record converted on open passes through unchanged. A legacy value the + * conversion cannot read stays as stored: this describes the record, and opening or changing + * the agent is what rejects the value. */ effectiveOptionsFor(provider: AgentProvider, own: ProviderOptions | undefined): ProviderOptions { const client = this.clients.get(provider); - const converted = this.convertLegacyOptions(provider, own); + const converted = this.convertStoredOptions(provider, own); return client ? this.launchOptionsFor({ client, own: converted }) : (converted ?? {}); } + private convertStoredOptions( + provider: AgentProvider, + own: ProviderOptions | undefined, + ): ProviderOptions | undefined { + try { + return this.convertLegacyOptions(provider, own); + } catch (error) { + if (error instanceof MalformedLegacyOptionError) return own; + throw error; + } + } + async setTitle(agentId: string, title: string): Promise { const agent = this.requireAgent(agentId); const normalizedTitle = title.trim(); diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts index e8e9ef03b3..f1c4183b26 100644 --- a/packages/server/src/server/agent/option-controls.ts +++ b/packages/server/src/server/agent/option-controls.ts @@ -40,6 +40,14 @@ interface LegacyOptionsInput { options: O; } +/** A legacy option value the conversion cannot read. */ +export class MalformedLegacyOptionError extends Error { + constructor(message: string) { + super(message); + this.name = "MalformedLegacyOptionError"; + } +} + export function convertLegacyOptions( input: LegacyOptionsInput>, ): Record; @@ -53,7 +61,7 @@ export function convertLegacyOptions( 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"); + throw new MalformedLegacyOptionError("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 };