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:
@@ -174,6 +174,8 @@ export function batchUpdateTaskModels(
|
||||
modelId?: string | null,
|
||||
validatorModelProvider?: string | null,
|
||||
validatorModelId?: string | null,
|
||||
planningModelProvider?: string | null,
|
||||
planningModelId?: string | null,
|
||||
projectId?: string,
|
||||
): Promise<{ updated: Task[]; count: number }> {
|
||||
return api<{ updated: Task[]; count: number }>(withProjectId("/tasks/batch-update-models", projectId), {
|
||||
@@ -184,6 +186,8 @@ export function batchUpdateTaskModels(
|
||||
modelId,
|
||||
validatorModelProvider,
|
||||
validatorModelId,
|
||||
planningModelProvider,
|
||||
planningModelId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,21 +102,30 @@ function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provider: stri
|
||||
|
||||
/**
|
||||
* Resolve the effective planning/triage model following the resolution order:
|
||||
* 1. Runtime triage model from agent log marker (if present)
|
||||
* 2. Project settings planningProvider/planningModelId
|
||||
* 3. Global settings defaultProvider/defaultModelId
|
||||
* 1. Per-task planningModelProvider/planningModelId override
|
||||
* 2. Runtime triage model from agent log marker (if present)
|
||||
* 3. Project settings planningProvider/planningModelId
|
||||
* 4. Global settings defaultProvider/defaultModelId
|
||||
*/
|
||||
function resolveEffectivePlanning(
|
||||
task: Task | TaskDetail,
|
||||
logEntries: AgentLogEntry[],
|
||||
settings?: Settings,
|
||||
): ModelSelection {
|
||||
// 1. Per-task override takes precedence
|
||||
if (task.planningModelProvider && task.planningModelId) {
|
||||
return { provider: task.planningModelProvider, modelId: task.planningModelId };
|
||||
}
|
||||
// 2. Runtime triage model from agent log marker
|
||||
const fromLog = extractPlanningModelFromLog(logEntries);
|
||||
if (fromLog) {
|
||||
return fromLog;
|
||||
}
|
||||
// 3. Project settings planningProvider/planningModelId
|
||||
if (settings?.planningProvider && settings.planningModelId) {
|
||||
return { provider: settings.planningProvider, modelId: settings.planningModelId };
|
||||
}
|
||||
// 4. Global settings defaultProvider/defaultModelId
|
||||
if (settings?.defaultProvider && settings.defaultModelId) {
|
||||
return { provider: settings.defaultProvider, modelId: settings.defaultModelId };
|
||||
}
|
||||
@@ -922,7 +931,7 @@ export function TaskDetailModal({
|
||||
loading={agentLogLoading}
|
||||
executorModel={resolveEffectiveExecutor(task, settings)}
|
||||
validatorModel={resolveEffectiveValidator(task, settings)}
|
||||
planningModel={resolveEffectivePlanning(agentLogEntries, settings)}
|
||||
planningModel={resolveEffectivePlanning(task, agentLogEntries, settings)}
|
||||
/>
|
||||
) : (
|
||||
<div className="detail-activity">
|
||||
|
||||
@@ -1439,6 +1439,59 @@ describe("TaskDetailModal", () => {
|
||||
expect(defaultBadges).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("per-task planning model override takes precedence over settings", async () => {
|
||||
const { fetchSettings } = await import("../../api");
|
||||
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
||||
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
planningProvider: "openai",
|
||||
planningModelId: "gpt-4o",
|
||||
} as any);
|
||||
|
||||
vi.mocked(useAgentLogs).mockReturnValue({
|
||||
entries: [mockLogEntry],
|
||||
loading: false,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({
|
||||
prompt: "# Hello\n\nContent",
|
||||
planningModelProvider: "google",
|
||||
planningModelId: "gemini-2.5-pro",
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Navigate to Agent Log subview
|
||||
fireEvent.click(screen.getByText("Logs"));
|
||||
fireEvent.click(screen.getByText("Agent Log"));
|
||||
|
||||
await waitFor(() => {
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
||||
expect(header).toBeTruthy();
|
||||
// Per-task override should take precedence over settings
|
||||
expect(header!.textContent).toContain("Planning/Triage:");
|
||||
expect(header!.textContent).toContain("google/gemini-2.5-pro");
|
||||
});
|
||||
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
||||
// Should NOT show the settings planning model
|
||||
expect(header.textContent).not.toContain("openai/gpt-4o");
|
||||
});
|
||||
|
||||
it("runtime triage marker takes precedence over planningProvider settings", async () => {
|
||||
const { fetchSettings } = await import("../../api");
|
||||
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user