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:
@@ -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