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.
This commit is contained in:
Mohamed Boudra
2026-11-05 19:31:56 +01:00
parent 9f39663722
commit 152a5ae731
3 changed files with 97 additions and 11 deletions
@@ -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"],
+55
View File
@@ -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<string, unknown>) =>
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" });
+25 -3
View File
@@ -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<string, unknown>,
): Promise<AgentOptionControl[]> | 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<AgentProviderNotice | null> {
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({