Surface all enabled providers in the model selector with inline loading and error states instead of silently hiding non-ready ones. Tap any row to drill in; loading and error states show in the drill-down body, with a Retry button on error. Providers with no models render a synthetic "Default" row so every provider's drill-down has symmetric structure. Redesign the per-provider settings sheet: search lives in the header, discovered and custom models share one scrolling list, and the footer hosts action buttons. Diagnostic and "Add model" each open as their own sub-sheet, so neither expands inline or shifts the surrounding layout. Snapshot refresh now keeps cached data visible: setQueryData replaces the cache atomically and sibling-scope caches are invalidated (not removed), so refreshing a provider no longer blanks its row. Search inputs across the app are uncontrolled — onChangeText drives parent state, and resetKey remounts the field when an explicit reset is needed — to avoid RN's controlled-input flicker. Adds a dev-only mock-slow provider whose discovery never resolves, so the 30s snapshot timeout produces a real error and lets loading and timeout-error UI be exercised end to end.
291 lines
7.7 KiB
TypeScript
291 lines
7.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type {
|
|
AgentModelDefinition,
|
|
ProviderSnapshotEntry,
|
|
} from "@server/server/agent/agent-sdk-types";
|
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
|
import {
|
|
buildProviderSelectorProviders,
|
|
buildSelectableProviderSelectorProviders,
|
|
buildSelectedTriggerLabel,
|
|
filterAndRankModelRows,
|
|
matchesModelSearch,
|
|
resolveSelectedModelLabel,
|
|
resolveSubmissionReadiness,
|
|
} from "./provider-selection";
|
|
|
|
describe("combined model selector data", () => {
|
|
const codexModel: AgentModelDefinition = {
|
|
provider: "codex",
|
|
id: "gpt-5.4",
|
|
label: "GPT-5.4",
|
|
};
|
|
|
|
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],
|
|
};
|
|
}
|
|
|
|
it("builds selector providers from ready enabled snapshot entries", () => {
|
|
expect(
|
|
buildSelectableProviderSelectorProviders([
|
|
snapshotEntry({
|
|
provider: "codex",
|
|
label: "Codex",
|
|
models: [codexModel],
|
|
}),
|
|
]),
|
|
).toEqual([
|
|
{
|
|
id: "codex",
|
|
label: "Codex",
|
|
modelSelection: {
|
|
kind: "models",
|
|
rows: [
|
|
{
|
|
favoriteKey: "codex:gpt-5.4",
|
|
provider: "codex",
|
|
providerLabel: "Codex",
|
|
modelId: "gpt-5.4",
|
|
modelLabel: "GPT-5.4",
|
|
description: undefined,
|
|
isDefault: undefined,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("represents ready enabled providers without explicit models as provider-default selection", () => {
|
|
expect(
|
|
buildSelectableProviderSelectorProviders([
|
|
snapshotEntry({
|
|
provider: "deepseek-tui",
|
|
label: "DeepSeek TUI",
|
|
models: [],
|
|
}),
|
|
]),
|
|
).toEqual([
|
|
{
|
|
id: "deepseek-tui",
|
|
label: "DeepSeek TUI",
|
|
modelSelection: { kind: "providerDefault", label: "Default" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("excludes disabled providers from selector data", () => {
|
|
expect(
|
|
buildSelectableProviderSelectorProviders([
|
|
snapshotEntry({
|
|
provider: "deepseek-tui",
|
|
label: "DeepSeek TUI",
|
|
enabled: false,
|
|
models: [],
|
|
}),
|
|
]),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("surfaces non-ready providers with their state-specific selection", () => {
|
|
expect(
|
|
buildSelectableProviderSelectorProviders([
|
|
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
|
|
snapshotEntry({
|
|
provider: "error-provider",
|
|
status: "error",
|
|
error: "boom",
|
|
models: [],
|
|
}),
|
|
snapshotEntry({
|
|
provider: "unavailable-provider",
|
|
status: "unavailable",
|
|
models: [],
|
|
}),
|
|
]),
|
|
).toEqual([
|
|
{
|
|
id: "loading-provider",
|
|
label: "loading-provider",
|
|
modelSelection: { kind: "loading" },
|
|
},
|
|
{
|
|
id: "error-provider",
|
|
label: "error-provider",
|
|
modelSelection: { kind: "error", message: "boom" },
|
|
},
|
|
{
|
|
id: "unavailable-provider",
|
|
label: "unavailable-provider",
|
|
modelSelection: { kind: "error", message: "Unavailable" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("builds selector providers from an already-curated provider list", () => {
|
|
const providerDefinitions: AgentProviderDefinition[] = [
|
|
{
|
|
id: "codex",
|
|
label: "Codex",
|
|
description: "Codex provider",
|
|
defaultModeId: "auto",
|
|
modes: [],
|
|
},
|
|
];
|
|
|
|
expect(
|
|
buildProviderSelectorProviders({
|
|
providerDefinitions,
|
|
modelsByProvider: new Map([["codex", [codexModel]]]),
|
|
}),
|
|
).toEqual([
|
|
{
|
|
id: "codex",
|
|
label: "Codex",
|
|
modelSelection: {
|
|
kind: "models",
|
|
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", () => {
|
|
const row = {
|
|
favoriteKey: "opencode:opencode-zen/kimi-k2.5",
|
|
provider: "opencode",
|
|
providerLabel: "OpenCode",
|
|
modelId: "opencode-zen/kimi-k2.5",
|
|
modelLabel: "Kimi K2.5",
|
|
description: "OpenCode Zen - kimi",
|
|
};
|
|
|
|
expect(matchesModelSearch(row, "kimi zen")).toBe(true);
|
|
expect(matchesModelSearch(row, "zen kimi")).toBe(true);
|
|
expect(matchesModelSearch(row, "k2.5 zen")).toBe(true);
|
|
expect(matchesModelSearch(row, "kimi gemini")).toBe(false);
|
|
});
|
|
|
|
it("ranks model search results by fuzzy match quality", () => {
|
|
const rows = [
|
|
{
|
|
favoriteKey: "openai:gpt-4.1",
|
|
provider: "openai",
|
|
providerLabel: "OpenAI",
|
|
modelId: "gpt-4.1",
|
|
modelLabel: "GPT-4.1",
|
|
},
|
|
{
|
|
favoriteKey: "openai:gpt-5.4",
|
|
provider: "openai",
|
|
providerLabel: "OpenAI",
|
|
modelId: "gpt-5.4",
|
|
modelLabel: "GPT-5.4",
|
|
},
|
|
{
|
|
favoriteKey: "google:gemini",
|
|
provider: "google",
|
|
providerLabel: "Google",
|
|
modelId: "gemini",
|
|
modelLabel: "Gemini",
|
|
},
|
|
];
|
|
|
|
expect(filterAndRankModelRows(rows, "gpt54").map((row) => row.modelId)).toEqual(["gpt-5.4"]);
|
|
});
|
|
|
|
it("keeps the selected trigger label model-only", () => {
|
|
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
|
});
|
|
|
|
it("resolves selected labels from explicit provider model-selection state", () => {
|
|
const providers = buildSelectableProviderSelectorProviders([
|
|
snapshotEntry({
|
|
provider: "codex",
|
|
label: "Codex",
|
|
models: [codexModel],
|
|
}),
|
|
snapshotEntry({
|
|
provider: "deepseek-tui",
|
|
label: "DeepSeek TUI",
|
|
models: [],
|
|
}),
|
|
]);
|
|
|
|
expect(
|
|
resolveSelectedModelLabel({
|
|
providers,
|
|
selectedProvider: "codex",
|
|
selectedModel: "gpt-5.4",
|
|
isLoading: false,
|
|
}),
|
|
).toBe("GPT-5.4");
|
|
expect(
|
|
resolveSelectedModelLabel({
|
|
providers,
|
|
selectedProvider: "deepseek-tui",
|
|
selectedModel: "",
|
|
isLoading: false,
|
|
}),
|
|
).toBe("Default");
|
|
});
|
|
|
|
it("returns observable submission readiness reasons", () => {
|
|
expect(
|
|
resolveSubmissionReadiness({
|
|
text: "hello",
|
|
allowsEmptyAutoSubmit: false,
|
|
providerCount: 1,
|
|
selection: {
|
|
provider: "codex",
|
|
modelId: "",
|
|
availableModels: [codexModel],
|
|
isModelLoading: false,
|
|
},
|
|
autoSubmitConfig: null,
|
|
workspaceDirectory: "/repo",
|
|
hasClient: true,
|
|
}),
|
|
).toEqual({
|
|
ok: false,
|
|
reason: "No model is available for the selected provider",
|
|
});
|
|
|
|
expect(
|
|
resolveSubmissionReadiness({
|
|
text: "hello",
|
|
allowsEmptyAutoSubmit: false,
|
|
providerCount: 1,
|
|
selection: {
|
|
provider: "deepseek-tui",
|
|
modelId: "",
|
|
availableModels: [],
|
|
isModelLoading: false,
|
|
},
|
|
autoSubmitConfig: null,
|
|
workspaceDirectory: "/repo",
|
|
hasClient: true,
|
|
}),
|
|
).toEqual({ ok: true });
|
|
});
|
|
});
|