Files
fusion/packages/dashboard/app/components/__tests__/ModelSelectionModal.test.tsx
Fusion Agent 4d7e7dba4c FN-021: expose model-supported thinking levels, including max
Expose thinking-level options from model capabilities through API, workflows, and dashboard controls.

- Add model-supported thinking-level metadata, including max, across core and model routes.
- Propagate capability-aware levels through planning, agent creation, and chat/task UI.
- Update localized labels, documentation, tests, dependencies, and add a release changeset.

Files changed:
 .changeset/fn-021-thinking-levels.md               |  7 ++++
 docs/settings-reference.md                         |  8 +++-
 docs/workflow-steps.md                             |  8 ++--
 packages/core/src/__tests__/openai-models.test.ts  |  1 +
 .../core/src/__tests__/thinking-levels.test.ts     |  4 +-
 packages/core/src/__tests__/workflow-ir.test.ts    |  2 +-
 packages/core/src/ai/openai-models.ts              |  5 ++-
 packages/core/src/types/board/board.ts             |  8 ++--
 .../app/api/agents/agent-import-generation.ts      |  4 +-
 .../dashboard/app/api/planning/models-usage.ts     | 13 +++++++
 packages/dashboard/app/api/planning/planning.ts    |  5 ++-
 .../dashboard/app/components/AgentDetailView.tsx   |  2 +-
 .../app/components/ChatThinkingLevelControl.tsx    | 28 +++++++++++---
 .../app/components/CustomModelDropdown.tsx         | 29 ++++++++++----
 packages/dashboard/app/components/NewTaskModal.tsx |  4 +-
 .../dashboard/app/components/TaskDetailModal.tsx   |  4 +-
 .../__tests__/AgentDetailView.test-helpers.ts      |  2 +-
 .../__tests__/AgentOnboardingModal.test.tsx        |  2 +-
 .../__tests__/ChatThinkingLevelControl.test.tsx    | 27 ++++++++++++-
 .../__tests__/CustomModelDropdown.test.tsx         | 33 +++++++++++++++-
 .../__tests__/ModelSelectionModal.test.tsx         |  3 +-
 .../components/__tests__/NewAgentDialog.test.tsx   |  2 +
 .../components/__tests__/QuickEntryBox.test.tsx    |  3 +-
 .../components/agent-presets/agentCreatePayload.ts |  3 +-
 .../settings/sections/GlobalModelsSection.tsx      |  8 ++--
 packages/dashboard/package.json                    |  2 +-
 .../dashboard/src/__tests__/chat-manager.test.ts   |  8 ++--
 packages/dashboard/src/__tests__/chat.test.ts      |  2 +-
 ...-model-routes-openai-codex-supplemental.test.ts | 44 +++++++++++++++++++++-
 .../src/__tests__/routes-automation.test.ts        |  4 +-
 .../dashboard/src/__tests__/routes-tasks.test.ts   |  8 ++--
 packages/dashboard/src/agent-generation.ts         |  9 +++--
 packages/dashboard/src/agent-onboarding.ts         |  8 ++--
 packages/dashboard/src/routes.ts                   | 14 ++++++-
 .../src/routes/register-chat-room-routes.ts        |  2 +-
 .../dashboard/src/routes/register-model-routes.ts  | 43 ++++++++++++++++-----
 packages/i18n/locales/en/app.json                  |  1 +
 packages/i18n/locales/es/app.json                  |  1 +
 packages/i18n/locales/fr/app.json                  |  1 +
 packages/i18n/locales/ko/app.json                  |  1 +
 packages/i18n/locales/pt-BR/app.json               |  1 +
 packages/i18n/locales/zh-CN/app.json               |  1 +
 packages/i18n/locales/zh-TW/app.json               |  1 +
 packages/i18n/src/resources.d.ts                   |  1 +
 pnpm-lock.yaml                                     |  6 +--
 45 files changed, 289 insertions(+), 84 deletions(-)

Fusion-Task-Id: FN-021
Fusion-Task-Lineage: af1b0e09-6375-4f0c-9501-66a10c8b9168
Co-authored-by: Fusion <noreply@runfusion.ai>
2026-08-20 03:55:38 +00:00

567 lines
20 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ModelSelectionModal } from "../ModelSelectionModal";
import type { ModelInfo } from "../../api";
import type { ModelPreset } from "@fusion/core";
const MOCK_MODELS: ModelInfo[] = [
{
provider: "anthropic",
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
reasoning: true,
contextWindow: 200_000,
},
{
provider: "openai",
id: "gpt-4o",
name: "GPT-4o",
reasoning: true,
contextWindow: 128_000,
},
];
const MOCK_PRESETS: ModelPreset[] = [
{
id: "fast",
name: "Fast",
executorProvider: "anthropic",
executorModelId: "claude-sonnet-4-5",
},
{
id: "thorough",
name: "Thorough",
executorProvider: "openai",
executorModelId: "gpt-4o",
validatorProvider: "anthropic",
validatorModelId: "claude-sonnet-4-5",
},
];
// Mock lucide-react
vi.mock("lucide-react", () => ({
Brain: () => null,
X: () => null,
}));
// Mock applyPresetToSelection
vi.mock("../../utils/modelPresets", () => ({
applyPresetToSelection: vi.fn((preset: ModelPreset | undefined) => {
if (!preset) return { executorValue: "", validatorValue: "" };
return {
executorValue: preset.executorProvider && preset.executorModelId
? `${preset.executorProvider}/${preset.executorModelId}`
: "",
validatorValue: preset.validatorProvider && preset.validatorModelId
? `${preset.validatorProvider}/${preset.validatorModelId}`
: "",
};
}),
}));
// Mock CustomModelDropdown
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({
id,
label,
value,
onChange,
models,
placeholder,
thinkingLevel,
onThinkingLevelChange,
defaultThinkingLevel,
}: {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
models: ModelInfo[];
placeholder: string;
thinkingLevel?: string;
onThinkingLevelChange?: (value: string) => void;
defaultThinkingLevel?: string;
}) => (
<div data-testid={`mock-dropdown-${id}`}>
<span data-testid={`dropdown-label-${id}`}>{label}</span>
<span data-testid={`dropdown-value-${id}`}>{value || "empty"}</span>
{onThinkingLevelChange ? (
<>
<span data-testid="custom-model-dropdown-thinking-badge" className={`model-badge ${thinkingLevel ? "model-badge-custom" : "model-badge-default"}`}>
{thinkingLevel || "Default"}
</span>
<select
data-testid="custom-model-dropdown-thinking"
value={thinkingLevel || ""}
onChange={(e) => onThinkingLevelChange(e.target.value)}
>
<option value="">Default ({defaultThinkingLevel ?? "off"})</option>
<option value="off">Off</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">Very High</option>
<option value="max">Max</option>
</select>
</>
) : null}
<select
data-testid={`dropdown-select-${id}`}
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{placeholder}</option>
{models.map((m) => (
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
{m.name}
</option>
))}
</select>
</div>
),
}));
function renderModelSelectionModal(props = {}) {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
models: MOCK_MODELS,
executorValue: "",
validatorValue: "",
onExecutorChange: vi.fn(),
onValidatorChange: vi.fn(),
modelsLoading: false,
modelsError: null,
onRetry: vi.fn(),
};
return render(<ModelSelectionModal {...defaultProps} {...props} />);
}
describe("ModelSelectionModal", () => {
it("renders null when isOpen is false", () => {
renderModelSelectionModal({ isOpen: false });
expect(screen.queryByTestId("model-selection-modal")).toBeNull();
});
it("renders when isOpen is true", () => {
renderModelSelectionModal({ isOpen: true });
expect(screen.getByTestId("model-selection-modal")).toBeTruthy();
});
it("shows loading state when modelsLoading is true", () => {
renderModelSelectionModal({ modelsLoading: true });
expect(screen.getByText("Loading models…")).toBeTruthy();
});
it("shows error state with retry button when modelsError is set", () => {
const onRetry = vi.fn();
renderModelSelectionModal({ modelsError: "Failed to fetch", onRetry });
expect(screen.getByText("Failed to fetch")).toBeTruthy();
const retryButton = screen.getByTestId("model-selection-retry");
expect(retryButton).toBeTruthy();
fireEvent.click(retryButton);
expect(onRetry).toHaveBeenCalled();
});
it("shows empty state when no models available", () => {
renderModelSelectionModal({ models: [] });
expect(
screen.getByText(/No models available. Configure authentication in Settings/),
).toBeTruthy();
});
it("renders CustomModelDropdown for executor and validator", () => {
renderModelSelectionModal();
expect(screen.getByTestId("mock-dropdown-model-selection-executor")).toBeTruthy();
expect(screen.getByTestId("mock-dropdown-model-selection-validator")).toBeTruthy();
});
it("renders merger and per-lane thinking controls through the shared dropdown", () => {
const onMergerChange = vi.fn();
const onMergerThinkingLevelChange = vi.fn();
renderModelSelectionModal({
onMergerChange,
mergerValue: "anthropic/claude-sonnet-4-5",
mergerThinkingLevel: "high",
onMergerThinkingLevelChange,
onValidatorThinkingLevelChange: vi.fn(),
onPlanningChange: vi.fn(),
onPlanningThinkingLevelChange: vi.fn(),
});
expect(screen.getByTestId("mock-dropdown-model-selection-merger")).toBeTruthy();
expect(screen.getByTestId("dropdown-value-model-selection-merger")).toHaveTextContent("anthropic/claude-sonnet-4-5");
fireEvent.change(screen.getByTestId("dropdown-select-model-selection-merger"), { target: { value: "openai/gpt-4o" } });
expect(onMergerChange).toHaveBeenCalledWith("openai/gpt-4o");
expect(screen.getAllByTestId("custom-model-dropdown-thinking")).toHaveLength(3);
});
it("calls onClose when clicking close button", () => {
const onClose = vi.fn();
renderModelSelectionModal({ onClose });
const closeButton = screen.getByTestId("model-selection-close");
fireEvent.click(closeButton);
expect(onClose).toHaveBeenCalled();
});
it("calls onClose when clicking overlay", () => {
const onClose = vi.fn();
renderModelSelectionModal({ onClose });
const overlay = screen.getByTestId("model-selection-modal");
fireEvent.mouseDown(overlay);
fireEvent.mouseUp(overlay);
expect(onClose).toHaveBeenCalled();
});
it("calls onClose when pressing Escape key", async () => {
const onClose = vi.fn();
renderModelSelectionModal({ onClose });
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it("calls onExecutorChange when executor selection changes", () => {
const onExecutorChange = vi.fn();
renderModelSelectionModal({ onExecutorChange });
const executorSelect = screen.getByTestId("dropdown-select-model-selection-executor");
fireEvent.change(executorSelect, { target: { value: "anthropic/claude-sonnet-4-5" } });
expect(onExecutorChange).toHaveBeenCalledWith("anthropic/claude-sonnet-4-5");
});
it("calls onValidatorChange when validator selection changes", () => {
const onValidatorChange = vi.fn();
renderModelSelectionModal({ onValidatorChange });
const validatorSelect = screen.getByTestId("dropdown-select-model-selection-validator");
fireEvent.change(validatorSelect, { target: { value: "openai/gpt-4o" } });
expect(onValidatorChange).toHaveBeenCalledWith("openai/gpt-4o");
});
it("displays executor badge with selected model", () => {
renderModelSelectionModal({
executorValue: "anthropic/claude-sonnet-4-5",
});
const executorBadge = screen.getByTestId("executor-badge");
expect(executorBadge.textContent).toBe("anthropic/claude-sonnet-4-5");
expect(executorBadge.classList.contains("model-badge-custom")).toBe(true);
});
it("displays executor badge with 'Using default' when no selection", () => {
renderModelSelectionModal({ executorValue: "" });
const executorBadge = screen.getByTestId("executor-badge");
expect(executorBadge.textContent).toBe("Using default");
expect(executorBadge.classList.contains("model-badge-default")).toBe(true);
});
it("displays validator badge with selected model", () => {
renderModelSelectionModal({
validatorValue: "openai/gpt-4o",
});
const validatorBadge = screen.getByTestId("validator-badge");
expect(validatorBadge.textContent).toBe("openai/gpt-4o");
expect(validatorBadge.classList.contains("model-badge-custom")).toBe(true);
});
it("displays validator badge with 'Using default' when no selection", () => {
renderModelSelectionModal({ validatorValue: "" });
const validatorBadge = screen.getByTestId("validator-badge");
expect(validatorBadge.textContent).toBe("Using default");
expect(validatorBadge.classList.contains("model-badge-default")).toBe(true);
});
it("calls onClose when clicking Done button", () => {
const onClose = vi.fn();
renderModelSelectionModal({ onClose });
const doneButton = screen.getByTestId("model-selection-done");
fireEvent.click(doneButton);
expect(onClose).toHaveBeenCalled();
});
it("passes correct props to executor dropdown", () => {
renderModelSelectionModal({
executorValue: "openai/gpt-4o",
});
expect(screen.getByTestId("dropdown-value-model-selection-executor").textContent).toBe(
"openai/gpt-4o",
);
expect(screen.getByTestId("dropdown-label-model-selection-executor").textContent).toBe(
"Executor Model",
);
});
it("passes correct props to validator dropdown", () => {
renderModelSelectionModal({
validatorValue: "anthropic/claude-sonnet-4-5",
});
expect(screen.getByTestId("dropdown-value-model-selection-validator").textContent).toBe(
"anthropic/claude-sonnet-4-5",
);
expect(screen.getByTestId("dropdown-label-model-selection-validator").textContent).toBe(
"Reviewer Model",
);
});
describe("Thinking level selection", () => {
it("does not render the thinking-level selector when onThinkingLevelChange is omitted", () => {
renderModelSelectionModal();
expect(screen.queryByTestId("custom-model-dropdown-thinking")).toBeNull();
expect(screen.queryByTestId("custom-model-dropdown-thinking-badge")).toBeNull();
});
it("renders the thinking-level selector with all six levels plus Default when onThinkingLevelChange is provided", () => {
renderModelSelectionModal({ onThinkingLevelChange: vi.fn() });
const select = screen.getByTestId("custom-model-dropdown-thinking") as HTMLSelectElement;
expect(select).toBeTruthy();
const options = Array.from(select.options).map((o) => o.value);
expect(options).toEqual(["", "off", "minimal", "low", "medium", "high", "xhigh", "max"]);
});
it("still omits the thinking-level selector while models are empty, even when onThinkingLevelChange is provided", () => {
renderModelSelectionModal({ models: [], onThinkingLevelChange: vi.fn() });
expect(screen.queryByTestId("custom-model-dropdown-thinking")).toBeNull();
});
it("still omits the thinking-level selector while models are loading", () => {
renderModelSelectionModal({ modelsLoading: true, onThinkingLevelChange: vi.fn() });
expect(screen.queryByTestId("custom-model-dropdown-thinking")).toBeNull();
});
it("still omits the thinking-level selector when models fail to load", () => {
renderModelSelectionModal({ modelsError: "Failed to fetch", onThinkingLevelChange: vi.fn() });
expect(screen.queryByTestId("custom-model-dropdown-thinking")).toBeNull();
});
it("calls onThinkingLevelChange with the selected level", () => {
const onThinkingLevelChange = vi.fn();
renderModelSelectionModal({ onThinkingLevelChange });
const select = screen.getByTestId("custom-model-dropdown-thinking");
fireEvent.change(select, { target: { value: "high" } });
expect(onThinkingLevelChange).toHaveBeenCalledWith("high");
});
it("calls onThinkingLevelChange with '' when selecting the Default option", () => {
const onThinkingLevelChange = vi.fn();
renderModelSelectionModal({ onThinkingLevelChange, thinkingLevel: "high" });
const select = screen.getByTestId("custom-model-dropdown-thinking");
fireEvent.change(select, { target: { value: "" } });
expect(onThinkingLevelChange).toHaveBeenCalledWith("");
});
it("displays the thinking badge as 'Using default' when thinkingLevel is empty", () => {
renderModelSelectionModal({ onThinkingLevelChange: vi.fn(), thinkingLevel: "" });
const badge = screen.getByTestId("custom-model-dropdown-thinking-badge");
expect(badge.textContent).toBe("Default");
expect(badge.classList.contains("model-badge-default")).toBe(true);
});
it("displays the thinking badge with the selected level when overridden", () => {
renderModelSelectionModal({ onThinkingLevelChange: vi.fn(), thinkingLevel: "xhigh" });
const badge = screen.getByTestId("custom-model-dropdown-thinking-badge");
expect(badge.textContent).toBe("xhigh");
expect(badge.classList.contains("model-badge-custom")).toBe(true);
});
});
describe("Preset selection", () => {
it("does not show preset selector when presets prop is omitted", () => {
renderModelSelectionModal();
expect(screen.queryByTestId("model-selection-preset")).toBeNull();
expect(screen.queryByTestId("preset-badge")).toBeNull();
});
it("does not show preset selector when presets array is empty", () => {
renderModelSelectionModal({
presets: [],
onPresetChange: vi.fn(),
});
expect(screen.queryByTestId("model-selection-preset")).toBeNull();
});
it("does not show preset selector when onPresetChange is not provided", () => {
renderModelSelectionModal({
presets: MOCK_PRESETS,
});
expect(screen.queryByTestId("model-selection-preset")).toBeNull();
});
it("shows preset selector when presets and onPresetChange are provided", () => {
renderModelSelectionModal({
presets: MOCK_PRESETS,
selectedPresetId: undefined,
onPresetChange: vi.fn(),
});
expect(screen.getByTestId("model-selection-preset")).toBeTruthy();
expect(screen.getByTestId("preset-badge")).toBeTruthy();
});
it("renders preset options in the select dropdown", () => {
renderModelSelectionModal({
presets: MOCK_PRESETS,
onPresetChange: vi.fn(),
});
const select = screen.getByTestId("model-selection-preset") as HTMLSelectElement;
expect(select).toBeTruthy();
// default, separator (disabled), fast, thorough, custom
const options = Array.from(select.options);
expect(options.map((o) => o.value)).toEqual(["default", "──────────", "fast", "thorough", "custom"]);
});
it("shows 'Use default' badge when no preset is selected", () => {
renderModelSelectionModal({
presets: MOCK_PRESETS,
onPresetChange: vi.fn(),
});
const badge = screen.getByTestId("preset-badge");
expect(badge.textContent).toBe("Use default");
expect(badge.classList.contains("model-badge-default")).toBe(true);
});
it("shows preset name badge when a preset is selected", () => {
renderModelSelectionModal({
presets: MOCK_PRESETS,
selectedPresetId: "fast",
onPresetChange: vi.fn(),
});
const badge = screen.getByTestId("preset-badge");
expect(badge.textContent).toBe("Fast");
expect(badge.classList.contains("model-badge-custom")).toBe(true);
});
it("calls onPresetChange with preset id and applies executor/validator when selecting a preset", () => {
const onPresetChange = vi.fn();
const onExecutorChange = vi.fn();
const onValidatorChange = vi.fn();
renderModelSelectionModal({
presets: MOCK_PRESETS,
onPresetChange,
onExecutorChange,
onValidatorChange,
});
const select = screen.getByTestId("model-selection-preset");
fireEvent.change(select, { target: { value: "thorough" } });
expect(onPresetChange).toHaveBeenCalledWith("thorough");
expect(onExecutorChange).toHaveBeenCalledWith("openai/gpt-4o");
expect(onValidatorChange).toHaveBeenCalledWith("anthropic/claude-sonnet-4-5");
});
it("clears executor/validator when selecting 'Use default'", () => {
const onPresetChange = vi.fn();
const onExecutorChange = vi.fn();
const onValidatorChange = vi.fn();
renderModelSelectionModal({
presets: MOCK_PRESETS,
selectedPresetId: "fast",
onPresetChange,
onExecutorChange,
onValidatorChange,
});
const select = screen.getByTestId("model-selection-preset");
fireEvent.change(select, { target: { value: "default" } });
expect(onPresetChange).toHaveBeenCalledWith(undefined);
expect(onExecutorChange).toHaveBeenCalledWith("");
expect(onValidatorChange).toHaveBeenCalledWith("");
});
it("clears preset mode without changing models when selecting 'Custom'", () => {
const onPresetChange = vi.fn();
const onExecutorChange = vi.fn();
const onValidatorChange = vi.fn();
renderModelSelectionModal({
presets: MOCK_PRESETS,
selectedPresetId: "fast",
executorValue: "anthropic/claude-sonnet-4-5",
onPresetChange,
onExecutorChange,
onValidatorChange,
});
const select = screen.getByTestId("model-selection-preset");
fireEvent.change(select, { target: { value: "custom" } });
expect(onPresetChange).toHaveBeenCalledWith(undefined);
// Should NOT change executor/validator when switching to custom
expect(onExecutorChange).not.toHaveBeenCalled();
expect(onValidatorChange).not.toHaveBeenCalled();
});
it("clears preset mode when executor is manually changed", () => {
const onPresetChange = vi.fn();
renderModelSelectionModal({
presets: MOCK_PRESETS,
selectedPresetId: "fast",
onPresetChange,
});
const executorSelect = screen.getByTestId("dropdown-select-model-selection-executor");
fireEvent.change(executorSelect, { target: { value: "openai/gpt-4o" } });
expect(onPresetChange).toHaveBeenCalledWith(undefined);
});
it("clears preset mode when validator is manually changed", () => {
const onPresetChange = vi.fn();
renderModelSelectionModal({
presets: MOCK_PRESETS,
selectedPresetId: "fast",
onPresetChange,
});
const validatorSelect = screen.getByTestId("dropdown-select-model-selection-validator");
fireEvent.change(validatorSelect, { target: { value: "openai/gpt-4o" } });
expect(onPresetChange).toHaveBeenCalledWith(undefined);
});
it("does not call onPresetChange when manually changing executor with no preset selected", () => {
const onPresetChange = vi.fn();
renderModelSelectionModal({
presets: MOCK_PRESETS,
selectedPresetId: undefined,
onPresetChange,
});
const executorSelect = screen.getByTestId("dropdown-select-model-selection-executor");
fireEvent.change(executorSelect, { target: { value: "openai/gpt-4o" } });
expect(onPresetChange).not.toHaveBeenCalled();
});
});
});