fix(KB-098): auto-save task model overrides
- Auto-save executor and validator model selections immediately from the model selector tab - Send explicit null override fields when users switch a task back to default models - Keep saved badges stable while requests are pending and restore the previous selection on save failures - Expand ModelSelectorTab tests to cover auto-save requests, status feedback, success toasts, and clear-to-default flows
This commit is contained in:
@@ -60,7 +60,7 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; modelProvider?: string; modelId?: string; validatorModelProvider?: string; validatorModelId?: string }): Promise<Task> {
|
||||
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null }): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { fetchModels, updateTask } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import type { Task, TaskDetail } from "@kb/core";
|
||||
@@ -11,19 +11,73 @@ interface ModelSelectorTabProps {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
interface ModelSelection {
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
function normalizeModelField(value: string | null | undefined): string | undefined {
|
||||
return value ?? undefined;
|
||||
}
|
||||
|
||||
function getExecutorSelection(task: Task | TaskDetail): ModelSelection {
|
||||
return {
|
||||
provider: normalizeModelField(task.modelProvider),
|
||||
modelId: normalizeModelField(task.modelId),
|
||||
};
|
||||
}
|
||||
|
||||
function getValidatorSelection(task: Task | TaskDetail): ModelSelection {
|
||||
return {
|
||||
provider: normalizeModelField(task.validatorModelProvider),
|
||||
modelId: normalizeModelField(task.validatorModelId),
|
||||
};
|
||||
}
|
||||
|
||||
function parseModelValue(value: string): ModelSelection {
|
||||
if (!value) {
|
||||
return { provider: undefined, modelId: undefined };
|
||||
}
|
||||
|
||||
const slashIdx = value.indexOf("/");
|
||||
return {
|
||||
provider: value.slice(0, slashIdx),
|
||||
modelId: value.slice(slashIdx + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function getDropdownValue(selection: ModelSelection): string {
|
||||
return selection.provider && selection.modelId
|
||||
? `${selection.provider}/${selection.modelId}`
|
||||
: "";
|
||||
}
|
||||
|
||||
function selectionsEqual(a: ModelSelection, b: ModelSelection): boolean {
|
||||
return a.provider === b.provider && a.modelId === b.modelId;
|
||||
}
|
||||
|
||||
function getSuccessToastMessage(target: "executor" | "validator", selection: ModelSelection): string {
|
||||
const label = target === "executor" ? "Executor" : "Validator";
|
||||
|
||||
if (!selection.provider || !selection.modelId) {
|
||||
return `${label} model set to default`;
|
||||
}
|
||||
|
||||
return `${label} model set to ${selection.provider}/${selection.modelId}`;
|
||||
}
|
||||
|
||||
export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
|
||||
// Local state for selections (not saved until user clicks Save)
|
||||
const [executorProvider, setExecutorProvider] = useState<string | undefined>(task.modelProvider);
|
||||
const [executorModelId, setExecutorModelId] = useState<string | undefined>(task.modelId);
|
||||
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(task.validatorModelProvider);
|
||||
const [validatorModelId, setValidatorModelId] = useState<string | undefined>(task.validatorModelId);
|
||||
const [selectedExecutor, setSelectedExecutor] = useState<ModelSelection>(() => getExecutorSelection(task));
|
||||
const [savedExecutor, setSavedExecutor] = useState<ModelSelection>(() => getExecutorSelection(task));
|
||||
const [selectedValidator, setSelectedValidator] = useState<ModelSelection>(() => getValidatorSelection(task));
|
||||
const [savedValidator, setSavedValidator] = useState<ModelSelection>(() => getValidatorSelection(task));
|
||||
const [savingTarget, setSavingTarget] = useState<"executor" | "validator" | null>(null);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const activeTaskIdRef = useRef(task.id);
|
||||
|
||||
// Load available models on mount
|
||||
useEffect(() => {
|
||||
@@ -41,75 +95,117 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Track if selections differ from task's saved values
|
||||
useEffect(() => {
|
||||
const executorChanged =
|
||||
executorProvider !== task.modelProvider ||
|
||||
executorModelId !== task.modelId;
|
||||
const validatorChanged =
|
||||
validatorProvider !== task.validatorModelProvider ||
|
||||
validatorModelId !== task.validatorModelId;
|
||||
setHasChanges(executorChanged || validatorChanged);
|
||||
}, [executorProvider, executorModelId, validatorProvider, validatorModelId, task]);
|
||||
activeTaskIdRef.current = task.id;
|
||||
|
||||
// Build combobox values (provider/id combination or empty for default)
|
||||
const executorValue = executorProvider && executorModelId
|
||||
? `${executorProvider}/${executorModelId}`
|
||||
: "";
|
||||
const validatorValue = validatorProvider && validatorModelId
|
||||
? `${validatorProvider}/${validatorModelId}`
|
||||
: "";
|
||||
const nextExecutor = getExecutorSelection(task);
|
||||
const nextValidator = getValidatorSelection(task);
|
||||
|
||||
const handleExecutorChange = useCallback((value: string) => {
|
||||
if (!value) {
|
||||
setExecutorProvider(undefined);
|
||||
setExecutorModelId(undefined);
|
||||
} else {
|
||||
const slashIdx = value.indexOf("/");
|
||||
setExecutorProvider(value.slice(0, slashIdx));
|
||||
setExecutorModelId(value.slice(slashIdx + 1));
|
||||
}
|
||||
}, []);
|
||||
setSelectedExecutor(nextExecutor);
|
||||
setSavedExecutor(nextExecutor);
|
||||
setSelectedValidator(nextValidator);
|
||||
setSavedValidator(nextValidator);
|
||||
setSavingTarget(null);
|
||||
}, [task.id, task.modelProvider, task.modelId, task.validatorModelProvider, task.validatorModelId]);
|
||||
|
||||
const handleValidatorChange = useCallback((value: string) => {
|
||||
if (!value) {
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
} else {
|
||||
const slashIdx = value.indexOf("/");
|
||||
setValidatorProvider(value.slice(0, slashIdx));
|
||||
setValidatorModelId(value.slice(slashIdx + 1));
|
||||
}
|
||||
}, []);
|
||||
const executorValue = useMemo(() => getDropdownValue(selectedExecutor), [selectedExecutor]);
|
||||
const validatorValue = useMemo(() => getDropdownValue(selectedValidator), [selectedValidator]);
|
||||
const isSaving = savingTarget !== null;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateTask(task.id, {
|
||||
modelProvider: executorProvider,
|
||||
modelId: executorModelId,
|
||||
validatorModelProvider: validatorProvider,
|
||||
validatorModelId: validatorModelId,
|
||||
});
|
||||
addToast("Model settings saved", "success");
|
||||
setHasChanges(false);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to save model settings", "error");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [task.id, executorProvider, executorModelId, validatorProvider, validatorModelId, addToast]);
|
||||
const saveSelection = useCallback(
|
||||
async (target: "executor" | "validator", nextSelection: ModelSelection) => {
|
||||
const requestTaskId = task.id;
|
||||
const previousSavedExecutor = savedExecutor;
|
||||
const previousSavedValidator = savedValidator;
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setExecutorProvider(task.modelProvider);
|
||||
setExecutorModelId(task.modelId);
|
||||
setValidatorProvider(task.validatorModelProvider);
|
||||
setValidatorModelId(task.validatorModelId);
|
||||
}, [task]);
|
||||
setSavingTarget(target);
|
||||
|
||||
// Check if using defaults (both provider and modelId are undefined)
|
||||
const executorUsingDefault = !task.modelProvider && !task.modelId;
|
||||
const validatorUsingDefault = !task.validatorModelProvider && !task.validatorModelId;
|
||||
try {
|
||||
const updatedTask = await updateTask(requestTaskId, {
|
||||
modelProvider: target === "executor"
|
||||
? nextSelection.provider ?? null
|
||||
: previousSavedExecutor.provider ?? null,
|
||||
modelId: target === "executor"
|
||||
? nextSelection.modelId ?? null
|
||||
: previousSavedExecutor.modelId ?? null,
|
||||
validatorModelProvider: target === "validator"
|
||||
? nextSelection.provider ?? null
|
||||
: previousSavedValidator.provider ?? null,
|
||||
validatorModelId: target === "validator"
|
||||
? nextSelection.modelId ?? null
|
||||
: previousSavedValidator.modelId ?? null,
|
||||
});
|
||||
|
||||
if (activeTaskIdRef.current !== requestTaskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSavedExecutor = getExecutorSelection(updatedTask);
|
||||
const nextSavedValidator = getValidatorSelection(updatedTask);
|
||||
|
||||
setSavedExecutor(nextSavedExecutor);
|
||||
setSelectedExecutor(nextSavedExecutor);
|
||||
setSavedValidator(nextSavedValidator);
|
||||
setSelectedValidator(nextSavedValidator);
|
||||
|
||||
addToast(
|
||||
getSuccessToastMessage(
|
||||
target,
|
||||
target === "executor" ? nextSavedExecutor : nextSavedValidator,
|
||||
),
|
||||
"success",
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (activeTaskIdRef.current !== requestTaskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target === "executor") {
|
||||
setSelectedExecutor(previousSavedExecutor);
|
||||
} else {
|
||||
setSelectedValidator(previousSavedValidator);
|
||||
}
|
||||
|
||||
addToast(err.message || "Failed to save model settings", "error");
|
||||
} finally {
|
||||
if (activeTaskIdRef.current === requestTaskId) {
|
||||
setSavingTarget(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[task.id, savedExecutor, savedValidator, addToast],
|
||||
);
|
||||
|
||||
const handleExecutorChange = useCallback(
|
||||
(value: string) => {
|
||||
const nextSelection = parseModelValue(value);
|
||||
setSelectedExecutor(nextSelection);
|
||||
|
||||
if (selectionsEqual(nextSelection, savedExecutor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void saveSelection("executor", nextSelection);
|
||||
},
|
||||
[savedExecutor, saveSelection],
|
||||
);
|
||||
|
||||
const handleValidatorChange = useCallback(
|
||||
(value: string) => {
|
||||
const nextSelection = parseModelValue(value);
|
||||
setSelectedValidator(nextSelection);
|
||||
|
||||
if (selectionsEqual(nextSelection, savedValidator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void saveSelection("validator", nextSelection);
|
||||
},
|
||||
[savedValidator, saveSelection],
|
||||
);
|
||||
|
||||
const executorUsingDefault = !savedExecutor.provider && !savedExecutor.modelId;
|
||||
const validatorUsingDefault = !savedValidator.provider && !savedValidator.modelId;
|
||||
|
||||
return (
|
||||
<div className="model-selector-tab">
|
||||
@@ -144,7 +240,6 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Executor Model Selector */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="executorModel">Executor Model</label>
|
||||
<div className="model-selector-current">
|
||||
@@ -152,8 +247,8 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
<span className="model-badge model-badge-default">Using default</span>
|
||||
) : (
|
||||
<span className="model-badge model-badge-custom">
|
||||
{task.modelProvider && <ProviderIcon provider={task.modelProvider} size="sm" />}
|
||||
{task.modelProvider}/{task.modelId}
|
||||
{savedExecutor.provider && <ProviderIcon provider={savedExecutor.provider} size="sm" />}
|
||||
{savedExecutor.provider}/{savedExecutor.modelId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -169,7 +264,6 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
<small>The AI model used to implement this task.</small>
|
||||
</div>
|
||||
|
||||
{/* Validator Model Selector */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="validatorModel">Validator Model</label>
|
||||
<div className="model-selector-current">
|
||||
@@ -177,8 +271,8 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
<span className="model-badge model-badge-default">Using default</span>
|
||||
) : (
|
||||
<span className="model-badge model-badge-custom">
|
||||
{task.validatorModelProvider && <ProviderIcon provider={task.validatorModelProvider} size="sm" />}
|
||||
{task.validatorModelProvider}/{task.validatorModelId}
|
||||
{savedValidator.provider && <ProviderIcon provider={savedValidator.provider} size="sm" />}
|
||||
{savedValidator.provider}/{savedValidator.modelId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -194,31 +288,11 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
<small>The AI model used to review code and plans for this task.</small>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="model-selector-actions">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || isSaving}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<div className="model-selector-status">
|
||||
{executorUsingDefault && validatorUsingDefault
|
||||
? "Using global default models."
|
||||
: "Model settings are up to date."}
|
||||
</div>
|
||||
|
||||
{!hasChanges && (
|
||||
<div className="model-selector-status">
|
||||
{executorUsingDefault && validatorUsingDefault
|
||||
? "Using global default models."
|
||||
: "Model settings are up to date."}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ModelSelectorTab } from "../ModelSelectorTab";
|
||||
import type { Task } from "@kb/core";
|
||||
import * as api from "../../api";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", async () => {
|
||||
const actual = await vi.importActual<typeof api>("../../api");
|
||||
return {
|
||||
@@ -15,7 +14,6 @@ vi.mock("../../api", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock ProviderIcon to avoid rendering actual icons
|
||||
vi.mock("../ProviderIcon", () => ({
|
||||
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
|
||||
}));
|
||||
@@ -44,39 +42,73 @@ const MOCK_MODELS = [
|
||||
describe("ModelSelectorTab", () => {
|
||||
const mockAddToast = vi.fn();
|
||||
|
||||
async function waitForSelectors() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
|
||||
function getSelector(label: string) {
|
||||
return screen.getByLabelText(label);
|
||||
}
|
||||
|
||||
function getSection(label: string): HTMLElement | null {
|
||||
const section = getSelector(label).closest(".form-group");
|
||||
return section instanceof HTMLElement ? section : null;
|
||||
}
|
||||
|
||||
async function openSelector(label: string) {
|
||||
const user = userEvent.setup();
|
||||
await user.click(getSelector(label));
|
||||
return user;
|
||||
}
|
||||
|
||||
async function selectOption(label: string, optionText: string) {
|
||||
const user = await openSelector(label);
|
||||
await user.click(screen.getByText(optionText));
|
||||
}
|
||||
|
||||
function getUseDefaultOption() {
|
||||
return screen.getAllByText("Use default").find(
|
||||
(element) => element.classList.contains("model-combobox-option-text--default"),
|
||||
) ?? screen.getAllByText("Use default")[0];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchModels.mockResolvedValue(MOCK_MODELS);
|
||||
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
|
||||
...FAKE_TASK,
|
||||
...updates,
|
||||
}));
|
||||
});
|
||||
|
||||
it("renders loading state initially", () => {
|
||||
mockFetchModels.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
expect(screen.getByText("Loading available models…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders model selectors after loading", async () => {
|
||||
it("renders model selectors after loading without save or reset buttons", async () => {
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
expect(screen.getByLabelText("Validator Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Save")).toBeInTheDocument();
|
||||
expect(screen.getByText("Reset")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Save")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Reset")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Using default' when no model overrides are set", async () => {
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
|
||||
const executorSection = getSection("Executor Model");
|
||||
expect(within(executorSection!).getByText("Using default")).toBeInTheDocument();
|
||||
|
||||
const validatorSection = screen.getByLabelText("Validator Model").closest(".form-group");
|
||||
const validatorSection = getSection("Validator Model");
|
||||
expect(within(validatorSection!).getByText("Using default")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -91,9 +123,7 @@ describe("ModelSelectorTab", () => {
|
||||
|
||||
render(<ModelSelectorTab task={taskWithModels} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
expect(screen.getByText("anthropic/claude-sonnet-4-5")).toBeInTheDocument();
|
||||
expect(screen.getByText("openai/gpt-4o")).toBeInTheDocument();
|
||||
@@ -110,11 +140,8 @@ describe("ModelSelectorTab", () => {
|
||||
|
||||
render(<ModelSelectorTab task={taskWithModels} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Verify provider icons are rendered in the component (both badges and dropdown trigger)
|
||||
const anthropicIcons = screen.getAllByTestId("provider-icon-anthropic");
|
||||
const openaiIcons = screen.getAllByTestId("provider-icon-openai");
|
||||
|
||||
@@ -125,11 +152,8 @@ describe("ModelSelectorTab", () => {
|
||||
it("does not display provider icon in badge when using default", async () => {
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// No provider icons should be rendered when using default
|
||||
expect(screen.queryByTestId(/provider-icon-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -137,15 +161,10 @@ describe("ModelSelectorTab", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Click the executor combobox trigger
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(getSelector("Executor Model"));
|
||||
|
||||
// Dropdown should be visible with models
|
||||
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
|
||||
expect(screen.getByText("3 models")).toBeInTheDocument();
|
||||
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
|
||||
@@ -154,83 +173,70 @@ describe("ModelSelectorTab", () => {
|
||||
});
|
||||
|
||||
it("groups models by provider in dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Open the combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await openSelector("Executor Model");
|
||||
|
||||
// Check provider headers are present
|
||||
expect(screen.getByText("anthropic")).toBeInTheDocument();
|
||||
expect(screen.getByText("openai")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays provider icons in dropdown group headers", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Open the combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await openSelector("Executor Model");
|
||||
|
||||
// Check provider icons are displayed in dropdown headers
|
||||
expect(screen.getByTestId("provider-icon-anthropic")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("provider-icon-openai")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables Save button when selections change", async () => {
|
||||
const user = userEvent.setup();
|
||||
it("auto-saves executor and validator changes immediately", async () => {
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitForSelectors();
|
||||
|
||||
await selectOption("Executor Model", "Claude Sonnet 4.5");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
expect(mockUpdateTask).toHaveBeenNthCalledWith(1, "KB-001", {
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
validatorModelProvider: null,
|
||||
validatorModelId: null,
|
||||
});
|
||||
});
|
||||
|
||||
const saveButton = screen.getByText("Save");
|
||||
expect(saveButton).toBeDisabled();
|
||||
await selectOption("Validator Model", "GPT-4o");
|
||||
|
||||
// Open combobox and select a model
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
// Click on a model option
|
||||
const modelOption = screen.getByText("Claude Sonnet 4.5");
|
||||
await user.click(modelOption);
|
||||
|
||||
expect(saveButton).toBeEnabled();
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenNthCalledWith(2, "KB-001", {
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("calls updateTask with correct model fields on save", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUpdateTask.mockResolvedValue({ ...FAKE_TASK });
|
||||
it("preserves the saved validator override when auto-saving an executor change", async () => {
|
||||
const taskWithValidator = {
|
||||
...FAKE_TASK,
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
};
|
||||
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
|
||||
...taskWithValidator,
|
||||
...updates,
|
||||
}));
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
render(<ModelSelectorTab task={taskWithValidator} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select executor model
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
|
||||
// Select validator model
|
||||
const validatorTrigger = screen.getByLabelText("Validator Model");
|
||||
await user.click(validatorTrigger);
|
||||
await user.click(screen.getByText("GPT-4o"));
|
||||
|
||||
// Click save
|
||||
await user.click(screen.getByText("Save"));
|
||||
await waitForSelectors();
|
||||
await selectOption("Executor Model", "Claude Sonnet 4.5");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
|
||||
@@ -240,70 +246,90 @@ describe("ModelSelectorTab", () => {
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Model settings saved", "success");
|
||||
});
|
||||
|
||||
it("calls updateTask with null to clear models on 'Use default' selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
it("calls updateTask with null fields to clear models on 'Use default' selection", async () => {
|
||||
const taskWithModels = {
|
||||
...FAKE_TASK,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
};
|
||||
mockUpdateTask.mockResolvedValue({ ...taskWithModels });
|
||||
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
|
||||
...taskWithModels,
|
||||
...updates,
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={taskWithModels} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
// Select "Use default"
|
||||
const defaultOption = screen.getAllByText("Use default").find(
|
||||
el => el.classList.contains("model-combobox-option-text--default")
|
||||
) || screen.getAllByText("Use default")[0];
|
||||
await user.click(defaultOption);
|
||||
|
||||
// Click save
|
||||
await user.click(screen.getByText("Save"));
|
||||
await user.click(getSelector("Executor Model"));
|
||||
await user.click(getUseDefaultOption());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
|
||||
modelProvider: undefined,
|
||||
modelId: undefined,
|
||||
validatorModelProvider: undefined,
|
||||
validatorModelId: undefined,
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
validatorModelProvider: null,
|
||||
validatorModelId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("resets selections to original values when Reset is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
it("preserves the saved executor override when auto-saving a validator change", async () => {
|
||||
const taskWithExecutor = {
|
||||
...FAKE_TASK,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
};
|
||||
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
|
||||
...taskWithExecutor,
|
||||
...updates,
|
||||
}));
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
render(<ModelSelectorTab task={taskWithExecutor} addToast={mockAddToast} />);
|
||||
|
||||
await waitForSelectors();
|
||||
await selectOption("Validator Model", "GPT-4o");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Open and select a model
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
it("clears the validator override with null fields when selecting 'Use default'", async () => {
|
||||
const taskWithValidator = {
|
||||
...FAKE_TASK,
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
};
|
||||
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
|
||||
...taskWithValidator,
|
||||
...updates,
|
||||
}));
|
||||
|
||||
// Verify Save is enabled
|
||||
expect(screen.getByText("Save")).toBeEnabled();
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={taskWithValidator} addToast={mockAddToast} />);
|
||||
|
||||
// Reset
|
||||
await user.click(screen.getByText("Reset"));
|
||||
await waitForSelectors();
|
||||
|
||||
// Save should be disabled again (no changes)
|
||||
expect(screen.getByText("Save")).toBeDisabled();
|
||||
await user.click(getSelector("Validator Model"));
|
||||
await user.click(getUseDefaultOption());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
validatorModelProvider: null,
|
||||
validatorModelId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state when fetchModels fails", async () => {
|
||||
@@ -328,71 +354,159 @@ describe("ModelSelectorTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("disables inputs while saving", async () => {
|
||||
it("disables both selectors while saving", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUpdateTask.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve({ ...FAKE_TASK }), 100)));
|
||||
let resolveUpdate: ((value: Task) => void) | undefined;
|
||||
mockUpdateTask.mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolveUpdate = resolve as (value: Task) => void;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Select a model
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(getSelector("Executor Model"));
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
|
||||
// Start save
|
||||
await user.click(screen.getByText("Save"));
|
||||
await waitFor(() => {
|
||||
expect(getSelector("Executor Model")).toBeDisabled();
|
||||
expect(getSelector("Validator Model")).toBeDisabled();
|
||||
});
|
||||
|
||||
// Should show saving state
|
||||
expect(screen.getByText("Saving…")).toBeInTheDocument();
|
||||
expect(executorTrigger).toBeDisabled();
|
||||
resolveUpdate?.({
|
||||
...FAKE_TASK,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelector("Executor Model")).not.toBeDisabled();
|
||||
expect(getSelector("Validator Model")).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when save fails", async () => {
|
||||
it("keeps the badge on the last saved value while an auto-save is pending", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveUpdate: ((value: Task) => void) | undefined;
|
||||
mockUpdateTask.mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolveUpdate = resolve as (value: Task) => void;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitForSelectors();
|
||||
|
||||
await user.click(getSelector("Executor Model"));
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelector("Executor Model")).toHaveTextContent("Claude Sonnet 4.5");
|
||||
});
|
||||
expect(within(getSection("Executor Model")!).getByText("Using default")).toBeInTheDocument();
|
||||
|
||||
resolveUpdate?.({
|
||||
...FAKE_TASK,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(getSection("Executor Model")!).getByText("anthropic/claude-sonnet-4-5")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast and reverts the dropdown when auto-save fails", async () => {
|
||||
mockUpdateTask.mockRejectedValue(new Error("Save failed"));
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select a model
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
|
||||
// Click save
|
||||
await user.click(screen.getByText("Save"));
|
||||
await waitForSelectors();
|
||||
await selectOption("Executor Model", "Claude Sonnet 4.5");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Save failed", "error");
|
||||
});
|
||||
|
||||
expect(getSelector("Executor Model")).toHaveTextContent("Use default");
|
||||
expect(within(getSection("Executor Model")!).getByText("Using default")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a specific executor success toast with the saved model name", async () => {
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitForSelectors();
|
||||
await selectOption("Executor Model", "Claude Sonnet 4.5");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
"Executor model set to anthropic/claude-sonnet-4-5",
|
||||
"success",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a specific validator success toast with the saved model name", async () => {
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitForSelectors();
|
||||
await selectOption("Validator Model", "GPT-4o");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Validator model set to openai/gpt-4o", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a 'set to default' toast when clearing a model override", async () => {
|
||||
const taskWithModel = {
|
||||
...FAKE_TASK,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
};
|
||||
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
|
||||
...taskWithModel,
|
||||
...updates,
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={taskWithModel} addToast={mockAddToast} />);
|
||||
|
||||
await waitForSelectors();
|
||||
|
||||
await user.click(getSelector("Executor Model"));
|
||||
await user.click(getUseDefaultOption());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Executor model set to default", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the saved badge after a successful save", async () => {
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitForSelectors();
|
||||
await selectOption("Executor Model", "Claude Sonnet 4.5");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(getSection("Executor Model")!).getByText("anthropic/claude-sonnet-4-5")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Combobox-specific tests
|
||||
describe("Combobox behavior", () => {
|
||||
it("filters models when typing in search input", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(getSelector("Executor Model"));
|
||||
|
||||
// Type filter text
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "openai");
|
||||
|
||||
// Should show filtered results
|
||||
expect(screen.getByText("1 model")).toBeInTheDocument();
|
||||
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Claude Sonnet 4.5")).not.toBeInTheDocument();
|
||||
@@ -403,19 +517,13 @@ describe("ModelSelectorTab", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(getSelector("Executor Model"));
|
||||
|
||||
// Type model ID
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "gpt-4o");
|
||||
|
||||
// Should show only GPT-4o
|
||||
expect(screen.getByText("1 model")).toBeInTheDocument();
|
||||
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Claude Sonnet 4.5")).not.toBeInTheDocument();
|
||||
@@ -425,19 +533,13 @@ describe("ModelSelectorTab", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(getSelector("Executor Model"));
|
||||
|
||||
// Type display name
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "opus");
|
||||
|
||||
// Should show only Opus
|
||||
expect(screen.getByText("1 model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Claude Sonnet 4.5")).not.toBeInTheDocument();
|
||||
@@ -447,19 +549,13 @@ describe("ModelSelectorTab", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(getSelector("Executor Model"));
|
||||
|
||||
// Type multi-word filter
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "anthropic claude");
|
||||
|
||||
// Should show only anthropic models
|
||||
expect(screen.getByText("2 models")).toBeInTheDocument();
|
||||
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
|
||||
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
|
||||
@@ -470,26 +566,18 @@ describe("ModelSelectorTab", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(getSelector("Executor Model"));
|
||||
|
||||
// Type a filter
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "openai");
|
||||
|
||||
// Verify filter is applied
|
||||
expect(screen.getByText("1 model")).toBeInTheDocument();
|
||||
|
||||
// Click clear button
|
||||
const clearButton = screen.getByLabelText("Clear filter");
|
||||
await user.click(clearButton);
|
||||
|
||||
// Filter should be cleared
|
||||
expect(searchInput).toHaveValue("");
|
||||
expect(screen.getByText("3 models")).toBeInTheDocument();
|
||||
});
|
||||
@@ -498,19 +586,13 @@ describe("ModelSelectorTab", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(getSelector("Executor Model"));
|
||||
|
||||
// Type a filter that matches nothing
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "xyz123");
|
||||
|
||||
// Should show no results message
|
||||
expect(screen.getByText("0 models")).toBeInTheDocument();
|
||||
expect(screen.getByText(/No models match/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -519,21 +601,13 @@ describe("ModelSelectorTab", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
// Dropdown should be visible
|
||||
await user.click(getSelector("Executor Model"));
|
||||
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
|
||||
|
||||
// Click outside (on the intro text)
|
||||
await user.click(screen.getByText(/Override the AI models/));
|
||||
|
||||
// Dropdown should be closed
|
||||
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -541,74 +615,58 @@ describe("ModelSelectorTab", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
// Dropdown should be visible
|
||||
await user.click(getSelector("Executor Model"));
|
||||
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
|
||||
|
||||
// Press Escape
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
// Dropdown should be closed
|
||||
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates with arrow keys and selects with Enter", async () => {
|
||||
it("navigates with arrow keys and auto-saves with Enter", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Focus and open combobox with arrow down
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
const executorTrigger = getSelector("Executor Model");
|
||||
executorTrigger.focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
|
||||
// Dropdown should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Navigate down and press Enter to select
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
// Dropdown should be closed and Save button should be enabled
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Save")).toBeEnabled();
|
||||
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
validatorModelProvider: null,
|
||||
validatorModelId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("Use default option is always visible", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(getSelector("Executor Model"));
|
||||
|
||||
// Use default should be visible
|
||||
const defaultOptions = screen.getAllByText("Use default");
|
||||
expect(defaultOptions.length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Use default").length).toBeGreaterThan(0);
|
||||
|
||||
// Type a filter that matches nothing
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "nonexistent123");
|
||||
|
||||
// Use default should still be visible
|
||||
expect(screen.getAllByText("Use default").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -616,52 +674,33 @@ describe("ModelSelectorTab", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(getSelector("Executor Model"));
|
||||
|
||||
// Model IDs should be visible next to names
|
||||
expect(screen.getByText("claude-sonnet-4-5")).toBeInTheDocument();
|
||||
expect(screen.getByText("claude-opus-4")).toBeInTheDocument();
|
||||
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting a model from filtered list works correctly", async () => {
|
||||
it("selecting a model from a filtered list auto-saves the correct value", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUpdateTask.mockResolvedValue({ ...FAKE_TASK });
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
await waitForSelectors();
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(getSelector("Executor Model"));
|
||||
|
||||
// Filter to show only openai
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "openai");
|
||||
await user.click(screen.getByText("GPT-4o"));
|
||||
|
||||
// Select the filtered model
|
||||
const modelOption = screen.getByText("GPT-4o");
|
||||
await user.click(modelOption);
|
||||
|
||||
// Save
|
||||
await user.click(screen.getByText("Save"));
|
||||
|
||||
// Verify correct model was saved
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
validatorModelProvider: undefined,
|
||||
validatorModelId: undefined,
|
||||
validatorModelProvider: null,
|
||||
validatorModelId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user