feat(FN-965): add per-task planning model override

- Add planningModelProvider and planningModelId fields to core Task type
- Update backend API validation to accept planning model fields on create/update
- Update frontend API client and TaskForm to handle planning model
- Add planning model selector row to ModelSelectorTab component
- Wire up planning model in TaskDetailModal edit mode with save support
- Update AGENTS.md with planning model override documentation
- Add comprehensive tests for ModelSelectorTab and API routes
- Bump schema version from 10 to 11
This commit is contained in:
gsxdsm
2026-04-06 22:55:25 -07:00
parent 05a00c2877
commit ffbe8cf59b
12 changed files with 473 additions and 66 deletions

View File

@@ -1103,6 +1103,7 @@ The kb dashboard allows overriding the global AI model selection on a per-task b
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)
- **Planning Model**: The AI model used for task specification (triage agent)
When not specified, tasks use the global default settings (`defaultProvider`/`defaultModelId`).
@@ -1122,7 +1123,9 @@ Per-task model overrides are stored in the task's `task.json`:
"modelProvider": "anthropic",
"modelId": "claude-sonnet-4-5",
"validatorModelProvider": "openai",
"validatorModelId": "gpt-4o"
"validatorModelId": "gpt-4o",
"planningModelProvider": "google",
"planningModelId": "gemini-2.5-pro"
}
```
@@ -1132,10 +1135,10 @@ To clear overrides, select "Use default" for both fields and save.
- **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()`.
- **Planning**: When both `planningModelProvider` and `planningModelId` are set, the triage agent uses those instead of global settings for task specification.
### Limitations
- **Planning Model**: Task specification (triage) uses the global `planningProvider`/`planningModelId` settings — there is no per-task override for planning
- Both provider and modelId must be set together; partial configuration falls back to defaults
## Model Settings Hierarchy
@@ -1143,9 +1146,10 @@ To clear overrides, select "Use default" for both fields and save.
The system uses the following precedence for model selection:
**For Task Specification (Triage):**
1. Global `planningProvider`/`planningModelId` (if both set)
2. Global `defaultProvider`/`defaultModelId` (if both set)
3. Automatic model resolution (fallback)
1. Per-task `planningModelProvider`/`planningModelId` (if both set)
2. Global `planningProvider`/`planningModelId` (if both set)
3. Global `defaultProvider`/`defaultModelId` (if both set)
4. Automatic model resolution (fallback)
**For Task Execution (Executor):**
1. Per-task `modelProvider`/`modelId` (if both set)
@@ -1182,7 +1186,7 @@ Each line in `archive.jsonl` is a JSON object containing:
- `attachments` (metadata only, no file content)
- `log` (task log entries)
- `createdAt`, `updatedAt`, `columnMovedAt`, `archivedAt`
- Model overrides: `modelProvider`, `modelId`, `validatorModelProvider`, `validatorModelId`
- Model overrides: `modelProvider`, `modelId`, `validatorModelProvider`, `validatorModelId`, `planningModelProvider`, `planningModelId`
**Explicitly excluded:** `agent.log` content (can be large, not needed for restoration)

View File

@@ -89,7 +89,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(10);
expect(db.getSchemaVersion()).toBe(11);
});
it("seeds lastModified", () => {
@@ -112,7 +112,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(10);
expect(db.getSchemaVersion()).toBe(11);
});
it("does not overwrite existing config on re-init", () => {
@@ -719,7 +719,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
expect(db.getSchemaVersion()).toBe(10);
expect(db.getSchemaVersion()).toBe(11);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -744,11 +744,11 @@ describe("schema migrations", () => {
const db = new Database(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(10);
expect(db.getSchemaVersion()).toBe(11);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(10);
expect(db.getSchemaVersion()).toBe(11);
db.close();
});
@@ -843,7 +843,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 5
expect(db.getSchemaVersion()).toBe(10);
expect(db.getSchemaVersion()).toBe(11);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1053,7 +1053,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(10);
expect(db.getSchemaVersion()).toBe(11);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 10;
const SCHEMA_VERSION = 11;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -141,6 +141,8 @@ CREATE TABLE IF NOT EXISTS tasks (
modelId TEXT,
validatorModelProvider TEXT,
validatorModelId TEXT,
planningModelProvider TEXT,
planningModelId TEXT,
mergeRetries INTEGER,
recoveryRetryCount INTEGER,
nextRecoveryAt TEXT,
@@ -449,7 +451,7 @@ export class Database {
}
// Future migrations go here:
// if (version < 11) { this.applyMigration(11, () => { ... }); }
// if (version < 12) { this.applyMigration(12, () => { ... }); }
if (version < 10) {
this.applyMigration(10, () => {
@@ -458,6 +460,13 @@ export class Database {
this.addColumnIfMissing("missions", "lastAutopilotActivityAt", "TEXT");
});
}
if (version < 11) {
this.applyMigration(11, () => {
this.addColumnIfMissing("tasks", "planningModelProvider", "TEXT");
this.addColumnIfMissing("tasks", "planningModelId", "TEXT");
});
}
}
/**

View File

@@ -174,6 +174,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
modelId: row.modelId || undefined,
validatorModelProvider: row.validatorModelProvider || undefined,
validatorModelId: row.validatorModelId || undefined,
planningModelProvider: row.planningModelProvider || undefined,
planningModelId: row.planningModelId || undefined,
mergeRetries: row.mergeRetries ?? undefined,
stuckKillCount: row.stuckKillCount ?? undefined,
recoveryRetryCount: row.recoveryRetryCount ?? undefined,
@@ -225,15 +227,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
INSERT OR REPLACE INTO tasks (
id, title, description, "column", status, size, reviewLevel, currentStep,
worktree, blockedBy, paused, baseBranch, branch, baseCommitSha, modelPresetId, modelProvider,
modelId, validatorModelProvider, validatorModelId, mergeRetries,
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
stuckKillCount, recoveryRetryCount, nextRecoveryAt, error,
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`).run(
task.id,
@@ -255,6 +257,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.modelId ?? null,
task.validatorModelProvider ?? null,
task.validatorModelId ?? null,
task.planningModelProvider ?? null,
task.planningModelId ?? null,
task.mergeRetries ?? null,
task.stuckKillCount ?? 0,
task.recoveryRetryCount ?? null,
@@ -808,6 +812,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
modelId: input.modelId,
validatorModelProvider: input.validatorModelProvider,
validatorModelId: input.validatorModelId,
planningModelProvider: input.planningModelProvider,
planningModelId: input.planningModelId,
steps: [],
currentStep: 0,
log: [{ timestamp: now, action: "Task created" }],
@@ -1095,7 +1101,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; stuckKillCount?: number | null; recoveryRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; stuckKillCount?: number | null; recoveryRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
): Promise<Task> {
return this.withTaskLock(id, async () => {
// Validate that task doesn't depend on itself
@@ -1203,6 +1209,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.validatorModelId !== undefined) {
task.validatorModelId = updates.validatorModelId;
}
if (updates.planningModelProvider === null) {
task.planningModelProvider = undefined;
} else if (updates.planningModelProvider !== undefined) {
task.planningModelProvider = updates.planningModelProvider;
}
if (updates.planningModelId === null) {
task.planningModelId = undefined;
} else if (updates.planningModelId !== undefined) {
task.planningModelId = updates.planningModelId;
}
if (updates.error === null) {
task.error = undefined;
} else if (updates.error !== undefined) {
@@ -1821,6 +1837,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
modelId: task.modelId,
validatorModelProvider: task.validatorModelProvider,
validatorModelId: task.validatorModelId,
planningModelProvider: task.planningModelProvider,
planningModelId: task.planningModelId,
breakIntoSubtasks: task.breakIntoSubtasks,
paused: task.paused,
baseBranch: task.baseBranch,
@@ -2709,6 +2727,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
modelId: entry.modelId,
validatorModelProvider: entry.validatorModelProvider,
validatorModelId: entry.validatorModelId,
planningModelProvider: entry.planningModelProvider,
planningModelId: entry.planningModelId,
breakIntoSubtasks: entry.breakIntoSubtasks,
modifiedFiles: entry.modifiedFiles,
// Intentionally NOT restoring: worktree, status, blockedBy, paused, baseBranch, baseCommitSha, error, comments

View File

@@ -542,6 +542,14 @@ export interface Task {
* Must be set together with `validatorModelProvider`. When both validator model
* fields are undefined, the reviewer uses global settings defaults. */
validatorModelId?: string;
/** AI model provider override for the planning/triage agent.
* Must be set together with `planningModelId`. When both planning model fields
* are undefined, the triage agent uses global settings defaults. */
planningModelProvider?: string;
/** AI model ID override for the planning/triage agent.
* Must be set together with `planningModelProvider`. When both planning model
* fields are undefined, the triage agent uses global settings defaults. */
planningModelId?: string;
/** IDs of workflow steps enabled for this task, run after implementation completes */
enabledWorkflowSteps?: string[];
/** Results from workflow step executions (populated after task implementation) */
@@ -608,6 +616,14 @@ export interface TaskCreateInput {
* Must be set together with `validatorModelProvider`. When both validator model
* fields are undefined, the reviewer uses global settings defaults. */
validatorModelId?: string;
/** AI model provider override for the planning/triage agent.
* Must be set together with `planningModelId`. When both planning model fields
* are undefined, the triage agent uses global settings defaults. */
planningModelProvider?: string;
/** AI model ID override for the planning/triage agent.
* Must be set together with `planningModelProvider`. When both planning model
* fields are undefined, the triage agent uses global settings defaults. */
planningModelId?: string;
/** Thinking level for AI agent sessions — controls reasoning effort (off/minimal/low/medium/high) */
thinkingLevel?: ThinkingLevel;
/** When true, trigger AI title summarization if description is long and no title provided */
@@ -1193,6 +1209,9 @@ export interface ArchivedTaskEntry {
modelId?: string;
validatorModelProvider?: string;
validatorModelId?: string;
/** Optional: planning model override for triage agent */
planningModelProvider?: string;
planningModelId?: string;
/** Optional: other metadata to preserve */
breakIntoSubtasks?: boolean;
paused?: boolean;

View File

@@ -143,7 +143,7 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
});
}
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null }, projectId?: string): Promise<Task> {
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null }, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),

View File

@@ -34,6 +34,13 @@ function getValidatorSelection(task: Task | TaskDetail): ModelSelection {
};
}
function getPlanningSelection(task: Task | TaskDetail): ModelSelection {
return {
provider: normalizeModelField(task.planningModelProvider),
modelId: normalizeModelField(task.planningModelId),
};
}
function parseModelValue(value: string): ModelSelection {
if (!value) {
return { provider: undefined, modelId: undefined };
@@ -56,8 +63,13 @@ function selectionsEqual(a: ModelSelection, b: ModelSelection): boolean {
return a.provider === b.provider && a.modelId === b.modelId;
}
function getSuccessToastMessage(target: "executor" | "validator", selection: ModelSelection): string {
const label = target === "executor" ? "Executor" : "Validator";
function getSuccessToastMessage(target: "executor" | "validator" | "planning", selection: ModelSelection): string {
const labels: Record<string, string> = {
executor: "Executor",
validator: "Validator",
planning: "Planning",
};
const label = labels[target] || target;
if (!selection.provider || !selection.modelId) {
return `${label} model set to default`;
@@ -77,7 +89,9 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
const [savedExecutor, setSavedExecutor] = useState<ModelSelection>(() => getExecutorSelection(task));
const [selectedValidator, setSelectedValidator] = useState<ModelSelection>(() => getValidatorSelection(task));
const [savedValidator, setSavedValidator] = useState<ModelSelection>(() => getValidatorSelection(task));
const [savingTarget, setSavingTarget] = useState<"executor" | "validator" | null>(null);
const [selectedPlanning, setSelectedPlanning] = useState<ModelSelection>(() => getPlanningSelection(task));
const [savedPlanning, setSavedPlanning] = useState<ModelSelection>(() => getPlanningSelection(task));
const [savingTarget, setSavingTarget] = useState<"executor" | "validator" | "planning" | null>(null);
const activeTaskIdRef = useRef(task.id);
@@ -142,23 +156,28 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
const nextExecutor = getExecutorSelection(task);
const nextValidator = getValidatorSelection(task);
const nextPlanning = getPlanningSelection(task);
setSelectedExecutor(nextExecutor);
setSavedExecutor(nextExecutor);
setSelectedValidator(nextValidator);
setSavedValidator(nextValidator);
setSelectedPlanning(nextPlanning);
setSavedPlanning(nextPlanning);
setSavingTarget(null);
}, [task.id, task.modelProvider, task.modelId, task.validatorModelProvider, task.validatorModelId]);
}, [task.id, task.modelProvider, task.modelId, task.validatorModelProvider, task.validatorModelId, task.planningModelProvider, task.planningModelId]);
const executorValue = useMemo(() => getDropdownValue(selectedExecutor), [selectedExecutor]);
const validatorValue = useMemo(() => getDropdownValue(selectedValidator), [selectedValidator]);
const planningValue = useMemo(() => getDropdownValue(selectedPlanning), [selectedPlanning]);
const isSaving = savingTarget !== null;
const saveSelection = useCallback(
async (target: "executor" | "validator", nextSelection: ModelSelection) => {
async (target: "executor" | "validator" | "planning", nextSelection: ModelSelection) => {
const requestTaskId = task.id;
const previousSavedExecutor = savedExecutor;
const previousSavedValidator = savedValidator;
const previousSavedPlanning = savedPlanning;
setSavingTarget(target);
@@ -176,6 +195,12 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
validatorModelId: target === "validator"
? nextSelection.modelId ?? null
: previousSavedValidator.modelId ?? null,
planningModelProvider: target === "planning"
? nextSelection.provider ?? null
: previousSavedPlanning.provider ?? null,
planningModelId: target === "planning"
? nextSelection.modelId ?? null
: previousSavedPlanning.modelId ?? null,
});
if (activeTaskIdRef.current !== requestTaskId) {
@@ -184,17 +209,23 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
const nextSavedExecutor = getExecutorSelection(updatedTask);
const nextSavedValidator = getValidatorSelection(updatedTask);
const nextSavedPlanning = getPlanningSelection(updatedTask);
setSavedExecutor(nextSavedExecutor);
setSelectedExecutor(nextSavedExecutor);
setSavedValidator(nextSavedValidator);
setSelectedValidator(nextSavedValidator);
setSavedPlanning(nextSavedPlanning);
setSelectedPlanning(nextSavedPlanning);
const targetSelections: Record<string, ModelSelection> = {
executor: nextSavedExecutor,
validator: nextSavedValidator,
planning: nextSavedPlanning,
};
addToast(
getSuccessToastMessage(
target,
target === "executor" ? nextSavedExecutor : nextSavedValidator,
),
getSuccessToastMessage(target, targetSelections[target]),
"success",
);
} catch (err: any) {
@@ -204,8 +235,10 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
if (target === "executor") {
setSelectedExecutor(previousSavedExecutor);
} else {
} else if (target === "validator") {
setSelectedValidator(previousSavedValidator);
} else {
setSelectedPlanning(previousSavedPlanning);
}
addToast(err.message || "Failed to save model settings", "error");
@@ -215,7 +248,7 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
}
}
},
[task.id, savedExecutor, savedValidator, addToast],
[task.id, savedExecutor, savedValidator, savedPlanning, addToast],
);
const handleExecutorChange = useCallback(
@@ -246,8 +279,23 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
[savedValidator, saveSelection],
);
const handlePlanningChange = useCallback(
(value: string) => {
const nextSelection = parseModelValue(value);
setSelectedPlanning(nextSelection);
if (selectionsEqual(nextSelection, savedPlanning)) {
return;
}
void saveSelection("planning", nextSelection);
},
[savedPlanning, saveSelection],
);
const executorUsingDefault = !savedExecutor.provider && !savedExecutor.modelId;
const validatorUsingDefault = !savedValidator.provider && !savedValidator.modelId;
const planningUsingDefault = !savedPlanning.provider && !savedPlanning.modelId;
return (
<div className="model-selector-tab">
@@ -342,8 +390,36 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
<small>The AI model used to review code and plans for this task.</small>
</div>
<div className="form-group">
<label htmlFor="planningModel">Planning Model</label>
<div className="model-selector-current">
{planningUsingDefault ? (
<span className="model-badge model-badge-default">Using default</span>
) : (
<span className="model-badge model-badge-custom">
{savedPlanning.provider && <ProviderIcon provider={savedPlanning.provider} size="sm" />}
{savedPlanning.provider}/{savedPlanning.modelId}
</span>
)}
</div>
<CustomModelDropdown
id="planningModel"
label="Planning Model"
value={planningValue}
onChange={handlePlanningChange}
models={availableModels}
disabled={isSaving}
placeholder="Select planning model…"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
<small>The AI model used for task specification (triage phase).</small>
</div>
<div className="model-selector-status">
{executorUsingDefault && validatorUsingDefault
{executorUsingDefault && validatorUsingDefault && planningUsingDefault
? "Using global default models."
: "Model settings are up to date."}
</div>

View File

@@ -229,6 +229,7 @@ export function TaskDetailModal({
const [editDependencies, setEditDependencies] = useState<string[]>(task.dependencies || []);
const [editExecutorModel, setEditExecutorModel] = useState("");
const [editValidatorModel, setEditValidatorModel] = useState("");
const [editPlanningModel, setEditPlanningModel] = useState("");
const [editPresetMode, setEditPresetMode] = useState<"default" | "preset" | "custom">("default");
const [editSelectedPresetId, setEditSelectedPresetId] = useState("");
const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
@@ -319,9 +320,11 @@ export function TaskDetailModal({
// Populate model overrides from task
const execModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "";
const valModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : "";
const planModel = task.planningModelProvider && task.planningModelId ? `${task.planningModelProvider}/${task.planningModelId}` : "";
setEditExecutorModel(execModel);
setEditValidatorModel(valModel);
setEditPresetMode(execModel || valModel ? "custom" : "default");
setEditPlanningModel(planModel);
setEditPresetMode(execModel || valModel || planModel ? "custom" : "default");
setEditSelectedPresetId("");
setEditSelectedWorkflowSteps(task.enabledWorkflowSteps || []);
setEditPendingImages([]);
@@ -342,6 +345,7 @@ export function TaskDetailModal({
// Build update payload with all changed fields
const executorSlashIdx = editExecutorModel.indexOf("/");
const validatorSlashIdx = editValidatorModel.indexOf("/");
const planningSlashIdx = editPlanningModel.indexOf("/");
const updates: Parameters<typeof updateTask>[1] = {
title: editTitle.trim() || undefined,
@@ -352,6 +356,8 @@ export function TaskDetailModal({
modelId: editExecutorModel && executorSlashIdx !== -1 ? editExecutorModel.slice(executorSlashIdx + 1) : null,
validatorModelProvider: editValidatorModel && validatorSlashIdx !== -1 ? editValidatorModel.slice(0, validatorSlashIdx) : null,
validatorModelId: editValidatorModel && validatorSlashIdx !== -1 ? editValidatorModel.slice(validatorSlashIdx + 1) : null,
planningModelProvider: editPlanningModel && planningSlashIdx !== -1 ? editPlanningModel.slice(0, planningSlashIdx) : null,
planningModelId: editPlanningModel && planningSlashIdx !== -1 ? editPlanningModel.slice(planningSlashIdx + 1) : null,
};
await updateTask(task.id, updates, projectId);
@@ -384,7 +390,7 @@ export function TaskDetailModal({
setIsSaving(false);
}
}
}, [task.id, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId]);
}, [task.id, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId]);
// Handle keyboard shortcuts for edit mode
const handleEditKeyDown = useCallback((e: KeyboardEvent) => {
@@ -796,6 +802,8 @@ export function TaskDetailModal({
onExecutorModelChange={setEditExecutorModel}
validatorModel={editValidatorModel}
onValidatorModelChange={setEditValidatorModel}
planningModel={editPlanningModel}
onPlanningModelChange={setEditPlanningModel}
presetMode={editPresetMode}
onPresetModeChange={setEditPresetMode}
selectedPresetId={editSelectedPresetId}

View File

@@ -31,6 +31,8 @@ export interface TaskFormProps {
onExecutorModelChange: (value: string) => void;
validatorModel: string;
onValidatorModelChange: (value: string) => void;
planningModel?: string;
onPlanningModelChange?: (value: string) => void;
presetMode: "default" | "preset" | "custom";
onPresetModeChange: (mode: "default" | "preset" | "custom") => void;
selectedPresetId: string;
@@ -71,6 +73,8 @@ export function TaskForm({
onExecutorModelChange,
validatorModel,
onValidatorModelChange,
planningModel,
onPlanningModelChange,
presetMode,
onPresetModeChange,
selectedPresetId,
@@ -641,6 +645,27 @@ export function TaskForm({
onToggleModelFavorite={handleToggleModelFavorite}
/>
</div>
{onPlanningModelChange && (
<div className="model-select-row">
<label htmlFor="planning-model" className="model-select-label">Planning</label>
<CustomModelDropdown
id="planning-model"
label="Planning Model"
value={planningModel || ""}
onChange={(value) => {
onPresetModeChange("custom");
onSelectedPresetIdChange("");
onPlanningModelChange(value);
}}
models={availableModels}
disabled={disabled || presetMode === "preset"}
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
</div>
)}
</>
)}
</div>

View File

@@ -81,6 +81,25 @@ describe("ModelSelectorTab", () => {
) ?? screen.getAllByText("Use default")[0];
}
/** Helper to build expected updateTask call with all model fields */
function expectedModelCall(overrides: {
modelProvider?: string | null;
modelId?: string | null;
validatorModelProvider?: string | null;
validatorModelId?: string | null;
planningModelProvider?: string | null;
planningModelId?: string | null;
} = {}) {
return {
modelProvider: overrides.modelProvider ?? null,
modelId: overrides.modelId ?? null,
validatorModelProvider: overrides.validatorModelProvider ?? null,
validatorModelId: overrides.validatorModelId ?? null,
planningModelProvider: overrides.planningModelProvider ?? null,
planningModelId: overrides.planningModelId ?? null,
};
}
beforeEach(() => {
vi.clearAllMocks();
mockFetchModels.mockResolvedValue(MOCK_MODELS_RESPONSE);
@@ -103,6 +122,7 @@ describe("ModelSelectorTab", () => {
await waitForSelectors();
expect(screen.getByLabelText("Validator Model")).toBeInTheDocument();
expect(screen.getByLabelText("Planning Model")).toBeInTheDocument();
expect(screen.queryByText("Save")).not.toBeInTheDocument();
expect(screen.queryByText("Reset")).not.toBeInTheDocument();
});
@@ -117,6 +137,9 @@ describe("ModelSelectorTab", () => {
const validatorSection = getSection("Validator Model");
expect(within(validatorSection!).getByText("Using default")).toBeInTheDocument();
const planningSection = getSection("Planning Model");
expect(within(planningSection!).getByText("Using default")).toBeInTheDocument();
});
it("shows current custom model when overrides are set", async () => {
@@ -214,23 +237,21 @@ describe("ModelSelectorTab", () => {
await selectOption("Executor Model", "Claude Sonnet 4.5");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenNthCalledWith(1, "FN-001", {
expect(mockUpdateTask).toHaveBeenNthCalledWith(1, "FN-001", expectedModelCall({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: null,
validatorModelId: null,
});
}));
});
await selectOption("Validator Model", "GPT-4o");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenNthCalledWith(2, "FN-001", {
expect(mockUpdateTask).toHaveBeenNthCalledWith(2, "FN-001", expectedModelCall({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
});
}));
});
});
@@ -251,12 +272,12 @@ describe("ModelSelectorTab", () => {
await selectOption("Executor Model", "Claude Sonnet 4.5");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
});
}));
});
});
@@ -280,12 +301,7 @@ describe("ModelSelectorTab", () => {
await user.click(getUseDefaultOption());
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
});
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall());
});
});
@@ -306,12 +322,12 @@ describe("ModelSelectorTab", () => {
await selectOption("Validator Model", "GPT-4o");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
});
}));
});
});
@@ -335,12 +351,7 @@ describe("ModelSelectorTab", () => {
await user.click(getUseDefaultOption());
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
});
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall());
});
});
@@ -366,7 +377,7 @@ describe("ModelSelectorTab", () => {
});
});
it("disables both selectors while saving", async () => {
it("disables all selectors while saving", async () => {
const user = userEvent.setup();
let resolveUpdate: ((value: Task) => void) | undefined;
mockUpdateTask.mockImplementation(
@@ -385,6 +396,7 @@ describe("ModelSelectorTab", () => {
await waitFor(() => {
expect(getSelector("Executor Model")).toBeDisabled();
expect(getSelector("Validator Model")).toBeDisabled();
expect(getSelector("Planning Model")).toBeDisabled();
});
resolveUpdate?.({
@@ -396,6 +408,7 @@ describe("ModelSelectorTab", () => {
await waitFor(() => {
expect(getSelector("Executor Model")).not.toBeDisabled();
expect(getSelector("Validator Model")).not.toBeDisabled();
expect(getSelector("Planning Model")).not.toBeDisabled();
});
});
@@ -658,12 +671,10 @@ describe("ModelSelectorTab", () => {
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: null,
validatorModelId: null,
});
}));
});
it("Use default option is always visible", async () => {
@@ -708,12 +719,151 @@ describe("ModelSelectorTab", () => {
await user.click(screen.getByText("GPT-4o"));
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall({
modelProvider: "openai",
modelId: "gpt-4o",
validatorModelProvider: null,
validatorModelId: null,
});
}));
});
});
});
describe("Planning model selector", () => {
it("renders planning model dropdown", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitForSelectors();
expect(screen.getByLabelText("Planning Model")).toBeInTheDocument();
});
it("shows 'Using default' badge when no planning model override is set", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitForSelectors();
const planningSection = getSection("Planning Model");
expect(within(planningSection!).getByText("Using default")).toBeInTheDocument();
});
it("shows custom badge when planning model override is set", async () => {
const taskWithPlanning = {
...FAKE_TASK,
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
};
render(<ModelSelectorTab task={taskWithPlanning} addToast={mockAddToast} />);
await waitForSelectors();
const planningSection = getSection("Planning Model");
const badge = within(planningSection!).getByText("google/gemini-2.5-pro", { selector: ".model-badge-custom" });
expect(badge).toBeInTheDocument();
});
it("auto-saves planning model selection correctly", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitForSelectors();
await selectOption("Planning Model", "Claude Sonnet 4.5");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall({
planningModelProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
}));
});
});
it("clears planning model override with 'Use default'", async () => {
const taskWithPlanning = {
...FAKE_TASK,
planningModelProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
};
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
...taskWithPlanning,
...updates,
}));
const user = userEvent.setup();
render(<ModelSelectorTab task={taskWithPlanning} addToast={mockAddToast} />);
await waitForSelectors();
await user.click(getSelector("Planning Model"));
await user.click(getUseDefaultOption());
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall());
});
});
it("preserves executor and validator overrides when saving planning model", async () => {
const taskWithModels = {
...FAKE_TASK,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
};
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
...taskWithModels,
...updates,
}));
render(<ModelSelectorTab task={taskWithModels} addToast={mockAddToast} />);
await waitForSelectors();
await selectOption("Planning Model", "Claude Opus 4");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
planningModelProvider: "anthropic",
planningModelId: "claude-opus-4",
}));
});
});
it("shows planning model success toast with correct model name", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitForSelectors();
await selectOption("Planning Model", "GPT-4o");
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
"Planning model set to openai/gpt-4o",
"success",
);
});
});
it("shows 'set to default' toast when clearing planning model override", async () => {
const taskWithPlanning = {
...FAKE_TASK,
planningModelProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
};
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
...taskWithPlanning,
...updates,
}));
const user = userEvent.setup();
render(<ModelSelectorTab task={taskWithPlanning} addToast={mockAddToast} />);
await waitForSelectors();
await user.click(getSelector("Planning Model"));
await user.click(getUseDefaultOption());
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("Planning model set to default", "success");
});
});
});

View File

@@ -1410,6 +1410,8 @@ describe("PATCH /tasks/:id", () => {
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: null,
planningModelId: null,
});
expect(res.body.dependencies).toEqual(["FN-002"]);
});
@@ -1432,6 +1434,8 @@ describe("PATCH /tasks/:id", () => {
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: null,
planningModelId: null,
});
});
@@ -1464,6 +1468,8 @@ describe("PATCH /tasks/:id", () => {
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
planningModelProvider: null,
planningModelId: null,
});
});
@@ -1514,6 +1520,90 @@ describe("PATCH /tasks/:id", () => {
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: null,
planningModelId: null,
});
});
it("forwards planning model override fields to store.updateTask", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: undefined,
enabledWorkflowSteps: undefined,
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
});
});
it("returns 400 for invalid planningModelProvider type", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
planningModelProvider: 123,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("planningModelProvider must be a string");
});
it("returns 400 for invalid planningModelId type", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
planningModelId: true,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("planningModelId must be a string");
});
it("accepts null to clear planning model fields", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
planningModelProvider: undefined,
planningModelId: undefined,
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
planningModelProvider: null,
planningModelId: null,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: undefined,
enabledWorkflowSteps: undefined,
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: null,
planningModelId: null,
});
});
@@ -1540,6 +1630,8 @@ describe("PATCH /tasks/:id", () => {
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: null,
planningModelId: null,
});
});

View File

@@ -2546,7 +2546,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.patch("/tasks/:id", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId } = req.body;
const { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId } = req.body;
// Validate model fields are strings or undefined/null
const validateModelField = (value: unknown, name: string): string | null | undefined => {
@@ -2561,6 +2561,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const validatedModelId = validateModelField(modelId, "modelId");
const validatedValidatorModelProvider = validateModelField(validatorModelProvider, "validatorModelProvider");
const validatedValidatorModelId = validateModelField(validatorModelId, "validatorModelId");
const validatedPlanningModelProvider = validateModelField(planningModelProvider, "planningModelProvider");
const validatedPlanningModelId = validateModelField(planningModelId, "planningModelId");
if (enabledWorkflowSteps !== undefined) {
if (!Array.isArray(enabledWorkflowSteps) || !enabledWorkflowSteps.every((id: unknown) => typeof id === "string")) {
@@ -2578,6 +2580,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
modelId: validatedModelId,
validatorModelProvider: validatedValidatorModelProvider,
validatorModelId: validatedValidatorModelId,
planningModelProvider: validatedPlanningModelProvider,
planningModelId: validatedPlanningModelId,
});
res.json(task);
} catch (err: any) {