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:
@@ -126,6 +126,9 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
|
||||
modelId,
|
||||
validatorModelProvider,
|
||||
validatorModelId,
|
||||
planningModelProvider,
|
||||
planningModelId,
|
||||
thinkingLevel,
|
||||
} = input;
|
||||
|
||||
return api<Task>(withProjectId("/tasks", projectId), {
|
||||
@@ -142,11 +145,14 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
|
||||
modelId,
|
||||
validatorModelProvider,
|
||||
validatorModelId,
|
||||
planningModelProvider,
|
||||
planningModelId,
|
||||
thinkingLevel,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null }, projectId?: string): Promise<Task> {
|
||||
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null }, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
|
||||
@@ -91,7 +91,9 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
const [savedValidator, setSavedValidator] = useState<ModelSelection>(() => getValidatorSelection(task));
|
||||
const [selectedPlanning, setSelectedPlanning] = useState<ModelSelection>(() => getPlanningSelection(task));
|
||||
const [savedPlanning, setSavedPlanning] = useState<ModelSelection>(() => getPlanningSelection(task));
|
||||
const [savingTarget, setSavingTarget] = useState<"executor" | "validator" | "planning" | null>(null);
|
||||
const [selectedThinking, setSelectedThinking] = useState<string>(() => task.thinkingLevel ?? "off");
|
||||
const [savedThinking, setSavedThinking] = useState<string>(() => task.thinkingLevel ?? "off");
|
||||
const [savingTarget, setSavingTarget] = useState<"executor" | "validator" | "planning" | "thinking" | null>(null);
|
||||
|
||||
const activeTaskIdRef = useRef(task.id);
|
||||
|
||||
@@ -164,8 +166,11 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
setSavedValidator(nextValidator);
|
||||
setSelectedPlanning(nextPlanning);
|
||||
setSavedPlanning(nextPlanning);
|
||||
const nextThinking = task.thinkingLevel ?? "off";
|
||||
setSelectedThinking(nextThinking);
|
||||
setSavedThinking(nextThinking);
|
||||
setSavingTarget(null);
|
||||
}, [task.id, task.modelProvider, task.modelId, task.validatorModelProvider, task.validatorModelId, task.planningModelProvider, task.planningModelId]);
|
||||
}, [task.id, task.modelProvider, task.modelId, task.validatorModelProvider, task.validatorModelId, task.planningModelProvider, task.planningModelId, task.thinkingLevel]);
|
||||
|
||||
const executorValue = useMemo(() => getDropdownValue(selectedExecutor), [selectedExecutor]);
|
||||
const validatorValue = useMemo(() => getDropdownValue(selectedValidator), [selectedValidator]);
|
||||
@@ -293,6 +298,49 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
[savedPlanning, saveSelection],
|
||||
);
|
||||
|
||||
const handleThinkingChange = useCallback(
|
||||
async (value: string) => {
|
||||
const requestTaskId = task.id;
|
||||
const previousThinking = savedThinking;
|
||||
|
||||
setSelectedThinking(value);
|
||||
setSavingTarget("thinking");
|
||||
|
||||
try {
|
||||
const updatedTask = await updateTask(requestTaskId, {
|
||||
thinkingLevel: value === "off" ? null : value,
|
||||
});
|
||||
|
||||
if (activeTaskIdRef.current !== requestTaskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextThinking = updatedTask.thinkingLevel ?? "off";
|
||||
setSavedThinking(nextThinking);
|
||||
setSelectedThinking(nextThinking);
|
||||
|
||||
addToast(
|
||||
nextThinking === "off"
|
||||
? "Thinking level set to default (off)"
|
||||
: `Thinking level set to ${nextThinking}`,
|
||||
"success",
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (activeTaskIdRef.current !== requestTaskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedThinking(previousThinking);
|
||||
addToast(err.message || "Failed to save thinking level", "error");
|
||||
} finally {
|
||||
if (activeTaskIdRef.current === requestTaskId) {
|
||||
setSavingTarget(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[task.id, savedThinking, addToast],
|
||||
);
|
||||
|
||||
const executorUsingDefault = !savedExecutor.provider && !savedExecutor.modelId;
|
||||
const validatorUsingDefault = !savedValidator.provider && !savedValidator.modelId;
|
||||
const planningUsingDefault = !savedPlanning.provider && !savedPlanning.modelId;
|
||||
@@ -418,8 +466,35 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
<small>The AI model used for task specification (triage phase).</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="thinkingLevel">Thinking Level</label>
|
||||
<div className="model-selector-current">
|
||||
{savedThinking === "off" ? (
|
||||
<span className="model-badge model-badge-default">Using default (off)</span>
|
||||
) : (
|
||||
<span className="model-badge model-badge-custom">
|
||||
{savedThinking}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
id="thinkingLevel"
|
||||
value={selectedThinking}
|
||||
onChange={(e) => handleThinkingChange(e.target.value)}
|
||||
disabled={isSaving}
|
||||
className="thinking-level-select"
|
||||
>
|
||||
<option value="off">Off (default)</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<small>Controls the reasoning effort for the AI agent. Higher levels use more tokens.</small>
|
||||
</div>
|
||||
|
||||
<div className="model-selector-status">
|
||||
{executorUsingDefault && validatorUsingDefault && planningUsingDefault
|
||||
{executorUsingDefault && validatorUsingDefault && planningUsingDefault && savedThinking === "off"
|
||||
? "Using global default models."
|
||||
: "Model settings are up to date."}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [executorModel, setExecutorModel] = useState("");
|
||||
const [validatorModel, setValidatorModel] = useState("");
|
||||
const [planningModel, setPlanningModel] = useState("");
|
||||
const [thinkingLevel, setThinkingLevel] = useState<string>("off");
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
|
||||
const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default");
|
||||
const [hasDirtyState, setHasDirtyState] = useState(false);
|
||||
@@ -42,15 +44,17 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
|
||||
// Track dirty state
|
||||
useEffect(() => {
|
||||
const isDirty =
|
||||
const isDirty =
|
||||
description.trim() !== "" ||
|
||||
dependencies.length > 0 ||
|
||||
pendingImages.length > 0 ||
|
||||
executorModel !== "" ||
|
||||
validatorModel !== "" ||
|
||||
planningModel !== "" ||
|
||||
thinkingLevel !== "off" ||
|
||||
selectedWorkflowSteps.length > 0;
|
||||
setHasDirtyState(isDirty);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, selectedWorkflowSteps]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasDirtyState) {
|
||||
@@ -64,6 +68,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setDependencies([]);
|
||||
setExecutorModel("");
|
||||
setValidatorModel("");
|
||||
setPlanningModel("");
|
||||
setThinkingLevel("off");
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setSelectedWorkflowSteps([]);
|
||||
@@ -80,6 +86,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
try {
|
||||
const executorSlashIdx = executorModel.indexOf("/");
|
||||
const validatorSlashIdx = validatorModel.indexOf("/");
|
||||
const planningSlashIdx = planningModel.indexOf("/");
|
||||
|
||||
const task = await onCreateTask({
|
||||
title: undefined,
|
||||
@@ -94,6 +101,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined,
|
||||
validatorModelProvider: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(0, validatorSlashIdx) : undefined,
|
||||
validatorModelId: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(validatorSlashIdx + 1) : undefined,
|
||||
planningModelProvider: planningModel && planningSlashIdx !== -1 ? planningModel.slice(0, planningSlashIdx) : undefined,
|
||||
planningModelId: planningModel && planningSlashIdx !== -1 ? planningModel.slice(planningSlashIdx + 1) : undefined,
|
||||
thinkingLevel: thinkingLevel !== "off" ? thinkingLevel as "minimal" | "low" | "medium" | "high" : undefined,
|
||||
});
|
||||
|
||||
// Upload pending images as attachments
|
||||
@@ -118,6 +128,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setDependencies([]);
|
||||
setExecutorModel("");
|
||||
setValidatorModel("");
|
||||
setPlanningModel("");
|
||||
setThinkingLevel("off");
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setSelectedWorkflowSteps([]);
|
||||
@@ -130,7 +142,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
@@ -183,6 +195,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
onPlanningMode={onPlanningMode}
|
||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||
onClose={handleClose}
|
||||
planningModel={planningModel}
|
||||
onPlanningModelChange={setPlanningModel}
|
||||
thinkingLevel={thinkingLevel}
|
||||
onThinkingLevelChange={setThinkingLevel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -342,6 +342,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
modelId: hasExecutorOverride ? executorModelId : undefined,
|
||||
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
|
||||
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
|
||||
planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
|
||||
planningModelId: hasPlanningOverride ? planningModelId : undefined,
|
||||
});
|
||||
// Clear input for rapid entry
|
||||
resetForm();
|
||||
@@ -363,6 +365,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
hasValidatorOverride,
|
||||
validatorProvider,
|
||||
validatorModelId,
|
||||
hasPlanningOverride,
|
||||
planningProvider,
|
||||
planningModelId,
|
||||
addToast,
|
||||
resetForm,
|
||||
]);
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface TaskFormProps {
|
||||
onValidatorModelChange: (value: string) => void;
|
||||
planningModel?: string;
|
||||
onPlanningModelChange?: (value: string) => void;
|
||||
thinkingLevel?: string;
|
||||
onThinkingLevelChange?: (value: string) => void;
|
||||
presetMode: "default" | "preset" | "custom";
|
||||
onPresetModeChange: (mode: "default" | "preset" | "custom") => void;
|
||||
selectedPresetId: string;
|
||||
@@ -75,6 +77,8 @@ export function TaskForm({
|
||||
onValidatorModelChange,
|
||||
planningModel,
|
||||
onPlanningModelChange,
|
||||
thinkingLevel,
|
||||
onThinkingLevelChange,
|
||||
presetMode,
|
||||
onPresetModeChange,
|
||||
selectedPresetId,
|
||||
@@ -666,6 +670,23 @@ export function TaskForm({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{onThinkingLevelChange && (
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="thinking-level" className="model-select-label">Thinking</label>
|
||||
<select
|
||||
id="thinking-level"
|
||||
value={thinkingLevel || "off"}
|
||||
onChange={(e) => onThinkingLevelChange(e.target.value)}
|
||||
disabled={disabled || presetMode === "preset"}
|
||||
>
|
||||
<option value="off">Off (default)</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -867,4 +867,90 @@ describe("ModelSelectorTab", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("thinkingLevel selector", () => {
|
||||
it("renders thinking level selector with default 'off'", async () => {
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitForSelectors();
|
||||
|
||||
const select = screen.getByLabelText("Thinking Level");
|
||||
expect(select).toBeInTheDocument();
|
||||
expect((select as HTMLSelectElement).value).toBe("off");
|
||||
});
|
||||
|
||||
it("renders current thinking level from task", async () => {
|
||||
const taskWithThinking = { ...FAKE_TASK, thinkingLevel: "high" as const };
|
||||
render(<ModelSelectorTab task={taskWithThinking} addToast={mockAddToast} />);
|
||||
|
||||
await waitForSelectors();
|
||||
|
||||
const select = screen.getByLabelText("Thinking Level");
|
||||
expect((select as HTMLSelectElement).value).toBe("high");
|
||||
});
|
||||
|
||||
it("saves thinking level when changed", async () => {
|
||||
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
|
||||
...FAKE_TASK,
|
||||
...updates,
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitForSelectors();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Thinking Level"), "high");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
"Thinking level set to high",
|
||||
"success",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'set to default' toast when clearing thinking level", async () => {
|
||||
const taskWithThinking = { ...FAKE_TASK, thinkingLevel: "high" as const };
|
||||
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
|
||||
...FAKE_TASK,
|
||||
...updates,
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={taskWithThinking} addToast={mockAddToast} />);
|
||||
|
||||
await waitForSelectors();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Thinking Level"), "off");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
|
||||
thinkingLevel: null,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
"Thinking level set to default (off)",
|
||||
"success",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows thinking level badge for non-default values", async () => {
|
||||
const taskWithThinking = { ...FAKE_TASK, thinkingLevel: "medium" as const };
|
||||
render(<ModelSelectorTab task={taskWithThinking} addToast={mockAddToast} />);
|
||||
|
||||
await waitForSelectors();
|
||||
|
||||
expect(screen.getByText("medium")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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