From 9f39663722e0e7f6261340e02880ec0cfc59485e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 5 Nov 2026 19:28:40 +0100 Subject: [PATCH] fix(client): resetting an option on an old daemon writes the default it can take setAgentOptions({ service_tier: null }) sent a feature write with value null, which the old Codex setter rejects, leaving the paid Fast tier on. A toggle now resets to off and a choice to its default; an option with no control to name a default fails with the host-update message before anything is written. --- .../src/compat/legacy-provider-options.ts | 22 +++++++ packages/client/src/daemon-client.test.ts | 62 +++++++++++++++++++ packages/client/src/daemon-client.ts | 7 ++- 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/packages/client/src/compat/legacy-provider-options.ts b/packages/client/src/compat/legacy-provider-options.ts index 7085d26bf7..cdf4f52a0e 100644 --- a/packages/client/src/compat/legacy-provider-options.ts +++ b/packages/client/src/compat/legacy-provider-options.ts @@ -1,4 +1,5 @@ import { controlsFromFeatures, optionsFromFeatures } from "@getpaseo/protocol/option-controls"; +import type { AgentOptionControl } from "@getpaseo/protocol/agent-types"; import type { AgentProfile, AgentSnapshotPayload, @@ -175,6 +176,27 @@ export class LegacyProviderOptions { } } + /** + * An options patch as feature writes. `null` removes an option, which the old API cannot say: + * its setters take a value, and the Codex one rejects anything but a choice id. A toggle goes + * off and a choice goes to its default; an option with no control to say what its default is + * cannot be reset, and nothing is written. + */ + featureWrites( + patch: Record, + controls: readonly AgentOptionControl[], + ): [string, unknown][] { + return Object.entries(patch).map(([key, value]) => { + if (value !== null) return [key, value]; + const control = controls.find((candidate) => candidate.key === key); + if (control?.type === "toggle") return [key, false]; + if (control?.type === "select" && control.defaultValue !== undefined) { + return [key, control.defaultValue]; + } + throw new Error(`Update the host to reset ${key}.`); + }); + } + /** The legacy answer to a controls listing, in the shape of the current response. */ controlsResponse( legacy: ListProviderFeaturesResponseMessage["payload"], diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index d2659171d2..b55dea7ac9 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -9081,6 +9081,68 @@ describe("provider options across daemon versions", () => { await write; }); + // The old Codex setter rejects a non-string `service_tier` and the old Claude setter reads + // `null` as off, so a reset is sent as the value the old API can represent. + async function resetOnLegacyDaemon(agent: Record, patch: Record) { + const { client, sentMessages, mock } = await connect({}); + const reply = (message: Record) => + mock.triggerMessage(wrapSessionMessage(message as never)); + const writing = client.setAgentOptions("agent-1", patch); + const [fetch] = sentMessages(); + expect(fetch).toMatchObject({ type: "fetch_agent_request", agentId: "agent-1" }); + reply({ + type: "fetch_agent_response", + payload: { requestId: fetch.requestId, agent, project: null, error: null }, + }); + return { writing, sentMessages, reply }; + } + + test("a legacy daemon resets a choice to the default the old API names", async () => { + const { writing, sentMessages, reply } = await resetOnLegacyDaemon( + { ...agentSnapshot, provider: "codex", features: [speedFeature] }, + { service_tier: null }, + ); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(2)); + const write = sentMessages()[1]; + expect(write).toMatchObject({ + type: "set_agent_feature_request", + featureId: "service_tier", + value: "default", + }); + reply({ + type: "set_agent_feature_response", + payload: { requestId: write.requestId, agentId: "agent-1", accepted: true, error: null }, + }); + await writing; + }); + + test("a legacy daemon resets a toggle to off", async () => { + const { writing, sentMessages, reply } = await resetOnLegacyDaemon( + { + ...agentSnapshot, + features: [{ type: "toggle", id: "fast_mode", label: "Fast", value: true }], + }, + { fast_mode: null }, + ); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(2)); + const write = sentMessages()[1]; + expect(write).toMatchObject({ featureId: "fast_mode", value: false }); + reply({ + type: "set_agent_feature_response", + payload: { requestId: write.requestId, agentId: "agent-1", accepted: true, error: null }, + }); + await writing; + }); + + test("a legacy daemon cannot reset an option no control draws, and writes nothing", async () => { + const { writing, sentMessages } = await resetOnLegacyDaemon( + { ...agentSnapshot, features: [speedFeature] }, + { token_budget: null }, + ); + await expect(writing).rejects.toThrow("Update the host to reset token_budget."); + expect(sentMessages()).toHaveLength(1); + }); + test("a current daemon takes option writes as one options patch", async () => { const { client, sentMessages, mock } = await connect({ providerOptionControls: true }); const write = client.setAgentOptions("agent-1", { service_tier: null, sandbox_mode: "x" }); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 9fee5d7f6d..a3e388ef56 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -4172,9 +4172,12 @@ export class DaemonClient { agentId: string, patch: Record, ): Promise { - if (this.legacyProviderOptions()) { + const legacy = this.legacyProviderOptions(); + if (legacy) { // COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. - for (const [featureId, value] of Object.entries(patch)) { + const resets = Object.values(patch).includes(null); + const controls = resets ? ((await this.fetchAgent(agentId))?.agent.controls ?? []) : []; + for (const [featureId, value] of legacy.featureWrites(patch, controls)) { await this.setAgentFeature(agentId, featureId, value); } return null;