diff --git a/packages/app/src/agent-controls/draft-options-model.test.ts b/packages/app/src/agent-controls/draft-options-model.test.ts index 12ea6979d9..1efee32348 100644 --- a/packages/app/src/agent-controls/draft-options-model.test.ts +++ b/packages/app/src/agent-controls/draft-options-model.test.ts @@ -107,14 +107,27 @@ describe("draft options model", () => { expect(ownDraftOptions(model.getState())).toEqual({ sandbox_mode: "read-only" }); - // Back on the first model, the toggle is still on. - model.applyResolved(answer("claude", "opus-again", { fast_mode: true }, [FAST_MODE])); + // Back on a model that draws the control, the toggle applies again. The daemon's answer + // for the request without it said Fast was off; the request that carries it follows. + model.applyResolved(answer("claude", "opus-again", { fast_mode: false }, [FAST_MODE])); expect(ownDraftOptions(model.getState())).toEqual({ sandbox_mode: "read-only", fast_mode: true, }); }); + it("always sends an option no control has drawn", () => { + const FAST_MODE: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" }; + const model = openDraftOptions({ provider: "claude" }); + model.applyResolved(answer("claude", "k1", {}, [FAST_MODE])); + + model.setOption("token_budget", 5); + + expect(ownDraftOptions(model.getState())).toEqual({ token_budget: 5 }); + model.applyResolved(answer("claude", "k2", {}, [])); + expect(ownDraftOptions(model.getState())).toEqual({ token_budget: 5 }); + }); + it("keeps a draft's seed when the first provider is chosen", () => { const model = openDraftOptions({ provider: null, seed: { web_search: true } }); diff --git a/packages/app/src/agent-controls/draft-options-model.ts b/packages/app/src/agent-controls/draft-options-model.ts index a2da5583a9..b68a283a41 100644 --- a/packages/app/src/agent-controls/draft-options-model.ts +++ b/packages/app/src/agent-controls/draft-options-model.ts @@ -31,6 +31,8 @@ export interface DraftOptionsState { remembered: ProviderOptions; edits: ProviderOptions; resolved: ResolvedDraftOptions | null; + /** Every key an answer for this provider has drawn as a control. */ + drawn: ReadonlySet; } export interface DraftOptionsModel { @@ -54,6 +56,7 @@ export function openDraftOptions(input: { remembered: {}, edits: {}, resolved: null, + drawn: new Set(), }; const listeners = new Set<() => void>(); @@ -77,7 +80,14 @@ export function openDraftOptions(input: { if (provider === current.provider) return current; // The first provider a draft picks does not discard what it was opened with. if (current.provider === null) return { ...current, provider }; - return { ...current, provider, seed: {}, edits: {}, resolved: null }; + return { + ...current, + provider, + seed: {}, + edits: {}, + resolved: null, + drawn: new Set(), + }; }), applyProfile: (profile) => publish((current) => ({ @@ -86,6 +96,7 @@ export function openDraftOptions(input: { seed: profile.options, edits: {}, resolved: current.provider === profile.provider ? current.resolved : null, + drawn: current.provider === profile.provider ? current.drawn : new Set(), })), setRemembered: (remembered) => publish((current) => ({ ...current, remembered })), setOption: (key, value) => @@ -94,15 +105,22 @@ export function openDraftOptions(input: { ? current : { ...current, edits: { ...current.edits, [key]: value } }, ), - applyResolved: (resolved) => publish((current) => ({ ...current, resolved })), + applyResolved: (resolved) => + publish((current) => ({ + ...current, + resolved, + drawn: new Set([...current.drawn, ...resolved.controls.map((control) => control.key)]), + })), }; } /** * Everything the draft would create with, for the daemon to resolve. A last-used value applies * once the provider has told us it still draws a control for that key, and never over the seed. - * A toggle made in this draft stops applying when the latest answer draws no control for it (the - * model changed to one without that control) and applies again when a control for it returns. + * An edit of a key an earlier answer drew as a control stops applying when the latest answer + * draws none for it (the model changed to one without that control) and applies again when a + * control for it returns. An edit of a key no answer has drawn is an option without a control + * and always applies. */ export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | undefined { const controlKeys = new Set((state.resolved?.controls ?? []).map((control) => control.key)); @@ -112,7 +130,7 @@ export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | und ), ); const edits = Object.fromEntries( - Object.entries(state.edits).filter(([key]) => !state.resolved || controlKeys.has(key)), + Object.entries(state.edits).filter(([key]) => !state.drawn.has(key) || controlKeys.has(key)), ); const own = { ...state.seed, ...remembered, ...edits }; return Object.keys(own).length > 0 ? own : undefined;