Files
paseo/packages/app/src/agent-controls/draft-options-model.ts
T
Mohamed Boudra aa558ab845 fix(plugins): composer option resets and model checks match the documented commands
A draft kept `null` as an option value, so `setOptions({ key: null })` sent the null to the
provider and creation failed. A null edit now removes the key from every draft layer, as the patch
does on a running agent. A running agent's `setModel` accepted ids its catalog does not list; it
now rejects them the way a draft does.

The composer controls imported the toast host through the toast context, which pulled the host's
animation module into every module that loads plugins. They use the API-only context instead.
The migration guide's registration table gets the `addComposerItem` row its test requires.
2026-11-02 06:45:57 +01:00

200 lines
7.2 KiB
TypeScript

import type {
AgentOptionControl,
AgentSessionConfig,
ProviderOptions,
} from "@getpaseo/protocol/agent-types";
/**
* The options of one draft agent, as a plain model (see docs/forms.md). It owns which layers
* make up the draft's options; the daemon owns what they mean. Every layer is sent to the daemon
* (`ownDraftOptions`) and the UI draws what comes back (`viewDraftOptions`), so no alias,
* default or legacy key is resolved here.
*
* - `seed`: options the draft started from (a profile, a forked agent).
* - `remembered`: the provider's last-used control values.
* - `edits`: toggles made in this draft.
*
* An edit of `null` removes the key from every layer, the way a patch does on a running agent, so
* the provider's default applies again.
*
* Choosing a provider by hand starts the options over; applying a profile replaces the provider
* and the options in one step, so the provider change that follows does not clear them.
*/
export interface ResolvedDraftOptions {
provider: string;
/** Names the request that produced this answer; see `viewDraftOptions`. */
requestKey: string;
controls: AgentOptionControl[];
options: ProviderOptions;
}
export interface DraftOptionsState {
provider: string | null;
seed: ProviderOptions;
remembered: ProviderOptions;
edits: ProviderOptions;
resolved: ResolvedDraftOptions | null;
}
export interface DraftOptionsModel {
getState: () => DraftOptionsState;
subscribe: (listener: () => void) => () => void;
/** The provider the draft's form now has. A different one than the model's is a manual choice. */
observeProvider: (provider: string | null) => void;
applyProfile: (profile: { provider: string; options: ProviderOptions }) => void;
setRemembered: (remembered: ProviderOptions) => void;
setOption: (key: string, value: unknown) => void;
applyResolved: (resolved: ResolvedDraftOptions) => void;
}
export function openDraftOptions(input: {
provider: string | null;
seed?: ProviderOptions;
/**
* The provider the draft's form has now. An edit belongs to it even before the screen has
* rendered the change, so `setOption` observes it first.
*/
currentProvider?: () => string | null;
}): DraftOptionsModel {
let state: DraftOptionsState = {
provider: input.provider,
seed: input.seed ?? {},
remembered: {},
edits: {},
resolved: null,
};
const listeners = new Set<() => void>();
function publish(update: (current: DraftOptionsState) => DraftOptionsState): void {
const next = update(state);
if (next === state) return;
state = next;
for (const listener of listeners) listener();
}
function observeProvider(provider: string | null): void {
publish((current) => {
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 {
getState: () => state,
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
observeProvider,
applyProfile: (profile) =>
publish((current) => ({
...current,
provider: profile.provider,
seed: profile.options,
edits: {},
resolved: current.provider === profile.provider ? current.resolved : null,
})),
setRemembered: (remembered) => publish((current) => ({ ...current, remembered })),
setOption: (key, value) => {
if (input.currentProvider) observeProvider(input.currentProvider());
publish((current) =>
Object.is(current.edits[key], value)
? current
: { ...current, edits: { ...current.edits, [key]: value } },
);
},
applyResolved: (resolved) => publish((current) => ({ ...current, resolved })),
};
}
/**
* 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.
*/
export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | undefined {
const controlKeys = new Set((state.resolved?.controls ?? []).map((control) => control.key));
const remembered = Object.fromEntries(
Object.entries(state.remembered).filter(
([key]) => controlKeys.has(key) && !(key in state.seed),
),
);
const own = withoutRemovals({ ...state.seed, ...remembered, ...state.edits });
return Object.keys(own).length > 0 ? own : undefined;
}
function withoutRemovals(options: ProviderOptions): ProviderOptions {
return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== null));
}
export type DraftControlsRequest = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions"
>;
/**
* 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
* previous provider's options until it does; that render has no request, so a request never
* pairs one provider with another's options.
*/
export function draftControlsRequest(
state: DraftOptionsState,
form: {
provider: string | null;
cwd: string;
modeId?: string | null;
modelId?: string | null;
thinkingOptionId?: string | null;
},
): DraftControlsRequest | null {
if (!form.provider || !form.cwd || form.provider !== state.provider) return null;
const providerOptions = ownDraftOptions(state);
return {
provider: form.provider,
cwd: form.cwd,
...(form.modeId ? { modeId: form.modeId } : {}),
...(form.modelId ? { model: form.modelId } : {}),
...(form.thinkingOptionId ? { thinkingOptionId: form.thinkingOptionId } : {}),
...(providerOptions ? { providerOptions } : {}),
};
}
/**
* What the controls draw: the daemon's answer for this provider. The last answer stays up while
* a newer request is in flight, with the toggles made since laid over it, so a switch does not
* wait for the round trip. It never carries a seed or remembered value the daemon has not
* resolved.
*/
export function viewDraftOptions(
state: DraftOptionsState,
current: { provider: string | null; requestKey: string },
): { controls: AgentOptionControl[]; options: ProviderOptions } | undefined {
const { resolved } = state;
if (!resolved || resolved.provider !== current.provider) return undefined;
return {
controls: resolved.controls,
options:
resolved.requestKey === current.requestKey
? resolved.options
: withoutRemovals({ ...resolved.options, ...state.edits }),
};
}
const NO_OPTIONS: ProviderOptions = {};
/**
* The options the draft will create with, for a plugin or a control to read: the daemon's answer
* for this provider when there is one, otherwise the draft's own layers, so an edit shows before
* the daemon has answered.
*/
export function shownDraftOptions(
state: DraftOptionsState,
current: { provider: string | null; requestKey: string },
): ProviderOptions {
return viewDraftOptions(state, current)?.options ?? ownDraftOptions(state) ?? NO_OPTIONS;
}