fix(server): apply an old app's profile featureValues over the profile's stored options

The unchanged app's profile form only knows the controls, so a name-only
edit dropped options it never shows, such as sandbox_mode. Its
featureValues now apply as a patch over the options already stored for the
profile (null removes a key), and a write with no featureValues keeps them.
This commit is contained in:
Mohamed Boudra
2026-11-01 15:10:35 +01:00
parent ac6b5440e8
commit 2dd83e43cb
3 changed files with 96 additions and 11 deletions
@@ -93,23 +93,28 @@ interface ProfileOptionFields {
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* Profiles saved before options carry `featureValues`. When the profile comes from storage,
* `options` wins over a stale `featureValues` key. When it comes from a client write, the
* client that sent `featureValues` is the unchanged app, which only knows that field, so its
* `featureValues` win over the `options` it received from the previous read.
* 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.
*/
export function foldProfileFeatureValues<T extends ProfileOptionFields>(
profile: T,
stored: Record<string, unknown> | undefined,
source: "stored" | "client write" = "stored",
): Omit<T, "featureValues"> {
const { featureValues, ...rest } = profile;
if (!featureValues) return rest;
if (source === "stored") {
return featureValues ? { ...rest, options: { ...featureValues, ...rest.options } } : rest;
}
// 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:
source === "stored"
? { ...featureValues, ...rest.options }
: { ...rest.options, ...featureValues },
options: applyProviderOptionsPatch(stored ?? rest.options, featureValues ?? {}),
};
}
@@ -250,6 +250,79 @@ describe("DaemonConfigStore", () => {
]);
});
describe("a profile edited by the unchanged app", () => {
function storeWithProfile(): 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: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
agentProfiles: [
{
id: "p",
name: "P",
provider: "codex",
options: { sandbox_mode: "read-only", fast_mode: false },
},
],
});
}
test("keeps the options its form never shows when it changes a control", async () => {
const store = storeWithProfile();
await store.patch({
agentProfiles: [
{ id: "p", name: "Renamed", provider: "codex", featureValues: { fast_mode: true } },
],
});
expect(store.get().agentProfiles).toEqual([
{
id: "p",
name: "Renamed",
provider: "codex",
options: { sandbox_mode: "read-only", fast_mode: true },
},
]);
});
test("keeps every option when it sends no featureValues at all", async () => {
const store = storeWithProfile();
await store.patch({ agentProfiles: [{ id: "p", name: "Renamed", provider: "codex" }] });
expect(store.get().agentProfiles).toEqual([
{
id: "p",
name: "Renamed",
provider: "codex",
options: { sandbox_mode: "read-only", fast_mode: false },
},
]);
});
test("removes an option whose featureValues entry is null", async () => {
const store = storeWithProfile();
await store.patch({
agentProfiles: [
{ id: "p", name: "P", provider: "codex", featureValues: { fast_mode: null } },
],
});
expect(store.get().agentProfiles).toEqual([
{ id: "p", name: "P", provider: "codex", options: { sandbox_mode: "read-only" } },
]);
});
});
test("a profile stored with featureValues loads as options", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -177,11 +177,18 @@ function omitProvidersFromOverrides(
function foldConfigProfiles<T extends { agentProfiles?: AgentProfile[] }>(
config: T,
source: "stored" | "client write",
held: AgentProfile[] = [],
): T {
if (!config.agentProfiles) return config;
return {
...config,
agentProfiles: config.agentProfiles.map((profile) => foldProfileFeatureValues(profile, source)),
agentProfiles: config.agentProfiles.map((profile) =>
foldProfileFeatureValues(
profile,
held.find((candidate) => candidate.id === profile.id)?.options,
source,
),
),
};
}
@@ -442,7 +449,7 @@ export class DaemonConfigStore {
);
}
const { removeProviders = [], ...submittedPatch } = parsedPatch;
const patch = foldConfigProfiles(submittedPatch, "client write");
const patch = foldConfigProfiles(submittedPatch, "client write", this.current.agentProfiles);
const { features: speechPatch, ...configPatch } = patch;
const removedProviders = Array.from(new Set(removeProviders));
const merged = deepMerge(this.current, configPatch);