From a0754e9f9b376c6045103a3c7e13317b581c0266 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 15:15:20 +0100 Subject: [PATCH] fix(server): fold an old app's profile featureValues by the provider and options the write carries A write with options folds featureValues over those options. A write without them patches the stored options, unless the profile moved to another provider, which starts from no options. --- .../src/server/agent/option-controls.ts | 20 ++++---- .../src/server/daemon-config-store.test.ts | 47 +++++++++++++++++++ .../server/src/server/daemon-config-store.ts | 2 +- 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts index 220a3d051a..f52c0dd6dd 100644 --- a/packages/server/src/server/agent/option-controls.ts +++ b/packages/server/src/server/agent/option-controls.ts @@ -87,6 +87,7 @@ export function foldHookFeatureValues< } interface ProfileOptionFields { + provider?: string; options?: Record; featureValues?: Record; } @@ -96,26 +97,27 @@ interface ProfileOptionFields { * Profiles saved before options carry `featureValues`; when one comes from storage, `options` * wins over a stale `featureValues` key. * - * A profile written by the unchanged app is a patch: its form only knows the controls, so its - * `featureValues` are applied over the options already stored for that profile (`null` - * deletes), and options the form never shows, such as `sandbox_mode`, survive an edit. + * A write that carries `options` is from a client that knows them: its `featureValues` fold + * over those. A write without `options` is from the unchanged app, whose form only knows the + * controls: its `featureValues` are a patch (`null` deletes) over the options stored for that + * profile, so options the form never shows, such as `sandbox_mode`, survive an edit. A profile + * moved to another provider starts from no options, because the old provider's keys mean + * nothing to the new one. */ export function foldProfileFeatureValues( profile: T, - stored: Record | undefined, + stored: ProfileOptionFields | undefined, source: "stored" | "client write" = "stored", ): Omit { const { featureValues, ...rest } = profile; if (source === "stored") { return featureValues ? { ...rest, options: { ...featureValues, ...rest.options } } : rest; } + const base = rest.options ?? (stored?.provider === rest.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 && (rest.options || !stored)) return rest; - return { - ...rest, - options: applyProviderOptionsPatch(stored ?? rest.options, featureValues ?? {}), - }; + if (!featureValues && !base) return rest; + return { ...rest, options: applyProviderOptionsPatch(base, featureValues ?? {}) }; } /** diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 02d09f93eb..02e64236fd 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -308,6 +308,53 @@ describe("DaemonConfigStore", () => { ]); }); + test("starts from no options when it moves the profile to another provider", async () => { + const store = storeWithProfile(); + + await store.patch({ + agentProfiles: [ + { id: "p", name: "P", provider: "claude", featureValues: { fast_mode: true } }, + ], + }); + + expect(store.get().agentProfiles).toEqual([ + { id: "p", name: "P", provider: "claude", options: { fast_mode: true } }, + ]); + }); + + test("drops the old provider's options when it moves the profile with no featureValues", async () => { + const store = storeWithProfile(); + + await store.patch({ agentProfiles: [{ id: "p", name: "P", provider: "claude" }] }); + + expect(store.get().agentProfiles).toEqual([{ id: "p", name: "P", provider: "claude" }]); + }); + + test("folds featureValues over the options a write carries, not over the stored ones", async () => { + const store = storeWithProfile(); + + await store.patch({ + agentProfiles: [ + { + id: "p", + name: "P", + provider: "codex", + options: { sandbox_mode: "workspace-write" }, + featureValues: { fast_mode: true }, + }, + ], + }); + + expect(store.get().agentProfiles).toEqual([ + { + id: "p", + name: "P", + provider: "codex", + options: { sandbox_mode: "workspace-write", fast_mode: true }, + }, + ]); + }); + test("removes an option whose featureValues entry is null", async () => { const store = storeWithProfile(); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 9dfe7c4fcc..6e450f0fef 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -185,7 +185,7 @@ function foldConfigProfiles( agentProfiles: config.agentProfiles.map((profile) => foldProfileFeatureValues( profile, - held.find((candidate) => candidate.id === profile.id)?.options, + held.find((candidate) => candidate.id === profile.id), source, ), ),