From 152a5ae7319b27aa77d6bd89b73b72b0c0625cf0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 5 Nov 2026 19:31:56 +0100 Subject: [PATCH] fix(client): applying a config with a reset option on an old daemon writes the default it can take applyAgentConfig({ options: { service_tier: null } }) reached an old daemon as featureValues with null, which its Codex setter rejects. The config now takes the same translation as setAgentOptions: a toggle goes off, a choice goes to its default, and an option with no named default fails with the host-update message before anything is sent. --- .../src/compat/legacy-provider-options.ts | 25 ++++++--- packages/client/src/daemon-client.test.ts | 55 +++++++++++++++++++ packages/client/src/daemon-client.ts | 28 +++++++++- 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/packages/client/src/compat/legacy-provider-options.ts b/packages/client/src/compat/legacy-provider-options.ts index cdf4f52a0e..083232253c 100644 --- a/packages/client/src/compat/legacy-provider-options.ts +++ b/packages/client/src/compat/legacy-provider-options.ts @@ -1,6 +1,7 @@ import { controlsFromFeatures, optionsFromFeatures } from "@getpaseo/protocol/option-controls"; import type { AgentOptionControl } from "@getpaseo/protocol/agent-types"; import type { + AgentConfigApply, AgentProfile, AgentSnapshotPayload, MutableDaemonConfig, @@ -143,14 +144,6 @@ export class LegacyProviderOptions { /** Sends options as `featureValues`, which is where a legacy daemon reads them. */ prepare(message: SessionInboundMessage): SessionInboundMessage { switch (message.type) { - case "agent.config.apply.request": { - const { options, ...config } = message.config; - if (!options) return message; - return { - ...message, - config: { ...config, featureValues: { ...config.featureValues, ...options } }, - }; - } case "list_commands_request": { if (!message.draftConfig?.providerOptions) return message; const { providerOptions, ...draftConfig } = message.draftConfig; @@ -197,6 +190,22 @@ export class LegacyProviderOptions { }); } + /** A config apply with its `options` as `featureValues`, the way a daemon without them takes it. */ + featureValuesConfig( + config: AgentConfigApply, + controls: readonly AgentOptionControl[], + ): AgentConfigApply { + const { options, ...rest } = config; + if (!options) return config; + return { + ...rest, + featureValues: { + ...rest.featureValues, + ...Object.fromEntries(this.featureWrites(options, controls)), + }, + }; + } + /** 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 b55dea7ac9..628ab3cc32 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -9143,6 +9143,61 @@ describe("provider options across daemon versions", () => { expect(sentMessages()).toHaveLength(1); }); + test("a legacy daemon applies a config reset as the default the old API names", async () => { + const { client, sentMessages, mock } = await connect({}); + const reply = (message: Record) => + mock.triggerMessage(wrapSessionMessage(message as never)); + const applying = client.applyAgentConfig("agent-1", { + modeId: "plan", + options: { service_tier: null, sandbox_mode: "read-only" }, + }); + const [fetch] = sentMessages(); + expect(fetch).toMatchObject({ type: "fetch_agent_request", agentId: "agent-1" }); + reply({ + type: "fetch_agent_response", + payload: { + requestId: fetch.requestId, + agent: { ...agentSnapshot, provider: "codex", features: [speedFeature] }, + project: null, + error: null, + }, + }); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(2)); + const apply = sentMessages()[1]; + expect(apply).toMatchObject({ + type: "agent.config.apply.request", + config: { + modeId: "plan", + featureValues: { service_tier: "default", sandbox_mode: "read-only" }, + }, + }); + expect(apply.config).not.toHaveProperty("options"); + reply({ + type: "agent.config.apply.response", + payload: { requestId: apply.requestId, agentId: "agent-1", accepted: true, error: null }, + }); + await applying; + }); + + test("a legacy daemon cannot apply a config reset it cannot name, and applies nothing", async () => { + const { client, sentMessages, mock } = await connect({}); + const applying = client.applyAgentConfig("agent-1", { options: { token_budget: null } }); + const [fetch] = sentMessages(); + mock.triggerMessage( + wrapSessionMessage({ + type: "fetch_agent_response", + payload: { + requestId: fetch.requestId, + agent: { ...agentSnapshot, features: [speedFeature] }, + project: null, + error: null, + }, + } as never), + ); + await expect(applying).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 a3e388ef56..3e924e98df 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -144,6 +144,7 @@ import type { AgentSkillsSaveResult, } from "@getpaseo/protocol/messages"; import type { + AgentOptionControl, AgentPermissionRequest, AgentPermissionResponse, AgentPersistenceHandle, @@ -4175,8 +4176,8 @@ export class DaemonClient { const legacy = this.legacyProviderOptions(); if (legacy) { // COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. - const resets = Object.values(patch).includes(null); - const controls = resets ? ((await this.fetchAgent(agentId))?.agent.controls ?? []) : []; + const pending = this.legacyResetControls(agentId, patch); + const controls = pending ? await pending : []; for (const [featureId, value] of legacy.featureWrites(patch, controls)) { await this.setAgentFeature(agentId, featureId, value); } @@ -4185,6 +4186,20 @@ export class DaemonClient { return this.applyAgentConfig(agentId, { options: patch }); } + /** + * COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. + * A reset (`null`) on a daemon without `providerOptionControls` is written as the value its + * control resets to, which takes the agent's controls. Without a reset nothing is fetched, so + * the write is not delayed. + */ + private legacyResetControls( + agentId: string, + patch: Record, + ): Promise | null { + if (!Object.values(patch).includes(null)) return null; + return this.fetchAgent(agentId).then((result) => result?.agent.controls ?? []); + } + async setAgentThinkingOption( agentId: string, thinkingOptionId: string | null, @@ -4227,11 +4242,18 @@ export class DaemonClient { agentId: string, config: AgentConfigApply, ): Promise { + let wireConfig = config; + const legacy = this.legacyProviderOptions(); + if (legacy && config.options) { + // COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. + const pending = this.legacyResetControls(agentId, config.options); + wireConfig = legacy.featureValuesConfig(config, pending ? await pending : []); + } const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ type: "agent.config.apply.request", agentId, - config, + config: wireConfig, requestId, }); const payload = await this.sendRequest({