fix(client): a legacy profile with both featureValues and options keeps both

A legacy daemon keeps an unknown `options` field on a profile it stored
(profiles are passthrough), for example after a downgrade, while its own app
keeps editing `featureValues`. Reading such a profile took `options` and
dropped `featureValues`, so a Fast setting there was lost and the next save
replaced it. Both are merged on read with an explicit `options` key winning,
the same rule the daemon applies when it folds `featureValues`.

The draft form input of draftControlsRequest is a named type.
This commit is contained in:
Mohamed Boudra
2026-11-04 08:09:21 +01:00
parent a7c976679d
commit 6566e5327c
3 changed files with 58 additions and 10 deletions
@@ -118,6 +118,15 @@ export type DraftControlsRequest = Pick<
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions"
>;
/** What the draft's form has chosen; the options come from the model. */
export interface DraftControlsForm {
provider: string | null;
cwd: string;
modeId?: string | null;
modelId?: string | null;
thinkingOptionId?: string | null;
}
/**
* The listing request for the draft's form, built from one snapshot of the model. The form
* reaches a new provider before the model has observed it, and the model still holds the
@@ -126,13 +135,7 @@ export type DraftControlsRequest = Pick<
*/
export function draftControlsRequest(
state: DraftOptionsState,
form: {
provider: string | null;
cwd: string;
modeId?: string | null;
modelId?: string | null;
thinkingOptionId?: string | null;
},
form: DraftControlsForm,
): DraftControlsRequest | null {
if (!form.provider || !form.cwd || form.provider !== state.provider) return null;
const providerOptions = ownDraftOptions(state);
@@ -203,11 +203,15 @@ export class LegacyProviderOptions {
};
}
/** A legacy daemon stores a profile's options under `featureValues`. */
/**
* A legacy daemon stores a profile's options under `featureValues`, and keeps an `options` field
* a newer daemon left on it. Both are read, and an explicit `options` key wins, as it does on the
* daemon (`foldFeatureValues`).
*/
function profileWithOptions(profile: AgentProfile): AgentProfile {
if (profile.options || !profile.featureValues) return profile;
if (!profile.featureValues) return profile;
const { featureValues, ...rest } = profile;
return { ...rest, options: featureValues };
return { ...rest, options: { ...featureValues, ...profile.options } };
}
function profileWithFeatureValues(profile: AgentProfile): AgentProfile {
+41
View File
@@ -9165,6 +9165,47 @@ describe("provider options across daemon versions", () => {
expect(written).not.toMatchObject({ config: { agentProfiles: [{ options: {} }] } });
});
test("a legacy daemon's profile with both featureValues and options keeps both when saved", async () => {
// `options` survives on a legacy daemon's passthrough profile after a downgrade, while its
// own app keeps editing `featureValues`.
const { client, sentMessages, mock } = await connect({});
const reading = client.getDaemonConfig();
const [read] = sentMessages();
mock.triggerMessage(
wrapSessionMessage({
type: "get_daemon_config_response",
payload: {
requestId: read.requestId,
config: {
mcp: { injectIntoAgents: true },
agentProfiles: [
{
id: "p1",
name: "Mixed",
provider: "codex",
featureValues: { service_tier: "fast", sandbox_mode: "workspace-write" },
options: { sandbox_mode: "read-only" },
},
],
},
},
}),
);
const { config } = await reading;
expect(config.agentProfiles?.[0]).toMatchObject({
options: { service_tier: "fast", sandbox_mode: "read-only" },
});
expect(config.agentProfiles?.[0]).not.toHaveProperty("featureValues");
void client.patchDaemonConfig({ agentProfiles: config.agentProfiles }).catch(() => {});
const written = sentMessages().find((message) => message.type === "set_daemon_config_request");
expect(written).toMatchObject({
config: {
agentProfiles: [{ featureValues: { service_tier: "fast", sandbox_mode: "read-only" } }],
},
});
});
test("a legacy daemon applies a profile's options as feature values", async () => {
const { client, sentMessages } = await connect({});
void client.applyAgentConfig("agent-1", { options: { service_tier: "fast" } }).catch(() => {});