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.
This commit is contained in:
Mohamed Boudra
2026-11-05 19:28:40 +01:00
parent 568bd447e3
commit 9f39663722
3 changed files with 89 additions and 2 deletions
@@ -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<string, unknown>,
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"],
+62
View File
@@ -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<string, unknown>, patch: Record<string, null>) {
const { client, sentMessages, mock } = await connect({});
const reply = (message: Record<string, unknown>) =>
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" });
+5 -2
View File
@@ -4172,9 +4172,12 @@ export class DaemonClient {
agentId: string,
patch: Record<string, unknown>,
): Promise<AgentProviderNotice | null> {
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;