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.
This commit is contained in:
Mohamed Boudra
2026-11-01 15:15:20 +01:00
parent 9d8747cd3a
commit a0754e9f9b
3 changed files with 59 additions and 10 deletions
@@ -87,6 +87,7 @@ export function foldHookFeatureValues<
}
interface ProfileOptionFields {
provider?: string;
options?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}
@@ -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<T extends ProfileOptionFields>(
profile: T,
stored: Record<string, unknown> | undefined,
stored: ProfileOptionFields | undefined,
source: "stored" | "client write" = "stored",
): Omit<T, "featureValues"> {
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 ?? {}) };
}
/**
@@ -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();
@@ -185,7 +185,7 @@ function foldConfigProfiles<T extends { agentProfiles?: AgentProfile[] }>(
agentProfiles: config.agentProfiles.map((profile) =>
foldProfileFeatureValues(
profile,
held.find((candidate) => candidate.id === profile.id)?.options,
held.find((candidate) => candidate.id === profile.id),
source,
),
),