fix(app): a draft keeps sending an option no control has drawn

The previous change stopped sending any edit the latest answer drew no control
for, which also dropped options set without a control (a plugin setting a
token budget on the draft). Only an edit of a key an earlier answer for this
draft drew as a control stops applying when a later answer draws none; every
other edit always applies.
This commit is contained in:
Mohamed Boudra
2026-11-05 15:30:11 +01:00
parent af2fb8ab35
commit f91ec81f7c
2 changed files with 38 additions and 7 deletions
@@ -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 } });
@@ -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<string>;
}
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;