feat(KB-184): add model presets for task creation
- Add model preset types and settings fields with API validation - Create model preset utility helpers for size-based auto-selection - Add settings UI for managing model presets (create, edit, delete) - Integrate preset selection into NewTaskModal with custom override option - Add inline preset selection to InlineCreateCard component - Persist modelPresetId in task store and preserve through archive/unarchive - Add changeset and update AGENTS.md with feature documentation
This commit is contained in:
@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { InlineCreateCard } from "../InlineCreateCard";
|
||||
import type { Task, Column } from "@kb/core";
|
||||
import { fetchModels } from "../../api";
|
||||
import { fetchModels, fetchSettings } from "../../api";
|
||||
import type { ModelInfo } from "../../api";
|
||||
|
||||
// Mock lucide-react
|
||||
@@ -16,11 +16,17 @@ vi.mock("lucide-react", () => ({
|
||||
Terminal: () => null,
|
||||
Lightbulb: () => null,
|
||||
ListTree: () => null,
|
||||
Zap: () => null,
|
||||
}));
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn().mockResolvedValue([]),
|
||||
fetchSettings: vi.fn().mockResolvedValue({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
}),
|
||||
uploadAttachment: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -85,6 +91,11 @@ function chooseModel(label: "Executor Model" | "Validator Model", optionText: st
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchModels).mockResolvedValue(MOCK_MODELS);
|
||||
vi.mocked(fetchSettings).mockResolvedValue({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard blur-to-cancel", () => {
|
||||
@@ -266,6 +277,48 @@ describe("InlineCreateCard model selector", () => {
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onCancel when focus leaves while the preset dropdown is open", () => {
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
|
||||
textarea.focus();
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes selected preset id in the submit payload", async () => {
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with preset" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Budget" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: "Task with preset",
|
||||
modelPresetId: "budget",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT call onCancel after a model override is selected and focus leaves the card", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { NewTaskModal } from "../NewTaskModal";
|
||||
import type { Task, Column } from "@kb/core";
|
||||
import { fetchSettings } from "../../api";
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
@@ -10,7 +11,11 @@ vi.mock("../../api", () => ({
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
]),
|
||||
updateTask: vi.fn().mockResolvedValue({}),
|
||||
fetchSettings: vi.fn().mockResolvedValue({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeTask(id: string): Task {
|
||||
@@ -196,6 +201,67 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("creates task with selected preset id and resolved models", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
modelPresets: [
|
||||
{
|
||||
id: "budget",
|
||||
name: "Budget",
|
||||
executorProvider: "openai",
|
||||
executorModelId: "gpt-4o",
|
||||
validatorProvider: "anthropic",
|
||||
validatorModelId: "claude-sonnet-4-5",
|
||||
},
|
||||
],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
fireEvent.change(screen.getByLabelText(/Description/i), { target: { value: "Preset task" } });
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText("Preset")).toBeTruthy());
|
||||
fireEvent.change(screen.getByLabelText("Preset"), { target: { value: "budget" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: "Preset task",
|
||||
modelPresetId: "budget",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
validatorModelProvider: "anthropic",
|
||||
validatorModelId: "claude-sonnet-4-5",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("allows overriding a selected preset with custom models", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
modelPresets: [
|
||||
{
|
||||
id: "budget",
|
||||
name: "Budget",
|
||||
executorProvider: "openai",
|
||||
executorModelId: "gpt-4o",
|
||||
},
|
||||
],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
|
||||
renderNewTaskModal();
|
||||
await waitFor(() => expect(screen.getByLabelText("Preset")).toBeTruthy());
|
||||
fireEvent.change(screen.getByLabelText("Preset"), { target: { value: "budget" } });
|
||||
expect(screen.getByText("Using preset: Budget")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Override" }));
|
||||
expect(screen.queryByText("Using preset: Budget")).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Preset"), { target: { value: "custom" } });
|
||||
expect((screen.getByLabelText("Preset") as HTMLSelectElement).value).toBe("custom");
|
||||
});
|
||||
|
||||
it("toggles planning mode checkbox", () => {
|
||||
renderNewTaskModal();
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ const defaultSettings: Settings = {
|
||||
buildCommand: "",
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
taskStuckTimeoutMs: undefined,
|
||||
@@ -373,6 +376,55 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getAllByText("Model").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("supports creating and saving a model preset", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model Presets"));
|
||||
await user.click(screen.getByText("Add Preset"));
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Budget");
|
||||
expect((screen.getByLabelText("ID") as HTMLInputElement).value).toBe("budget");
|
||||
|
||||
await user.click(screen.getByText("Save preset"));
|
||||
await user.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.modelPresets).toEqual([
|
||||
expect.objectContaining({ id: "budget", name: "Budget" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports auto-select preset mappings by size", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
modelPresets: [
|
||||
{ id: "budget", name: "Budget" },
|
||||
{ id: "normal", name: "Normal" },
|
||||
{ id: "complex", name: "Complex" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model Presets"));
|
||||
await user.click(screen.getByLabelText("Auto-select preset based on task size"));
|
||||
fireEvent.change(screen.getByLabelText("Small tasks (S):"), { target: { value: "budget" } });
|
||||
fireEvent.change(screen.getByLabelText("Medium tasks (M):"), { target: { value: "normal" } });
|
||||
fireEvent.change(screen.getByLabelText("Large tasks (L):"), { target: { value: "complex" } });
|
||||
|
||||
await user.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.autoSelectModelPreset).toBe(true);
|
||||
expect(payload.defaultPresetBySize).toEqual({ S: "budget", M: "normal", L: "complex" });
|
||||
});
|
||||
|
||||
it("shows model selector with available models grouped by provider", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
Reference in New Issue
Block a user