Add DeepSeek TUI to the ACP catalog (#1096)
* Consolidate provider selection handling * List DeepSeek TUI in docs and website
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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("excludes providers that are not ready", () => {
|
||||
expect(
|
||||
buildSelectableProviderSelectorProviders([
|
||||
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(
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
||||
|
||||
export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean };
|
||||
|
||||
export type ProviderModelSelection =
|
||||
| { kind: "models"; rows: ProviderSelectionModelRow[] }
|
||||
| { kind: "providerDefault"; label: string }
|
||||
| { kind: "loading" }
|
||||
| { kind: "unavailable"; reason: string };
|
||||
|
||||
export interface ProviderSelectorProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
modelSelection: ProviderModelSelection;
|
||||
}
|
||||
|
||||
export interface ProviderSelectionState {
|
||||
provider: AgentProvider | null;
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
modeOptions: AgentMode[];
|
||||
}
|
||||
|
||||
export interface ProviderSelectionReadiness {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function buildModelRows(
|
||||
provider: string,
|
||||
providerLabel: string,
|
||||
models: AgentModelDefinition[],
|
||||
): ProviderSelectionModelRow[] {
|
||||
return models.map((model) => ({
|
||||
favoriteKey: buildFavoriteModelKey({ provider, modelId: model.id }),
|
||||
provider,
|
||||
providerLabel,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
description: model.description,
|
||||
isDefault: model.isDefault,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildModelSelection(
|
||||
provider: string,
|
||||
providerLabel: string,
|
||||
models: AgentModelDefinition[] | null,
|
||||
): ProviderModelSelection {
|
||||
if (models === null) {
|
||||
return { kind: "loading" };
|
||||
}
|
||||
if (models.length === 0) {
|
||||
return { kind: "providerDefault", label: "Default" };
|
||||
}
|
||||
return { kind: "models", rows: buildModelRows(provider, providerLabel, models) };
|
||||
}
|
||||
|
||||
function isSelectableProvider(entry: ProviderSnapshotEntry): boolean {
|
||||
return entry.enabled && entry.status === "ready";
|
||||
}
|
||||
|
||||
export function buildProviderSelectorProviders(input: {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
modelsByProvider: Map<string, AgentModelDefinition[]>;
|
||||
}): ProviderSelectorProvider[] {
|
||||
return input.providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
modelSelection: buildModelSelection(
|
||||
definition.id,
|
||||
definition.label,
|
||||
input.modelsByProvider.has(definition.id)
|
||||
? (input.modelsByProvider.get(definition.id) ?? [])
|
||||
: null,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSelectableProviderSelectorProviders(
|
||||
entries: ProviderSnapshotEntry[] | undefined,
|
||||
): ProviderSelectorProvider[] {
|
||||
return (entries ?? []).filter(isSelectableProvider).map((entry) => {
|
||||
const label = entry.label ?? entry.provider;
|
||||
return {
|
||||
id: entry.provider,
|
||||
label,
|
||||
modelSelection: buildModelSelection(entry.provider, label, entry.models ?? []),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getProviderModelRows(
|
||||
provider: ProviderSelectorProvider,
|
||||
): ProviderSelectionModelRow[] {
|
||||
return provider.modelSelection.kind === "models" ? provider.modelSelection.rows : [];
|
||||
}
|
||||
|
||||
export function getAllProviderModelRows(
|
||||
providers: ProviderSelectorProvider[],
|
||||
): ProviderSelectionModelRow[] {
|
||||
return providers.flatMap(getProviderModelRows);
|
||||
}
|
||||
|
||||
export function getProviderDefaultLabel(provider: ProviderSelectorProvider): string | null {
|
||||
return provider.modelSelection.kind === "providerDefault" ? provider.modelSelection.label : null;
|
||||
}
|
||||
|
||||
export function resolveSelectedModelLabel(input: {
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
isLoading: boolean;
|
||||
}): string {
|
||||
const selectedProvider = input.selectedProvider.trim();
|
||||
if (!selectedProvider) {
|
||||
return "Select model";
|
||||
}
|
||||
|
||||
const provider = input.providers.find((entry) => entry.id === selectedProvider);
|
||||
if (!input.selectedModel) {
|
||||
if (provider?.modelSelection.kind === "providerDefault") {
|
||||
return provider.modelSelection.label;
|
||||
}
|
||||
if (provider?.modelSelection.kind === "loading") {
|
||||
return "Loading...";
|
||||
}
|
||||
return input.isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
return input.isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
if (provider.modelSelection.kind !== "models") {
|
||||
return provider.modelSelection.kind === "providerDefault"
|
||||
? provider.modelSelection.label
|
||||
: "Select model";
|
||||
}
|
||||
|
||||
const model = provider.modelSelection.rows.find((entry) => entry.modelId === input.selectedModel);
|
||||
const defaultModel = provider.modelSelection.rows.find((row) => row.isDefault);
|
||||
return (
|
||||
model?.modelLabel ??
|
||||
defaultModel?.modelLabel ??
|
||||
provider.modelSelection.rows[0]?.modelLabel ??
|
||||
"Select model"
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSelectedTriggerLabel(modelLabel: string): string {
|
||||
return modelLabel;
|
||||
}
|
||||
|
||||
export function matchesModelSearch(
|
||||
row: ProviderSelectionModelRow,
|
||||
normalizedQuery: string,
|
||||
): boolean {
|
||||
return scoreModelRow(row, normalizedQuery) !== null;
|
||||
}
|
||||
|
||||
function getModelRowSearchFields(row: ProviderSelectionModelRow): string[] {
|
||||
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
|
||||
}
|
||||
|
||||
export function scoreModelRow(row: ProviderSelectionModelRow, normalizedQuery: string) {
|
||||
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
|
||||
}
|
||||
|
||||
export function filterAndRankModelRows(
|
||||
rows: ProviderSelectionModelRow[],
|
||||
normalizedQuery: string,
|
||||
): ProviderSelectionModelRow[] {
|
||||
if (!normalizedQuery) return rows;
|
||||
const scored = rows
|
||||
.map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) }))
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is { row: ProviderSelectionModelRow; score: NonNullable<typeof entry.score> } =>
|
||||
Boolean(entry.score),
|
||||
);
|
||||
|
||||
scored.sort((a, b) => {
|
||||
const cmp = compareMatchScores(a.score, b.score);
|
||||
if (cmp !== 0) return cmp;
|
||||
return a.row.modelLabel.localeCompare(b.row.modelLabel);
|
||||
});
|
||||
|
||||
return scored.map((entry) => entry.row);
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerModelId(selection: ProviderSelectionState): string {
|
||||
return selection.modelId.trim();
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerThinkingOptionId(
|
||||
selection: ProviderSelectionState,
|
||||
effectiveModelId: string,
|
||||
): string {
|
||||
const selectedThinkingOptionId = selection.thinkingOptionId.trim();
|
||||
if (selectedThinkingOptionId) {
|
||||
return selectedThinkingOptionId;
|
||||
}
|
||||
|
||||
const selectedModelDefinition =
|
||||
selection.availableModels.find((model) => model.id === effectiveModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}
|
||||
|
||||
export function buildDraftCommandConfig(input: {
|
||||
selection: ProviderSelectionState;
|
||||
cwd: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}): DraftCommandConfig | undefined {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!input.selection.provider || !cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: input.selection.provider,
|
||||
cwd,
|
||||
...(input.selection.modeOptions.length > 0 && input.selection.modeId !== ""
|
||||
? { modeId: input.selection.modeId }
|
||||
: {}),
|
||||
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
||||
...(input.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSubmissionReadiness(input: {
|
||||
text: string;
|
||||
allowsEmptyAutoSubmit: boolean;
|
||||
providerCount: number;
|
||||
selection: {
|
||||
provider: AgentProvider | string | null;
|
||||
modelId: string;
|
||||
availableModels: readonly unknown[];
|
||||
isModelLoading: boolean;
|
||||
};
|
||||
autoSubmitConfig: { provider: string; model: string | null } | null;
|
||||
workspaceDirectory: string | null;
|
||||
hasClient: boolean;
|
||||
}): ProviderSelectionReadiness {
|
||||
if (!input.allowsEmptyAutoSubmit && !input.text.trim()) {
|
||||
return { ok: false, reason: "Initial prompt is required" };
|
||||
}
|
||||
if (input.providerCount === 0) {
|
||||
return { ok: false, reason: "No available providers on the selected host" };
|
||||
}
|
||||
if (!(input.autoSubmitConfig?.provider ?? input.selection.provider)) {
|
||||
return { ok: false, reason: "Select a model" };
|
||||
}
|
||||
if (input.selection.isModelLoading) {
|
||||
return { ok: false, reason: "Model defaults are still loading" };
|
||||
}
|
||||
const hasSelectedModel = Boolean(input.autoSubmitConfig?.model ?? input.selection.modelId);
|
||||
if (!hasSelectedModel && input.selection.availableModels.length > 0) {
|
||||
return { ok: false, reason: "No model is available for the selected provider" };
|
||||
}
|
||||
if (!input.workspaceDirectory) {
|
||||
return { ok: false, reason: "Workspace directory not found" };
|
||||
}
|
||||
if (!input.hasClient) {
|
||||
return { ok: false, reason: "Host is not connected" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,967 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveAgentForm,
|
||||
resolveFormState,
|
||||
resolveThinkingOptionId,
|
||||
mergeSelectedComposerPreferences,
|
||||
combineInitialValues,
|
||||
buildProviderDefinitionMap,
|
||||
buildProviderDefinitionMapForStatuses,
|
||||
resolveDefaultModel,
|
||||
INITIAL_USER_MODIFIED,
|
||||
type AgentFormReducerState,
|
||||
type UserModifiedFields,
|
||||
} from "./resolve-agent-form";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import type {
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description: "Codex test provider",
|
||||
defaultModeId: "auto",
|
||||
modes: [
|
||||
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
|
||||
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
|
||||
],
|
||||
};
|
||||
|
||||
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
description: "Claude test provider",
|
||||
defaultModeId: "default",
|
||||
modes: [
|
||||
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
|
||||
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
|
||||
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
|
||||
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
|
||||
],
|
||||
};
|
||||
|
||||
const CODEX_MODELS: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.3-codex",
|
||||
label: "gpt-5.3-codex",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "xhigh",
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "low" },
|
||||
{ id: "xhigh", label: "xhigh", isDefault: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function makeProviderMap(
|
||||
...definitions: AgentProviderDefinition[]
|
||||
): Map<AgentProvider, AgentProviderDefinition> {
|
||||
return new Map(definitions.map((d) => [d.id, d]));
|
||||
}
|
||||
|
||||
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
|
||||
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
|
||||
const bothProviderMap = makeProviderMap(TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION);
|
||||
|
||||
function makeState(
|
||||
overrides: Partial<AgentFormReducerState["form"]> = {},
|
||||
modified: Partial<UserModifiedFields> = {},
|
||||
): AgentFormReducerState {
|
||||
return {
|
||||
form: {
|
||||
serverId: null,
|
||||
provider: null,
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
...overrides,
|
||||
},
|
||||
userModified: { ...INITIAL_USER_MODIFIED, ...modified },
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveDefaultModel", () => {
|
||||
it("returns null for empty or null input", () => {
|
||||
expect(resolveDefaultModel(null)).toBeNull();
|
||||
expect(resolveDefaultModel([])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the model marked isDefault", () => {
|
||||
const models: AgentModelDefinition[] = [
|
||||
{ provider: "codex", id: "a", label: "A", isDefault: false },
|
||||
{ provider: "codex", id: "b", label: "B", isDefault: true },
|
||||
];
|
||||
expect(resolveDefaultModel(models)?.id).toBe("b");
|
||||
});
|
||||
|
||||
it("falls back to the first model when none is marked default", () => {
|
||||
const models: AgentModelDefinition[] = [
|
||||
{ provider: "codex", id: "a", label: "A", isDefault: false },
|
||||
{ provider: "codex", id: "b", label: "B", isDefault: false },
|
||||
];
|
||||
expect(resolveDefaultModel(models)?.id).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveThinkingOptionId", () => {
|
||||
it("returns empty string when model has no thinking options", () => {
|
||||
const modelsWithoutThinking: AgentModelDefinition[] = [
|
||||
{ provider: "claude", id: "claude-sonnet-4-6", label: "Sonnet 4.6", isDefault: true },
|
||||
];
|
||||
expect(
|
||||
resolveThinkingOptionId({
|
||||
availableModels: modelsWithoutThinking,
|
||||
modelId: "claude-sonnet-4-6",
|
||||
requestedThinkingOptionId: "",
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("returns the requested option when it is valid", () => {
|
||||
expect(
|
||||
resolveThinkingOptionId({
|
||||
availableModels: CODEX_MODELS,
|
||||
modelId: "gpt-5.3-codex",
|
||||
requestedThinkingOptionId: "low",
|
||||
}),
|
||||
).toBe("low");
|
||||
});
|
||||
|
||||
it("falls back to defaultThinkingOptionId when requested option is invalid", () => {
|
||||
expect(
|
||||
resolveThinkingOptionId({
|
||||
availableModels: CODEX_MODELS,
|
||||
modelId: "gpt-5.3-codex",
|
||||
requestedThinkingOptionId: "invalid",
|
||||
}),
|
||||
).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("falls back to first option when no default and requested is invalid", () => {
|
||||
const modelsNoDefault: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "m",
|
||||
label: "M",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(
|
||||
resolveThinkingOptionId({
|
||||
availableModels: modelsNoDefault,
|
||||
modelId: "m",
|
||||
requestedThinkingOptionId: "",
|
||||
}),
|
||||
).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("combineInitialValues", () => {
|
||||
it("returns undefined when no initial values and no initial server id", () => {
|
||||
expect(combineInitialValues(undefined, null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inject a null serverId override when initialValues are present but serverId is absent", () => {
|
||||
const combined = combineInitialValues({}, null);
|
||||
expect(combined).toEqual({});
|
||||
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
||||
});
|
||||
|
||||
it("injects serverId from options when provided", () => {
|
||||
expect(combineInitialValues({}, "daemon-1")).toEqual({ serverId: "daemon-1" });
|
||||
});
|
||||
|
||||
it("keeps other initial values without forcing serverId", () => {
|
||||
const combined = combineInitialValues({ workingDir: "/repo" }, null);
|
||||
expect(combined).toEqual({ workingDir: "/repo" });
|
||||
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
||||
});
|
||||
|
||||
it("respects an explicit serverId override (including null) over initialServerId", () => {
|
||||
expect(combineInitialValues({ serverId: null }, "daemon-1")).toEqual({ serverId: null });
|
||||
expect(combineInitialValues({ serverId: "daemon-2" }, "daemon-1")).toEqual({
|
||||
serverId: "daemon-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeSelectedComposerPreferences", () => {
|
||||
it("stores the selected model for the selected provider", () => {
|
||||
expect(
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: {},
|
||||
provider: "codex",
|
||||
updates: { model: "gpt-5.4" },
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
providerPreferences: { codex: { model: "gpt-5.4" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves existing provider preferences when the selected model changes", () => {
|
||||
expect(
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: {
|
||||
provider: "claude",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
mode: "full-access",
|
||||
thinkingByModel: { "gpt-5.4-mini": "medium" },
|
||||
featureValues: { fast_mode: true },
|
||||
},
|
||||
claude: { model: "claude-sonnet-4-6" },
|
||||
},
|
||||
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
||||
},
|
||||
provider: "codex",
|
||||
updates: { model: "gpt-5.4" },
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4",
|
||||
mode: "full-access",
|
||||
thinkingByModel: { "gpt-5.4-mini": "medium" },
|
||||
featureValues: { fast_mode: true },
|
||||
},
|
||||
claude: { model: "claude-sonnet-4-6" },
|
||||
},
|
||||
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("stores mode and thinking preferences without dropping the selected model", () => {
|
||||
expect(
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: {
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4",
|
||||
mode: "auto",
|
||||
thinkingByModel: { "gpt-5.4-mini": "low" },
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "codex",
|
||||
updates: {
|
||||
mode: "full-access",
|
||||
thinkingByModel: { "gpt-5.4": "xhigh" },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4",
|
||||
mode: "full-access",
|
||||
thinkingByModel: { "gpt-5.4-mini": "low", "gpt-5.4": "xhigh" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProviderDefinitions", () => {
|
||||
it("returns empty array when snapshot data is unavailable", () => {
|
||||
expect(buildProviderDefinitions(undefined)).toEqual([]);
|
||||
expect(buildProviderDefinitions([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds provider definitions from snapshot metadata", () => {
|
||||
const entries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "zai",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: "ZAI",
|
||||
description: "Claude with ZAI config",
|
||||
defaultModeId: "default",
|
||||
modes: [
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
description: "Safe mode",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "safe",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildProviderDefinitions(entries)).toEqual([
|
||||
{
|
||||
id: "zai",
|
||||
label: "ZAI",
|
||||
description: "Claude with ZAI config",
|
||||
defaultModeId: "default",
|
||||
modes: [
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
description: "Safe mode",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "safe",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFormState", () => {
|
||||
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{},
|
||||
null,
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState().form,
|
||||
|
||||
bothProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBeNull();
|
||||
expect(resolved.modeId).toBe("");
|
||||
expect(resolved.model).toBe("");
|
||||
expect(resolved.thinkingOptionId).toBe("");
|
||||
});
|
||||
|
||||
it("does not auto-select a model on fresh drafts without preferences", () => {
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{ provider: "codex" },
|
||||
CODEX_MODELS,
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState({ provider: "codex" }).form,
|
||||
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("");
|
||||
expect(resolved.thinkingOptionId).toBe("");
|
||||
});
|
||||
|
||||
it("auto-selects the model's default thinking option when model is preferred but thinking is not", () => {
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
|
||||
CODEX_MODELS,
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState({ provider: "codex" }).form,
|
||||
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("falls back to model default when saved thinking preference is invalid", () => {
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
|
||||
CODEX_MODELS,
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState({ provider: "codex" }).form,
|
||||
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
|
||||
const resolved = resolveFormState(
|
||||
{ model: "default" },
|
||||
{ provider: "codex" },
|
||||
CODEX_MODELS,
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState({ provider: "codex" }).form,
|
||||
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
});
|
||||
|
||||
it("keeps an explicit initial thinking option when it is valid", () => {
|
||||
const resolved = resolveFormState(
|
||||
{ model: "gpt-5.3-codex", thinkingOptionId: "low" },
|
||||
{ provider: "codex" },
|
||||
CODEX_MODELS,
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState({ provider: "codex" }).form,
|
||||
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
expect(resolved.thinkingOptionId).toBe("low");
|
||||
});
|
||||
|
||||
it("falls back to the first thinking option when model exposes options without a provider default", () => {
|
||||
const claudeWithThinking: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "default",
|
||||
label: "Default (Sonnet 4.6)",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{ provider: "claude", providerPreferences: { claude: { model: "default" } } },
|
||||
claudeWithThinking,
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState({ provider: "claude" }).form,
|
||||
|
||||
claudeProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("default");
|
||||
expect(resolved.thinkingOptionId).toBe("low");
|
||||
});
|
||||
|
||||
it("clears an invalid provider instead of falling back to the first allowed provider", () => {
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{ provider: "codex" },
|
||||
null,
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState({ provider: "codex" }).form,
|
||||
|
||||
claudeProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
|
||||
const loadingEntries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
enabled: true,
|
||||
label: TEST_CODEX_DEFINITION.label,
|
||||
description: TEST_CODEX_DEFINITION.description,
|
||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||
modes: TEST_CODEX_DEFINITION.modes,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: TEST_CLAUDE_DEFINITION.label,
|
||||
description: TEST_CLAUDE_DEFINITION.description,
|
||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||
},
|
||||
];
|
||||
const providerDefinitions = buildProviderDefinitions(loadingEntries);
|
||||
const resolvableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries: loadingEntries,
|
||||
providerDefinitions,
|
||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||
});
|
||||
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{},
|
||||
null,
|
||||
{
|
||||
serverId: false,
|
||||
provider: true,
|
||||
modeId: true,
|
||||
model: true,
|
||||
thinkingOptionId: true,
|
||||
workingDir: false,
|
||||
},
|
||||
makeState({
|
||||
provider: "codex",
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.3-codex",
|
||||
thinkingOptionId: "xhigh",
|
||||
}).form,
|
||||
|
||||
resolvableProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("codex");
|
||||
expect(resolved.modeId).toBe("full-access");
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("ignores disabled ready providers when resolving selectable defaults", () => {
|
||||
const entries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: TEST_CODEX_DEFINITION.label,
|
||||
description: TEST_CODEX_DEFINITION.description,
|
||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||
modes: TEST_CODEX_DEFINITION.modes,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: false,
|
||||
label: TEST_CLAUDE_DEFINITION.label,
|
||||
description: TEST_CLAUDE_DEFINITION.description,
|
||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||
},
|
||||
];
|
||||
const providerDefinitions = buildProviderDefinitions(entries);
|
||||
const selectableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries: entries,
|
||||
providerDefinitions,
|
||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
||||
});
|
||||
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{ provider: "claude" },
|
||||
null,
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState({ provider: "codex" }).form,
|
||||
|
||||
selectableProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("codex");
|
||||
expect(resolved.modeId).toBe("auto");
|
||||
});
|
||||
|
||||
it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => {
|
||||
const entries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: TEST_CODEX_DEFINITION.label,
|
||||
description: TEST_CODEX_DEFINITION.description,
|
||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||
modes: TEST_CODEX_DEFINITION.modes,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: false,
|
||||
label: TEST_CLAUDE_DEFINITION.label,
|
||||
description: TEST_CLAUDE_DEFINITION.description,
|
||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||
},
|
||||
];
|
||||
const providerDefinitions = buildProviderDefinitions(entries);
|
||||
|
||||
const selectableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries: entries,
|
||||
providerDefinitions,
|
||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
||||
});
|
||||
|
||||
expect([...selectableProviderMap.keys()]).toEqual(["codex"]);
|
||||
expect(providerDefinitions.map((d) => d.id)).toEqual(["codex", "claude"]);
|
||||
});
|
||||
|
||||
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
|
||||
const unavailableEntries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "unavailable",
|
||||
enabled: true,
|
||||
label: TEST_CODEX_DEFINITION.label,
|
||||
description: TEST_CODEX_DEFINITION.description,
|
||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||
modes: TEST_CODEX_DEFINITION.modes,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: TEST_CLAUDE_DEFINITION.label,
|
||||
description: TEST_CLAUDE_DEFINITION.description,
|
||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||
},
|
||||
];
|
||||
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
|
||||
const resolvableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries: unavailableEntries,
|
||||
providerDefinitions,
|
||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||
});
|
||||
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{},
|
||||
null,
|
||||
{ ...INITIAL_USER_MODIFIED, provider: true },
|
||||
makeState({
|
||||
provider: "codex",
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.3-codex",
|
||||
thinkingOptionId: "xhigh",
|
||||
}).form,
|
||||
|
||||
resolvableProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBeNull();
|
||||
expect(resolved.modeId).toBe("");
|
||||
expect(resolved.model).toBe("");
|
||||
expect(resolved.thinkingOptionId).toBe("");
|
||||
});
|
||||
|
||||
it("does not force fallback provider when allowed provider map is empty", () => {
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{ provider: "codex" },
|
||||
null,
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState({ provider: "codex" }).form,
|
||||
|
||||
new Map<AgentProvider, AgentProviderDefinition>(),
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("codex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentForm", () => {
|
||||
describe("RESOLVE", () => {
|
||||
it("applies resolved provider and mode when no user modifications", () => {
|
||||
const state = makeState();
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "RESOLVE",
|
||||
initialValues: undefined,
|
||||
preferences: { provider: "codex" },
|
||||
availableModels: null,
|
||||
|
||||
allowedProviderMap: codexProviderMap,
|
||||
});
|
||||
|
||||
expect(next.form.provider).toBe("codex");
|
||||
expect(next.form.modeId).toBe("auto");
|
||||
});
|
||||
|
||||
it("returns the same state reference when nothing changed", () => {
|
||||
const state = makeState({ provider: "codex", modeId: "auto" });
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "RESOLVE",
|
||||
initialValues: undefined,
|
||||
preferences: { provider: "codex" },
|
||||
availableModels: null,
|
||||
|
||||
allowedProviderMap: codexProviderMap,
|
||||
});
|
||||
|
||||
expect(next).toBe(state);
|
||||
});
|
||||
|
||||
it("does not override user-modified provider", () => {
|
||||
const state = makeState({ provider: "codex", modeId: "auto" }, { provider: true });
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "RESOLVE",
|
||||
initialValues: undefined,
|
||||
preferences: { provider: "claude" },
|
||||
availableModels: null,
|
||||
|
||||
allowedProviderMap: bothProviderMap,
|
||||
});
|
||||
|
||||
expect(next.form.provider).toBe("codex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SET_SERVER_ID", () => {
|
||||
it("updates serverId without marking it user-modified", () => {
|
||||
const state = makeState();
|
||||
const next = resolveAgentForm(state, { type: "SET_SERVER_ID", value: "host-1" });
|
||||
|
||||
expect(next.form.serverId).toBe("host-1");
|
||||
expect(next.userModified.serverId).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SET_SERVER_ID_FROM_USER", () => {
|
||||
it("updates serverId and marks it user-modified", () => {
|
||||
const state = makeState();
|
||||
const next = resolveAgentForm(state, { type: "SET_SERVER_ID_FROM_USER", value: "host-2" });
|
||||
|
||||
expect(next.form.serverId).toBe("host-2");
|
||||
expect(next.userModified.serverId).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SET_PROVIDER_FROM_USER", () => {
|
||||
it("switches provider, picks preferred model and mode, marks provider modified", () => {
|
||||
const state = makeState();
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "SET_PROVIDER_FROM_USER",
|
||||
provider: "codex",
|
||||
providerModels: CODEX_MODELS,
|
||||
providerDef: TEST_CODEX_DEFINITION,
|
||||
providerPrefs: { model: "gpt-5.3-codex", mode: "full-access" },
|
||||
});
|
||||
|
||||
expect(next.form.provider).toBe("codex");
|
||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||
expect(next.form.modeId).toBe("full-access");
|
||||
expect(next.userModified.provider).toBe(true);
|
||||
expect(next.userModified.model).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to provider defaults when no prefs", () => {
|
||||
const state = makeState();
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "SET_PROVIDER_FROM_USER",
|
||||
provider: "codex",
|
||||
providerModels: CODEX_MODELS,
|
||||
providerDef: TEST_CODEX_DEFINITION,
|
||||
providerPrefs: undefined,
|
||||
});
|
||||
|
||||
expect(next.form.modeId).toBe("auto");
|
||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => {
|
||||
it("sets provider, model, and default mode; marks both modified", () => {
|
||||
const state = makeState();
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.3-codex",
|
||||
providerDef: TEST_CODEX_DEFINITION,
|
||||
providerModels: CODEX_MODELS,
|
||||
});
|
||||
|
||||
expect(next.form.provider).toBe("codex");
|
||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||
expect(next.form.modeId).toBe("auto");
|
||||
expect(next.userModified.provider).toBe(true);
|
||||
expect(next.userModified.model).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to provider default model when modelId is empty", () => {
|
||||
const state = makeState();
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||
provider: "codex",
|
||||
modelId: "",
|
||||
providerDef: TEST_CODEX_DEFINITION,
|
||||
providerModels: CODEX_MODELS,
|
||||
});
|
||||
|
||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||
});
|
||||
|
||||
it("selects default thinking option for the chosen model", () => {
|
||||
const state = makeState();
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.3-codex",
|
||||
providerDef: TEST_CODEX_DEFINITION,
|
||||
providerModels: CODEX_MODELS,
|
||||
});
|
||||
|
||||
expect(next.form.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SET_MODE_FROM_USER", () => {
|
||||
it("updates modeId and marks it modified", () => {
|
||||
const state = makeState({ provider: "codex", modeId: "auto" });
|
||||
const next = resolveAgentForm(state, { type: "SET_MODE_FROM_USER", modeId: "full-access" });
|
||||
|
||||
expect(next.form.modeId).toBe("full-access");
|
||||
expect(next.userModified.modeId).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SET_MODEL_FROM_USER", () => {
|
||||
it("updates model and resets thinking to model default when thinking is not user-modified", () => {
|
||||
const state = makeState({ provider: "codex", model: "", thinkingOptionId: "" });
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId: "gpt-5.3-codex",
|
||||
availableModels: CODEX_MODELS,
|
||||
});
|
||||
|
||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||
expect(next.form.thinkingOptionId).toBe("xhigh");
|
||||
expect(next.userModified.model).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves user-chosen thinking option when switching to same model", () => {
|
||||
const state = makeState(
|
||||
{ provider: "codex", model: "gpt-5.3-codex", thinkingOptionId: "low" },
|
||||
{ thinkingOptionId: true },
|
||||
);
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId: "gpt-5.3-codex",
|
||||
availableModels: CODEX_MODELS,
|
||||
});
|
||||
|
||||
expect(next.form.thinkingOptionId).toBe("low");
|
||||
});
|
||||
|
||||
it("falls back to provider default model when modelId is blank", () => {
|
||||
const state = makeState({ provider: "codex" });
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId: " ",
|
||||
availableModels: CODEX_MODELS,
|
||||
});
|
||||
|
||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SET_THINKING_OPTION_FROM_USER", () => {
|
||||
it("updates thinkingOptionId and marks it modified", () => {
|
||||
const state = makeState({ thinkingOptionId: "xhigh" });
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "SET_THINKING_OPTION_FROM_USER",
|
||||
thinkingOptionId: "low",
|
||||
});
|
||||
|
||||
expect(next.form.thinkingOptionId).toBe("low");
|
||||
expect(next.userModified.thinkingOptionId).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SET_WORKING_DIR", () => {
|
||||
it("updates workingDir without marking it modified", () => {
|
||||
const state = makeState();
|
||||
const next = resolveAgentForm(state, { type: "SET_WORKING_DIR", value: "/home/user/proj" });
|
||||
|
||||
expect(next.form.workingDir).toBe("/home/user/proj");
|
||||
expect(next.userModified.workingDir).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SET_WORKING_DIR_FROM_USER", () => {
|
||||
it("updates workingDir and marks it modified", () => {
|
||||
const state = makeState();
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "SET_WORKING_DIR_FROM_USER",
|
||||
value: "/home/user/proj",
|
||||
});
|
||||
|
||||
expect(next.form.workingDir).toBe("/home/user/proj");
|
||||
expect(next.userModified.workingDir).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AUTO_SELECT_SERVER", () => {
|
||||
it("sets serverId when currently null", () => {
|
||||
const state = makeState({ serverId: null });
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "AUTO_SELECT_SERVER",
|
||||
candidateServerId: "host-1",
|
||||
});
|
||||
|
||||
expect(next.form.serverId).toBe("host-1");
|
||||
});
|
||||
|
||||
it("does not override an already-set serverId", () => {
|
||||
const state = makeState({ serverId: "existing" });
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "AUTO_SELECT_SERVER",
|
||||
candidateServerId: "host-1",
|
||||
});
|
||||
|
||||
expect(next).toBe(state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RESET", () => {
|
||||
it("resets userModified flags while keeping form state", () => {
|
||||
const state = makeState(
|
||||
{ provider: "codex", modeId: "full-access", model: "gpt-5.3-codex" },
|
||||
{ provider: true, modeId: true, model: true },
|
||||
);
|
||||
const next = resolveAgentForm(state, { type: "RESET" });
|
||||
|
||||
expect(next.userModified).toEqual(INITIAL_USER_MODIFIED);
|
||||
expect(next.form).toEqual(state.form);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProviderDefinitionMap", () => {
|
||||
it("builds a map from provider id to definition", () => {
|
||||
const map = buildProviderDefinitionMap([TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION]);
|
||||
expect(map.get("codex")).toBe(TEST_CODEX_DEFINITION);
|
||||
expect(map.get("claude")).toBe(TEST_CLAUDE_DEFINITION);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProviderDefinitionMapForStatuses", () => {
|
||||
it("returns all definitions when no snapshot entries", () => {
|
||||
const map = buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries: undefined,
|
||||
providerDefinitions: [TEST_CODEX_DEFINITION],
|
||||
statuses: new Set(["ready"]),
|
||||
});
|
||||
expect([...map.keys()]).toEqual(["codex"]);
|
||||
});
|
||||
|
||||
it("filters to only matching-status enabled providers", () => {
|
||||
const entries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: "Codex",
|
||||
description: "",
|
||||
defaultModeId: "auto",
|
||||
modes: [],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "loading",
|
||||
enabled: true,
|
||||
label: "Claude",
|
||||
description: "",
|
||||
defaultModeId: "default",
|
||||
modes: [],
|
||||
},
|
||||
];
|
||||
const map = buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries: entries,
|
||||
providerDefinitions: [TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION],
|
||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
||||
});
|
||||
|
||||
expect([...map.keys()]).toEqual(["codex"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,559 @@
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import type {
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
mergeProviderPreferences,
|
||||
type FormPreferences,
|
||||
type ProviderPreferences,
|
||||
} from "@/hooks/use-form-preferences";
|
||||
|
||||
export interface FormInitialValues {
|
||||
serverId?: string | null;
|
||||
provider?: AgentProvider;
|
||||
modeId?: string | null;
|
||||
model?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
workingDir?: string;
|
||||
}
|
||||
|
||||
export interface FormState {
|
||||
serverId: string | null;
|
||||
provider: AgentProvider | null;
|
||||
modeId: string;
|
||||
model: string;
|
||||
thinkingOptionId: string;
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
export interface UserModifiedFields {
|
||||
serverId: boolean;
|
||||
provider: boolean;
|
||||
modeId: boolean;
|
||||
model: boolean;
|
||||
thinkingOptionId: boolean;
|
||||
workingDir: boolean;
|
||||
}
|
||||
|
||||
export interface AgentFormReducerState {
|
||||
form: FormState;
|
||||
userModified: UserModifiedFields;
|
||||
}
|
||||
|
||||
export const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
};
|
||||
|
||||
type ProviderPrefs = NonNullable<FormPreferences["providerPreferences"]>[AgentProvider];
|
||||
|
||||
export const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>([
|
||||
"ready",
|
||||
"loading",
|
||||
]);
|
||||
export const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
|
||||
|
||||
export type AgentFormAction =
|
||||
| {
|
||||
type: "RESOLVE";
|
||||
initialValues: FormInitialValues | undefined;
|
||||
preferences: FormPreferences | null;
|
||||
availableModels: AgentModelDefinition[] | null;
|
||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
|
||||
}
|
||||
| { type: "SET_SERVER_ID"; value: string | null }
|
||||
| { type: "SET_SERVER_ID_FROM_USER"; value: string | null }
|
||||
| {
|
||||
type: "SET_PROVIDER_FROM_USER";
|
||||
provider: AgentProvider;
|
||||
providerModels: AgentModelDefinition[] | null;
|
||||
providerDef: AgentProviderDefinition | undefined;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
}
|
||||
| {
|
||||
type: "SET_PROVIDER_AND_MODEL_FROM_USER";
|
||||
provider: AgentProvider;
|
||||
modelId: string;
|
||||
providerDef: AgentProviderDefinition | undefined;
|
||||
providerModels: AgentModelDefinition[] | null;
|
||||
}
|
||||
| { type: "SET_MODE_FROM_USER"; modeId: string }
|
||||
| {
|
||||
type: "SET_MODEL_FROM_USER";
|
||||
modelId: string;
|
||||
availableModels: AgentModelDefinition[] | null;
|
||||
}
|
||||
| { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string }
|
||||
| { type: "SET_WORKING_DIR"; value: string }
|
||||
| { type: "SET_WORKING_DIR_FROM_USER"; value: string }
|
||||
| { type: "AUTO_SELECT_SERVER"; candidateServerId: string }
|
||||
| { type: "RESET" };
|
||||
|
||||
export function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||
return typeof modelId === "string" ? modelId.trim() : "";
|
||||
}
|
||||
|
||||
export function resolveDefaultModel(
|
||||
availableModels: AgentModelDefinition[] | null,
|
||||
): AgentModelDefinition | null {
|
||||
if (!availableModels || availableModels.length === 0) return null;
|
||||
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
|
||||
return resolveDefaultModel(availableModels)?.id ?? "";
|
||||
}
|
||||
|
||||
export function resolveEffectiveModel(
|
||||
availableModels: AgentModelDefinition[] | null,
|
||||
modelId: string,
|
||||
): AgentModelDefinition | null {
|
||||
if (!availableModels || availableModels.length === 0) return null;
|
||||
const normalizedModelId = modelId.trim();
|
||||
if (!normalizedModelId) return null;
|
||||
return (
|
||||
availableModels.find((model) => model.id === normalizedModelId) ??
|
||||
resolveDefaultModel(availableModels)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveThinkingOptionId(args: {
|
||||
availableModels: AgentModelDefinition[] | null;
|
||||
modelId: string;
|
||||
requestedThinkingOptionId: string;
|
||||
}): string {
|
||||
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
|
||||
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
if (thinkingOptions.length === 0) return "";
|
||||
|
||||
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
|
||||
if (
|
||||
normalizedThinkingOptionId &&
|
||||
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
|
||||
) {
|
||||
return normalizedThinkingOptionId;
|
||||
}
|
||||
|
||||
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
||||
}
|
||||
|
||||
export function mergeSelectedComposerPreferences(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: AgentProvider;
|
||||
updates: Partial<ProviderPreferences>;
|
||||
}): FormPreferences {
|
||||
return mergeProviderPreferences({
|
||||
preferences: args.preferences,
|
||||
provider: args.provider,
|
||||
updates: args.updates,
|
||||
});
|
||||
}
|
||||
|
||||
export function combineInitialValues(
|
||||
initialValues: FormInitialValues | undefined,
|
||||
initialServerId: string | null,
|
||||
): FormInitialValues | undefined {
|
||||
const hasExplicitServerId = initialValues?.serverId !== undefined;
|
||||
const serverIdFromOptions = initialServerId === null ? undefined : initialServerId;
|
||||
|
||||
if (!initialValues && !hasExplicitServerId && serverIdFromOptions === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (hasExplicitServerId) {
|
||||
return { ...initialValues, serverId: initialValues?.serverId };
|
||||
}
|
||||
|
||||
if (serverIdFromOptions !== undefined) {
|
||||
return { ...initialValues, serverId: serverIdFromOptions };
|
||||
}
|
||||
|
||||
return initialValues;
|
||||
}
|
||||
|
||||
export function hasFormStateChanged(prev: FormState, next: FormState): boolean {
|
||||
return (
|
||||
prev.serverId !== next.serverId ||
|
||||
prev.provider !== next.provider ||
|
||||
prev.modeId !== next.modeId ||
|
||||
prev.model !== next.model ||
|
||||
prev.thinkingOptionId !== next.thinkingOptionId ||
|
||||
prev.workingDir !== next.workingDir
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProviderDefinitionMap(
|
||||
providerDefinitions: AgentProviderDefinition[],
|
||||
): Map<AgentProvider, AgentProviderDefinition> {
|
||||
return new Map<AgentProvider, AgentProviderDefinition>(
|
||||
providerDefinitions.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProviderDefinitionMapForStatuses(args: {
|
||||
snapshotEntries: ProviderSnapshotEntry[] | undefined;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
statuses: ReadonlySet<ProviderSnapshotEntry["status"]>;
|
||||
}): Map<AgentProvider, AgentProviderDefinition> {
|
||||
if (!args.snapshotEntries?.length) {
|
||||
return buildProviderDefinitionMap(args.providerDefinitions);
|
||||
}
|
||||
|
||||
const matchingProviders = new Set(
|
||||
args.snapshotEntries
|
||||
.filter((entry) => args.statuses.has(entry.status) && entry.enabled)
|
||||
.map((entry) => entry.provider),
|
||||
);
|
||||
|
||||
return buildProviderDefinitionMap(
|
||||
args.providerDefinitions.filter((definition) => matchingProviders.has(definition.id)),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveProvider(input: {
|
||||
currentProvider: AgentProvider | null;
|
||||
userModified: boolean;
|
||||
initialValues: FormInitialValues | undefined;
|
||||
preferences: FormPreferences | null;
|
||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
|
||||
}): AgentProvider | null {
|
||||
const { currentProvider, userModified, initialValues, preferences, allowedProviderMap } = input;
|
||||
if (userModified) {
|
||||
if (
|
||||
currentProvider &&
|
||||
allowedProviderMap.size > 0 &&
|
||||
!allowedProviderMap.has(currentProvider)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return currentProvider;
|
||||
}
|
||||
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
||||
return initialValues.provider;
|
||||
}
|
||||
if (preferences?.provider && allowedProviderMap.has(preferences.provider)) {
|
||||
return preferences.provider;
|
||||
}
|
||||
if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) {
|
||||
return null;
|
||||
}
|
||||
return currentProvider;
|
||||
}
|
||||
|
||||
function resolveModeId(input: {
|
||||
provider: AgentProvider | null;
|
||||
userModified: boolean;
|
||||
currentModeId: string;
|
||||
initialValues: FormInitialValues | undefined;
|
||||
providerDef: AgentProviderDefinition | undefined;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
}): string {
|
||||
const { provider, userModified, currentModeId, initialValues, providerDef, providerPrefs } =
|
||||
input;
|
||||
if (userModified) return currentModeId;
|
||||
if (!provider) return "";
|
||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||
if (
|
||||
typeof initialValues?.modeId === "string" &&
|
||||
initialValues.modeId.length > 0 &&
|
||||
validModeIds.includes(initialValues.modeId)
|
||||
) {
|
||||
return initialValues.modeId;
|
||||
}
|
||||
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
||||
return providerPrefs.mode;
|
||||
}
|
||||
return providerDef?.defaultModeId ?? validModeIds[0] ?? "";
|
||||
}
|
||||
|
||||
function resolveModelField(input: {
|
||||
provider: AgentProvider | null;
|
||||
userModified: boolean;
|
||||
currentModel: string;
|
||||
initialValues: FormInitialValues | undefined;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
availableModels: AgentModelDefinition[] | null;
|
||||
}): string {
|
||||
const { provider, userModified, currentModel, initialValues, providerPrefs, availableModels } =
|
||||
input;
|
||||
if (userModified) return currentModel;
|
||||
if (!provider) return "";
|
||||
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
||||
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||
if (initialModel) {
|
||||
return !availableModels || isValidModel(initialModel) ? initialModel : defaultModelId;
|
||||
}
|
||||
if (preferredModel) {
|
||||
return !availableModels || isValidModel(preferredModel) ? preferredModel : defaultModelId;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveThinkingOption(input: {
|
||||
provider: AgentProvider | null;
|
||||
userModified: boolean;
|
||||
currentThinkingOptionId: string;
|
||||
modelId: string;
|
||||
initialValues: FormInitialValues | undefined;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
}): string {
|
||||
const { provider, userModified, currentThinkingOptionId, modelId, initialValues, providerPrefs } =
|
||||
input;
|
||||
if (!provider) return "";
|
||||
if (userModified) return currentThinkingOptionId;
|
||||
const initialThinkingOptionId =
|
||||
typeof initialValues?.thinkingOptionId === "string"
|
||||
? initialValues.thinkingOptionId.trim()
|
||||
: "";
|
||||
const effectiveModelId = modelId.trim();
|
||||
const preferredThinking = effectiveModelId
|
||||
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
||||
: "";
|
||||
if (initialThinkingOptionId.length > 0) return initialThinkingOptionId;
|
||||
if (preferredThinking.length > 0) return preferredThinking;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function resolveFormState(
|
||||
initialValues: FormInitialValues | undefined,
|
||||
preferences: FormPreferences | null,
|
||||
availableModels: AgentModelDefinition[] | null,
|
||||
userModified: UserModifiedFields,
|
||||
currentState: FormState,
|
||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
|
||||
): FormState {
|
||||
const result = { ...currentState };
|
||||
|
||||
result.provider = resolveProvider({
|
||||
currentProvider: result.provider,
|
||||
userModified: userModified.provider,
|
||||
initialValues,
|
||||
preferences,
|
||||
allowedProviderMap,
|
||||
});
|
||||
|
||||
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
|
||||
const providerPrefs = result.provider
|
||||
? preferences?.providerPreferences?.[result.provider]
|
||||
: undefined;
|
||||
|
||||
result.modeId = resolveModeId({
|
||||
provider: result.provider,
|
||||
userModified: userModified.modeId,
|
||||
currentModeId: result.modeId,
|
||||
initialValues,
|
||||
providerDef,
|
||||
providerPrefs,
|
||||
});
|
||||
|
||||
result.model = resolveModelField({
|
||||
provider: result.provider,
|
||||
userModified: userModified.model,
|
||||
currentModel: result.model,
|
||||
initialValues,
|
||||
providerPrefs,
|
||||
availableModels,
|
||||
});
|
||||
|
||||
result.thinkingOptionId = resolveThinkingOption({
|
||||
provider: result.provider,
|
||||
userModified: userModified.thinkingOptionId,
|
||||
currentThinkingOptionId: result.thinkingOptionId,
|
||||
modelId: result.model,
|
||||
initialValues,
|
||||
providerPrefs,
|
||||
});
|
||||
|
||||
if (result.provider && availableModels) {
|
||||
result.thinkingOptionId = resolveThinkingOptionId({
|
||||
availableModels,
|
||||
modelId: result.model,
|
||||
requestedThinkingOptionId: result.thinkingOptionId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!userModified.serverId && initialValues?.serverId !== undefined) {
|
||||
result.serverId = initialValues.serverId;
|
||||
}
|
||||
|
||||
if (!userModified.workingDir && initialValues?.workingDir !== undefined) {
|
||||
result.workingDir = initialValues.workingDir;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickNextModelForProvider(input: {
|
||||
providerModels: AgentModelDefinition[] | null;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
}): string {
|
||||
const { providerModels, providerPrefs } = input;
|
||||
const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false;
|
||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||
const defaultModelId = resolveDefaultModelId(providerModels);
|
||||
if (preferredModel && (!providerModels || isValidModel(preferredModel))) {
|
||||
return preferredModel;
|
||||
}
|
||||
return defaultModelId;
|
||||
}
|
||||
|
||||
function pickNextModeForProvider(input: {
|
||||
providerDef: AgentProviderDefinition | undefined;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
}): string {
|
||||
const { providerDef, providerPrefs } = input;
|
||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
||||
return providerPrefs.mode;
|
||||
}
|
||||
return providerDef?.defaultModeId ?? "";
|
||||
}
|
||||
|
||||
function pickNextThinkingOptionForProvider(input: {
|
||||
providerModels: AgentModelDefinition[] | null;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
modelId: string;
|
||||
}): string {
|
||||
const { providerModels, providerPrefs, modelId } = input;
|
||||
const preferredThinking = modelId
|
||||
? (providerPrefs?.thinkingByModel?.[modelId]?.trim() ?? "")
|
||||
: "";
|
||||
return resolveThinkingOptionId({
|
||||
availableModels: providerModels,
|
||||
modelId,
|
||||
requestedThinkingOptionId: preferredThinking,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAgentForm(
|
||||
state: AgentFormReducerState,
|
||||
action: AgentFormAction,
|
||||
): AgentFormReducerState {
|
||||
switch (action.type) {
|
||||
case "RESOLVE": {
|
||||
const resolved = resolveFormState(
|
||||
action.initialValues,
|
||||
action.preferences,
|
||||
action.availableModels,
|
||||
state.userModified,
|
||||
state.form,
|
||||
action.allowedProviderMap,
|
||||
);
|
||||
if (!hasFormStateChanged(state.form, resolved)) return state;
|
||||
return { ...state, form: resolved };
|
||||
}
|
||||
|
||||
case "SET_SERVER_ID":
|
||||
return { ...state, form: { ...state.form, serverId: action.value } };
|
||||
|
||||
case "SET_SERVER_ID_FROM_USER":
|
||||
return {
|
||||
form: { ...state.form, serverId: action.value },
|
||||
userModified: { ...state.userModified, serverId: true },
|
||||
};
|
||||
|
||||
case "SET_PROVIDER_FROM_USER": {
|
||||
const nextModelId = pickNextModelForProvider({
|
||||
providerModels: action.providerModels,
|
||||
providerPrefs: action.providerPrefs,
|
||||
});
|
||||
const nextModeId = pickNextModeForProvider({
|
||||
providerDef: action.providerDef,
|
||||
providerPrefs: action.providerPrefs,
|
||||
});
|
||||
const nextThinkingOptionId = pickNextThinkingOptionForProvider({
|
||||
providerModels: action.providerModels,
|
||||
providerPrefs: action.providerPrefs,
|
||||
modelId: nextModelId,
|
||||
});
|
||||
return {
|
||||
form: {
|
||||
...state.form,
|
||||
provider: action.provider,
|
||||
modeId: nextModeId,
|
||||
model: nextModelId,
|
||||
thinkingOptionId: nextThinkingOptionId,
|
||||
},
|
||||
userModified: { ...state.userModified, provider: true },
|
||||
};
|
||||
}
|
||||
|
||||
case "SET_PROVIDER_AND_MODEL_FROM_USER": {
|
||||
const normalizedModelId = normalizeSelectedModelId(action.modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels);
|
||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||
availableModels: action.providerModels,
|
||||
modelId: nextModelId,
|
||||
requestedThinkingOptionId: "",
|
||||
});
|
||||
return {
|
||||
form: {
|
||||
...state.form,
|
||||
provider: action.provider,
|
||||
model: nextModelId,
|
||||
modeId: action.providerDef?.defaultModeId ?? "",
|
||||
thinkingOptionId: nextThinkingOptionId,
|
||||
},
|
||||
userModified: { ...state.userModified, provider: true, model: true },
|
||||
};
|
||||
}
|
||||
|
||||
case "SET_MODE_FROM_USER":
|
||||
return {
|
||||
form: { ...state.form, modeId: action.modeId },
|
||||
userModified: { ...state.userModified, modeId: true },
|
||||
};
|
||||
|
||||
case "SET_MODEL_FROM_USER": {
|
||||
const normalizedModelId = normalizeSelectedModelId(action.modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(action.availableModels);
|
||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||
availableModels: action.availableModels,
|
||||
modelId: nextModelId,
|
||||
requestedThinkingOptionId: state.userModified.thinkingOptionId
|
||||
? state.form.thinkingOptionId
|
||||
: "",
|
||||
});
|
||||
return {
|
||||
form: {
|
||||
...state.form,
|
||||
model: nextModelId,
|
||||
thinkingOptionId: nextThinkingOptionId,
|
||||
},
|
||||
userModified: { ...state.userModified, model: true },
|
||||
};
|
||||
}
|
||||
|
||||
case "SET_THINKING_OPTION_FROM_USER":
|
||||
return {
|
||||
form: { ...state.form, thinkingOptionId: action.thinkingOptionId },
|
||||
userModified: { ...state.userModified, thinkingOptionId: true },
|
||||
};
|
||||
|
||||
case "SET_WORKING_DIR":
|
||||
return { ...state, form: { ...state.form, workingDir: action.value } };
|
||||
|
||||
case "SET_WORKING_DIR_FROM_USER":
|
||||
return {
|
||||
form: { ...state.form, workingDir: action.value },
|
||||
userModified: { ...state.userModified, workingDir: true },
|
||||
};
|
||||
|
||||
case "AUTO_SELECT_SERVER":
|
||||
if (state.form.serverId) return state;
|
||||
return { ...state, form: { ...state.form, serverId: action.candidateServerId } };
|
||||
|
||||
case "RESET":
|
||||
return { ...state, userModified: INITIAL_USER_MODIFIED };
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user