From 8e83d9346a8baba3498608cb9a9ba557cc3e306f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 07:28:23 +0100 Subject: [PATCH] fix(server): keep snapshot options as configured, restore OMP baseline, cover options through public interfaces - Codex: drop displayOptions; a select choice may carry aliases so a saved Fast preference selects the model's Fast tier from options + controls alone. - OMP: removing fast_mode restores the runtime's starting setting. - ACP/Cursor removal tests run through real session initialization. - agent.config.apply of a non-control option reaches the provider and shows in the next snapshot (daemon e2e). - Daemon config store persists legacy-featureValues profiles as options on the next profile save. --- packages/protocol/src/agent-types.ts | 5 ++ packages/protocol/src/messages.ts | 1 + .../src/server/agent/agent-manager.test.ts | 33 ++++++++---- .../server/src/server/agent/agent-manager.ts | 11 +--- .../src/server/agent/agent-sdk-types.ts | 6 --- .../src/server/agent/option-controls.ts | 7 ++- .../src/server/agent/provider-registry.ts | 1 - .../server/agent/providers/acp-agent.test.ts | 44 ---------------- .../codex-app-server-agent.features.test.ts | 32 ++++++------ .../agent/providers/codex-app-server-agent.ts | 7 --- .../providers/codex-feature-definitions.ts | 9 +++- .../agent/providers/cursor-acp-agent.test.ts | 50 ++++++++++++++++++- .../server/agent/providers/omp/agent.test.ts | 21 ++++++++ .../src/server/agent/providers/omp/agent.ts | 48 +++++++++++------- .../src/server/daemon-client.e2e.test.ts | 50 +++++++++++++++++++ .../src/server/daemon-config-store.test.ts | 29 +++++++++++ .../server/src/server/daemon-config-store.ts | 11 +++- 17 files changed, 251 insertions(+), 114 deletions(-) diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 6ece6958f9..641c2bf20b 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -158,6 +158,11 @@ export interface AgentOptionChoice { value: string; label: string; description?: string; + /** + * Other stored option values that select this choice. A saved value the provider has since + * renamed still reads back as this choice. + */ + aliases?: string[]; } interface AgentOptionControlBase { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 30519993f2..fe082d5a68 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -365,6 +365,7 @@ export const AgentOptionChoiceSchema = z.object({ value: z.string(), label: z.string(), description: z.string().optional(), + aliases: z.array(z.string()).optional(), }); export const AgentOptionToggleControlSchema = z.object({ diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index b80c637458..c6442ca1d3 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -12784,20 +12784,31 @@ describe("agent options", () => { expect(agent?.effectiveOptions).toEqual({ service_tier: "default" }); }); - test("shows the option as the session resolved it while the stored intent stays", async () => { - class ResolvingSession extends OptionsSession { - displayOptions(options: ProviderOptions): ProviderOptions { - return options.service_tier === "fast" ? { ...options, service_tier: "priority" } : options; + test("the snapshot reports the configured options and resolves aliases through controls", async () => { + class AliasSession extends OptionsSession { + get controls(): AgentOptionControl[] { + return [ + { + type: "select", + key: "service_tier", + label: "Speed", + defaultValue: "default", + choices: [ + { value: "default", label: "Normal" }, + { value: "priority", label: "Fast", aliases: ["fast"] }, + ], + }, + ]; } } - class ResolvingClient extends OptionsClient { + class AliasClient extends OptionsClient { override async createSession(config: AgentSessionConfig): Promise { - return new ResolvingSession(config); + return new AliasSession(config); } } const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-")); const manager = new AgentManager({ - clients: { codex: new ResolvingClient() }, + clients: { codex: new AliasClient() }, registry: new AgentStorage(join(workdir, "agents"), logger), logger, }); @@ -12807,9 +12818,11 @@ describe("agent options", () => { { workspaceId: undefined }, ); - const agent = manager.getAgent(created.id); - expect(agent?.config.providerOptions).toEqual({ service_tier: "fast" }); - expect(agent?.effectiveOptions).toEqual({ service_tier: "priority" }); + const payload = toAgentPayload(manager.getAgent(created.id)!); + expect(payload.options).toEqual({ service_tier: "fast" }); + expect(payload.features).toEqual([ + expect.objectContaining({ id: "service_tier", value: "priority" }), + ]); }); test("an option without a control is settable through the patch and readable in the snapshot", async () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 84c9ec99bc..48e8be0b65 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1271,10 +1271,7 @@ export class AgentManager { const session = await client.createSession(normalizedConfig); try { - return { - controls: session.controls ?? [], - options: session.displayOptions?.(options) ?? options, - }; + return { controls: session.controls ?? [], options }; } finally { try { await session.close(); @@ -5250,11 +5247,7 @@ export class AgentManager { if ("session" in agent && agent.session?.controls) { agent.controls = agent.session.controls; } - const effective = this.effectiveOptionsFor(agent.provider, agent.config.providerOptions); - agent.effectiveOptions = - "session" in agent && agent.session?.displayOptions - ? agent.session.displayOptions(effective) - : effective; + agent.effectiveOptions = this.effectiveOptionsFor(agent.provider, agent.config.providerOptions); } private checkAndSetAttention(agent: ManagedAgent): void { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index ce0193fbdd..6067ed997c 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -722,12 +722,6 @@ export interface AgentSession { * the daemon stores the patch only when this resolves. */ applyOptions?(options: ProviderOptions): Promise; - /** - * The effective options as this session honors them, for display. The daemon keeps the - * user's stored intent; a value the provider resolves differently (a renamed tier, an - * unavailable choice) is shown as the provider resolved it. - */ - displayOptions?(options: ProviderOptions): ProviderOptions; revertConversation?(input: { messageId: string }): Promise; revertFiles?(input: { messageId: string }): Promise; revertBoth?(input: { messageId: string }): Promise; diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts index 21c27c0d58..c69065c1f5 100644 --- a/packages/server/src/server/agent/option-controls.ts +++ b/packages/server/src/server/agent/option-controls.ts @@ -115,8 +115,11 @@ export function withLegacyProfileFeatureValues(pr function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) { const value = options[control.key]; - if (typeof value === "string" && control.choices.some((choice) => choice.value === value)) { - return value; + if (typeof value === "string") { + const choice = control.choices.find( + (candidate) => candidate.value === value || candidate.aliases?.includes(value), + ); + if (choice) return choice.value; } return control.defaultValue ?? null; } diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 38ab8ec3f8..90af6506d9 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -474,7 +474,6 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession setModel: inner.setModel?.bind(inner), setThinkingOption: inner.setThinkingOption?.bind(inner), applyOptions: inner.applyOptions?.bind(inner), - displayOptions: inner.displayOptions?.bind(inner), revertConversation: inner.revertConversation?.bind(inner), revertFiles: inner.revertFiles?.bind(inner), revertBoth: inner.revertBoth?.bind(inner), diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 844aadd19d..f4804a8ada 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1601,29 +1601,6 @@ describe("ACPAgentSession Zed parity", () => { }); }); - test("stops auto-accepting permissions once auto_accept is removed from the options", async () => { - const session = createSessionWithConfig({ provider: "generic-acp" }); - const internals = asInternals(session); - internals.sessionId = "session-1"; - internals.connection = {}; - - await session.applyOptions({ auto_accept: true }); - await session.applyOptions({}); - - const permission = session.requestPermission({ - sessionId: "session-1", - toolCall: { - toolCallId: "tool-1", - title: "Run command", - kind: "execute", - status: "pending", - }, - options: [{ optionId: "allow-always", name: "Always allow", kind: "allow_always" }], - } satisfies RequestPermissionRequest); - await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1)); - void permission; - }); - test("surfaces an ACP permission when auto-accept has no allow option", async () => { const session = createSessionWithConfig({ providerOptions: { auto_accept: true } }); const events: Array<{ type: string; request?: { id: string } }> = []; @@ -1826,27 +1803,6 @@ describe("ACPAgentSession Zed parity", () => { ]); }); - test("returns a select to its session default once its option is removed", async () => { - const setSessionConfigOption = vi.fn(async (request: { value: string }) => ({ - configOptions: [copilotAgentConfigOption(request.value)], - })); - const session = createCopilotSessionWithConfig(); - const { internals } = prepareConfiguredOverrideSession(session, { - configOptions: [copilotAgentConfigOption("")], - connection: { setSessionConfigOption }, - }); - asInternals<{ recordConfigFeatureDefaults(): void }>(internals).recordConfigFeatureDefaults(); - - await session.applyOptions({ agent: "Probe Agent" }); - await session.applyOptions({}); - - expect(setSessionConfigOption).toHaveBeenLastCalledWith({ - sessionId: "session-1", - configId: "agent", - value: "", - }); - }); - test("sets Copilot custom agent through ACP config options", async () => { const setSessionConfigOption = vi.fn(async () => ({ configOptions: [copilotAgentConfigOption("Probe Agent")], diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index 77e83d7412..3ff31807e7 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -1,3 +1,4 @@ +import { featuresFromControls } from "../option-controls.js"; import pino from "pino"; import { describe, expect, test } from "vitest"; @@ -49,7 +50,7 @@ function speedControl() { defaultValue: "default", choices: [ { value: "default", label: "Normal" }, - { value: "priority", label: "Fast" }, + { value: "priority", label: "Fast", aliases: ["fast"] }, ], }; } @@ -165,7 +166,7 @@ describe("Codex app-server provider controls", () => { defaultValue: "default", choices: [ { value: "default", label: "Normal" }, - { value: "priority", label: "Fast" }, + { value: "priority", label: "Fast", aliases: ["fast"] }, { value: "ultrafast", label: "Ultrafast" }, ], }), @@ -304,7 +305,7 @@ describe("Codex app-server provider controls", () => { key: "service_tier", choices: [ { value: "default", label: "Normal" }, - { value: "priority", label: "Fast" }, + { value: "priority", label: "Fast", aliases: ["fast"] }, ], }), ); @@ -522,23 +523,24 @@ describe("Codex app-server provider controls", () => { }); }); - test("shows a saved Fast preference as the tier the model resolves it to", async () => { + test("a saved Fast preference selects the model's Fast tier without rewriting the stored option", async () => { const { session, appServer } = await createConnectedSession({ providerOptions: { service_tier: "fast" }, }); - // Stored intent stays "fast"; the model's Fast tier is "priority", which is what runs and what is shown. - expect(session.displayOptions?.({ service_tier: "fast" })).toEqual({ - service_tier: "priority", + // The model's Fast tier is "priority". The choice names "fast" as an alias, so a client reading + // `options` + `controls` selects it, and the legacy features projection agrees. + const speed = session.controls.find((control) => control.key === "service_tier"); + expect(speed).toMatchObject({ + choices: expect.arrayContaining([ + expect.objectContaining({ value: "priority", aliases: ["fast"] }), + ]), }); - expect(session.displayOptions?.({ fast_mode: true })).toMatchObject({ - service_tier: "priority", - }); - expect(session.displayOptions?.({ plan_mode: true })).toEqual({ plan_mode: true }); - expect(session.displayOptions?.({ service_tier: "default" })).toEqual({ - service_tier: "default", - }); - expect(session.displayOptions?.({ service_tier: "gone" })).toEqual({}); + expect( + featuresFromControls(session.controls, { service_tier: "fast" }).find( + (feature) => feature.id === "service_tier", + ), + ).toMatchObject({ value: "priority" }); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 85b02231fc..ba5d9fe9a0 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -4673,13 +4673,6 @@ export class CodexAppServerAgentSession implements AgentSession { this.cachedRuntimeInfo = null; } - displayOptions(options: ProviderOptions): ProviderOptions { - if (readCodexServiceTier(options) === null) return options; - const { service_tier: _stored, ...rest } = options; - const resolved = this.resolveServiceTier(readCodexServiceTier(options)); - return resolved === null ? rest : { ...rest, service_tier: resolved }; - } - private isServiceTierAvailable(tier: string | null): boolean { if (tier === null || tier === "default") return true; const tiers = this.currentServiceTiers(); diff --git a/packages/server/src/server/agent/providers/codex-feature-definitions.ts b/packages/server/src/server/agent/providers/codex-feature-definitions.ts index d2ffdece1c..f6ee7620ec 100644 --- a/packages/server/src/server/agent/providers/codex-feature-definitions.ts +++ b/packages/server/src/server/agent/providers/codex-feature-definitions.ts @@ -23,7 +23,14 @@ function buildCodexSpeedControl(tiers: CodexServiceTier[]): AgentOptionControl[] defaultValue: "default", choices: [ { value: "default", label: "Normal" }, - ...tiers.map((tier) => ({ value: tier.id, label: tier.name })), + ...tiers.map((tier) => ({ + value: tier.id, + label: tier.name, + // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated. + ...(tier.id !== "fast" && tier.name.toLowerCase() === "fast" + ? { aliases: ["fast"] } + : {}), + })), ], }, ]; diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts index c1ca1a2cdc..76ddd3dcb3 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts @@ -215,7 +215,9 @@ describe("CursorACPAgentClient session start", () => { session: { currentModelId: string; configOptions: SessionConfigOption[]; - setSessionConfigOption?: () => Promise<{ configOptions: SessionConfigOption[] }>; + setSessionConfigOption?: (request: { + value: string; + }) => Promise<{ configOptions: SessionConfigOption[] }>; } = { currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS }, logger = createTestLogger(), ): ACPAgentSession { @@ -326,6 +328,52 @@ describe("CursorACPAgentClient session start", () => { ); }); + test("removing auto_accept stops answering permission requests on its own", async () => { + const session = createCursorSession({ model: "composer-2.5" }); + await session.initializeNewSession(); + const request = { + sessionId: "session-1", + toolCall: { toolCallId: "tool-1", title: "Run command", kind: "execute", status: "pending" }, + options: [{ optionId: "allow-always", name: "Always allow", kind: "allow_always" }], + } as const; + + await session.applyOptions({ auto_accept: true }); + await expect(session.requestPermission({ ...request })).resolves.toEqual({ + outcome: { outcome: "selected", optionId: "allow-always" }, + }); + + await session.applyOptions({}); + void session.requestPermission({ ...request }); + await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1)); + }); + + test("removing the Fast option returns the session to the value it started with", async () => { + const setSessionConfigOption = vi.fn(async (request: { value: string }) => ({ + configOptions: [fastConfigOption(request.value === "true" ? "true" : "false")], + })); + const session = createCursorSession( + { model: "composer-2.5" }, + { + currentModelId: "composer-2.5", + configOptions: [fastConfigOption("false")], + setSessionConfigOption, + }, + ); + await session.initializeNewSession(); + + await session.applyOptions({ [CURSOR_FAST_FEATURE_OPTION.id]: "true" }); + // `{ fast: null }` with no config.json default reaches the provider as no key at all. + await session.applyOptions({}); + + expect(setSessionConfigOption.mock.calls.map(([request]) => request.value)).toEqual([ + "true", + "false", + ]); + expect(session.controls).toContainEqual( + expect.objectContaining({ key: "fast", defaultValue: "false" }), + ); + }); + test("still fails session start when the provider rejects a write it should accept", async () => { // No model switch, so the session's options came straight from session/new and the // provider disagreeing about a feature it just advertised is a real failure. diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 9bcf54a56b..794dc41278 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -638,6 +638,27 @@ describe("OMP agent client and session", () => { ]); }); + test("removing the fast_mode option restores the runtime's own setting", async () => { + const omp = new OmpHarness(); + await omp.start(); + const session = omp.requireSession(); + expect(omp.runtime().state.fastModeEnabled).toBe(false); + + omp.runtime().fastModeResult = { enabled: true, active: true }; + await session.applyOptions?.({ fast_mode: true }); + expect(omp.runtime().setFastModeRequests).toEqual([true]); + + // `{ fast_mode: null }` with no config.json default reaches the provider as no key at all. + omp.runtime().fastModeResult = { enabled: false, active: false }; + await session.applyOptions?.({}); + + expect(omp.runtime().setFastModeRequests).toEqual([true, false]); + expect(session.controls).toEqual([expect.objectContaining({ defaultValue: false })]); + // A later change to an unrelated option leaves the runtime alone. + await session.applyOptions?.({}); + expect(omp.runtime().setFastModeRequests).toEqual([true, false]); + }); + test("shows Fast only when OMP reports its state fields", async () => { const omp = new OmpHarness(); await omp.start(); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 3e2465b5fe..1bcf34d7f3 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -203,6 +203,8 @@ interface OmpAgentSessionOptions { }>; config: AgentSessionConfig; initialState: OmpSessionState; + /** Fast mode as the runtime reported it before any option was applied; removal restores it. */ + fastModeBaseline?: boolean; currentModeId?: string | null; logger: Logger; providerIdleScheduler?: OmpProviderIdleScheduler; @@ -501,6 +503,10 @@ function isOmpSteerTransportFailure(error: unknown): boolean { ); } +function fastModeBaselineOf(state: OmpSessionState): boolean | undefined { + return typeof state.fastModeEnabled === "boolean" ? state.fastModeEnabled : undefined; +} + function hasOmpFastMode(state: OmpSessionState): boolean { return typeof state.fastModeEnabled === "boolean" && typeof state.fastModeActive === "boolean"; } @@ -751,6 +757,7 @@ export class OmpAgentSession implements AgentSession { this.restartRuntime = options.restartRuntime; this.config = options.config; this.state = options.initialState; + this.fastModeBaseline = options.fastModeBaseline; this.currentModeId = options.currentModeId ?? null; this.logger = options.logger; this.live = options.live ?? true; @@ -823,20 +830,28 @@ export class OmpAgentSession implements AgentSession { } async applyOptions(options: ProviderOptions): Promise { - const value = options.fast_mode; - if (value === undefined) return; - if (typeof value !== "boolean") throw new Error("OMP fast mode requires a boolean"); - if (value === this.config.providerOptions?.fast_mode) return; - if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable"); - const result = await this.runtimeSession.setFastMode(value); - this.state = { - ...this.state, - fastModeEnabled: result.enabled, - fastModeActive: result.active, - }; - this.config.providerOptions = { ...this.config.providerOptions, fast_mode: result.enabled }; + const requested = options.fast_mode; + if (requested !== undefined && typeof requested !== "boolean") { + throw new Error("OMP fast mode requires a boolean"); + } + // An absent option means the runtime's own setting at session start. + const target = requested ?? this.fastModeBaseline; + if (target !== undefined && target !== this.state.fastModeEnabled) { + if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable"); + const result = await this.runtimeSession.setFastMode(target); + this.state = { + ...this.state, + fastModeEnabled: result.enabled, + fastModeActive: result.active, + }; + } + const { fast_mode: _previous, ...others } = this.config.providerOptions ?? {}; + this.config.providerOptions = + requested === undefined ? others : { ...others, fast_mode: requested }; } + private readonly fastModeBaseline: boolean | undefined; + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { return runProviderTurn({ prompt, @@ -2333,12 +2348,10 @@ export class OmpAgentClient implements AgentClient { config, startInput.env, ); - const initialState = await this.restoreFastMode( - runtimeSession, - config, - await runtimeSession.getState(), - ); + const startState = await runtimeSession.getState(); + const initialState = await this.restoreFastMode(runtimeSession, config, startState); return new OmpAgentSession({ + fastModeBaseline: fastModeBaselineOf(startState), runtimeSession, hostTools, restartRuntime: this.buildRestartRuntime(startInput, config, launchContext), @@ -2410,6 +2423,7 @@ export class OmpAgentClient implements AgentClient { }; const initialState = await this.restoreFastMode(runtimeSession, config, resumedState); return new OmpAgentSession({ + fastModeBaseline: fastModeBaselineOf(resumedState), runtimeSession, hostTools, restartRuntime: this.buildRestartRuntime(startInput, config, launchContext), diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index a442dfe6a9..14c88aca81 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -180,6 +180,56 @@ test("DaemonClient uploads file bytes to daemon temp storage", async () => { } }); +test("agent.config.apply sets an option that has no control and the next snapshot reports it", async () => { + const clients = createTestAgentClients(); + const codex = clients.codex as AgentClient; + const applied: unknown[] = []; + // Records what reaches the provider session; behavior is the fake client's. + const recordingCodex: AgentClient = Object.create(codex, { + createSession: { + value: async (...args: Parameters) => { + const session = await codex.createSession(...args); + const apply = session.applyOptions?.bind(session); + session.applyOptions = async (options) => { + applied.push(options); + await apply?.(options); + }; + return session; + }, + }, + }); + const daemon = await createTestPaseoDaemon({ + agentClients: { ...clients, codex: recordingCodex }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const agent = await client.createAgent({ + provider: "codex", + cwd: tmpCwd(), + title: "Options agent", + modeId: "full-access", + model: "gpt-5.4-mini", + }); + expect(agent.options?.sandbox_mode).toBeUndefined(); + + await client.applyAgentConfig(agent.id, { options: { sandbox_mode: "read-only" } }); + + expect(applied).toEqual([expect.objectContaining({ sandbox_mode: "read-only" })]); + const next = await client.fetchAgent(agent.id); + expect(next?.agent.options).toMatchObject({ sandbox_mode: "read-only" }); + expect(next?.agent.controls?.map((control) => control.key)).not.toContain("sandbox_mode"); + } finally { + await client.close(); + await daemon.close(); + } +}); + test("createAgent with background initialPrompt returns a running snapshot before turn completion", async () => { const daemon = await createTestPaseoDaemon(); const client = new DaemonClient({ diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index ab0c125336..812b1d13d2 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -1348,6 +1348,35 @@ describe("DaemonConfigStore reload", () => { expect(store.get().agentProfiles).toBeUndefined(); }); + test("reload folds a profile file written with featureValues into options, and saving it persists options", async () => { + const { paseoHome, store, persisted } = createReloadableStore(); + writeConfig(paseoHome, { + ...persisted, + daemon: { + ...persisted.daemon, + agentProfiles: [ + { + id: "legacy", + name: "Legacy", + provider: "codex", + featureValues: { service_tier: "fast" }, + }, + ], + }, + }); + + await store.reload(); + + expect(store.get().agentProfiles).toEqual([ + { id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } }, + ]); + // The app reads the profile and writes the list back on its next edit. + await store.patch({ agentProfiles: store.get().agentProfiles }); + expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([ + { id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } }, + ]); + }); + test("reports a launch-controlled edit without changing live state", async () => { const { paseoHome, store, persisted } = createReloadableStore({ overrideControlledPaths: ["daemon.relay.enabled"], diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 9713082b93..83ff80d498 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -461,7 +461,16 @@ export class DaemonConfigStore { const configChanged = !isEqualValue(this.current, next); // A replaced API key leaves the speech view unchanged, so a speech patch always persists. - if (!configChanged && removedProviders.length === 0 && speechPatch === undefined) { + // Profiles held folded in memory may still be on disk with `featureValues`; saving them writes `options`. + const profilesStaleOnDisk = + patch.agentProfiles !== undefined && + !isEqualValue(patch.agentProfiles, this.lastKnownPersisted.daemon?.agentProfiles); + if ( + !configChanged && + removedProviders.length === 0 && + speechPatch === undefined && + !profilesStaleOnDisk + ) { return this.current; }