feat(FN-991): add per-task planning model override for triage and dashboard

- Add planningModelProvider/planningModelId fields to task model and API batch-update endpoint
- Wire triage engine to use per-task planning model override with settings hierarchy fallback
- Add planning model selector to TaskDetailModal Model tab
- Add comprehensive tests for triage engine planning model resolution and dashboard API/routes
This commit is contained in:
gsxdsm
2026-04-07 13:46:25 -07:00
parent b8aa019fab
commit 6b269f85c6
7 changed files with 364 additions and 15 deletions

View File

@@ -1458,6 +1458,72 @@ describe("POST /tasks/batch-update-models", () => {
consoleSpy.mockRestore();
});
it("updates only planning model when only planning fields provided", async () => {
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" };
const updated1 = { ...task1, planningModelProvider: "google", planningModelId: "gemini-2.5-pro" };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
taskIds: ["FN-001"],
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.count).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
});
});
it("updates executor, validator, and planning models together", async () => {
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" };
const updated1 = { ...task1, modelProvider: "openai", modelId: "gpt-4o", validatorModelProvider: "anthropic", validatorModelId: "claude-sonnet-4-5", planningModelProvider: "google", planningModelId: "gemini-2.5-pro" };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
taskIds: ["FN-001"],
modelProvider: "openai",
modelId: "gpt-4o",
validatorModelProvider: "anthropic",
validatorModelId: "claude-sonnet-4-5",
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.count).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
modelProvider: "openai",
modelId: "gpt-4o",
validatorModelProvider: "anthropic",
validatorModelId: "claude-sonnet-4-5",
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
});
});
it("returns 400 when only planning provider provided (missing modelId)", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
taskIds: ["FN-001"],
planningModelProvider: "google",
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Planning model must include both provider and modelId");
});
});
describe("PATCH /tasks/:id", () => {

View File

@@ -1954,13 +1954,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
/**
* POST /api/tasks/batch-update-models
* Batch update AI model configuration for multiple tasks.
* Body: { taskIds: string[], modelProvider?: string | null, modelId?: string | null, validatorModelProvider?: string | null, validatorModelId?: string | null }
* Body: { taskIds: string[], modelProvider?: string | null, modelId?: string | null, validatorModelProvider?: string | null, validatorModelId?: string | null, planningModelProvider?: string | null, planningModelId?: string | null }
* Returns: { updated: Task[], count: number }
*/
router.post("/tasks/batch-update-models", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { taskIds, modelProvider, modelId, validatorModelProvider, validatorModelId } = req.body;
const { taskIds, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId } = req.body;
// Validate taskIds
if (!Array.isArray(taskIds)) {
@@ -1979,7 +1979,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Validate that at least one model field is being updated
const hasExecutorModel = modelProvider !== undefined || modelId !== undefined;
const hasValidatorModel = validatorModelProvider !== undefined || validatorModelId !== undefined;
if (!hasExecutorModel && !hasValidatorModel) {
const hasPlanningModel = planningModelProvider !== undefined || planningModelId !== undefined;
if (!hasExecutorModel && !hasValidatorModel && !hasPlanningModel) {
res.status(400).json({ error: "At least one model field must be provided" });
return;
}
@@ -2003,10 +2004,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
let validatedExecutor: { provider?: string | null; modelId?: string | null };
let validatedValidator: { provider?: string | null; modelId?: string | null };
let validatedPlanning: { provider?: string | null; modelId?: string | null };
try {
validatedExecutor = validateModelPair(modelProvider, modelId, "Executor model");
validatedValidator = validateModelPair(validatorModelProvider, validatorModelId, "Validator model");
validatedPlanning = validateModelPair(planningModelProvider, planningModelId, "Planning model");
} catch (err: any) {
res.status(400).json({ error: err.message });
return;
@@ -2028,7 +2031,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
// Build update payload (only include fields that were explicitly provided)
const updates: { modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null } = {};
const updates: { modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null } = {};
if (validatedExecutor.provider !== undefined) {
updates.modelProvider = validatedExecutor.provider;
}
@@ -2041,6 +2044,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (validatedValidator.modelId !== undefined) {
updates.validatorModelId = validatedValidator.modelId;
}
if (validatedPlanning.provider !== undefined) {
updates.planningModelProvider = validatedPlanning.provider;
}
if (validatedPlanning.modelId !== undefined) {
updates.planningModelId = validatedPlanning.modelId;
}
// Update all tasks in parallel
const updatePromises = taskIds.map(async (taskId) => {