diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx index 0750608c40..b37fae29ca 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx @@ -103,17 +103,19 @@ const splitPaneContext: PaneContextValue = { function availableModel({ value, label, + description = "", isDefault = false, }: { value: string; label: string; + description?: string; isDefault?: boolean; }): AvailableModel { return { id: value, model: value, displayName: label, - description: "", + description, supportedReasoningEfforts: [ { reasoningEffort: "medium", description: "Medium" }, ], @@ -546,6 +548,43 @@ describe("ModelReasoningPicker", () => { expect(onModelChange).toHaveBeenCalledWith(apiModel); }); + it("distinguishes duplicate preview models by description and preserves value", async () => { + const { onModelChange } = renderPicker({ + alternateProviderModels: [ + availableModel({ + value: "zai/glm-4.7", + label: "GLM 4.7", + description: "zai/glm-4.7", + isDefault: true, + }), + availableModel({ + value: "openrouter/glm-4.7", + label: "GLM 4.7", + description: "openrouter/glm-4.7", + }), + ], + }); + const trigger = screen.getByRole("button", { + name: "Provider, model and reasoning", + }); + fireEvent.click(trigger); + fireEvent.click(screen.getByTitle("Claude Code")); + + const zaiDescription = await screen.findByText("zai/glm-4.7"); + const openRouterDescription = screen.getByText("openrouter/glm-4.7"); + const zaiRow = zaiDescription.closest("button"); + const openRouterRow = openRouterDescription.closest("button"); + expect(zaiRow).not.toBeNull(); + expect(openRouterRow).not.toBeNull(); + expect(zaiRow).not.toBe(openRouterRow); + expect(zaiRow?.textContent).toContain("GLM 4.7"); + expect(openRouterRow?.textContent).toContain("GLM 4.7"); + + fireEvent.click(openRouterDescription); + + expect(onModelChange).toHaveBeenCalledWith("openrouter/glm-4.7"); + }); + it("fuzzy-filters a long model list and selects the match by keyboard", () => { const { onModelChange } = renderPicker({ modelOptions: manyCodexModels }); diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index 1301d4644a..92bbf3df45 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -391,6 +391,7 @@ export function ModelReasoningPicker({ ...(model.routeProviderId ? { routeProviderId: model.routeProviderId } : {}), + ...(model.description ? { description: model.description } : {}), })); }, [isPreviewing, modelOptions, previewQuery.data?.models, formatModelLabel]); const previewMoreModelOptions = useMemo((): readonly ModelPickerOption[] => { @@ -405,6 +406,7 @@ export function ModelReasoningPicker({ ...(model.routeProviderId ? { routeProviderId: model.routeProviderId } : {}), + ...(model.description ? { description: model.description } : {}), })); }, [ isPreviewing, @@ -1045,6 +1047,7 @@ export function ModelReasoningPicker({ activeProviderId, )} qualifier={option.routeProviderId} + description={option.description} selected={!isPreviewing && option.value === modelValue} onClick={() => handleModelSelect(option.value)} /> @@ -1359,6 +1362,7 @@ function MoreModelsSubmenu({ key={option.value} label={stripModelBrandPrefix(option.label, activeProviderId)} qualifier={option.routeProviderId} + description={option.description} selected={!isPreviewing && option.value === modelValue} onClick={() => onSelect(option.value)} /> @@ -1386,6 +1390,7 @@ function ResetBrowseStateOnContentUnmount({ function MenuRowButton({ label, qualifier, + description, selected, onClick, isActive, @@ -1396,6 +1401,7 @@ function MenuRowButton({ }: { label: string; qualifier?: string; + description?: string; selected: boolean; onClick: () => void; isActive?: boolean; @@ -1429,16 +1435,21 @@ function MenuRowButton({ )} {...hoverProps} > - - {base} - {tag ? ( - {tag} - ) : null} - {qualifier ? ( - {qualifier} + + + {base} + {tag ? ( + {tag} + ) : null} + {qualifier ? ( + {qualifier} + ) : null} + + {description ? ( + {description} ) : null} diff --git a/apps/app/src/hooks/useThreadCreationOptions.ts b/apps/app/src/hooks/useThreadCreationOptions.ts index 1b743e7ea3..b7bcb1c902 100644 --- a/apps/app/src/hooks/useThreadCreationOptions.ts +++ b/apps/app/src/hooks/useThreadCreationOptions.ts @@ -599,6 +599,7 @@ export function useThreadCreationOptions( ...(model.routeProviderId ? { routeProviderId: model.routeProviderId } : {}), + ...(model.description ? { description: model.description } : {}), })), [availableModels], ); @@ -619,6 +620,7 @@ export function useThreadCreationOptions( ...(model.routeProviderId ? { routeProviderId: model.routeProviderId } : {}), + ...(model.description ? { description: model.description } : {}), })), [executionOptionsQuery.data?.selectedOnlyModels, availableModels], ); diff --git a/apps/mobile/src/data/compose/execution-options.test.ts b/apps/mobile/src/data/compose/execution-options.test.ts index 891b7c4afb..73c6ee02a1 100644 --- a/apps/mobile/src/data/compose/execution-options.test.ts +++ b/apps/mobile/src/data/compose/execution-options.test.ts @@ -66,6 +66,29 @@ describe("resolveModelSelection", () => { expect(resolved.isRecovery).toBe(false); }); + it("carries the model description verbatim into the picker option", () => { + const resolved = resolveModelSelection({ + executionOptions: response({ + models: [ + model({ + model: "acme/glm-4.7", + displayName: "GLM 4.7", + description: "acme/glm-4.7", + }), + ], + }), + selectedModel: "acme/glm-4.7", + catalogVerified: true, + }); + expect(resolved.options).toEqual([ + { + value: "acme/glm-4.7", + label: "GLM 4.7", + description: "acme/glm-4.7", + }, + ]); + }); + it("promotes a retired-but-selected model instead of silently switching", () => { const resolved = resolveModelSelection({ executionOptions: catalog, diff --git a/plugins/provider-acp/src/bridge/model-catalog.test.ts b/plugins/provider-acp/src/bridge/model-catalog.test.ts index 64085c1197..f092d565b0 100644 --- a/plugins/provider-acp/src/bridge/model-catalog.test.ts +++ b/plugins/provider-acp/src/bridge/model-catalog.test.ts @@ -438,6 +438,47 @@ describe("acp configOptions model catalog", () => { ]); }); + it("keeps per-option descriptions that disambiguate duplicate model names", () => { + // OMP-style agents send description: "provider/modelId" on every model + // select option, and two providers can expose the same display name; + // the picker needs each option's description to tell them apart. + const models = buildModelCatalogFromConfigOptions({ + id: "model", + category: "model", + type: "select", + options: [ + { value: "zai/glm-4.7", name: "GLM 4.7", description: "zai/glm-4.7" }, + { + value: "openai/glm-4.7", + name: "GLM 4.7", + description: "openai/glm-4.7", + }, + { value: "opencode/big-pickle", name: "Big Pickle" }, + ], + }); + + expect(models).toMatchObject([ + { + id: "zai/glm-4.7", + model: "zai/glm-4.7", + displayName: "GLM 4.7", + description: "zai/glm-4.7", + }, + { + id: "openai/glm-4.7", + model: "openai/glm-4.7", + displayName: "GLM 4.7", + description: "openai/glm-4.7", + }, + { + id: "opencode/big-pickle", + model: "opencode/big-pickle", + displayName: "Big Pickle", + description: "", + }, + ]); + }); + it("finds and maps ACP thought_level config options", () => { const thoughtLevel = { id: "effort", diff --git a/plugins/provider-acp/src/bridge/model-catalog.ts b/plugins/provider-acp/src/bridge/model-catalog.ts index ba8be40327..1ecc8358f6 100644 --- a/plugins/provider-acp/src/bridge/model-catalog.ts +++ b/plugins/provider-acp/src/bridge/model-catalog.ts @@ -280,7 +280,7 @@ export function buildModelCatalogFromConfigOptions( id: option.value, model: option.value, displayName: option.name ?? option.value, - description: "", + description: option.description ?? "", supportedReasoningEfforts: reasoning.supportedReasoningEfforts, defaultReasoningEffort: reasoning.defaultReasoningEffort, isDefault, diff --git a/plugins/provider-acp/src/wire.test.ts b/plugins/provider-acp/src/wire.test.ts index beff3c8cf7..14df9554cc 100644 --- a/plugins/provider-acp/src/wire.test.ts +++ b/plugins/provider-acp/src/wire.test.ts @@ -47,6 +47,11 @@ describe("acpSessionNewResultSchema", () => { { value: "openai-codex/gpt-5.5", name: "openai-codex/GPT-5.5", + description: "openai-codex/gpt-5.5", + }, + { + value: "zai/glm-4.7", + name: "GLM 4.7", description: null, }, ], @@ -74,6 +79,12 @@ describe("acpSessionNewResultSchema", () => { ); expect(parsed.data.configOptions?.[1].category).toBeUndefined(); expect(parsed.data.configOptions?.[1].options?.[0].name).toBeUndefined(); + expect(parsed.data.configOptions?.[0].options?.[0].description).toBe( + "openai-codex/gpt-5.5", + ); + expect( + parsed.data.configOptions?.[0].options?.[1].description, + ).toBeUndefined(); }); }); diff --git a/plugins/provider-acp/src/wire.ts b/plugins/provider-acp/src/wire.ts index 664bcce8dd..5ca7a93b20 100644 --- a/plugins/provider-acp/src/wire.ts +++ b/plugins/provider-acp/src/wire.ts @@ -236,6 +236,7 @@ const acpConfigOptionSelectOptionSchema = z .object({ value: z.string(), name: acpOptionalString, + description: acpOptionalString, }) .passthrough(); export type AcpConfigOptionSelectOption = z.infer<