diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 09a01e2c32..ccc1b41f79 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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"], + ]); + }); }); diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index 9c8296d0f6..b2d152559f 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -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" }, diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts index c69065c1f5..b0ed1305fa 100644 --- a/packages/server/src/server/agent/option-controls.ts +++ b/packages/server/src/server/agent/option-controls.ts @@ -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,