mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 00:14:54 +00:00
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:
@@ -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"],
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user