fix(server): keep a legacy feature's recorded value when migrating it to a control

An agent stored with features and no controls showed its toggles as off and its selects as unset in a snapshot built while its runtime was unloaded, because only the default choice survived the migration. The recorded value is now the migrated control's unset value; an explicit effective option still wins.
This commit is contained in:
Mohamed Boudra
2026-11-01 10:12:16 +01:00
parent a144b5c7af
commit b27b0b9d5c
3 changed files with 68 additions and 5 deletions
@@ -6,7 +6,7 @@ import {
} from "./agent-messages/index.js";
import { expect, test, vi, onTestFinished } from "vitest";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
@@ -13094,4 +13094,62 @@ describe("agent options", () => {
value: true,
});
});
test("a metadata-only update of an agent stored with legacy features reports their values", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-legacy-features-"));
const agentsDir = join(workdir, "agents");
const first = new AgentManager({
clients: { codex: new OptionsClient() },
registry: new AgentStorage(agentsDir, logger),
logger,
});
const created = await first.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: "workspace-1",
});
await first.closeAgent(created.id);
// Rewrite the record as an older daemon stored it: features with values, no controls.
const dir = readdirSync(agentsDir, { withFileTypes: true }).find(
(entry) =>
entry.isDirectory() && existsSync(join(agentsDir, entry.name, `${created.id}.json`)),
)!;
const file = join(agentsDir, dir.name, `${created.id}.json`);
const stored = JSON.parse(readFileSync(file, "utf8"));
delete stored.controls;
stored.features = [
{ type: "toggle", id: "fast_mode", label: "Fast", value: true },
{
type: "select",
id: "voice",
label: "Voice",
value: "soft",
options: [
{ id: "direct", label: "Direct" },
{ id: "soft", label: "Soft" },
],
},
];
writeFileSync(file, JSON.stringify(stored));
const manager = new AgentManager({
clients: { codex: new OptionsClient() },
registry: new AgentStorage(agentsDir, logger),
logger,
});
const updates: ManagedAgent[] = [];
manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === created.id)
updates.push(event.agent);
},
{ replayState: false },
);
await manager.markAgentUnread(created.id);
const payload = toAgentPayload(updates.at(-1)!);
expect(payload.features?.map((feature) => [feature.id, feature.value])).toEqual([
["fast_mode", true],
["voice", "soft"],
]);
});
});
@@ -252,12 +252,12 @@ describe("AgentStorage", () => {
expect(persisted?.config).not.toHaveProperty("featureValues");
expect(persisted).not.toHaveProperty("features");
expect(persisted?.controls).toEqual([
{ type: "toggle", key: "fast_mode", label: "Fast mode" },
{ type: "toggle", key: "fast_mode", label: "Fast mode", defaultValue: true },
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
defaultValue: "fast",
choices: [
{ value: "default", label: "Normal" },
{ value: "fast", label: "Fast" },
@@ -175,6 +175,10 @@ export function featuresFromControls(
return (controls ?? []).map((control) => featureFromControl(control, effective));
}
/**
* A recorded feature's value is the control's unset value: it is what the agent showed before
* any option was stored, and an explicit effective option still wins over it.
*/
function controlFromFeature(feature: AgentFeature): AgentOptionControl {
const descriptor = {
key: feature.id,
@@ -182,14 +186,15 @@ function controlFromFeature(feature: AgentFeature): AgentOptionControl {
...describe(feature),
};
if (feature.type === "toggle") {
return { type: "toggle", ...descriptor };
return { type: "toggle", ...descriptor, defaultValue: feature.value };
}
const defaultChoice = feature.options.find((option) => option.isDefault);
const unsetValue = feature.value ?? defaultChoice?.id;
return {
type: "select",
...descriptor,
...(feature.desktopTrigger ? { desktopTrigger: feature.desktopTrigger } : {}),
...(defaultChoice ? { defaultValue: defaultChoice.id } : {}),
...(unsetValue != null ? { defaultValue: unsetValue } : {}),
choices: feature.options.map((option) => ({
value: option.id,
label: option.label,