mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
fix(server): a profile is stored in the current option keys, converted for the adapter its provider extends
Profiles in config.json are old data entering, so the store converts them (stored value and incoming patch) with the same conversion the agent manager uses. Storage and clients now share one vocabulary: resetting a legacy Codex profile's speed removes what was shown, and a malformed fast_mode rejects the write instead of breaking later config reads. The config API goes back to copying the field shape only. The extends chain is followed by one function over either the registry's definitions or config.json's providers.
This commit is contained in:
@@ -66,6 +66,7 @@ import {
|
||||
applyProviderOptionsPatch,
|
||||
exposeFeatureValuesToHook,
|
||||
convertLegacyOptions,
|
||||
resolveAdapter,
|
||||
foldHookFeatureValues,
|
||||
type ProviderOptionsPatch,
|
||||
} from "./option-controls.js";
|
||||
@@ -5691,21 +5692,17 @@ export class AgentManager {
|
||||
|
||||
/** The adapter a provider id runs on: a custom provider is named by the one it extends. */
|
||||
private adapterOf(provider: AgentProvider): AgentProvider {
|
||||
const seen = new Set<AgentProvider>();
|
||||
let current = provider;
|
||||
for (;;) {
|
||||
seen.add(current);
|
||||
const base = this.providerDefinitions.get(current)?.derivedFromProviderId;
|
||||
if (!base || seen.has(base)) return current;
|
||||
current = base;
|
||||
}
|
||||
return resolveAdapter({
|
||||
provider,
|
||||
extendsOf: (id) => this.providerDefinitions.get(id)?.derivedFromProviderId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Options older data carries are converted for the adapter the provider runs on, before
|
||||
* defaults merge and before anything is stored; nothing past the manager sees the old keys.
|
||||
*/
|
||||
convertLegacyOptions<T extends ProviderOptions | undefined>(
|
||||
private convertLegacyOptions<T extends ProviderOptions | undefined>(
|
||||
provider: AgentProvider,
|
||||
options: T,
|
||||
): T {
|
||||
|
||||
@@ -65,6 +65,25 @@ export function convertLegacyOptions(
|
||||
return { ...rest, service_tier: legacy ? "fast" : "default" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in adapter a provider id runs on: a custom provider is named by the one it
|
||||
* `extends`, followed until a provider that extends nothing. `extendsOf` is where the caller
|
||||
* reads that link (the registry's definitions, or `config.json`'s `providers` section).
|
||||
*/
|
||||
export function resolveAdapter(input: {
|
||||
provider: string;
|
||||
extendsOf: (provider: string) => string | null | undefined;
|
||||
}): string {
|
||||
const seen = new Set<string>();
|
||||
let current = input.provider;
|
||||
for (;;) {
|
||||
seen.add(current);
|
||||
const base = input.extendsOf(current);
|
||||
if (!base || seen.has(base)) return current;
|
||||
current = base;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
* Older clients, stored records, profiles, and plugin hooks carry composer values in
|
||||
@@ -131,6 +150,8 @@ export function foldHookFeatureValues<
|
||||
};
|
||||
}
|
||||
|
||||
type ConvertedOptions = Record<string, unknown> | undefined;
|
||||
|
||||
interface ProfileOptionFields {
|
||||
provider?: string;
|
||||
options?: Record<string, unknown>;
|
||||
@@ -153,16 +174,22 @@ export function foldProfileFeatureValues<T extends ProfileOptionFields>(input: {
|
||||
profile: T;
|
||||
stored: ProfileOptionFields | undefined;
|
||||
source: "stored" | "client write";
|
||||
/** Renames old option keys for the adapter the profile's provider runs on. */
|
||||
convert: (provider: string, options: Record<string, unknown> | undefined) => ConvertedOptions;
|
||||
}): Omit<T, "featureValues"> {
|
||||
const { profile, stored, source } = input;
|
||||
const { profile, stored, source, convert } = input;
|
||||
const { featureValues: legacyValues, ...rest } = profile;
|
||||
// Keys pass through unchanged: renaming an old key needs the adapter the provider runs on, which
|
||||
// only the agent manager knows.
|
||||
const featureValues = legacyValues;
|
||||
// A profile is stored in the current keys, so removal and merging below see one vocabulary.
|
||||
const provider = rest.provider ?? "";
|
||||
const featureValues = convert(provider, legacyValues);
|
||||
const ownOptions = convert(provider, rest.options);
|
||||
if (source === "stored") {
|
||||
return featureValues ? { ...rest, options: { ...featureValues, ...rest.options } } : rest;
|
||||
if (featureValues) return { ...rest, options: { ...featureValues, ...ownOptions } };
|
||||
return ownOptions ? { ...rest, options: ownOptions } : rest;
|
||||
}
|
||||
const base = rest.options ?? (stored?.provider === rest.provider ? stored?.options : undefined);
|
||||
const base =
|
||||
ownOptions ??
|
||||
(stored?.provider === rest.provider ? convert(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 && !base) return rest;
|
||||
|
||||
@@ -7,11 +7,6 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import { toAgentPayload } from "../agent-projections.js";
|
||||
import { AgentStorage } from "../agent-storage.js";
|
||||
import {
|
||||
toWireDaemonConfig,
|
||||
type ConvertProfileOptions,
|
||||
type MutableDaemonConfig,
|
||||
} from "../../daemon-config-store.js";
|
||||
import { convertLegacyOptions } from "../option-controls.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
@@ -895,28 +890,6 @@ describe("Codex app-server provider controls", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["codex", "my-codex"])(
|
||||
"the config API shows a %s profile's legacy fast_mode as service_tier",
|
||||
(providerId) => {
|
||||
const { manager } = createLegacyFastHarness({}, providerId);
|
||||
const stored: MutableDaemonConfig = {
|
||||
agentProfiles: [
|
||||
{ id: "p", name: "P", provider: providerId, options: { fast_mode: true } },
|
||||
],
|
||||
} as MutableDaemonConfig;
|
||||
|
||||
const convert: ConvertProfileOptions = (provider, options) =>
|
||||
manager.convertLegacyOptions(provider, options);
|
||||
|
||||
const wire = toWireDaemonConfig(stored, convert);
|
||||
|
||||
expect(wire.agentProfiles?.[0]).toMatchObject({
|
||||
options: { service_tier: "fast" },
|
||||
featureValues: { service_tier: "fast" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
["a string", "false"],
|
||||
["a number", 1],
|
||||
|
||||
@@ -239,7 +239,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(store.get().agentProfiles).toEqual([
|
||||
{ id: "p", name: "P", provider: "claude", options: { fast_mode: true } },
|
||||
]);
|
||||
expect(toWireDaemonConfig(store.get(), (_provider, options) => options).agentProfiles).toEqual([
|
||||
expect(toWireDaemonConfig(store.get()).agentProfiles).toEqual([
|
||||
{
|
||||
id: "p",
|
||||
name: "P",
|
||||
@@ -355,7 +355,7 @@ describe("DaemonConfigStore", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps a profile's legacy fast_mode key; the agent manager renames it", async () => {
|
||||
test("converts a Codex profile's legacy fast_mode to service_tier", async () => {
|
||||
const store = storeWithProfile();
|
||||
|
||||
await store.patch({
|
||||
@@ -369,7 +369,7 @@ describe("DaemonConfigStore", () => {
|
||||
id: "p",
|
||||
name: "P",
|
||||
provider: "codex",
|
||||
options: { sandbox_mode: "read-only", plan_mode: false, fast_mode: true },
|
||||
options: { sandbox_mode: "read-only", plan_mode: false, service_tier: "fast" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -416,6 +416,81 @@ describe("DaemonConfigStore", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
describe("a profile holding Codex's legacy fast_mode", () => {
|
||||
const legacyProfiles = ["codex", "my-codex"].map((provider) => ({
|
||||
id: provider,
|
||||
name: provider,
|
||||
provider,
|
||||
options: { fast_mode: true },
|
||||
}));
|
||||
|
||||
function storeWithLegacyProfiles(agentProfiles: unknown[] = legacyProfiles): 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: { "my-codex": { extends: "codex" } },
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
appendSystemPrompt: "",
|
||||
agentProfiles,
|
||||
} as MutableDaemonConfig);
|
||||
}
|
||||
|
||||
test("reads the same for codex and a provider that extends it", () => {
|
||||
const store = storeWithLegacyProfiles();
|
||||
|
||||
expect(store.get().agentProfiles).toEqual([
|
||||
{ id: "codex", name: "codex", provider: "codex", options: { service_tier: "fast" } },
|
||||
{
|
||||
id: "my-codex",
|
||||
name: "my-codex",
|
||||
provider: "my-codex",
|
||||
options: { service_tier: "fast" },
|
||||
},
|
||||
]);
|
||||
expect(toWireDaemonConfig(store.get()).agentProfiles).toEqual([
|
||||
expect.objectContaining({ featureValues: { service_tier: "fast" } }),
|
||||
expect.objectContaining({ featureValues: { service_tier: "fast" } }),
|
||||
]);
|
||||
});
|
||||
|
||||
test.each(["codex", "my-codex"])("resetting %s's speed removes what was shown", async (id) => {
|
||||
const store = storeWithLegacyProfiles();
|
||||
|
||||
await store.patch({
|
||||
agentProfiles: [{ id, name: id, provider: id, featureValues: { service_tier: null } }],
|
||||
});
|
||||
|
||||
expect(store.get().agentProfiles).toEqual([{ id, name: id, provider: id, options: {} }]);
|
||||
});
|
||||
|
||||
test("a malformed value rejects the write and leaves config reads working", async () => {
|
||||
const store = storeWithLegacyProfiles();
|
||||
|
||||
await expect(
|
||||
store.patch({
|
||||
agentProfiles: [
|
||||
{ id: "p", name: "P", provider: "my-codex", options: { fast_mode: "false" } },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/fast_mode must be true, false or null/);
|
||||
|
||||
expect(toWireDaemonConfig(store.get()).agentProfiles).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a malformed value in a stored profile does not stop the config from loading", () => {
|
||||
const store = storeWithLegacyProfiles([
|
||||
{ id: "p", name: "P", provider: "codex", options: { fast_mode: "false" } },
|
||||
]);
|
||||
|
||||
expect(store.get().agentProfiles?.[0]?.options).toEqual({ fast_mode: "false" });
|
||||
});
|
||||
});
|
||||
|
||||
test("patch replaces the whole agent profile list rather than merging entries", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
} from "./persisted-config.js";
|
||||
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
||||
import {
|
||||
convertLegacyOptions,
|
||||
foldProfileFeatureValues,
|
||||
resolveAdapter,
|
||||
withLegacyProfileFeatureValues,
|
||||
} from "./agent/option-controls.js";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/agent-profile";
|
||||
@@ -173,49 +175,52 @@ function omitProvidersFromOverrides(
|
||||
return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined;
|
||||
}
|
||||
|
||||
/** Profiles are held and persisted with `options` only. */
|
||||
function foldConfigProfiles<T extends { agentProfiles?: AgentProfile[] }>(
|
||||
config: T,
|
||||
source: "stored" | "client write",
|
||||
held: AgentProfile[] = [],
|
||||
): T {
|
||||
/**
|
||||
* Profiles are held and persisted with `options` only, in the current keys: a profile is old
|
||||
* data entering, so it is converted for the adapter its provider runs on, which `config.json`'s
|
||||
* `providers` section names through `extends`.
|
||||
*/
|
||||
function foldConfigProfiles<T extends { agentProfiles?: AgentProfile[] }>(input: {
|
||||
config: T;
|
||||
providers: Record<string, ProviderOverride> | undefined;
|
||||
source: "stored" | "client write";
|
||||
held?: AgentProfile[];
|
||||
}): T {
|
||||
const { config, providers, source, held = [] } = input;
|
||||
if (!config.agentProfiles) return config;
|
||||
const convert = (provider: string, options: Record<string, unknown> | undefined) =>
|
||||
convertLegacyOptions({
|
||||
provider: resolveAdapter({ provider, extendsOf: (id) => providers?.[id]?.extends }),
|
||||
options,
|
||||
});
|
||||
const identity = (_provider: string, options: Record<string, unknown> | undefined) => options;
|
||||
const foldWith = (profile: AgentProfile, convertWith: typeof convert) =>
|
||||
foldProfileFeatureValues({
|
||||
profile,
|
||||
stored: held.find((candidate) => candidate.id === profile.id),
|
||||
source,
|
||||
convert: convertWith,
|
||||
});
|
||||
return {
|
||||
...config,
|
||||
agentProfiles: config.agentProfiles.map((profile) =>
|
||||
foldProfileFeatureValues({
|
||||
profile,
|
||||
stored: held.find((candidate) => candidate.id === profile.id),
|
||||
source,
|
||||
}),
|
||||
),
|
||||
agentProfiles: config.agentProfiles.map((profile) => {
|
||||
// A malformed value rejects a client write before anything is committed.
|
||||
if (source === "client write") return foldWith(profile, convert);
|
||||
try {
|
||||
return foldWith(profile, convert);
|
||||
} catch {
|
||||
// A hand-edited `config.json` value that cannot convert must not stop the daemon from
|
||||
// starting; it keeps its keys and is rejected when an agent is created from it.
|
||||
return foldWith(profile, identity);
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Renames old option keys for the adapter a provider runs on (the agent manager's conversion). */
|
||||
export type ConvertProfileOptions = (
|
||||
provider: string,
|
||||
options: Record<string, unknown> | undefined,
|
||||
) => Record<string, unknown> | undefined;
|
||||
|
||||
/**
|
||||
* The config API boundary: clients see each profile's options in their current keys, and the
|
||||
* unchanged app reads them from `featureValues`.
|
||||
*/
|
||||
export function toWireDaemonConfig(
|
||||
config: MutableDaemonConfig,
|
||||
convertOptions: ConvertProfileOptions,
|
||||
): MutableDaemonConfig {
|
||||
/** The config API boundary: the unchanged app reads profile options from `featureValues`. */
|
||||
export function toWireDaemonConfig(config: MutableDaemonConfig): MutableDaemonConfig {
|
||||
if (!config.agentProfiles) return config;
|
||||
return {
|
||||
...config,
|
||||
agentProfiles: config.agentProfiles.map((profile) =>
|
||||
withLegacyProfileFeatureValues({
|
||||
...profile,
|
||||
options: convertOptions(profile.provider, profile.options),
|
||||
}),
|
||||
),
|
||||
};
|
||||
return { ...config, agentProfiles: config.agentProfiles.map(withLegacyProfileFeatureValues) };
|
||||
}
|
||||
|
||||
function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
|
||||
@@ -430,7 +435,7 @@ export class DaemonConfigStore {
|
||||
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
|
||||
this.lastKnownPersisted = this.startupPersisted;
|
||||
this.current = MutableDaemonConfigSchema.parse({
|
||||
...foldConfigProfiles(initial, "stored"),
|
||||
...foldConfigProfiles({ config: initial, providers: initial.providers, source: "stored" }),
|
||||
relay: initial.relay ?? { enabled: true },
|
||||
...this.readSpeech(this.startupPersisted),
|
||||
});
|
||||
@@ -469,7 +474,12 @@ export class DaemonConfigStore {
|
||||
);
|
||||
}
|
||||
const { removeProviders = [], ...submittedPatch } = parsedPatch;
|
||||
const patch = foldConfigProfiles(submittedPatch, "client write", this.current.agentProfiles);
|
||||
const patch = foldConfigProfiles({
|
||||
config: submittedPatch,
|
||||
providers: { ...this.current.providers, ...submittedPatch.providers },
|
||||
source: "client write",
|
||||
held: this.current.agentProfiles,
|
||||
});
|
||||
const { features: speechPatch, ...configPatch } = patch;
|
||||
const removedProviders = Array.from(new Set(removeProviders));
|
||||
const merged = deepMerge(this.current, configPatch);
|
||||
@@ -555,7 +565,11 @@ export class DaemonConfigStore {
|
||||
// Plugin source changes require the plugin lifecycle operation or a daemon
|
||||
// restart. The global switch is independently reloadable.
|
||||
const desired = MutableDaemonConfigSchema.parse({
|
||||
...foldConfigProfiles(resolved.mutable, "stored"),
|
||||
...foldConfigProfiles({
|
||||
config: resolved.mutable,
|
||||
providers: resolved.mutable.providers,
|
||||
source: "stored",
|
||||
}),
|
||||
plugins: this.current.plugins,
|
||||
...this.readSpeech(persisted),
|
||||
});
|
||||
|
||||
@@ -80,11 +80,7 @@ import {
|
||||
createWorkspaceScriptsService,
|
||||
type WorkspaceScriptsService,
|
||||
} from "./session/workspace-scripts/workspace-scripts-service.js";
|
||||
import {
|
||||
toWireDaemonConfig,
|
||||
type DaemonConfigStore,
|
||||
type MutableDaemonConfig,
|
||||
} from "./daemon-config-store.js";
|
||||
import { toWireDaemonConfig, type DaemonConfigStore } from "./daemon-config-store.js";
|
||||
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
|
||||
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
||||
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
||||
@@ -2955,7 +2951,7 @@ export class Session {
|
||||
type: "get_daemon_config_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
config: this.toWireDaemonConfig(this.daemonConfigStore.get()),
|
||||
config: toWireDaemonConfig(this.daemonConfigStore.get()),
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
@@ -2980,7 +2976,7 @@ export class Session {
|
||||
type: "set_daemon_config_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
config: this.toWireDaemonConfig(config),
|
||||
config: toWireDaemonConfig(config),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -8627,12 +8623,6 @@ export class Session {
|
||||
return true;
|
||||
}
|
||||
|
||||
private toWireDaemonConfig(config: MutableDaemonConfig): MutableDaemonConfig {
|
||||
return toWireDaemonConfig(config, (provider, options) =>
|
||||
this.agentManager.convertLegacyOptions(provider, options),
|
||||
);
|
||||
}
|
||||
|
||||
private emit(msg: SessionOutboundMessage): void {
|
||||
if (!this.authorization.allowsOutbound(msg)) return;
|
||||
if (msg.type === "workspace_setup_progress") {
|
||||
|
||||
@@ -2201,9 +2201,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "daemon_config_changed",
|
||||
config: toWireDaemonConfig(config, (provider, options) =>
|
||||
this.agentManager.convertLegacyOptions(provider, options),
|
||||
),
|
||||
config: toWireDaemonConfig(config),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user