feat(FN-1044): add thinkingLevel and planning model support to task creation
- Add thinkingLevel field to createTask and updateTask in core store - Accept thinkingLevel and planningModel fields in dashboard task routes - Send thinkingLevel and planningModel in frontend API calls from task forms - Add ThinkingLevel selector to ModelSelectorTab component with tests - Wire up planning model and thinking level in QuickEntryBox, TaskForm, and NewTaskModal - Add unit tests for store, routes, and ModelSelectorTab coverage - Add changeset and update AGENTS.md documentation
This commit is contained in:
@@ -489,6 +489,88 @@ describe("POST /tasks", () => {
|
||||
expect(res.body.error).toContain("breakIntoSubtasks must be a boolean");
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards thinkingLevel when provided", async () => {
|
||||
const createdTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
column: "triage",
|
||||
thinkingLevel: "high",
|
||||
};
|
||||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({
|
||||
description: "Deep reasoning task",
|
||||
thinkingLevel: "high",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Deep reasoning task",
|
||||
thinkingLevel: "high",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
settings: { autoSummarizeTitles: undefined },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid thinkingLevel value", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({
|
||||
description: "Bad thinking level",
|
||||
thinkingLevel: "ultra",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("thinkingLevel must be one of");
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards planningModelProvider and planningModelId when provided", async () => {
|
||||
const createdTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
column: "triage",
|
||||
planningModelProvider: "google",
|
||||
planningModelId: "gemini-2.5-pro",
|
||||
};
|
||||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({
|
||||
description: "Use planning model",
|
||||
planningModelProvider: "google",
|
||||
planningModelId: "gemini-2.5-pro",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Use planning model",
|
||||
planningModelProvider: "google",
|
||||
planningModelId: "gemini-2.5-pro",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
settings: { autoSummarizeTitles: undefined },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /subtasks/*", () => {
|
||||
@@ -1412,6 +1494,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
validatorModelId: null,
|
||||
planningModelProvider: null,
|
||||
planningModelId: null,
|
||||
thinkingLevel: undefined,
|
||||
});
|
||||
expect(res.body.dependencies).toEqual(["FN-002"]);
|
||||
});
|
||||
@@ -1436,6 +1519,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
validatorModelId: null,
|
||||
planningModelProvider: null,
|
||||
planningModelId: null,
|
||||
thinkingLevel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1470,6 +1554,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
validatorModelId: "gpt-4o",
|
||||
planningModelProvider: null,
|
||||
planningModelId: null,
|
||||
thinkingLevel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1522,6 +1607,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
validatorModelId: null,
|
||||
planningModelProvider: null,
|
||||
planningModelId: null,
|
||||
thinkingLevel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1530,6 +1616,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
...FAKE_TASK_DETAIL,
|
||||
planningModelProvider: "google",
|
||||
planningModelId: "gemini-2.5-pro",
|
||||
thinkingLevel: undefined,
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||||
@@ -1552,6 +1639,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
validatorModelId: null,
|
||||
planningModelProvider: "google",
|
||||
planningModelId: "gemini-2.5-pro",
|
||||
thinkingLevel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1604,6 +1692,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
validatorModelId: null,
|
||||
planningModelProvider: null,
|
||||
planningModelId: null,
|
||||
thinkingLevel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1632,6 +1721,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
validatorModelId: null,
|
||||
planningModelProvider: null,
|
||||
planningModelId: null,
|
||||
thinkingLevel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1645,8 +1735,78 @@ describe("PATCH /tasks/:id", () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("enabledWorkflowSteps must be an array of strings");
|
||||
});
|
||||
|
||||
it("forwards thinkingLevel to store.updateTask", async () => {
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||||
thinkingLevel: "high",
|
||||
}), {
|
||||
"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,
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts null to clear thinkingLevel via PATCH", async () => {
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
thinkingLevel: undefined,
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||||
thinkingLevel: 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,
|
||||
thinkingLevel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 for invalid thinkingLevel value via PATCH", async () => {
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||||
thinkingLevel: "invalid",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("thinkingLevel must be one of");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("Attachment routes", () => {
|
||||
const FAKE_ATTACHMENT: TaskAttachment = {
|
||||
filename: "1234-screenshot.png",
|
||||
|
||||
@@ -1721,6 +1721,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
modelId,
|
||||
validatorModelProvider,
|
||||
validatorModelId,
|
||||
planningModelProvider,
|
||||
planningModelId,
|
||||
thinkingLevel,
|
||||
} = req.body;
|
||||
if (!description || typeof description !== "string") {
|
||||
res.status(400).json({ error: "description is required" });
|
||||
@@ -1735,9 +1738,19 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
const validatedModelId = validateOptionalModelField(modelId, "modelId");
|
||||
const validatedValidatorModelProvider = validateOptionalModelField(validatorModelProvider, "validatorModelProvider");
|
||||
const validatedValidatorModelId = validateOptionalModelField(validatorModelId, "validatorModelId");
|
||||
const validatedPlanningModelProvider = validateOptionalModelField(planningModelProvider, "planningModelProvider");
|
||||
const validatedPlanningModelId = validateOptionalModelField(planningModelId, "planningModelId");
|
||||
|
||||
// Validate thinkingLevel if provided
|
||||
const validThinkingLevels = ["off", "minimal", "low", "medium", "high"];
|
||||
if (thinkingLevel !== undefined && thinkingLevel !== null && !validThinkingLevels.includes(thinkingLevel)) {
|
||||
res.status(400).json({ error: `thinkingLevel must be one of: ${validThinkingLevels.join(", ")}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const executorModel = normalizeModelSelectionPair(validatedModelProvider, validatedModelId);
|
||||
const validatorModel = normalizeModelSelectionPair(validatedValidatorModelProvider, validatedValidatorModelId);
|
||||
const planningModel = normalizeModelSelectionPair(validatedPlanningModelProvider, validatedPlanningModelId);
|
||||
|
||||
// Validate enabledWorkflowSteps if provided
|
||||
if (enabledWorkflowSteps !== undefined) {
|
||||
@@ -1791,6 +1804,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
modelId: executorModel.modelId,
|
||||
validatorModelProvider: validatorModel.provider,
|
||||
validatorModelId: validatorModel.modelId,
|
||||
planningModelProvider: planningModel.provider,
|
||||
planningModelId: planningModel.modelId,
|
||||
thinkingLevel: thinkingLevel || undefined,
|
||||
summarize,
|
||||
},
|
||||
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } }
|
||||
@@ -2546,7 +2562,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, planningModelProvider, planningModelId } = req.body;
|
||||
const { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel } = req.body;
|
||||
|
||||
// Validate model fields are strings or undefined/null
|
||||
const validateModelField = (value: unknown, name: string): string | null | undefined => {
|
||||
@@ -2564,6 +2580,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
const validatedPlanningModelProvider = validateModelField(planningModelProvider, "planningModelProvider");
|
||||
const validatedPlanningModelId = validateModelField(planningModelId, "planningModelId");
|
||||
|
||||
// Validate thinkingLevel if provided
|
||||
const validThinkingLevels = ["off", "minimal", "low", "medium", "high"];
|
||||
if (thinkingLevel !== undefined && thinkingLevel !== null && !validThinkingLevels.includes(thinkingLevel)) {
|
||||
throw new Error(`thinkingLevel must be one of: ${validThinkingLevels.join(", ")}`);
|
||||
}
|
||||
|
||||
if (enabledWorkflowSteps !== undefined) {
|
||||
if (!Array.isArray(enabledWorkflowSteps) || !enabledWorkflowSteps.every((id: unknown) => typeof id === "string")) {
|
||||
throw new Error("enabledWorkflowSteps must be an array of strings");
|
||||
@@ -2582,10 +2604,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
validatorModelId: validatedValidatorModelId,
|
||||
planningModelProvider: validatedPlanningModelProvider,
|
||||
planningModelId: validatedPlanningModelId,
|
||||
thinkingLevel: thinkingLevel === null ? null : thinkingLevel,
|
||||
});
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("must be a string") || err.message?.includes("must be an array of strings") ? 400 : 500;
|
||||
const status = err.message?.includes("must be a string") || err.message?.includes("must be an array of strings") || err.message?.includes("thinkingLevel must be one of") ? 400 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user