Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -27,6 +27,8 @@ export const ACP_PROVIDER_ICON_SVGS = {
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">\n <path fill="currentColor" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>\n</svg>\n',
|
||||
deepagents:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 128 128">\n <path fill="currentColor" d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"/>\n <path fill="currentColor" d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"/>\n <path fill="currentColor" d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"/>\n <path fill="currentColor" d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"/>\n</svg>\n',
|
||||
"deepseek-tui":
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">\n <rect width="32" height="32" fill="currentColor" opacity="0.18"/>\n <text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-family="\'Noto Serif SC\', serif" font-weight="700" font-size="20" fill="currentColor">深</text>\n <rect x="0" y="29" width="32" height="3" fill="currentColor"/>\n</svg>\n',
|
||||
dimcode:
|
||||
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M3.12109 11.0078H1.99902V5.49316H3.12109V11.0078ZM3.7041 5.49316C4.91979 5.49316 5.31142 5.57449 5.80762 5.7373C6.50208 5.97365 6.85299 6.40958 6.86133 7.04492V9.45605C6.86125 10.207 6.37767 10.6797 5.41016 10.874C4.95546 10.9633 4.69632 11.0078 3.7041 11.0078V10.6064C4.72131 10.6064 4.95994 10.5507 5.34863 10.4404C5.91072 10.2671 6.19576 9.93907 6.2041 9.45605V7.04492C6.20402 6.4883 5.83211 6.13886 5.08789 5.99707C4.74057 5.93405 4.58897 5.89978 3.7041 5.89453V5.49316ZM9.16797 5.49316V11.0078H8.0459V5.49316H9.16797ZM14 6.79297V11.0078H12.8779V8.0791L13.8877 6.79297H14ZM14 5.49316V5.7373L11.3594 8.97852H10.7852L9.74219 6.94531V6.07812H9.86719L11.0723 8.33203L13.4258 5.49316H14Z" fill="currentColor"/>\n</svg>\n',
|
||||
dirac:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="currentColor" opacity="0.18"/>
|
||||
<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-family="'Noto Serif SC', serif" font-weight="700" font-size="20" fill="currentColor">深</text>
|
||||
<rect x="0" y="29" width="32" height="3" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 397 B |
@@ -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<string, AgentModelDefinition[]>;
|
||||
canSelectModelProvider?: (providerId: string) => boolean;
|
||||
modelSelectorProviders?: ModelSelectorProvider[];
|
||||
favoriteKeys?: Set<string>;
|
||||
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<string, AgentModelDefinition[]>;
|
||||
modelSelectorProviders: ModelSelectorProvider[];
|
||||
isAllModelsLoading: boolean;
|
||||
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
|
||||
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||
@@ -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<string, AgentModelDefinition[]> {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
): 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<string>(),
|
||||
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<ComboboxOption[]>(
|
||||
() => 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<string, AgentModelDefinition[]>;
|
||||
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) {
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<View>
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={effectiveProviderDefinitions}
|
||||
allProviderModels={effectiveAllProviderModels}
|
||||
providers={modelSelectorProviders}
|
||||
selectedProvider={provider}
|
||||
selectedModel={selectedModelId ?? ""}
|
||||
canSelectProvider={canSelectProviderInModelMenu}
|
||||
onSelect={handleDesktopModelSelect}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
@@ -1069,10 +1058,8 @@ interface SheetStatusBarContentProps {
|
||||
canSelectMode: boolean;
|
||||
canSelectModel: boolean;
|
||||
canSelectThinking: boolean;
|
||||
canSelectProviderInModelMenu: (providerId: string) => boolean;
|
||||
modelSelectorProviders: ModelSelectorProvider[];
|
||||
modelDisabled: boolean;
|
||||
effectiveProviderDefinitions: AgentProviderDefinition[];
|
||||
effectiveAllProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
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 ? (
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={effectiveProviderDefinitions}
|
||||
allProviderModels={effectiveAllProviderModels}
|
||||
providers={modelSelectorProviders}
|
||||
selectedProvider={provider}
|
||||
selectedModel={selectedModelId ?? ""}
|
||||
canSelectProvider={canSelectProviderInModelMenu}
|
||||
onSelect={handleSheetModelSelect}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
@@ -1678,6 +1661,10 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
() => 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 (
|
||||
<View style={styles.container}>
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
providers={modelSelectorProviders}
|
||||
selectedProvider={selectedProvider ?? ""}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={onSelectProviderAndModel}
|
||||
@@ -1973,7 +1959,7 @@ export function DraftAgentStatusBar({
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider ?? ""}
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
modelSelectorProviders={modelSelectorProviders}
|
||||
modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
|
||||
@@ -1,71 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 {
|
||||
buildModelRows,
|
||||
buildModelSelectorProviders,
|
||||
buildSelectableModelSelectorProviders,
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
matchesSearch,
|
||||
resolveProviderLabel,
|
||||
} from "./combined-model-selector.utils";
|
||||
|
||||
describe("combined model selector helpers", () => {
|
||||
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<ProviderSnapshotEntry> & Pick<ProviderSnapshotEntry, "provider">,
|
||||
): 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, AgentModelDefinition[]>;
|
||||
providers: ModelSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
onSelect: (provider: AgentProvider, modelId: string) => void;
|
||||
isLoading: boolean;
|
||||
canSelectProvider?: (provider: string) => boolean;
|
||||
favoriteKeys?: Set<string>;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
renderTrigger?: (input: {
|
||||
@@ -83,23 +79,21 @@ interface CombinedModelSelectorProps {
|
||||
|
||||
interface SelectorContentProps {
|
||||
view: SelectorView;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
providers: ModelSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
searchQuery: string;
|
||||
favoriteKeys: Set<string>;
|
||||
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 ? (
|
||||
<Pressable
|
||||
onPress={handleToggleFavorite}
|
||||
hitSlop={8}
|
||||
@@ -207,7 +174,6 @@ function ModelRow({
|
||||
) : null,
|
||||
[
|
||||
onToggleFavorite,
|
||||
disabled,
|
||||
handleToggleFavorite,
|
||||
isFavorite,
|
||||
row.provider,
|
||||
@@ -225,7 +191,6 @@ function ModelRow({
|
||||
label={row.modelLabel}
|
||||
description={showDescription ? row.description : undefined}
|
||||
selected={isSelected}
|
||||
disabled={disabled}
|
||||
elevated={elevated}
|
||||
onPress={onPress}
|
||||
leadingSlot={leadingSlot}
|
||||
@@ -238,7 +203,6 @@ interface SelectableModelRowProps {
|
||||
row: SelectorModelRow;
|
||||
isSelected: boolean;
|
||||
isFavorite: boolean;
|
||||
disabled?: boolean;
|
||||
elevated?: boolean;
|
||||
onSelect: (provider: string, modelId: string) => 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<string>;
|
||||
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 (
|
||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
<Text style={styles.drillDownCount}>
|
||||
{rowCount} {rowCount === 1 ? "model" : "models"}
|
||||
{hasNoModels ? "Default" : `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
||||
</Text>
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
{hasNoModels ? null : (
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View>
|
||||
{groupedRows.map((group, index) => {
|
||||
{providers.map((provider, index) => {
|
||||
const hasNoModels = provider.rows.length === 0;
|
||||
return (
|
||||
<View key={group.providerId}>
|
||||
<View key={provider.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
<GroupProviderButton
|
||||
providerId={group.providerId}
|
||||
providerLabel={group.providerLabel}
|
||||
rowCount={group.rows.length}
|
||||
providerId={provider.id}
|
||||
providerLabel={provider.label}
|
||||
rowCount={provider.rows.length}
|
||||
hasNoModels={hasNoModels}
|
||||
onDrillDown={onDrillDown}
|
||||
onSelectDefault={onSelectDefault}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -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 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
label="Default"
|
||||
selected={isSelected}
|
||||
onPress={handlePress}
|
||||
leadingSlot={leadingSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModelRows({
|
||||
rows,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
favoriteKeys,
|
||||
onSelect,
|
||||
canSelectProvider,
|
||||
onToggleFavorite,
|
||||
normalizedQuery,
|
||||
}: {
|
||||
@@ -386,7 +386,6 @@ function ProviderModelRows({
|
||||
selectedModel: string;
|
||||
favoriteKeys: Set<string>;
|
||||
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 = (
|
||||
<View style={styles.emptyState}>
|
||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
@@ -482,6 +481,20 @@ function SelectorContent({
|
||||
);
|
||||
|
||||
if (view.kind === "provider") {
|
||||
if (!selectedViewProvider) {
|
||||
return emptyState;
|
||||
}
|
||||
|
||||
if (selectedViewProvider.rows.length === 0 && !normalizedQuery) {
|
||||
return (
|
||||
<DefaultProviderRow
|
||||
providerId={view.providerId}
|
||||
isSelected={view.providerId === selectedProvider && !selectedModel}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<GroupedProviderRows groupedRows={allGroupedRows} onDrillDown={onDrillDown} />
|
||||
{providers.length > 0 ? (
|
||||
<GroupedProviderRows
|
||||
providers={providers}
|
||||
onDrillDown={onDrillDown}
|
||||
onSelectDefault={handleSelectDefaultProvider}
|
||||
/>
|
||||
) : 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<string>(),
|
||||
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<SelectorView | null>(() => {
|
||||
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 ? (
|
||||
<SelectorContent
|
||||
view={view}
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
providers={providers}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
searchQuery={searchQuery}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onSelect={handleSelect}
|
||||
canSelectProvider={canSelectProvider}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDrillDown={handleDrillDown}
|
||||
/>
|
||||
|
||||
@@ -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<string, AgentModelDefinition[]>,
|
||||
): 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 {
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("resolveStatusControlMode", () => {
|
||||
selectedModel: "",
|
||||
onSelectModel: () => undefined,
|
||||
isModelLoading: false,
|
||||
allProviderModels: new Map(),
|
||||
modelSelectorProviders: [],
|
||||
isAllModelsLoading: false,
|
||||
onSelectProviderAndModel: () => undefined,
|
||||
thinkingOptions: [],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, typeof Bot> = {
|
||||
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<ProviderIconProps>;
|
||||
|
||||
const PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
|
||||
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<string, ProviderIconComponent>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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<string, AgentModelDefinition[]>;
|
||||
modelSelectorProviders: ModelSelectorProvider[];
|
||||
isAllModelsLoading: boolean;
|
||||
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -362,6 +362,33 @@ describe("createLoggedNdJsonStream", () => {
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
test("normalizes stringified numeric ACP response ids", async () => {
|
||||
const input = new TransformStream<Uint8Array, Uint8Array>();
|
||||
const output = new TransformStream<Uint8Array, Uint8Array>();
|
||||
const logger = {
|
||||
warn: vi.fn(),
|
||||
};
|
||||
|
||||
const stream = createLoggedNdJsonStream(output.writable, input.readable, {
|
||||
logger: asInternals<ReturnType<typeof createTestLogger>>(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<Uint8Array, Uint8Array>();
|
||||
const output = new TransformStream<Uint8Array, Uint8Array>();
|
||||
@@ -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<typeof logger>(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<ACPSessionInternals>(session);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user