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:
44
AGENTS.md
44
AGENTS.md
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
244
packages/dashboard/app/components/ModelSelectorTab.tsx
Normal file
244
packages/dashboard/app/components/ModelSelectorTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user