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.
This commit is contained in:
Mohamed Boudra
2026-11-02 06:45:57 +01:00
parent a2b76f77f3
commit aa558ab845
11 changed files with 62 additions and 10 deletions
@@ -14,6 +14,9 @@ import type {
* - `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.
*/
@@ -119,10 +122,14 @@ export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | und
([key]) => controlKeys.has(key) && !(key in state.seed),
),
);
const own = { ...state.seed, ...remembered, ...state.edits };
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"
@@ -173,7 +180,7 @@ export function viewDraftOptions(
options:
resolved.requestKey === current.requestKey
? resolved.options
: { ...resolved.options, ...state.edits },
: withoutRemovals({ ...resolved.options, ...state.edits }),
};
}
@@ -16,7 +16,7 @@ import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AgentControlTrigger } from "@/composer/agent-controls/control";
import { useToast } from "@/contexts/toast-context";
import { useToast } from "@/contexts/toast-api-context";
import { toErrorMessage } from "@/utils/error-messages";
interface ComposerControlBase {
@@ -21,6 +21,7 @@ import {
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
import { isUnlistedModel } from "@/provider-selection/resolve-agent-form";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
@@ -1146,6 +1147,7 @@ export const AgentControls = memo(function AgentControls({
metadata: agent.metadata ?? EMPTY_METADATA,
setProvider: () => Promise.reject(new Error("A running agent cannot change provider.")),
setModel: async (modelId) => {
if (isUnlistedModel(models, modelId)) throw new Error(`Unknown model: ${modelId}`);
await client.setAgentModel(agentId, modelId);
},
setMode: async (modeId) => {
@@ -1168,6 +1170,7 @@ export const AgentControls = memo(function AgentControls({
client,
modeControl?.selectedModeId,
modelSelection.selectedThinkingId,
models,
]);
const commandCenterControls = useMemo<AgentControlCommandCenterSource>(
@@ -8,7 +8,7 @@ import {
useComposerAgent,
useComposerInput,
} from "@/composer/session";
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
import { ToastApiProvider, useToast } from "@/contexts/toast-api-context";
/**
* A phone's overflow draws in a sheet whose content lands at the app root, away from the
@@ -139,6 +139,22 @@ describe.each(["draft", "new-workspace"] as const)("the %s composer's agent", (c
expect(draft.agent().options).toEqual({});
});
it("creates with the provider's default again once a plugin resets an option", async () => {
const draft = openDraft(context);
const retained = draft.agent();
await retained.setOptions({ context: "1m", effort: "high" });
await retained.setOptions({ context: null });
expect(draft.agent().options).toEqual({ effort: "high" });
expect(draft.request()?.providerOptions).toEqual({ effort: "high" });
await retained.setOptions({ effort: null });
expect(draft.agent().options).toEqual({});
expect(draft.request()?.providerOptions).toBeUndefined();
});
it("rejects a provider the host cannot run and a model the catalog does not list", async () => {
const draft = openDraft(context);
const retained = draft.agent();
@@ -8,7 +8,7 @@ import {
} from "@/composer/agent-controls/composer-control";
import { useComposerAgent, useComposerInput } from "@/composer/session";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useToast } from "@/contexts/toast-api-context";
import { useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import { createPluginClientStateSource } from "../client-state/source";
@@ -4,9 +4,8 @@ import type { FormPreferences } from "@/hooks/use-form-preferences";
import {
INITIAL_AGENT_FORM_RESOLUTION,
INITIAL_USER_MODIFIED,
normalizeSelectedModelId,
isUnlistedModel,
resolveAgentForm,
resolveEffectiveModel,
type AgentFormAction,
type AgentFormReducerState,
} from "@/provider-selection/resolve-agent-form";
@@ -75,9 +74,7 @@ export function openAgentForm(catalog: () => AgentFormCatalog): AgentFormStore {
const host = catalog();
const listed = provider ? host.models.get(provider) : undefined;
const models = listed?.length ? listed : null;
if (models && normalizeSelectedModelId(modelId) && !resolveEffectiveModel(models, modelId)) {
return false;
}
if (isUnlistedModel(models, modelId)) return false;
dispatch({
type: "SET_MODEL_FROM_USER",
modelId,
@@ -3,6 +3,7 @@ import {
resolveAgentForm,
resolveFormState,
resolveEffectiveModel,
isUnlistedModel,
resolveThinkingOptionId,
mergeSelectedComposerPreferences,
buildProviderDefinitionMap,
@@ -131,6 +132,19 @@ describe("resolveDefaultModel", () => {
});
});
describe("isUnlistedModel", () => {
it("rejects an id the provider's catalog lacks, and accepts a listed id or alias", () => {
expect(isUnlistedModel(ALIASED_CODEX_MODELS, "not-in-the-catalog")).toBe(true);
expect(isUnlistedModel(ALIASED_CODEX_MODELS, "gpt-5.3-codex")).toBe(false);
expect(isUnlistedModel(ALIASED_CODEX_MODELS, "gpt-5.3-codex-legacy")).toBe(false);
});
it("accepts any id while the provider has listed no models", () => {
expect(isUnlistedModel(null, "anything")).toBe(false);
expect(isUnlistedModel([], "anything")).toBe(false);
});
});
describe("model aliases", () => {
it("canonicalizes a retired preferred model and restores thinking from its alias key", () => {
const resolved = resolveFormState(
@@ -153,6 +153,19 @@ export function resolveEffectiveModel(
return findModelByReference(availableModels, modelId) ?? null;
}
/**
* True when the provider has listed its models and this id is not among them. A provider that
* lists none accepts any id.
*/
export function isUnlistedModel(
availableModels: AgentModelDefinition[] | null,
modelId: string,
): boolean {
if (!availableModels || availableModels.length === 0) return false;
if (!normalizeSelectedModelId(modelId)) return false;
return !resolveEffectiveModel(availableModels, modelId);
}
function resolvePreferredThinkingOptionId(input: {
availableModels: AgentModelDefinition[] | null;
providerPrefs: ProviderPrefs | undefined;
+1
View File
@@ -80,6 +80,7 @@ Use this table as the complete registration checklist.
| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` |
| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function |
| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function |
| New composer item contribution | `client.addComposerItem(item)` in `index.client.tsx`; see [composer items](reference#composer-items) |
| New header contribution | `client.addHeaderButton({ id, workspaceId, button })` |
| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` |
| New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) |
+1
View File
@@ -73,6 +73,7 @@ my-plugin/
| 旧根入口中的 `plugin.addClientSlashCommand(command)` | `index.client.tsx` 中的 `client.addSlashCommand(command)` |
| 旧根入口中的 `plugin.addClientSide(fn)` | 删除这层包装,把 `fn` 的函数体移入默认的客户端入口函数 |
| 旧客户端回调中的 `client.addComposerPill(pill)` | `index.client.tsx` 或被导入的 `client/` 函数中的 `client.addComposerPill(pill)` |
| 新的编辑器项贡献 | `index.client.tsx` 中的 `client.addComposerItem(item)`;见[编辑器项](reference#composer-items) |
| 新的标题栏贡献 | `client.addHeaderButton({ id, workspaceId, button })` |
| 旧根入口中的 `plugin.addAttachmentSource(source)` | `index.client.tsx` 中的 `client.addAttachmentSource(source)` |
| 新的设置界面贡献 | `index.client.tsx` 中的 `client.addSettingsScreen(screen)`;见[设置界面](reference#settings-screens) |