diff --git a/packages/app/src/assets/acp-provider-icons.ts b/packages/app/src/assets/acp-provider-icons.ts index 527a78e45..2abd45b48 100644 --- a/packages/app/src/assets/acp-provider-icons.ts +++ b/packages/app/src/assets/acp-provider-icons.ts @@ -27,6 +27,8 @@ export const ACP_PROVIDER_ICON_SVGS = { '\n \n\n', deepagents: '\n \n \n \n \n\n', + "deepseek-tui": + '\n \n 深\n \n\n', dimcode: '\n\n\n', dirac: diff --git a/packages/app/src/assets/acp-provider-icons/deepseek-tui.svg b/packages/app/src/assets/acp-provider-icons/deepseek-tui.svg new file mode 100644 index 000000000..afad0032b --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/deepseek-tui.svg @@ -0,0 +1,5 @@ + + + 深 + + diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 05320cebe..cb4672b38 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -32,6 +32,10 @@ import { } from "lucide-react-native"; import { getProviderIcon } from "@/components/provider-icons"; import { CombinedModelSelector } from "@/components/combined-model-selector"; +import { + buildModelSelectorProviders, + type ModelSelectorProvider, +} from "@/components/combined-model-selector.utils"; import { useSessionStore } from "@/stores/session-store"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { resolveProviderDefinition } from "@/utils/provider-definitions"; @@ -93,8 +97,7 @@ interface ControlledAgentStatusBarProps { disabled?: boolean; isModelLoading?: boolean; providerDefinitions: AgentProviderDefinition[]; - allProviderModels?: Map; - canSelectModelProvider?: (providerId: string) => boolean; + modelSelectorProviders?: ModelSelectorProvider[]; favoriteKeys?: Set; onToggleFavoriteModel?: (provider: string, modelId: string) => void; features?: AgentFeature[]; @@ -114,7 +117,7 @@ export interface DraftAgentStatusBarProps { selectedModel: string; onSelectModel: (modelId: string) => void; isModelLoading: boolean; - allProviderModels: Map; + modelSelectorProviders: ModelSelectorProvider[]; isAllModelsLoading: boolean; onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void; thinkingOptions: NonNullable; @@ -184,10 +187,6 @@ const MODE_ICONS = { ShieldQuestionMark, } as const; -function alwaysTrue() { - return true; -} - function resolveDisplayModel( isModelLoading: boolean, modelOptions: StatusOption[] | undefined, @@ -235,23 +234,26 @@ function toComboboxOptions(options: StatusOption[] | undefined): ComboboxOption[ return (options ?? []).map((o) => ({ id: o.id, label: o.label })); } -function buildFallbackAllProviderModels( +function buildFallbackModelSelectorProviders( provider: string, modelOptions: StatusOption[] | undefined, -): Map { - const map = new Map(); +): ModelSelectorProvider[] { if (!modelOptions || modelOptions.length === 0) { - return map; + return []; } - map.set( - provider, - modelOptions.map((option) => ({ - provider: provider, - id: option.id, - label: option.label, - })), - ); - return map; + return [ + { + id: provider, + label: provider, + rows: modelOptions.map((option) => ({ + favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }), + provider, + providerLabel: provider, + modelId: option.id, + modelLabel: option.label, + })), + }, + ]; } function makeBadgePressableStyle( @@ -459,8 +461,7 @@ function ControlledStatusBar({ disabled = false, isModelLoading = false, providerDefinitions, - allProviderModels, - canSelectModelProvider, + modelSelectorProviders, favoriteKeys = new Set(), onToggleFavoriteModel, features, @@ -521,13 +522,11 @@ function ControlledStatusBar({ () => toComboboxOptions(modeOptions), [modeOptions], ); - const fallbackAllProviderModels = useMemo( - () => buildFallbackAllProviderModels(provider, modelOptions), + const fallbackModelSelectorProviders = useMemo( + () => buildFallbackModelSelectorProviders(provider, modelOptions), [modelOptions, provider], ); - const effectiveProviderDefinitions = providerDefinitions; - const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels; - const canSelectProviderInModelMenu = canSelectModelProvider ?? alwaysTrue; + const effectiveModelSelectorProviders = modelSelectorProviders ?? fallbackModelSelectorProviders; const comboboxThinkingOptions = useMemo( () => toComboboxOptions(thinkingOptions), [thinkingOptions], @@ -701,10 +700,8 @@ function ControlledStatusBar({ canSelectMode={canSelectMode} canSelectModel={canSelectModel} canSelectThinking={canSelectThinking} - canSelectProviderInModelMenu={canSelectProviderInModelMenu} + modelSelectorProviders={effectiveModelSelectorProviders} modelDisabled={modelDisabled} - effectiveProviderDefinitions={effectiveProviderDefinitions} - effectiveAllProviderModels={effectiveAllProviderModels} comboboxProviderOptions={comboboxProviderOptions} comboboxModeOptions={comboboxModeOptions} comboboxThinkingOptions={comboboxThinkingOptions} @@ -751,10 +748,8 @@ function ControlledStatusBar({ canSelectMode={canSelectMode} canSelectModel={canSelectModel} canSelectThinking={canSelectThinking} - canSelectProviderInModelMenu={canSelectProviderInModelMenu} + modelSelectorProviders={effectiveModelSelectorProviders} modelDisabled={modelDisabled} - effectiveProviderDefinitions={effectiveProviderDefinitions} - effectiveAllProviderModels={effectiveAllProviderModels} comboboxModeOptions={comboboxModeOptions} comboboxThinkingOptions={comboboxThinkingOptions} ModeIconComponent={ModeIconComponent} @@ -799,10 +794,8 @@ interface DesktopStatusBarContentProps { canSelectMode: boolean; canSelectModel: boolean; canSelectThinking: boolean; - canSelectProviderInModelMenu: (providerId: string) => boolean; + modelSelectorProviders: ModelSelectorProvider[]; modelDisabled: boolean; - effectiveProviderDefinitions: AgentProviderDefinition[]; - effectiveAllProviderModels: Map; comboboxProviderOptions: ComboboxOption[]; comboboxModeOptions: ComboboxOption[]; comboboxThinkingOptions: ComboboxOption[]; @@ -868,10 +861,8 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) { canSelectMode, canSelectModel, canSelectThinking, - canSelectProviderInModelMenu, + modelSelectorProviders, modelDisabled, - effectiveProviderDefinitions, - effectiveAllProviderModels, comboboxProviderOptions, comboboxModeOptions, comboboxThinkingOptions, @@ -942,11 +933,9 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) { boolean; + modelSelectorProviders: ModelSelectorProvider[]; modelDisabled: boolean; - effectiveProviderDefinitions: AgentProviderDefinition[]; - effectiveAllProviderModels: Map; comboboxModeOptions: ComboboxOption[]; comboboxThinkingOptions: ComboboxOption[]; ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null; @@ -1118,10 +1105,8 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) { canSelectMode, canSelectModel, canSelectThinking, - canSelectProviderInModelMenu, + modelSelectorProviders, modelDisabled, - effectiveProviderDefinitions, - effectiveAllProviderModels, comboboxModeOptions, comboboxThinkingOptions, ModeIconComponent, @@ -1214,11 +1199,9 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) { <> {canSelectModel ? ( buildAgentProviderModels(agent?.provider, models), [agent?.provider, models], ); + const agentModelSelectorProviders = useMemo( + () => buildModelSelectorProviders(agentProviderDefinitions, agentProviderModels), + [agentProviderDefinitions, agentProviderModels], + ); const displayMode = resolveAgentDisplayMode(availableModes, agent?.currentModeId); @@ -1841,7 +1828,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({ modeOptions={fallbackModeOptions} selectedModeId={agent.currentModeId ?? undefined} providerDefinitions={agentProviderDefinitions} - allProviderModels={agentProviderModels} + modelSelectorProviders={agentModelSelectorProviders} onSelectMode={handleSelectMode} modelOptions={modelOptions} selectedModelId={modelSelection.activeModelId ?? undefined} @@ -1872,7 +1859,7 @@ export function DraftAgentStatusBar({ selectedModel, onSelectModel, isModelLoading: _isModelLoading, - allProviderModels, + modelSelectorProviders, isAllModelsLoading, onSelectProviderAndModel, thinkingOptions, @@ -1937,8 +1924,7 @@ export function DraftAgentStatusBar({ return ( { - const providerDefinitions = [ - { - id: "claude", - label: "Claude", - description: "Claude provider", - defaultModeId: "default", - modes: [], - }, - { - id: "codex", - label: "Codex", - description: "Codex provider", - defaultModeId: "auto", - modes: [], - }, - ]; +describe("combined model selector data", () => { + const codexModel: AgentModelDefinition = { + provider: "codex", + id: "gpt-5.4", + label: "GPT-5.4", + }; - const claudeModels: AgentModelDefinition[] = [ - { - provider: "claude", - id: "sonnet-4.6", - label: "Sonnet 4.6", - }, - ]; + function snapshotEntry( + overrides: Partial & Pick, + ): ProviderSnapshotEntry { + return { + ...overrides, + provider: overrides.provider, + status: overrides.status ?? "ready", + enabled: overrides.enabled ?? true, + label: overrides.label ?? overrides.provider, + description: overrides.description ?? `${overrides.provider} provider`, + defaultModeId: overrides.defaultModeId ?? "default", + modes: overrides.modes ?? [], + models: overrides.models ?? [codexModel], + }; + } - const codexModels: AgentModelDefinition[] = [ - { - provider: "codex", - id: "gpt-5.4", - label: "GPT-5.4", - }, - ]; - - it("keeps enough data to search by model and provider name", async () => { - const rows = buildModelRows( - providerDefinitions, - new Map([ - ["claude", claudeModels], - ["codex", codexModels], + it("builds selector providers from ready enabled snapshot entries", () => { + expect( + buildSelectableModelSelectorProviders([ + snapshotEntry({ + provider: "codex", + label: "Codex", + models: [codexModel], + }), ]), - ); - - expect(rows).toEqual([ - expect.objectContaining({ - providerLabel: "Claude", - modelLabel: "Sonnet 4.6", - modelId: "sonnet-4.6", - }), - expect.objectContaining({ - providerLabel: "Codex", - modelLabel: "GPT-5.4", - modelId: "gpt-5.4", - }), + ).toEqual([ + { + id: "codex", + label: "Codex", + rows: [ + { + favoriteKey: "codex:gpt-5.4", + provider: "codex", + providerLabel: "Codex", + modelId: "gpt-5.4", + modelLabel: "GPT-5.4", + description: undefined, + isDefault: undefined, + }, + ], + }, ]); + }); - expect(matchesSearch(rows[0], "claude")).toBe(true); - expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true); + it("keeps ready enabled providers with no models as model-less providers", () => { + expect( + buildSelectableModelSelectorProviders([ + snapshotEntry({ + provider: "deepseek-tui", + label: "DeepSeek TUI", + models: [], + }), + ]), + ).toEqual([ + { + id: "deepseek-tui", + label: "DeepSeek TUI", + rows: [], + }, + ]); + }); + + it("excludes disabled providers from selector data", () => { + expect( + buildSelectableModelSelectorProviders([ + snapshotEntry({ + provider: "deepseek-tui", + label: "DeepSeek TUI", + enabled: false, + models: [], + }), + ]), + ).toEqual([]); + }); + + it("excludes providers that are not ready", () => { + expect( + buildSelectableModelSelectorProviders([ + snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }), + snapshotEntry({ provider: "error-provider", status: "error", models: [] }), + snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }), + ]), + ).toEqual([]); + }); + + it("builds selector providers from an already-curated provider list", () => { + const providerDefinitions: AgentProviderDefinition[] = [ + { + id: "codex", + label: "Codex", + description: "Codex provider", + defaultModeId: "auto", + modes: [], + }, + ]; + + expect( + buildModelSelectorProviders(providerDefinitions, new Map([["codex", [codexModel]]])), + ).toEqual([ + { + id: "codex", + label: "Codex", + rows: [ + expect.objectContaining({ + provider: "codex", + providerLabel: "Codex", + modelId: "gpt-5.4", + modelLabel: "GPT-5.4", + }), + ], + }, + ]); }); it("matches across label, provider, and description with multi-token fuzzy search", () => { @@ -113,7 +178,6 @@ describe("combined model selector helpers", () => { }); it("keeps the selected trigger label model-only", () => { - expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex"); expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4"); }); }); diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 081a6b760..23ff82cd1 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -12,8 +12,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb as platformIsWeb } from "@/constants/platform"; import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native"; -import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; -import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; +import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; import type { SheetHeader } from "@/components/adaptive-modal-sheet"; const IS_WEB = platformIsWeb; @@ -46,10 +45,9 @@ function drillDownRowStyle({ } import { getProviderIcon } from "@/components/provider-icons"; import { - buildModelRows, buildSelectedTriggerLabel, filterAndRankModelRows, - resolveProviderLabel, + type ModelSelectorProvider, type SelectorModelRow, } from "./combined-model-selector.utils"; @@ -61,13 +59,11 @@ type SelectorView = | { kind: "provider"; providerId: string; providerLabel: string }; interface CombinedModelSelectorProps { - providerDefinitions: AgentProviderDefinition[]; - allProviderModels: Map; + providers: ModelSelectorProvider[]; selectedProvider: string; selectedModel: string; onSelect: (provider: AgentProvider, modelId: string) => void; isLoading: boolean; - canSelectProvider?: (provider: string) => boolean; favoriteKeys?: Set; onToggleFavorite?: (provider: string, modelId: string) => void; renderTrigger?: (input: { @@ -83,23 +79,21 @@ interface CombinedModelSelectorProps { interface SelectorContentProps { view: SelectorView; - providerDefinitions: AgentProviderDefinition[]; - allProviderModels: Map; + providers: ModelSelectorProvider[]; selectedProvider: string; selectedModel: string; searchQuery: string; favoriteKeys: Set; onSelect: (provider: string, modelId: string) => void; - canSelectProvider: (provider: string) => boolean; onToggleFavorite?: (provider: string, modelId: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void; } -function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string { - if (!models || models.length === 0) { +function resolveDefaultModelLabel(rows: SelectorModelRow[] | undefined): string { + if (!rows || rows.length === 0) { return "Select model"; } - return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model"; + return (rows.find((row) => row.isDefault) ?? rows[0])?.modelLabel ?? "Select model"; } function normalizeSearchQuery(value: string): string { @@ -122,36 +116,10 @@ function sortFavoritesFirst( return [...favorites, ...rest]; } -function groupRowsByProvider( - rows: SelectorModelRow[], -): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> { - const grouped = new Map< - string, - { providerId: string; providerLabel: string; rows: SelectorModelRow[] } - >(); - - for (const row of rows) { - const existing = grouped.get(row.provider); - if (existing) { - existing.rows.push(row); - continue; - } - - grouped.set(row.provider, { - providerId: row.provider, - providerLabel: row.providerLabel, - rows: [row], - }); - } - - return Array.from(grouped.values()); -} - function ModelRow({ row, isSelected, isFavorite, - disabled = false, elevated = false, onPress, onToggleFavorite, @@ -159,7 +127,6 @@ function ModelRow({ row: SelectorModelRow; isSelected: boolean; isFavorite: boolean; - disabled?: boolean; elevated?: boolean; onPress: () => void; onToggleFavorite?: (provider: string, modelId: string) => void; @@ -181,7 +148,7 @@ function ModelRow({ ); const trailingSlot = useMemo( () => - onToggleFavorite && !disabled ? ( + onToggleFavorite ? ( void; onToggleFavorite?: (provider: string, modelId: string) => void; @@ -248,7 +212,6 @@ function SelectableModelRow({ row, isSelected, isFavorite, - disabled, elevated, onSelect, onToggleFavorite, @@ -261,7 +224,6 @@ function SelectableModelRow({ row={row} isSelected={isSelected} isFavorite={isFavorite} - disabled={disabled} elevated={elevated} onPress={handlePress} onToggleFavorite={onToggleFavorite} @@ -275,7 +237,6 @@ function FavoritesSection({ selectedModel, favoriteKeys, onSelect, - canSelectProvider, onToggleFavorite, }: { favoriteRows: SelectorModelRow[]; @@ -283,11 +244,8 @@ function FavoritesSection({ selectedModel: string; favoriteKeys: Set; onSelect: (provider: string, modelId: string) => void; - canSelectProvider: (provider: string) => boolean; onToggleFavorite?: (provider: string, modelId: string) => void; }) { - const { theme: _theme } = useUnistyles(); - if (favoriteRows.length === 0) { return null; } @@ -303,7 +261,6 @@ function FavoritesSection({ row={row} isSelected={row.provider === selectedProvider && row.modelId === selectedModel} isFavorite={favoriteKeys.has(row.favoriteKey)} - disabled={!canSelectProvider(row.provider)} elevated onSelect={onSelect} onToggleFavorite={onToggleFavorite} @@ -317,52 +274,67 @@ interface GroupProviderButtonProps { providerId: string; providerLabel: string; rowCount: number; + hasNoModels: boolean; onDrillDown: (providerId: string, providerLabel: string) => void; + onSelectDefault: (providerId: string) => void; } function GroupProviderButton({ providerId, providerLabel, rowCount, + hasNoModels, onDrillDown, + onSelectDefault, }: GroupProviderButtonProps) { const { theme } = useUnistyles(); const ProvIcon = getProviderIcon(providerId); const handlePress = useCallback(() => { + if (hasNoModels) { + onSelectDefault(providerId); + return; + } onDrillDown(providerId, providerLabel); - }, [onDrillDown, providerId, providerLabel]); + }, [hasNoModels, onDrillDown, onSelectDefault, providerId, providerLabel]); return ( {providerLabel} - {rowCount} {rowCount === 1 ? "model" : "models"} + {hasNoModels ? "Default" : `${rowCount} ${rowCount === 1 ? "model" : "models"}`} - + {hasNoModels ? null : ( + + )} ); } function GroupedProviderRows({ - groupedRows, + providers, onDrillDown, + onSelectDefault, }: { - groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>; + providers: ModelSelectorProvider[]; onDrillDown: (providerId: string, providerLabel: string) => void; + onSelectDefault: (providerId: string) => void; }) { return ( - {groupedRows.map((group, index) => { + {providers.map((provider, index) => { + const hasNoModels = provider.rows.length === 0; return ( - + {index > 0 ? : null} ); @@ -371,13 +343,41 @@ function GroupedProviderRows({ ); } +function DefaultProviderRow({ + providerId, + isSelected, + onSelect, +}: { + providerId: string; + isSelected: boolean; + onSelect: (provider: string, modelId: string) => void; +}) { + const { theme } = useUnistyles(); + const ProviderIcon = getProviderIcon(providerId); + const handlePress = useCallback(() => { + onSelect(providerId, ""); + }, [onSelect, providerId]); + const leadingSlot = useMemo( + () => , + [ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted], + ); + + return ( + + ); +} + function ProviderModelRows({ rows, selectedProvider, selectedModel, favoriteKeys, onSelect, - canSelectProvider, onToggleFavorite, normalizedQuery, }: { @@ -386,7 +386,6 @@ function ProviderModelRows({ selectedModel: string; favoriteKeys: Set; onSelect: (provider: string, modelId: string) => void; - canSelectProvider: (provider: string) => boolean; onToggleFavorite?: (provider: string, modelId: string) => void; normalizedQuery: string; }) { @@ -402,12 +401,11 @@ function ProviderModelRows({ row={item} isSelected={item.provider === selectedProvider && item.modelId === selectedModel} isFavorite={favoriteKeys.has(item.favoriteKey)} - disabled={!canSelectProvider(item.provider)} onSelect={onSelect} onToggleFavorite={onToggleFavorite} /> ), - [canSelectProvider, favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider], + [favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider], ); const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []); @@ -436,44 +434,45 @@ function ProviderModelRows({ function SelectorContent({ view, - providerDefinitions, - allProviderModels, + providers, selectedProvider, selectedModel, searchQuery, favoriteKeys, onSelect, - canSelectProvider, onToggleFavorite, onDrillDown, }: SelectorContentProps) { const { theme } = useUnistyles(); - const allRows = useMemo( - () => buildModelRows(providerDefinitions, allProviderModels), - [allProviderModels, providerDefinitions], - ); - - const scopedRows = useMemo(() => { - if (view.kind === "provider") { - return allRows.filter((row) => row.provider === view.providerId); - } - return allRows; - }, [allRows, view]); - const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); - + const selectedViewProvider = useMemo( + () => + view.kind === "provider" + ? providers.find((provider) => provider.id === view.providerId) + : null, + [providers, view], + ); const visibleRows = useMemo( - () => filterAndRankModelRows(scopedRows, normalizedQuery), - [normalizedQuery, scopedRows], + () => + selectedViewProvider + ? filterAndRankModelRows(selectedViewProvider.rows, normalizedQuery) + : [], + [normalizedQuery, selectedViewProvider], ); - const favoriteRows = useMemo( - () => visibleRows.filter((row) => favoriteKeys.has(row.favoriteKey)), - [favoriteKeys, visibleRows], + () => + providers.flatMap((provider) => + provider.rows.filter((row) => favoriteKeys.has(row.favoriteKey)), + ), + [favoriteKeys, providers], ); - - const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]); - const hasResults = favoriteRows.length > 0 || allGroupedRows.length > 0; + const handleSelectDefaultProvider = useCallback( + (providerId: string) => { + onSelect(providerId, ""); + }, + [onSelect], + ); + const hasResults = favoriteRows.length > 0 || providers.length > 0; const emptyState = ( @@ -482,6 +481,20 @@ function SelectorContent({ ); if (view.kind === "provider") { + if (!selectedViewProvider) { + return emptyState; + } + + if (selectedViewProvider.rows.length === 0 && !normalizedQuery) { + return ( + + ); + } + if (visibleRows.length === 0) { return emptyState; } @@ -493,7 +506,6 @@ function SelectorContent({ selectedModel={selectedModel} favoriteKeys={favoriteKeys} onSelect={onSelect} - canSelectProvider={canSelectProvider} onToggleFavorite={onToggleFavorite} normalizedQuery={normalizedQuery} /> @@ -508,12 +520,15 @@ function SelectorContent({ selectedModel={selectedModel} favoriteKeys={favoriteKeys} onSelect={onSelect} - canSelectProvider={canSelectProvider} onToggleFavorite={onToggleFavorite} /> - {allGroupedRows.length > 0 ? ( - + {providers.length > 0 ? ( + ) : null} {!hasResults ? emptyState : null} @@ -522,13 +537,11 @@ function SelectorContent({ } export function CombinedModelSelector({ - providerDefinitions, - allProviderModels, + providers, selectedProvider, selectedModel, onSelect, isLoading, - canSelectProvider = () => true, favoriteKeys = new Set(), onToggleFavorite, renderTrigger, @@ -544,26 +557,26 @@ export function CombinedModelSelector({ const [searchQuery, setSearchQuery] = useState(""); const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0); - // Single-provider mode: only one provider with models → skip Level 1 entirely + // Single-provider mode: only one provider → skip Level 1 entirely const singleProviderView = useMemo(() => { - const providers = Array.from(allProviderModels.keys()); if (providers.length !== 1) return null; - const providerId = providers[0]; - const label = resolveProviderLabel(providerDefinitions, providerId); - return { kind: "provider", providerId, providerLabel: label }; - }, [allProviderModels, providerDefinitions]); + const provider = providers[0]; + if (!provider) return null; + return { kind: "provider", providerId: provider.id, providerLabel: provider.label }; + }, [providers]); const computeInitialView = useCallback((): SelectorView => { if (singleProviderView) return singleProviderView; const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`; if (selectedProvider && selectedModel && !favoriteKeys.has(selectedFavoriteKey)) { - const label = resolveProviderLabel(providerDefinitions, selectedProvider); - return { kind: "provider", providerId: selectedProvider, providerLabel: label }; + const provider = providers.find((entry) => entry.id === selectedProvider); + if (provider) + return { kind: "provider", providerId: provider.id, providerLabel: provider.label }; } return { kind: "all" }; - }, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providerDefinitions]); + }, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providers]); const handleOpenChange = useCallback( (open: boolean) => { @@ -598,24 +611,28 @@ export function CombinedModelSelector({ if (!hasSelectedProvider) { return "Select model"; } + const provider = providers.find((entry) => entry.id === selectedProvider); + if (provider?.rows.length === 0) { + return "Default"; + } return isLoading ? "Loading..." : "Select model"; } - const models = allProviderModels.get(selectedProvider); - if (!models) { + const provider = providers.find((entry) => entry.id === selectedProvider); + if (!provider) { return isLoading ? "Loading..." : "Select model"; } - const model = models.find((entry) => entry.id === selectedModel); - return model?.label ?? resolveDefaultModelLabel(models); - }, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]); + const model = provider.rows.find((entry) => entry.modelId === selectedModel); + return model?.modelLabel ?? resolveDefaultModelLabel(provider.rows); + }, [hasSelectedProvider, isLoading, providers, selectedModel, selectedProvider]); const desktopFixedHeight = useMemo(() => { if (view.kind !== "provider") { return undefined; } - const models = allProviderModels.get(view.providerId); - const modelCount = models?.length ?? 0; + const modelCount = + providers.find((provider) => provider.id === view.providerId)?.rows.length ?? 0; return Math.min(80 + modelCount * 40, 400); - }, [allProviderModels, view]); + }, [providers, view]); const triggerLabel = useMemo(() => { if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") { @@ -750,14 +767,12 @@ export function CombinedModelSelector({ {isContentReady ? ( diff --git a/packages/app/src/components/combined-model-selector.utils.ts b/packages/app/src/components/combined-model-selector.utils.ts index 3cea39d62..b39324019 100644 --- a/packages/app/src/components/combined-model-selector.utils.ts +++ b/packages/app/src/components/combined-model-selector.utils.ts @@ -1,47 +1,60 @@ -import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; +import type { + AgentModelDefinition, + ProviderSnapshotEntry, +} from "@server/server/agent/agent-sdk-types"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences"; import { compareMatchScores, scoreTextFields } from "@/utils/score-match"; -export type SelectorModelRow = FavoriteModelRow; +export type SelectorModelRow = FavoriteModelRow & { isDefault?: boolean }; -export function resolveProviderLabel( - providerDefinitions: AgentProviderDefinition[], - providerId: string, -): string { - return ( - providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId - ); +export interface ModelSelectorProvider { + id: string; + label: string; + rows: SelectorModelRow[]; } export function buildSelectedTriggerLabel(modelLabel: string): string { return modelLabel; } -export function buildModelRows( +export function buildModelSelectorProviders( providerDefinitions: AgentProviderDefinition[], allProviderModels: Map, -): SelectorModelRow[] { - const providerLabelMap = new Map( - providerDefinitions.map((definition) => [definition.id, definition.label]), - ); - const rows: SelectorModelRow[] = []; +): ModelSelectorProvider[] { + return providerDefinitions.map((definition) => ({ + id: definition.id, + label: definition.label, + rows: (allProviderModels.get(definition.id) ?? []).map((model) => ({ + favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }), + provider: definition.id, + providerLabel: definition.label, + modelId: model.id, + modelLabel: model.label, + description: model.description, + isDefault: model.isDefault, + })), + })); +} - for (const definition of providerDefinitions) { - const providerLabel = providerLabelMap.get(definition.id) ?? definition.label; - for (const model of allProviderModels.get(definition.id) ?? []) { - rows.push({ - favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }), - provider: definition.id, - providerLabel, +export function buildSelectableModelSelectorProviders( + entries: ProviderSnapshotEntry[] | undefined, +): ModelSelectorProvider[] { + return (entries ?? []) + .filter((entry) => entry.enabled && entry.status === "ready") + .map((entry) => ({ + id: entry.provider, + label: entry.label ?? entry.provider, + rows: (entry.models ?? []).map((model) => ({ + favoriteKey: buildFavoriteModelKey({ provider: entry.provider, modelId: model.id }), + provider: entry.provider, + providerLabel: entry.label ?? entry.provider, modelId: model.id, modelLabel: model.label, description: model.description, - }); - } - } - - return rows; + isDefault: model.isDefault, + })), + })); } export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean { diff --git a/packages/app/src/components/composer.status-controls.test.ts b/packages/app/src/components/composer.status-controls.test.ts index 7491a87cb..d15de6c7b 100644 --- a/packages/app/src/components/composer.status-controls.test.ts +++ b/packages/app/src/components/composer.status-controls.test.ts @@ -19,7 +19,7 @@ describe("resolveStatusControlMode", () => { selectedModel: "", onSelectModel: () => undefined, isModelLoading: false, - allProviderModels: new Map(), + modelSelectorProviders: [], isAllModelsLoading: false, onSelectProviderAndModel: () => undefined, thinkingOptions: [], diff --git a/packages/app/src/components/provider-icons.test.ts b/packages/app/src/components/provider-icons.test.ts new file mode 100644 index 000000000..681f44ecf --- /dev/null +++ b/packages/app/src/components/provider-icons.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("lucide-react-native", () => ({ + Bot: function Bot() { + return null; + }, + PackagePlus: function PackagePlus() { + return null; + }, +})); + +import { Bot, PackagePlus } from "lucide-react-native"; +import { getProviderIcon } from "./provider-icons"; + +describe("getProviderIcon", () => { + it("keeps built-in provider icons", () => { + expect(getProviderIcon("kiro")).toBe(PackagePlus); + }); + + it("uses vendored ACP catalog icons for catalog provider ids", () => { + const icon = getProviderIcon("amp-acp"); + + expect(icon).not.toBe(Bot); + expect(getProviderIcon("amp-acp")).toBe(icon); + }); + + it("falls back to the robot icon for unknown custom providers", () => { + expect(getProviderIcon("custom-claude-profile")).toBe(Bot); + }); +}); diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 1a85cf97f..941c861ad 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -1,19 +1,63 @@ import { Bot, PackagePlus } from "lucide-react-native"; +import { createElement, type ComponentType } from "react"; +import { SvgXml } from "react-native-svg"; import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; import { CopilotIcon } from "@/components/icons/copilot-icon"; import { OpenCodeIcon } from "@/components/icons/opencode-icon"; import { PiIcon } from "@/components/icons/pi-icon"; +import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog"; -const PROVIDER_ICONS: Record = { - claude: ClaudeIcon as unknown as typeof Bot, - codex: CodexIcon as unknown as typeof Bot, - copilot: CopilotIcon as unknown as typeof Bot, +export interface ProviderIconProps { + size: number; + color: string; +} + +export type ProviderIconComponent = ComponentType; + +const PROVIDER_ICONS: Record = { + claude: ClaudeIcon as unknown as ProviderIconComponent, + codex: CodexIcon as unknown as ProviderIconComponent, + copilot: CopilotIcon as unknown as ProviderIconComponent, kiro: PackagePlus, - opencode: OpenCodeIcon as unknown as typeof Bot, - pi: PiIcon as unknown as typeof Bot, + opencode: OpenCodeIcon as unknown as ProviderIconComponent, + pi: PiIcon as unknown as ProviderIconComponent, }; -export function getProviderIcon(provider: string): typeof Bot { - return PROVIDER_ICONS[provider] ?? Bot; +const CATALOG_ICON_SVGS = new Map( + ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [[entry.id, entry.iconSvg]] : [])), +); + +const catalogIconComponents = new Map(); + +function createCatalogIcon(provider: string, iconSvg: string): ProviderIconComponent { + const CatalogProviderIcon: ProviderIconComponent = ({ size, color }) => + createElement(SvgXml, { + xml: iconSvg, + width: size, + height: size, + color, + }); + CatalogProviderIcon.displayName = `CatalogProviderIcon(${provider})`; + return CatalogProviderIcon; +} + +function getCatalogProviderIcon(provider: string): ProviderIconComponent | undefined { + const cached = catalogIconComponents.get(provider); + if (cached) { + return cached; + } + + const iconSvg = CATALOG_ICON_SVGS.get(provider); + if (!iconSvg) { + return undefined; + } + + const icon = createCatalogIcon(provider, iconSvg); + catalogIconComponents.set(provider, icon); + return icon; +} + +export function getProviderIcon(provider: string): ProviderIconComponent { + return PROVIDER_ICONS[provider] ?? getCatalogProviderIcon(provider) ?? Bot; } diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index 85895109c..a772bfe50 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -135,6 +135,15 @@ const CATALOG_DATA = [ installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview", command: ["npx", "-y", "deepagents-acp@0.1.7"], }, + { + id: "deepseek-tui", + title: "DeepSeek TUI", + description: "Terminal coding agent for DeepSeek V4", + version: "0.8.39", + iconId: "deepseek-tui", + installLink: "https://github.com/Hmbown/DeepSeek-TUI", + command: ["deepseek", "serve", "--acp"], + }, { id: "dimcode", title: "DimCode", diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts index 13ff91a1a..4ecbf0368 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -37,6 +37,7 @@ describe("ACP provider catalog", () => { it("uses PATH commands for entries that were binary distributions upstream", () => { expect(findProvider("amp-acp").command).toEqual(["amp-acp"]); expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]); + expect(findProvider("deepseek-tui").command).toEqual(["deepseek", "serve", "--acp"]); expect(findProvider("goose").command).toEqual(["goose", "acp"]); expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]); expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]); diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 989dfbeff..fb579a53c 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -8,6 +8,10 @@ import type { } from "@server/server/agent/agent-sdk-types"; import { useHosts } from "@/runtime/host-runtime"; import { buildProviderDefinitions } from "@/utils/provider-definitions"; +import { + buildSelectableModelSelectorProviders, + type ModelSelectorProvider, +} from "@/components/combined-model-selector.utils"; import { useProvidersSnapshot } from "./use-providers-snapshot"; import { useFormPreferences, @@ -63,6 +67,7 @@ export interface UseAgentFormStateResult { modeOptions: AgentMode[]; availableModels: AgentModelDefinition[]; allProviderModels: Map; + modelSelectorProviders: ModelSelectorProvider[]; isAllModelsLoading: boolean; availableThinkingOptions: NonNullable; isModelLoading: boolean; @@ -237,6 +242,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg () => buildAllProviderModels(snapshotEntries), [snapshotEntries], ); + const snapshotModelSelectorProviders = useMemo( + () => buildSelectableModelSelectorProviders(snapshotEntries), + [snapshotEntries], + ); const snapshotSelectedEntry = useMemo( () => formState.provider @@ -255,6 +264,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const providerDefinitionMap = snapshotProviderDefinitionMap; const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap; const allProviderModels = snapshotAllProviderModels; + const modelSelectorProviders = snapshotModelSelectorProviders; const availableModels = snapshotSelectedProviderModels; const modeOptions = snapshotSelectedProviderModes; const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading; @@ -516,6 +526,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg modeOptions, availableModels: availableModels ?? [], allProviderModels, + modelSelectorProviders, isAllModelsLoading, availableThinkingOptions, isModelLoading, @@ -548,6 +559,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg modeOptions, availableModels, allProviderModels, + modelSelectorProviders, isAllModelsLoading, availableThinkingOptions, isModelLoading, diff --git a/packages/app/src/hooks/use-agent-input-draft-core.ts b/packages/app/src/hooks/use-agent-input-draft-core.ts index bde8d45ef..91c16bbab 100644 --- a/packages/app/src/hooks/use-agent-input-draft-core.ts +++ b/packages/app/src/hooks/use-agent-input-draft-core.ts @@ -88,7 +88,7 @@ export function buildDraftStatusControls(input: { selectedModel: formState.selectedModel, onSelectModel: formState.setModelFromUser, isModelLoading: formState.isModelLoading, - allProviderModels: formState.allProviderModels, + modelSelectorProviders: formState.modelSelectorProviders, isAllModelsLoading: formState.isAllModelsLoading, onSelectProviderAndModel: formState.setProviderAndModelFromUser, thinkingOptions: formState.availableThinkingOptions, diff --git a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx index abcfe5815..07cc2aed9 100644 --- a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx +++ b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx @@ -81,6 +81,22 @@ vi.mock("./use-agent-form-state", () => ({ ], ], ]), + modelSelectorProviders: [ + { + id: "codex", + label: "Codex", + rows: [ + { + favoriteKey: "codex:gpt-5.4", + provider: "codex", + providerLabel: "Codex", + modelId: "gpt-5.4", + modelLabel: "gpt-5.4", + isDefault: true, + }, + ], + }, + ], isAllModelsLoading: false, availableThinkingOptions: [ { id: "medium", label: "Medium" }, diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts b/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts new file mode 100644 index 000000000..c9d0d55e2 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts @@ -0,0 +1,51 @@ +export interface WorkspaceDraftAutoSubmitConfig { + provider: string; + model: string | null; +} + +export function validateDraftSubmission(input: { + text: string; + allowsEmptyAutoSubmit: boolean; + composerState: { + providerDefinitions: unknown[]; + selectedProvider: string | null; + isModelLoading: boolean; + effectiveModelId: string | null; + availableModels: unknown[]; + }; + autoSubmitConfig: WorkspaceDraftAutoSubmitConfig | null; + workspaceDirectory: string | null; + hasClient: boolean; +}): string | null { + const { + text, + allowsEmptyAutoSubmit, + composerState, + autoSubmitConfig, + workspaceDirectory, + hasClient, + } = input; + if (!allowsEmptyAutoSubmit && !text.trim()) { + return "Initial prompt is required"; + } + if (composerState.providerDefinitions.length === 0) { + return "No available providers on the selected host"; + } + if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) { + return "Select a model"; + } + if (composerState.isModelLoading) { + return "Model defaults are still loading"; + } + const hasSelectedModel = Boolean(autoSubmitConfig?.model ?? composerState.effectiveModelId); + if (!hasSelectedModel && composerState.availableModels.length > 0) { + return "No model is available for the selected provider"; + } + if (!workspaceDirectory) { + return "Workspace directory not found"; + } + if (!hasClient) { + return "Host is not connected"; + } + return null; +} diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.test.ts b/packages/app/src/screens/workspace/workspace-draft-agent-tab.test.ts new file mode 100644 index 000000000..0d7d5dff8 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; + +import { validateDraftSubmission } from "./workspace-draft-agent-tab-core"; + +const baseComposerState = { + providerDefinitions: [{ id: "deepseek-tui" }], + selectedProvider: "deepseek-tui", + isModelLoading: false, + effectiveModelId: "", + availableModels: [], +}; + +function validate(overrides = {}) { + return validateDraftSubmission({ + text: "hello", + allowsEmptyAutoSubmit: false, + composerState: baseComposerState, + autoSubmitConfig: null, + workspaceDirectory: "/tmp/project", + hasClient: true, + ...overrides, + }); +} + +describe("workspace draft agent model validation", () => { + test("allows a ready provider with no models to submit without a selected model", () => { + expect(validate({})).toBeNull(); + }); + + test("keeps waiting while model defaults are loading", () => { + expect( + validate({ + composerState: { + ...baseComposerState, + isModelLoading: true, + }, + }), + ).toBe("Model defaults are still loading"); + }); + + test("still requires a selected model when the provider exposes models", () => { + expect( + validate({ + composerState: { + ...baseComposerState, + availableModels: [{ id: "deepseek/deepseek-v4-pro" }], + }, + }), + ).toBe("No model is available for the selected provider"); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 20236d2c3..28442654e 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -22,6 +22,7 @@ import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submi import { encodeImages } from "@/utils/encode-images"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; +import { validateDraftSubmission } from "@/screens/workspace/workspace-draft-agent-tab-core"; import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types"; import type { AgentSnapshotPayload } from "@server/shared/messages"; import type { DaemonClient } from "@server/client/daemon-client"; @@ -73,51 +74,6 @@ function resolveAutoSubmitConfig( }; } -function validateDraftSubmission(input: { - text: string; - allowsEmptyAutoSubmit: boolean; - composerState: { - providerDefinitions: unknown[]; - selectedProvider: string | null; - isModelLoading: boolean; - effectiveModelId: string | null; - }; - autoSubmitConfig: AutoSubmitConfig | null; - workspaceDirectory: string | null; - hasClient: boolean; -}): string | null { - const { - text, - allowsEmptyAutoSubmit, - composerState, - autoSubmitConfig, - workspaceDirectory, - hasClient, - } = input; - if (!allowsEmptyAutoSubmit && !text.trim()) { - return "Initial prompt is required"; - } - if (composerState.providerDefinitions.length === 0) { - return "No available providers on the selected host"; - } - if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) { - return "Select a model"; - } - if (composerState.isModelLoading) { - return "Model defaults are still loading"; - } - if (!(autoSubmitConfig?.model ?? composerState.effectiveModelId)) { - return "No model is available for the selected provider"; - } - if (!workspaceDirectory) { - return "Workspace directory not found"; - } - if (!hasClient) { - return "Host is not connected"; - } - return null; -} - function resolveDraftModeIdOverride(input: { autoSubmitConfig: AutoSubmitConfig | null; modeOptionsCount: number; diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 1f3e9fe30..b8f889c56 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -362,6 +362,33 @@ describe("createLoggedNdJsonStream", () => { consoleError.mockRestore(); }); + test("normalizes stringified numeric ACP response ids", async () => { + const input = new TransformStream(); + const output = new TransformStream(); + const logger = { + warn: vi.fn(), + }; + + const stream = createLoggedNdJsonStream(output.writable, input.readable, { + logger: asInternals>(logger), + provider: "deepseek-tui", + }); + const reader = stream.readable.getReader(); + const writer = input.writable.getWriter(); + + await writer.write( + new TextEncoder().encode('{"jsonrpc":"2.0","id":"0","result":{"ok":true}}\n'), + ); + + const parsed = await reader.read(); + + expect(parsed.value).toEqual({ jsonrpc: "2.0", id: 0, result: { ok: true } }); + expect(logger.warn).not.toHaveBeenCalled(); + + await writer.close(); + reader.releaseLock(); + }); + test("does not log terminal control sequences from malformed ACP stdout", async () => { const input = new TransformStream(); const output = new TransformStream(); @@ -704,6 +731,31 @@ describe("ACPAgentSession Zed parity", () => { expect(setSessionConfigOption).not.toHaveBeenCalled(); }); + test("does not fail session start when configured model cannot be applied by ACP", async () => { + const logger = createTestLogger(); + const childLogger = { trace: vi.fn(), warn: vi.fn() }; + vi.spyOn(logger, "child").mockReturnValue(asInternals(childLogger)); + const session = createSessionWithConfig( + { provider: "deepseek-tui", model: "deepseek/v4" }, + logger, + ); + const { internals, setSessionConfigOption, unstableSetSessionModel } = + prepareConfiguredOverrideSession(session, { + currentModel: null, + availableModels: null, + configOptions: [], + connection: { unstable_setSessionModel: undefined }, + }); + + await expect(internals.applyConfiguredOverrides()).resolves.toBeUndefined(); + expect(unstableSetSessionModel).not.toHaveBeenCalled(); + expect(setSessionConfigOption).not.toHaveBeenCalled(); + expect(childLogger.warn).toHaveBeenCalledWith( + { value: "deepseek/v4" }, + "deepseek-tui does not expose ACP model selection; using provider default model", + ); + }); + test("routes config_option_update and refreshes derived mode, model, and thinking state", async () => { const session = createSession(); const internals = asInternals(session); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index dbf2e3d02..583fe88b6 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -180,6 +180,26 @@ function summarizeMalformedACPStdoutError(error: unknown): { type: string; messa }; } +function normalizeACPIncomingMessage(message: AnyMessage): AnyMessage { + if ( + "id" in message && + !("method" in message) && + typeof message.id === "string" && + /^\d+$/.test(message.id) + ) { + const numericId = Number(message.id); + if (Number.isSafeInteger(numericId)) { + return { + ...message, + // COMPAT(deepseek-tui-acp-id): added v0.1.78, remove after 2026-11-19 + // once the ACP SDK accepts stringified numeric response IDs. + id: numericId, + } as AnyMessage; + } + } + return message; +} + export function createLoggedNdJsonStream( output: NodeWritableStream, input: NodeReadableStream, @@ -214,7 +234,7 @@ export function createLoggedNdJsonStream( try { const message: AnyMessage = JSON.parse(trimmedLine); - controller.enqueue(message); + controller.enqueue(normalizeACPIncomingMessage(message)); } catch (error) { options.logger.warn( { @@ -1356,7 +1376,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { } if (typeof this.connection.unstable_setSessionModel !== "function") { - throw new Error(`${this.provider} does not expose ACP model selection`); + throw new Error(this.modelSelectionUnavailableMessage()); } try { @@ -1378,7 +1398,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { const modelOption = selection.configOption; if (!modelOption) { - throw new Error(`${this.provider} does not expose ACP model selection`); + throw new Error(this.modelSelectionUnavailableMessage()); } if (!selection.configChoice) { this.warnInvalidSelection( @@ -1884,7 +1904,17 @@ export class ACPAgentSession implements AgentSession, ACPClient { availableModels: this.availableModels, configOptions: this.configOptions, }); - await this.setModelWithSelection({ modelId: configuredModelId, selection }); + try { + await this.setModelWithSelection({ modelId: configuredModelId, selection }); + } catch (error) { + if (!this.isModelSelectionUnavailableError(error)) { + throw error; + } + this.logger.warn( + { value: configuredModelId }, + `${this.provider} does not expose ACP model selection; using provider default model`, + ); + } } if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) { await this.setThinkingOption(this.config.thinkingOptionId); @@ -1895,6 +1925,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.logger.warn({ value }, message); } + private modelSelectionUnavailableMessage(): string { + return `${this.provider} does not expose ACP model selection`; + } + + private isModelSelectionUnavailableError(error: unknown): boolean { + return error instanceof Error && error.message === this.modelSelectionUnavailableMessage(); + } + private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] { switch (update.sessionUpdate) { case "user_message_chunk": {