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 6d98946aeb
commit ab064ebc26
12 changed files with 473 additions and 66 deletions

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) {