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:
16
AGENTS.md
16
AGENTS.md
@@ -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)
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user