From 568bd447e305deba363ddfe1623543127e61142a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 5 Nov 2026 15:03:44 +0100 Subject: [PATCH] fix(app): a profile stops saving a toggle the model in use does not offer Switching a profile from Opus to Haiku kept the Fast edit, so the saved profile carried fast_mode that no control draws and an old daemon's provider rejects at create. The profile editor now drops a key an earlier answer drew as a control and the latest one does not, the rule the draft already follows; options no control ever drew are never touched. --- .../src/agent-controls/draft-options-model.ts | 5 +- .../app/src/agent-controls/lost-controls.ts | 17 ++++ .../internal/profile-form-model.test.ts | 80 +++++++++++++++++++ .../internal/profile-form-model.ts | 43 ++++++++-- 4 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 packages/app/src/agent-controls/lost-controls.ts diff --git a/packages/app/src/agent-controls/draft-options-model.ts b/packages/app/src/agent-controls/draft-options-model.ts index b68a283a41..f7801ec2b7 100644 --- a/packages/app/src/agent-controls/draft-options-model.ts +++ b/packages/app/src/agent-controls/draft-options-model.ts @@ -3,6 +3,7 @@ import type { AgentSessionConfig, ProviderOptions, } from "@getpaseo/protocol/agent-types"; +import { withoutLostControls } from "./lost-controls"; /** * The options of one draft agent, as a plain model (see docs/forms.md). It owns which layers @@ -129,9 +130,7 @@ export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | und ([key]) => controlKeys.has(key) && !(key in state.seed), ), ); - const edits = Object.fromEntries( - Object.entries(state.edits).filter(([key]) => !state.drawn.has(key) || controlKeys.has(key)), - ); + const edits = withoutLostControls(state.edits, state.drawn, controlKeys); const own = { ...state.seed, ...remembered, ...edits }; return Object.keys(own).length > 0 ? own : undefined; } diff --git a/packages/app/src/agent-controls/lost-controls.ts b/packages/app/src/agent-controls/lost-controls.ts new file mode 100644 index 0000000000..0ea7d76940 --- /dev/null +++ b/packages/app/src/agent-controls/lost-controls.ts @@ -0,0 +1,17 @@ +import type { ProviderOptions } from "@getpaseo/protocol/agent-types"; + +/** + * The options that still apply after an answer from the daemon. A key an earlier answer drew as a + * control and the latest answer does not (the model changed to one without that control) is + * dropped, and applies again if a control for it returns. A key no answer has drawn is an option + * without a control and always applies. + */ +export function withoutLostControls( + options: ProviderOptions, + drawn: ReadonlySet, + current: ReadonlySet, +): ProviderOptions { + return Object.fromEntries( + Object.entries(options).filter(([key]) => !drawn.has(key) || current.has(key)), + ); +} diff --git a/packages/app/src/agent-profiles/internal/profile-form-model.test.ts b/packages/app/src/agent-profiles/internal/profile-form-model.test.ts index 34c5aa1594..7ec5733593 100644 --- a/packages/app/src/agent-profiles/internal/profile-form-model.test.ts +++ b/packages/app/src/agent-profiles/internal/profile-form-model.test.ts @@ -70,6 +70,21 @@ function optionValues(options: readonly { value: string }[]): string[] { return options.map((option) => option.value); } +const FAST = { + controls: [{ type: "toggle" as const, key: "fast_mode", label: "Fast" }], + options: { fast_mode: false }, +}; +const NO_CONTROLS = { controls: [], options: {} }; + +function toggledOnOpus(): AgentProfileFormModel { + const model = openWithCatalog({ mode: "create" }); + model.setName("Fast"); + selectClaude(model); + model.applyControls(model.getState().controlsRequestKey ?? "", FAST); + model.setOption("fast_mode", true); + return model; +} + describe("openAgentProfileForm", () => { it("starts a create form empty and cannot submit", () => { const model = openWithCatalog({ mode: "create" }); @@ -522,6 +537,71 @@ describe("openAgentProfileForm", () => { expect(model.getState().controls).toEqual([]); }); + it("does not save a toggle once the model that drew it is replaced by one without it", () => { + const model = toggledOnOpus(); + expect(model.getState().submitValue?.options).toEqual({ fast_mode: true }); + + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); + model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS); + + expect(model.getState().submitValue?.options).toBeUndefined(); + expect(model.getState().controlsRequest?.providerOptions).toBeUndefined(); + }); + + it("saves a toggle again when the model that draws it returns", () => { + const model = toggledOnOpus(); + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); + model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS); + + model.setModel("claude-opus-5", { label: "Opus 5" }); + model.applyControls(model.getState().controlsRequestKey ?? "", FAST); + + expect(model.getState().submitValue?.options).toEqual({ fast_mode: true }); + }); + + it("does not save a stored toggle profile either", () => { + const model = openAgentProfileForm({ + mode: "edit", + profile: { + id: "p1", + name: "Fast", + provider: "claude", + model: "claude-opus-5", + options: { fast_mode: true }, + }, + }); + model.applyProviderCatalog(ENTRIES); + model.applyControls(model.getState().controlsRequestKey ?? "", FAST); + + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); + model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS); + + expect(model.getState().submitValue?.options).toBeUndefined(); + }); + + it("never drops an option no control has drawn", () => { + const model = openWithCatalog({ mode: "create" }); + model.setName("Fast"); + selectClaude(model); + model.applyControls(model.getState().controlsRequestKey ?? "", FAST); + model.setOption("token_budget", 8000); + model.setOption("fast_mode", true); + + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); + model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS); + + expect(model.getState().submitValue?.options).toEqual({ token_budget: 8000 }); + }); + + it("keeps a toggle when the daemon cannot list controls", () => { + const model = toggledOnOpus(); + + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); + model.applyControlsUnavailable(model.getState().controlsRequestKey ?? ""); + + expect(model.getState().submitValue?.options).toEqual({ fast_mode: true }); + }); + it("resolves an unavailable feature listing without showing the field", () => { const model = openWithCatalog({ mode: "create" }); selectClaude(model); diff --git a/packages/app/src/agent-profiles/internal/profile-form-model.ts b/packages/app/src/agent-profiles/internal/profile-form-model.ts index 88d15c52dd..5320993f6f 100644 --- a/packages/app/src/agent-profiles/internal/profile-form-model.ts +++ b/packages/app/src/agent-profiles/internal/profile-form-model.ts @@ -7,6 +7,7 @@ import type { ProviderSnapshotEntry, } from "@getpaseo/protocol/agent-types"; import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { withoutLostControls } from "@/agent-controls/lost-controls"; import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels"; import { filterSelectableModels } from "@/provider-selection/model-catalog"; @@ -102,6 +103,10 @@ export interface AgentProfileFormState { controls: AgentOptionControl[]; /** What the controls show: the options the daemon resolved from `options`, then the edits. */ controlOptions: ProviderOptions; + /** Every key an answer for this provider has drawn as a control. */ + drawnControlKeys: ReadonlySet; + /** The keys the latest answer draws as controls. */ + answeredControlKeys: ReadonlySet; providerDisplay: AgentProfileFormDisplay | null; modelDisplay: AgentProfileFormDisplay | null; @@ -338,11 +343,21 @@ function buildControlsRequest(state: AgentProfileFormState): AgentProfileControl }; } -/** Everything the profile would save as its options; the daemon resolves them for the form. */ +/** + * Everything the profile would save as its options; the daemon resolves them for the form. A + * toggle the model in use does not draw is not saved (see `withoutLostControls`). + */ function ownOptionsOf( - state: Pick, + state: Pick< + AgentProfileFormState, + "options" | "optionEdits" | "drawnControlKeys" | "answeredControlKeys" + >, ): ProviderOptions { - return { ...state.options, ...state.optionEdits }; + return withoutLostControls( + { ...state.options, ...state.optionEdits }, + state.drawnControlKeys, + state.answeredControlKeys, + ); } /** The part of a request that decides which controls exist; options only change their values. */ @@ -435,6 +450,8 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm thinkingOptions: [], controls: [], controlOptions: {}, + drawnControlKeys: new Set(), + answeredControlKeys: new Set(), providerDisplay: seedDisplay(provider), modelDisplay: seedDisplay(modelId), modeDisplay: seedDisplay(profile.modeId), @@ -463,17 +480,23 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP let resolvedScopeKey: string | null = null; let resolvedControls: AgentOptionControl[] = []; let resolvedOptions: ProviderOptions = {}; + let drawnKeys: ReadonlySet = new Set(); + let answeredKeys: ReadonlySet = new Set(); let listeners = new Set<() => void>(); let closed = false; function derive(incoming: AgentProfileFormState): AgentProfileFormState { const models = resolveModels(entries, incoming.provider); const modes = resolveModes(entries, incoming.provider); - const next = seedSelections(incoming, { - entry: findEntry(entries, incoming.provider), - models, - modes, - }); + const next: AgentProfileFormState = { + ...seedSelections(incoming, { + entry: findEntry(entries, incoming.provider), + models, + modes, + }), + drawnControlKeys: drawnKeys, + answeredControlKeys: answeredKeys, + }; const thinking = resolveThinkingOptions(entries, next.provider, next.modelId); const controlsRequest = buildControlsRequest(next); const controlsRequestKey = buildControlsRequestKey(controlsRequest); @@ -588,6 +611,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP resolvedScopeKey = buildControlsScopeKey(state.controlsRequest); resolvedControls = [...resolved.controls]; resolvedOptions = resolved.options; + answeredKeys = new Set(resolved.controls.map((control) => control.key)); + drawnKeys = new Set([...drawnKeys, ...answeredKeys]); publish((current) => current); }, applyControlsUnavailable: (requestKey) => { @@ -612,6 +637,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP // Everything below the provider is provider-scoped: a model id, mode id, // thinking id or feature id from the old provider means nothing here. // Clearing them is enough — `derive` seeds the new provider's defaults. + drawnKeys = new Set(); + answeredKeys = new Set(); return { ...current, provider: providerId,