mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user