feat(KB-025): add per-task model overrides for executor and validator

- Add model override fields to Task type and TaskStore (modelProvider, modelId, validatorModelProvider, validatorModelId)

- Extend PATCH /api/tasks/:id endpoint with validation for model fields

- Create ModelSelectorTab component with provider/model dropdowns and tests

- Integrate Model tab into TaskDetailModal for per-task model configuration

- Update executor to use per-task model overrides when both provider and modelId are set

- Update reviewer to use per-task validator model overrides in reviewStep

- Document per-task model selection feature in AGENTS.md settings section
This commit is contained in:
gsxdsm
2026-03-29 19:36:15 -07:00
parent 2c865d9914
commit a5393463d5
16 changed files with 1157 additions and 13 deletions

View File

@@ -120,3 +120,47 @@ To enable manual plan approval:
}
}
```
## Per-Task Model Overrides
The kb dashboard allows overriding the global AI model selection on a per-task basis. This enables using different models for different types of work without changing global settings.
### How It Works
Each task can optionally specify:
- **Executor Model**: The AI model used to implement the task (executor agent)
- **Validator Model**: The AI model used to review code and plans (reviewer agent)
When not specified, tasks use the global default settings (`defaultProvider`/`defaultModelId`).
### Setting Per-Task Models
In the dashboard, open any task's detail modal and click the **Model** tab. Select models from the dropdown:
- **Use default**: Uses the global default model (shown when no override is set)
- **Specific model**: Override with a chosen provider/model combination
Both provider and model ID must be selected together — selecting only one is treated as "not set" and falls back to defaults.
### Storage
Per-task model overrides are stored in the task's `task.json`:
```json
{
"modelProvider": "anthropic",
"modelId": "claude-sonnet-4-5",
"validatorModelProvider": "openai",
"validatorModelId": "gpt-4o"
}
```
To clear overrides, select "Use default" for both fields and save.
### Engine Behavior
- **Executor**: When both `modelProvider` and `modelId` are set on a task, the executor uses those instead of global settings when creating the agent session.
- **Reviewer**: When both `validatorModelProvider` and `validatorModelId` are set, the reviewer uses those instead of global settings. The validator model is passed via `ReviewOptions` to `reviewStep()`.
### Limitations
- Triage (task specification) always uses global defaults — per-task overrides apply only to execution and review
- Both provider and modelId must be set together; partial configuration falls back to defaults

View File

@@ -615,6 +615,64 @@ describe("TaskStore", () => {
});
});
describe("updateTask — model overrides", () => {
it("sets executor model provider and id via updateTask", async () => {
const task = await createTestTask();
const updated = await store.updateTask(task.id, { modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
expect(updated.modelProvider).toBe("anthropic");
expect(updated.modelId).toBe("claude-sonnet-4-5");
});
it("sets validator model provider and id via updateTask", async () => {
const task = await createTestTask();
const updated = await store.updateTask(task.id, { validatorModelProvider: "openai", validatorModelId: "gpt-4o" });
expect(updated.validatorModelProvider).toBe("openai");
expect(updated.validatorModelId).toBe("gpt-4o");
});
it("clears executor model fields via null", async () => {
const task = await createTestTask();
await store.updateTask(task.id, { modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
const updated = await store.updateTask(task.id, { modelProvider: null, modelId: null });
expect(updated.modelProvider).toBeUndefined();
expect(updated.modelId).toBeUndefined();
});
it("clears validator model fields via null", async () => {
const task = await createTestTask();
await store.updateTask(task.id, { validatorModelProvider: "openai", validatorModelId: "gpt-4o" });
const updated = await store.updateTask(task.id, { validatorModelProvider: null, validatorModelId: null });
expect(updated.validatorModelProvider).toBeUndefined();
expect(updated.validatorModelId).toBeUndefined();
});
it("sets only executor model without affecting validator model", async () => {
const task = await createTestTask();
await store.updateTask(task.id, { validatorModelProvider: "openai", validatorModelId: "gpt-4o" });
const updated = await store.updateTask(task.id, { modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
expect(updated.modelProvider).toBe("anthropic");
expect(updated.modelId).toBe("claude-sonnet-4-5");
expect(updated.validatorModelProvider).toBe("openai");
expect(updated.validatorModelId).toBe("gpt-4o");
});
it("preserves model fields when updating unrelated fields", async () => {
const task = await createTestTask();
await store.updateTask(task.id, {
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
});
const updated = await store.updateTask(task.id, { title: "Updated title" });
expect(updated.modelProvider).toBe("anthropic");
expect(updated.modelId).toBe("claude-sonnet-4-5");
expect(updated.validatorModelProvider).toBe("openai");
expect(updated.validatorModelId).toBe("gpt-4o");
expect(updated.title).toBe("Updated title");
});
});
describe("agent log persistence", () => {
it("appendAgentLog creates agent.log and getAgentLogs reads it back", async () => {
const task = await createTestTask();

View File

@@ -340,7 +340,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; size?: "S" | "M" | "L"; reviewLevel?: number },
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; size?: "S" | "M" | "L"; reviewLevel?: number; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null },
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
@@ -382,6 +382,26 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (updates.baseBranch !== undefined) task.baseBranch = updates.baseBranch;
if (updates.size !== undefined) task.size = updates.size;
if (updates.reviewLevel !== undefined) task.reviewLevel = updates.reviewLevel;
if (updates.modelProvider === null) {
task.modelProvider = undefined;
} else if (updates.modelProvider !== undefined) {
task.modelProvider = updates.modelProvider;
}
if (updates.modelId === null) {
task.modelId = undefined;
} else if (updates.modelId !== undefined) {
task.modelId = updates.modelId;
}
if (updates.validatorModelProvider === null) {
task.validatorModelProvider = undefined;
} else if (updates.validatorModelProvider !== undefined) {
task.validatorModelProvider = updates.validatorModelProvider;
}
if (updates.validatorModelId === null) {
task.validatorModelId = undefined;
} else if (updates.validatorModelId !== undefined) {
task.validatorModelId = updates.validatorModelId;
}
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);

View File

@@ -100,6 +100,22 @@ export interface Task {
log: TaskLogEntry[];
size?: "S" | "M" | "L";
reviewLevel?: number;
/** AI model provider override for the executor agent (e.g., "anthropic").
* Must be set together with `modelId`. When both model fields are undefined,
* the executor uses global settings defaults. */
modelProvider?: string;
/** AI model ID override for the executor agent (e.g., "claude-sonnet-4-5").
* Must be set together with `modelProvider`. When both model fields are undefined,
* the executor uses global settings defaults. */
modelId?: string;
/** AI model provider override for the validator/reviewer agent.
* Must be set together with `validatorModelId`. When both validator model fields
* are undefined, the reviewer uses global settings defaults. */
validatorModelProvider?: string;
/** AI model ID override for the validator/reviewer agent.
* Must be set together with `validatorModelProvider`. When both validator model
* fields are undefined, the reviewer uses global settings defaults. */
validatorModelId?: string;
/** Number of merge retry attempts made for this task (auto-merge conflict recovery) */
mergeRetries?: number;
/** ISO-8601 timestamp of when the task last entered its current column.

View File

@@ -37,7 +37,7 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
});
}
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[] }): 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> {
return api<Task>(`/tasks/${id}`, {
method: "PATCH",
body: JSON.stringify(updates),

View File

@@ -0,0 +1,244 @@
import { useState, useEffect, useCallback } from "react";
import { fetchModels, updateTask } from "../api";
import type { ModelInfo } from "../api";
import type { Task, TaskDetail } from "@kb/core";
import type { ToastType } from "../hooks/useToast";
interface ModelSelectorTabProps {
task: Task | TaskDetail;
addToast: (message: string, type?: ToastType) => void;
}
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 [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Load available models on mount
useEffect(() => {
setModelsLoading(true);
setModelsError(null);
fetchModels()
.then((models) => {
setAvailableModels(models);
})
.catch((err) => {
setModelsError(err.message || "Failed to load models");
})
.finally(() => {
setModelsLoading(false);
});
}, []);
// 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]);
// Group models by provider
const modelsByProvider = availableModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
(acc[m.provider] ??= []).push(m);
return acc;
}, {});
// Build select values (provider/id combination or empty for default)
const executorValue = executorProvider && executorModelId
? `${executorProvider}/${executorModelId}`
: "";
const validatorValue = validatorProvider && validatorModelId
? `${validatorProvider}/${validatorModelId}`
: "";
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));
}
}, []);
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 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 handleReset = useCallback(() => {
setExecutorProvider(task.modelProvider);
setExecutorModelId(task.modelId);
setValidatorProvider(task.validatorModelProvider);
setValidatorModelId(task.validatorModelId);
}, [task]);
// Check if using defaults (both provider and modelId are undefined)
const executorUsingDefault = !task.modelProvider && !task.modelId;
const validatorUsingDefault = !task.validatorModelProvider && !task.validatorModelId;
return (
<div className="model-selector-tab">
<h4>Model Configuration</h4>
<p className="model-selector-intro">
Override the AI models used for this task. When not specified, global default settings are used.
</p>
{modelsLoading ? (
<div className="model-selector-loading">Loading available models</div>
) : modelsError ? (
<div className="model-selector-error">
Error loading models: {modelsError}
<button
className="btn btn-sm"
onClick={() => {
setModelsLoading(true);
setModelsError(null);
fetchModels()
.then(setAvailableModels)
.catch((err) => setModelsError(err.message))
.finally(() => setModelsLoading(false));
}}
style={{ marginLeft: "8px" }}
>
Retry
</button>
</div>
) : availableModels.length === 0 ? (
<div className="model-selector-empty">
No models available. Configure authentication in Settings to enable model selection.
</div>
) : (
<>
{/* Executor Model Selector */}
<div className="form-group">
<label htmlFor="executorModel">Executor Model</label>
<div className="model-selector-current">
{executorUsingDefault ? (
<span className="model-badge model-badge-default">Using default</span>
) : (
<span className="model-badge model-badge-custom">
{task.modelProvider}/{task.modelId}
</span>
)}
</div>
<select
id="executorModel"
value={executorValue}
onChange={(e) => handleExecutorChange(e.target.value)}
disabled={isSaving}
>
<option value="">Use default</option>
{Object.entries(modelsByProvider).map(([provider, models]) => (
<optgroup key={provider} label={provider}>
{models.map((m) => (
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
{m.name}
</option>
))}
</optgroup>
))}
</select>
<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">
{validatorUsingDefault ? (
<span className="model-badge model-badge-default">Using default</span>
) : (
<span className="model-badge model-badge-custom">
{task.validatorModelProvider}/{task.validatorModelId}
</span>
)}
</div>
<select
id="validatorModel"
value={validatorValue}
onChange={(e) => handleValidatorChange(e.target.value)}
disabled={isSaving}
>
<option value="">Use default</option>
{Object.entries(modelsByProvider).map(([provider, models]) => (
<optgroup key={provider} label={provider}>
{models.map((m) => (
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
{m.name}
</option>
))}
</optgroup>
))}
</select>
<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>
{!hasChanges && (
<div className="model-selector-status">
{executorUsingDefault && validatorUsingDefault
? "Using global default models."
: "Model settings are up to date."}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import type { ToastType } from "../hooks/useToast";
import { useAgentLogs } from "../hooks/useAgentLogs";
import { AgentLogViewer } from "./AgentLogViewer";
import { SteeringTab } from "./SteeringTab";
import { ModelSelectorTab } from "./ModelSelectorTab";
import { PrSection } from "./PrSection";
import { SpecEditor } from "./SpecEditor";
@@ -75,7 +76,7 @@ export function TaskDetailModal({
addToast,
githubTokenConfigured,
}: TaskDetailModalProps) {
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "spec">("definition");
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "model" | "spec">("definition");
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
const [uploading, setUploading] = useState(false);
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
@@ -381,6 +382,12 @@ export function TaskDetailModal({
>
Steering
</button>
<button
className={`detail-tab${activeTab === "model" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("model")}
>
Model
</button>
<button
className={`detail-tab${activeTab === "spec" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("spec")}
@@ -398,6 +405,10 @@ export function TaskDetailModal({
isRequesting={isRequestingRevision}
/>
</div>
) : activeTab === "model" ? (
<div className="detail-section">
<ModelSelectorTab task={task} addToast={addToast} />
</div>
) : activeTab === "agent-log" ? (
<div className="detail-section">
<AgentLogViewer entries={agentLogEntries} loading={agentLogLoading} />

View File

@@ -0,0 +1,281 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
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 {
...actual,
fetchModels: vi.fn(),
updateTask: vi.fn(),
};
});
const mockFetchModels = api.fetchModels as ReturnType<typeof vi.fn>;
const mockUpdateTask = api.updateTask as ReturnType<typeof vi.fn>;
const FAKE_TASK: Task = {
id: "KB-001",
description: "Test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const MOCK_MODELS = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "anthropic", id: "claude-opus-4", name: "Claude Opus 4", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
];
describe("ModelSelectorTab", () => {
const mockAddToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockFetchModels.mockResolvedValue(MOCK_MODELS);
});
it("renders loading state initially", () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
expect(screen.getByText("Loading available models…")).toBeInTheDocument();
});
it("renders model selectors after loading", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
expect(screen.getByLabelText("Validator Model")).toBeInTheDocument();
expect(screen.getByText("Save")).toBeInTheDocument();
expect(screen.getByText("Reset")).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();
});
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
expect(within(executorSection!).getByText("Using default")).toBeInTheDocument();
const validatorSection = screen.getByLabelText("Validator Model").closest(".form-group");
expect(within(validatorSection!).getByText("Using default")).toBeInTheDocument();
});
it("shows current custom model when overrides are set", async () => {
const taskWithModels = {
...FAKE_TASK,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
};
render(<ModelSelectorTab task={taskWithModels} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
expect(screen.getByText("anthropic/claude-sonnet-4-5")).toBeInTheDocument();
expect(screen.getByText("openai/gpt-4o")).toBeInTheDocument();
});
it("groups models by provider in select options", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
expect(executorSelect).toBeInTheDocument();
// Check options exist with model names
expect(screen.getAllByText("Claude Sonnet 4.5").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Claude Opus 4").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("GPT-4o").length).toBeGreaterThanOrEqual(1);
});
it("enables Save button when selections change", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
const saveButton = screen.getByText("Save");
expect(saveButton).toBeDisabled();
// Select a model
const executorSelect = screen.getByLabelText("Executor Model");
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
expect(saveButton).toBeEnabled();
});
it("calls updateTask with correct model fields on save", async () => {
const user = userEvent.setup();
mockUpdateTask.mockResolvedValue({ ...FAKE_TASK });
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Select executor model
const executorSelect = screen.getByLabelText("Executor Model");
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
// Select validator model
const validatorSelect = screen.getByLabelText("Validator Model");
await user.selectOptions(validatorSelect, "openai/gpt-4o");
// Click save
await user.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
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();
const taskWithModels = {
...FAKE_TASK,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
};
mockUpdateTask.mockResolvedValue({ ...taskWithModels });
render(<ModelSelectorTab task={taskWithModels} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Select "Use default" (empty value)
const executorSelect = screen.getByLabelText("Executor Model");
await user.selectOptions(executorSelect, "");
// Click save
await user.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
modelProvider: undefined,
modelId: undefined,
validatorModelProvider: undefined,
validatorModelId: undefined,
});
});
});
it("resets selections to original values when Reset is clicked", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Change selection
const executorSelect = screen.getByLabelText("Executor Model");
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
// Reset
await user.click(screen.getByText("Reset"));
// Selection should be back to empty (Use default)
expect(executorSelect).toHaveValue("");
});
it("shows error state when fetchModels fails", async () => {
mockFetchModels.mockRejectedValue(new Error("Network error"));
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText(/Error loading models:/)).toBeInTheDocument();
});
expect(screen.getByText("Retry")).toBeInTheDocument();
});
it("shows empty state when no models available", async () => {
mockFetchModels.mockResolvedValue([]);
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText(/No models available/)).toBeInTheDocument();
});
});
it("disables inputs while saving", async () => {
const user = userEvent.setup();
mockUpdateTask.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve({ ...FAKE_TASK }), 100)));
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Select a model
const executorSelect = screen.getByLabelText("Executor Model");
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
// Start save
await user.click(screen.getByText("Save"));
// Should show saving state
expect(screen.getByText("Saving…")).toBeInTheDocument();
expect(executorSelect).toBeDisabled();
});
it("shows error toast when save fails", async () => {
const user = userEvent.setup();
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 executorSelect = screen.getByLabelText("Executor Model");
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
// Click save
await user.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("Save failed", "error");
});
});
});

View File

@@ -1147,7 +1147,7 @@ describe("TaskDetailModal", () => {
);
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(5); // Definition, Activity, Agent Log, Steering, Spec
expect(tabs.length).toBe(6); // Definition, Activity, Agent Log, Steering, Model, Spec
// Tabs should use class-based styling, not inline styles
expect(tabs[0].classList.contains("detail-tab")).toBe(true);
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true); // Definition is default active
@@ -1155,6 +1155,7 @@ describe("TaskDetailModal", () => {
expect(tabs[2].classList.contains("detail-tab-active")).toBe(false);
expect(tabs[3].classList.contains("detail-tab-active")).toBe(false);
expect(tabs[4].classList.contains("detail-tab-active")).toBe(false);
expect(tabs[5].classList.contains("detail-tab-active")).toBe(false);
// Verify no inline padding/fontSize (responsive CSS controls this)
expect((tabs[0] as HTMLElement).style.padding).toBe("");
expect((tabs[0] as HTMLElement).style.fontSize).toBe("");
@@ -1511,12 +1512,13 @@ describe("TaskDetailModal", () => {
);
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(5);
expect(tabs.length).toBe(6);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Activity");
expect(tabs[2].textContent).toBe("Agent Log");
expect(tabs[3].textContent).toBe("Steering");
expect(tabs[4].textContent).toBe("Spec");
expect(tabs[4].textContent).toBe("Model");
expect(tabs[5].textContent).toBe("Spec");
});
it("shows empty state in Spec tab when no prompt", () => {

View File

@@ -2946,3 +2946,73 @@ body {
}
/* === Model Selector Tab === */
.model-selector-tab {
padding: 16px 0;
}
.model-selector-tab h4 {
margin-bottom: 8px;
}
.model-selector-intro {
color: var(--text-muted);
font-size: 13px;
margin-bottom: 16px;
}
.model-selector-loading,
.model-selector-empty {
padding: 24px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
.model-selector-error {
padding: 16px;
background: rgba(248, 81, 73, 0.1);
color: var(--color-error);
border-radius: var(--radius);
font-size: 13px;
}
.model-selector-current {
margin-bottom: 8px;
}
.model-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.model-badge-default {
background: var(--text-dim);
color: var(--text);
}
.model-badge-custom {
background: var(--todo);
color: var(--bg);
}
.model-selector-actions {
display: flex;
gap: 8px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.model-selector-status {
margin-top: 12px;
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}

View File

@@ -289,6 +289,10 @@ describe("PATCH /tasks/:id", () => {
description: undefined,
prompt: undefined,
dependencies: ["KB-002"],
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
});
expect(res.body.dependencies).toEqual(["KB-002"]);
});
@@ -306,6 +310,90 @@ describe("PATCH /tasks/:id", () => {
description: undefined,
prompt: undefined,
dependencies: undefined,
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
});
});
it("forwards model override fields to store.updateTask", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: undefined,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
});
});
it("returns 400 for invalid modelProvider type", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
modelProvider: 123,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("modelProvider must be a string");
});
it("returns 400 for invalid modelId type", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
modelId: true,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("modelId must be a string");
});
it("accepts null to clear model fields", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
modelProvider: undefined,
modelId: undefined,
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
modelProvider: null,
modelId: null,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: undefined,
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
});
});
});

View File

@@ -916,16 +916,36 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Update task
router.patch("/tasks/:id", async (req, res) => {
try {
const { title, description, prompt, dependencies } = req.body;
const { title, description, prompt, dependencies, modelProvider, modelId, validatorModelProvider, validatorModelId } = req.body;
// Validate model fields are strings or undefined/null
const validateModelField = (value: unknown, name: string): string | null | undefined => {
if (value === undefined || value === null) return null;
if (typeof value !== "string") {
throw new Error(`${name} must be a string`);
}
return value;
};
const validatedModelProvider = validateModelField(modelProvider, "modelProvider");
const validatedModelId = validateModelField(modelId, "modelId");
const validatedValidatorModelProvider = validateModelField(validatorModelProvider, "validatorModelProvider");
const validatedValidatorModelId = validateModelField(validatorModelId, "validatorModelId");
const task = await store.updateTask(req.params.id, {
title,
description,
prompt,
dependencies,
modelProvider: validatedModelProvider,
modelId: validatedModelId,
validatorModelProvider: validatedValidatorModelProvider,
validatorModelId: validatedValidatorModelId,
});
res.json(task);
} catch (err: any) {
res.status(500).json({ error: err.message });
const status = err.message?.includes("must be a string") ? 400 : 500;
res.status(status).json({ error: err.message });
}
});

View File

@@ -2852,3 +2852,176 @@ describe("TaskExecutor usage limit detection", () => {
);
});
});
describe("Per-task model overrides", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
it("uses per-task model overrides when both provider and modelId are set", async () => {
const store = createMockStore();
const capturedOptions: any[] = [];
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedOptions.push(opts);
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {},
},
} as any;
});
const executor = new TaskExecutor(store, "/tmp/test");
// Override getTask to return task with model overrides
store.getTask.mockResolvedValue({
id: "KB-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# test",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
await executor.execute({
id: "KB-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
// Should use per-task model overrides
expect(capturedOptions[0].defaultProvider).toBe("anthropic");
expect(capturedOptions[0].defaultModelId).toBe("claude-sonnet-4-5");
});
it("falls back to global settings when per-task model is not fully specified", async () => {
const store = createMockStore();
const capturedOptions: any[] = [];
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedOptions.push(opts);
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {},
},
} as any;
});
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
worktreeInitCommand: undefined,
defaultProvider: "openai",
defaultModelId: "gpt-4o",
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "KB-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// No modelProvider/modelId set
});
// Should use global settings (not task overrides)
expect(capturedOptions[0].defaultProvider).toBe("openai");
expect(capturedOptions[0].defaultModelId).toBe("gpt-4o");
});
it("falls back to global settings when only modelProvider is set (missing modelId)", async () => {
const store = createMockStore();
const capturedOptions: any[] = [];
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedOptions.push(opts);
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {},
},
} as any;
});
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
worktreeInitCommand: undefined,
defaultProvider: "openai",
defaultModelId: "gpt-4o",
});
const executor = new TaskExecutor(store, "/tmp/test");
// Override getTask to return task with only modelProvider set
store.getTask.mockResolvedValue({
id: "KB-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# test",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
modelProvider: "anthropic",
// modelId is missing
});
await executor.execute({
id: "KB-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
modelProvider: "anthropic",
// modelId is missing
});
// Should fall back to global settings since modelId is not set
expect(capturedOptions[0].defaultProvider).toBe("openai");
expect(capturedOptions[0].defaultModelId).toBe("gpt-4o");
});
});

View File

@@ -396,7 +396,7 @@ export class TaskExecutor {
this.createTaskCreateTool(),
this.createTaskAddDepTool(task.id),
this.createTaskDoneTool(task.id, () => { taskDone = true; }),
this.createReviewStepTool(task.id, worktreePath, detail.prompt, codeReviewVerdicts, sessionRef, stepCheckpoints),
this.createReviewStepTool(task.id, worktreePath, detail.prompt, codeReviewVerdicts, sessionRef, stepCheckpoints, detail),
];
const agentLogger = new AgentLogger({
@@ -408,6 +408,15 @@ export class TaskExecutor {
});
const agentWork = async () => {
// Resolve model settings: use per-task overrides if both provider and modelId are set,
// otherwise fall back to global settings
const executorProvider = detail.modelProvider && detail.modelId
? detail.modelProvider
: settings.defaultProvider;
const executorModelId = detail.modelProvider && detail.modelId
? detail.modelId
: settings.defaultModelId;
const { session } = await createKbAgent({
cwd: worktreePath,
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
@@ -417,8 +426,8 @@ export class TaskExecutor {
onThinking: agentLogger.onThinking,
onToolStart: agentLogger.onToolStart,
onToolEnd: agentLogger.onToolEnd,
defaultProvider: settings.defaultProvider,
defaultModelId: settings.defaultModelId,
defaultProvider: executorProvider,
defaultModelId: executorModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
});
@@ -732,6 +741,7 @@ export class TaskExecutor {
codeReviewVerdicts: Map<number, ReviewVerdict>,
sessionRef: { current: AgentSession | null },
stepCheckpoints: Map<number, string>,
detail: TaskDetail,
): ToolDefinition {
const store = this.store;
const options = this.options;
@@ -761,6 +771,8 @@ export class TaskExecutor {
defaultProvider: settings.defaultProvider,
defaultModelId: settings.defaultModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
validatorModelProvider: detail.validatorModelProvider,
validatorModelId: detail.validatorModelId,
store,
taskId,
},

View File

@@ -245,3 +245,95 @@ describe("reviewStep — exhausted-retry error detection", () => {
expect(result.verdict).toBe("APPROVE");
});
});
describe("reviewStep — validator model overrides", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses validatorModelProvider and validatorModelId when both are set", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
);
await reviewStep(
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "openai",
defaultModelId: "gpt-4o",
validatorModelProvider: "anthropic",
validatorModelId: "claude-sonnet-4-5",
},
);
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.defaultProvider).toBe("anthropic");
expect(opts.defaultModelId).toBe("claude-sonnet-4-5");
});
it("falls back to defaultProvider/defaultModelId when validatorModelProvider is missing", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
);
await reviewStep(
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "openai",
defaultModelId: "gpt-4o",
// validatorModelProvider is missing
validatorModelId: "claude-sonnet-4-5",
},
);
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.defaultProvider).toBe("openai");
expect(opts.defaultModelId).toBe("gpt-4o");
});
it("falls back to defaultProvider/defaultModelId when validatorModelId is missing", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
);
await reviewStep(
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "openai",
defaultModelId: "gpt-4o",
validatorModelProvider: "anthropic",
// validatorModelId is missing
},
);
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.defaultProvider).toBe("openai");
expect(opts.defaultModelId).toBe("gpt-4o");
});
it("falls back to defaultProvider/defaultModelId when both validator fields are undefined", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
);
await reviewStep(
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "openai",
defaultModelId: "gpt-4o",
},
);
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.defaultProvider).toBe("openai");
expect(opts.defaultModelId).toBe("gpt-4o");
});
});

View File

@@ -144,6 +144,10 @@ export interface ReviewOptions {
defaultProvider?: string;
/** Default model ID within the provider (e.g. "claude-sonnet-4-5"). When set with `defaultProvider`, overrides the reviewer's model selection. */
defaultModelId?: string;
/** Validator model provider override. When both `validatorModelProvider` and `validatorModelId` are set, they take precedence over `defaultProvider`/`defaultModelId`. */
validatorModelProvider?: string;
/** Validator model ID override. When both `validatorModelProvider` and `validatorModelId` are set, they take precedence over `defaultProvider`/`defaultModelId`. */
validatorModelId?: string;
/** Default thinking effort level for the reviewer agent session. */
defaultThinkingLevel?: string;
/** Task store for persisting agent log entries. When provided with `taskId`, enables full conversation logging. */
@@ -182,6 +186,15 @@ export async function reviewStep(
})
: null;
// Resolve validator model settings: use per-task overrides if both provider and modelId are set,
// otherwise fall back to defaultProvider/defaultModelId
const validatorProvider = options.validatorModelProvider && options.validatorModelId
? options.validatorModelProvider
: options.defaultProvider;
const validatorModelId = options.validatorModelProvider && options.validatorModelId
? options.validatorModelId
: options.defaultModelId;
// Spawn a reviewer agent with read-only tools
const { session } = await createKbAgent({
cwd,
@@ -191,8 +204,8 @@ export async function reviewStep(
onThinking: agentLogger?.onThinking,
onToolStart: agentLogger?.onToolStart,
onToolEnd: agentLogger?.onToolEnd,
defaultProvider: options.defaultProvider,
defaultModelId: options.defaultModelId,
defaultProvider: validatorProvider,
defaultModelId: validatorModelId,
defaultThinkingLevel: options.defaultThinkingLevel,
});