diff --git a/packages/app/src/agent-controls/draft-options-model.ts b/packages/app/src/agent-controls/draft-options-model.ts index 3e21119959..8d8ca842d7 100644 --- a/packages/app/src/agent-controls/draft-options-model.ts +++ b/packages/app/src/agent-controls/draft-options-model.ts @@ -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 }), }; } diff --git a/packages/app/src/composer/agent-controls/composer-control.tsx b/packages/app/src/composer/agent-controls/composer-control.tsx index 8af7d4114f..83210b024f 100644 --- a/packages/app/src/composer/agent-controls/composer-control.tsx +++ b/packages/app/src/composer/agent-controls/composer-control.tsx @@ -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 { diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 7724c2c544..877493cac1 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -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( diff --git a/packages/app/src/composer/agent-controls/sheet-bridge.tsx b/packages/app/src/composer/agent-controls/sheet-bridge.tsx index 294312e55c..17fa08f060 100644 --- a/packages/app/src/composer/agent-controls/sheet-bridge.tsx +++ b/packages/app/src/composer/agent-controls/sheet-bridge.tsx @@ -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 diff --git a/packages/app/src/composer/draft/composer-agent.test.ts b/packages/app/src/composer/draft/composer-agent.test.ts index e09b71d92f..209bedd0c0 100644 --- a/packages/app/src/composer/draft/composer-agent.test.ts +++ b/packages/app/src/composer/draft/composer-agent.test.ts @@ -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(); diff --git a/packages/app/src/plugins/composer-items/index.tsx b/packages/app/src/plugins/composer-items/index.tsx index 617289bfeb..309dac4c36 100644 --- a/packages/app/src/plugins/composer-items/index.tsx +++ b/packages/app/src/plugins/composer-items/index.tsx @@ -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"; diff --git a/packages/app/src/provider-selection/agent-form-store.ts b/packages/app/src/provider-selection/agent-form-store.ts index cbfb25a284..73aad728ac 100644 --- a/packages/app/src/provider-selection/agent-form-store.ts +++ b/packages/app/src/provider-selection/agent-form-store.ts @@ -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, diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index 877898b979..95119e542e 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -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( diff --git a/packages/app/src/provider-selection/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts index 08e88fd7a3..b77ba5a37a 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -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; diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index 43ab4ae6e0..eec88acc51 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -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) | diff --git a/public-docs/plugins/migration.zh-CN.md b/public-docs/plugins/migration.zh-CN.md index 3e8ff003e7..bd2e101fc7 100644 --- a/public-docs/plugins/migration.zh-CN.md +++ b/public-docs/plugins/migration.zh-CN.md @@ -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) |