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