feat(FN-2241): add review level selection in task creation and editing

- Extend core task types/store and dashboard API route handling to persist task reviewLevel
- Add review level controls to TaskForm, NewTaskModal, and TaskDetailModal flows
- Improve workflow step selector presentation in WorkflowResultsTab and styles for clearer review settings UX
- Document the new review level behavior and add route/form/modal tests to cover create and edit scenarios
This commit is contained in:
Fusion
2026-04-22 09:23:57 -07:00
committed by gsxdsm
parent b71697f9bd
commit d3c6186b52
13 changed files with 413 additions and 88 deletions

View File

@@ -184,6 +184,7 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
planningModelId,
thinkingLevel,
summarize,
reviewLevel,
} = input;
return api<Task>(withProjectId("/tasks", projectId), {
@@ -205,11 +206,12 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
planningModelId,
thinkingLevel,
summarize,
reviewLevel,
}),
});
}
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> {
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; reviewLevel?: number | null }, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),

View File

@@ -33,6 +33,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [hasDirtyState, setHasDirtyState] = useState(false);
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
const [workflowStepsExplicitlySet, setWorkflowStepsExplicitlySet] = useState(false);
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
// Agent assignment state
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
@@ -142,9 +143,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
planningModel !== "" ||
thinkingLevel !== "" ||
selectedWorkflowSteps.length > 0 ||
selectedAgentId !== null;
selectedAgentId !== null ||
reviewLevel !== undefined;
setHasDirtyState(isDirty);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel]);
const handleClose = useCallback(() => {
if (hasDirtyState) {
@@ -166,6 +168,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setWorkflowStepsExplicitlySet(false);
setSelectedAgentId(null);
setShowAgentPicker(false);
setReviewLevel(undefined);
setHasDirtyState(false);
onClose();
}, [hasDirtyState, onClose, pendingImages]);
@@ -197,6 +200,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
planningModelProvider: planningModel && planningSlashIdx !== -1 ? planningModel.slice(0, planningSlashIdx) : undefined,
planningModelId: planningModel && planningSlashIdx !== -1 ? planningModel.slice(planningSlashIdx + 1) : undefined,
thinkingLevel: thinkingLevel !== "" ? thinkingLevel as "minimal" | "low" | "medium" | "high" : undefined,
reviewLevel,
});
// Upload pending images as attachments
@@ -229,6 +233,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setWorkflowStepsExplicitlySet(false);
setSelectedAgentId(null);
setShowAgentPicker(false);
setReviewLevel(undefined);
addToast(`Created ${task.id}`, "success");
onClose();
@@ -436,6 +441,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
onPlanningModelChange={setPlanningModel}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={setThinkingLevel}
reviewLevel={reviewLevel}
onReviewLevelChange={setReviewLevel}
renderBelowPrimary={quickFields}
hideDependencies={true}
/>

View File

@@ -321,6 +321,7 @@ export function TaskDetailModal({
const [editPlanningModel, setEditPlanningModel] = useState("");
const [editThinkingLevel, setEditThinkingLevel] = useState("");
const [editPresetMode, setEditPresetMode] = useState<"default" | "preset" | "custom">("default");
const [editReviewLevel, setEditReviewLevel] = useState<number | undefined>(undefined);
const [editSelectedPresetId, setEditSelectedPresetId] = useState("");
const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
const [editPendingImages, setEditPendingImages] = useState<PendingImage[]>([]);
@@ -526,6 +527,7 @@ export function TaskDetailModal({
setEditSelectedPresetId("");
setEditSelectedWorkflowSteps(task.enabledWorkflowSteps || []);
setEditPendingImages([]);
setEditReviewLevel(task.reviewLevel);
}, [canEdit, task]);
const exitEditMode = useCallback(() => {
@@ -583,6 +585,11 @@ export function TaskDetailModal({
updates.thinkingLevel = editThinkingLevel !== "" ? (editThinkingLevel as "minimal" | "low" | "medium" | "high") : null;
}
const currentReviewLevel = task.reviewLevel;
if (editReviewLevel !== currentReviewLevel) {
updates.reviewLevel = editReviewLevel;
}
const hasTaskUpdates = Object.keys(updates).length > 0;
if (hasTaskUpdates) {
const updatedTask = await updateTask(task.id, updates, projectId);
@@ -617,7 +624,7 @@ export function TaskDetailModal({
setIsSaving(false);
}
}
}, [task, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId, onTaskUpdated]);
}, [task, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editReviewLevel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId, onTaskUpdated]);
const handleAutoSaveDescription = useCallback(async (description: string) => {
try {
@@ -1147,6 +1154,8 @@ export function TaskDetailModal({
addToast={addToast}
isActive={isEditing}
onAutoSaveDescription={handleAutoSaveDescription}
reviewLevel={editReviewLevel}
onReviewLevelChange={setEditReviewLevel}
/>
</div>
) : (

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect, useRef, type ReactNode } from "react";
import type { Task, ModelPreset, Settings, WorkflowStep } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, type RefinementType, type ModelInfo } from "../api";
@@ -8,6 +8,19 @@ import { Sparkles, ChevronUp, ChevronDown, X, Maximize2, Minimize2 } from "lucid
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
/** Renders a phase badge using shared .phase-badge classes for consistency */
function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: string): ReactNode {
const phaseClass = phase === "post-merge" ? "phase-badge--post-merge" : "phase-badge--pre-merge";
return (
<span
className={`phase-badge ${phaseClass}`}
data-testid={`${prefix}-${id}`}
>
{phase === "post-merge" ? "Post-merge" : "Pre-merge"}
</span>
);
}
export interface PendingImage {
file: File;
previewUrl: string;
@@ -60,6 +73,10 @@ export interface TaskFormProps {
// Auto-save callback (edit mode)
onAutoSaveDescription?: (description: string) => Promise<void>;
// Review level (0=None, 1=Plan Only, 2=Plan and Code, 3=Full)
reviewLevel?: number;
onReviewLevelChange?: (value: number) => void;
// AI-assisted creation callbacks (create mode only)
onPlanningMode?: (initialPlan: string) => void;
onSubtaskBreakdown?: (description: string) => void;
@@ -107,6 +124,8 @@ export function TaskForm({
onClose,
renderBelowPrimary,
hideDependencies,
reviewLevel,
onReviewLevelChange,
}: TaskFormProps) {
const hasInitialMoreOptions =
(hideDependencies ? false : dependencies.length > 0) ||
@@ -116,7 +135,8 @@ export function TaskForm({
executorModel !== "" ||
validatorModel !== "" ||
(planningModel || "") !== "" ||
(thinkingLevel || "") !== "";
(thinkingLevel || "") !== "" ||
reviewLevel !== undefined;
const [showDepDropdown, setShowDepDropdown] = useState(false);
const [showMoreOptions, setShowMoreOptions] = useState(hasInitialMoreOptions);
@@ -176,7 +196,8 @@ export function TaskForm({
executorModel !== "" ||
validatorModel !== "" ||
(planningModel || "") !== "" ||
(thinkingLevel || "") !== "";
(thinkingLevel || "") !== "" ||
reviewLevel !== undefined;
// Auto-select preset by size (create mode only)
useEffect(() => {
@@ -952,6 +973,23 @@ export function TaskForm({
</select>
</div>
)}
{onReviewLevelChange && (
<div className="model-select-row">
<label htmlFor="review-level" className="model-select-label">Review</label>
<select
id="review-level"
value={reviewLevel ?? ""}
onChange={(e) => onReviewLevelChange(e.target.value === "" ? 0 : parseInt(e.target.value, 10))}
disabled={disabled}
>
<option value="">Default (None)</option>
<option value="0">0 — None</option>
<option value="1">1 — Plan Only</option>
<option value="2">2 — Plan and Code</option>
<option value="3">3 — Full</option>
</select>
</div>
)}
</>
)}
</div>
@@ -959,57 +997,41 @@ export function TaskForm({
{/* Workflow Steps */}
<div className="form-group" data-testid="workflow-steps-section">
<label>Workflow Steps</label>
<small style={{ marginBottom: "8px", display: "block" }}>
Select steps to run after task implementation completes
</small>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{workflowSteps.length > 0 && workflowSteps.map((step) => (
<label
key={step.id}
className="checkbox-label"
style={{ display: "flex", alignItems: "flex-start", gap: "8px" }}
data-testid={`workflow-step-checkbox-${step.id}`}
>
<input
type="checkbox"
checked={selectedWorkflowSteps.includes(step.id)}
onChange={(e) => {
onWorkflowStepsChange(
e.target.checked
? [...selectedWorkflowSteps, step.id]
: selectedWorkflowSteps.filter((id) => id !== step.id)
);
}}
disabled={disabled}
style={{ marginTop: "2px" }}
/>
<div>
<span style={{ fontWeight: 500, fontSize: "13px" }}>
{step.name}
<span
style={{
marginLeft: "6px",
fontSize: "11px",
padding: "1px 6px",
borderRadius: "4px",
background: (step.phase || "pre-merge") === "post-merge"
? "rgba(139, 92, 246, 0.15)"
: "rgba(59, 130, 246, 0.15)",
color: (step.phase || "pre-merge") === "post-merge"
? "#8b5cf6"
: "#3b82f6",
}}
data-testid={`workflow-step-phase-${step.id}`}
>
{(step.phase || "pre-merge") === "post-merge" ? "Post-merge" : "Pre-merge"}
<div className="workflow-steps-section">
<small className="workflow-steps-description">
Select steps to run after task implementation completes
</small>
<div className="workflow-steps-list">
{workflowSteps.length > 0 && workflowSteps.map((step) => (
<label
key={step.id}
className="checkbox-label workflow-step-item"
data-testid={`workflow-step-checkbox-${step.id}`}
>
<input
type="checkbox"
checked={selectedWorkflowSteps.includes(step.id)}
onChange={(e) => {
onWorkflowStepsChange(
e.target.checked
? [...selectedWorkflowSteps, step.id]
: selectedWorkflowSteps.filter((id) => id !== step.id)
);
}}
disabled={disabled}
/>
<div>
<span className="workflow-step-name">
{step.name}
{phaseBadge(step.phase || "pre-merge", step.id, "workflow-step-phase")}
</span>
</span>
<div style={{ fontSize: "12px", color: "var(--text-secondary)", marginTop: "2px" }}>
{step.description}
<div className="workflow-step-description">
{step.description}
</div>
</div>
</div>
</label>
))}
</label>
))}
</div>
</div>
{/* Selected steps — execution order with reorder controls */}

View File

@@ -361,37 +361,36 @@ export function WorkflowResultsTab({
return (
<div className="workflow-results-editor" data-testid="workflow-steps-editor">
<small style={{ marginBottom: "8px", display: "block" }}>
Select steps to run after task implementation completes
</small>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{workflowStepOptions.map((step) => (
<label
key={step.id}
className="checkbox-label"
style={{ display: "flex", alignItems: "flex-start", gap: "8px" }}
data-testid={step.id === "browser-verification"
? "browser-verification-checkbox"
: `workflow-step-checkbox-${step.id}`}
>
<input
type="checkbox"
checked={selectedWorkflowSteps.includes(step.id)}
onChange={(event) => toggleStep(step.id, event.target.checked)}
style={{ marginTop: "2px" }}
/>
<div>
<span style={{ fontWeight: 500, fontSize: "13px" }}>
{step.name}
{phaseBadge(step.phase, step.id, "workflow-step-phase")}
</span>
<div style={{ fontSize: "12px", color: "var(--text-secondary)", marginTop: "2px" }}>
{step.description}
<div className="workflow-steps-section">
<small className="workflow-steps-description">
Select steps to run after task implementation completes
</small>
<div className="workflow-steps-list">
{workflowStepOptions.map((step) => (
<label
key={step.id}
className="checkbox-label workflow-step-item"
data-testid={step.id === "browser-verification"
? "browser-verification-checkbox"
: `workflow-step-checkbox-${step.id}`}
>
<input
type="checkbox"
checked={selectedWorkflowSteps.includes(step.id)}
onChange={(event) => toggleStep(step.id, event.target.checked)}
/>
<div>
<span className="workflow-step-name">
{step.name}
{phaseBadge(step.phase, step.id, "workflow-step-phase")}
</span>
<div className="workflow-step-description">
{step.description}
</div>
</div>
</div>
</label>
))}
</label>
))}
</div>
</div>
{selectedWorkflowSteps.length > 1 && (

View File

@@ -630,6 +630,82 @@ describe("NewTaskModal", () => {
});
});
// Review level tests (FN-2241)
describe("review level selection payload", () => {
it("omits reviewLevel from payload when not selected", async () => {
const { props } = renderNewTaskModal();
const descTextarea = screen.getByRole('textbox');
fireEvent.change(descTextarea, { target: { value: "Task without review level" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
reviewLevel: undefined,
}),
);
});
});
it("includes reviewLevel in payload when selected", async () => {
const { props } = renderNewTaskModal();
// Open more options to access the review level selector
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
expect(screen.getByLabelText("Review")).toBeTruthy();
});
// Select review level 2 (Plan and Code)
const select = document.getElementById("review-level") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "2" } });
const descTextarea = screen.getByRole('textbox');
fireEvent.change(descTextarea, { target: { value: "Task with review level" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
reviewLevel: 2,
}),
);
});
});
it("includes reviewLevel 3 in payload when Full review is selected", async () => {
const { props } = renderNewTaskModal();
// Open more options to access the review level selector
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
expect(screen.getByLabelText("Review")).toBeTruthy();
});
// Select review level 3 (Full)
const select = document.getElementById("review-level") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "3" } });
const descTextarea = screen.getByRole('textbox');
fireEvent.change(descTextarea, { target: { value: "Task with full review" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
reviewLevel: 3,
}),
);
});
});
});
// Agent assignment tests (FN-1483)
describe("agent assignment", () => {
it("renders agent picker button", () => {

View File

@@ -69,6 +69,8 @@ function renderTaskForm(props: Partial<React.ComponentProps<typeof TaskForm>> =
tasks: [],
addToast: vi.fn(),
isActive: true,
reviewLevel: undefined,
onReviewLevelChange: vi.fn(),
};
const mergedProps = { ...defaultProps, ...props };
const result = render(<TaskForm {...mergedProps} />);
@@ -99,6 +101,8 @@ function renderTaskFormWithDescriptionState(props: Partial<React.ComponentProps<
tasks: [],
addToast: vi.fn(),
isActive: true,
reviewLevel: undefined,
onReviewLevelChange: vi.fn(),
};
const mergedProps = { ...defaultProps, ...props };

View File

@@ -15364,6 +15364,45 @@ html .column.drag-over * {
margin-bottom: var(--space-sm);
}
/* === Workflow Steps Section (TaskForm & WorkflowResultsTab) === */
.workflow-steps-section {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.workflow-steps-description {
margin-bottom: var(--space-xs);
display: block;
color: var(--text-muted);
font-size: var(--space-xs);
}
.workflow-steps-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.workflow-step-item {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
}
.workflow-step-name {
font-weight: 500;
font-size: 13px;
line-height: 1.4;
}
.workflow-step-description {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
line-height: 1.4;
}
/* Workflow step reorder controls */
.workflow-step-order {
margin-top: var(--space-sm);
@@ -15459,6 +15498,13 @@ html .column.drag-over * {
cursor: not-allowed;
}
@media (max-width: 768px) {
.workflow-step-order-actions .btn-icon {
width: 36px;
height: 36px;
}
}
/* === New Task Modal Quick Fields === */
.new-task-quick-fields {
display: flex;

View File

@@ -758,6 +758,72 @@ describe("POST /tasks", () => {
expect(store.createTask).not.toHaveBeenCalled();
});
it("forwards reviewLevel when provided", async () => {
const createdTask = { ...FAKE_TASK_DETAIL, column: "triage" };
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ description: "Test task", reviewLevel: 2 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({ reviewLevel: 2 }),
expect.any(Object),
);
});
it("accepts reviewLevel 0 (None) via POST", async () => {
const createdTask = { ...FAKE_TASK_DETAIL, column: "triage", reviewLevel: 0 };
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ description: "Test task", reviewLevel: 0 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({ reviewLevel: 0 }),
expect.any(Object),
);
});
it("returns 400 for invalid reviewLevel value via POST", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ description: "Test task", reviewLevel: 5 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("reviewLevel must be an integer between 0 and 3");
expect(store.createTask).not.toHaveBeenCalled();
});
it("returns 400 for non-integer reviewLevel via POST", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ description: "Test task", reviewLevel: 1.5 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("reviewLevel must be an integer between 0 and 3");
expect(store.createTask).not.toHaveBeenCalled();
});
it("forwards planningModelProvider and planningModelId when provided", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
@@ -2674,6 +2740,64 @@ describe("PATCH /tasks/:id", () => {
expect(res.status).toBe(400);
expect(res.body.error).toContain("thinkingLevel must be one of");
});
it("forwards reviewLevel to store.updateTask", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
reviewLevel: 2,
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
reviewLevel: 2,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
reviewLevel: 2,
});
});
it("accepts null to clear reviewLevel via PATCH", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
reviewLevel: undefined,
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
reviewLevel: null,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
reviewLevel: null,
});
});
it("returns 400 for invalid reviewLevel value via PATCH", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
reviewLevel: 5,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("reviewLevel must be an integer between 0 and 3");
});
it("returns 400 for non-integer reviewLevel via PATCH", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
reviewLevel: 1.5,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("reviewLevel must be an integer between 0 and 3");
});
});

View File

@@ -3805,6 +3805,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
planningModelProvider,
planningModelId,
thinkingLevel,
reviewLevel,
} = req.body;
if (!description || typeof description !== "string") {
throw badRequest("description is required");
@@ -3826,6 +3827,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest(`thinkingLevel must be one of: ${validThinkingLevels.join(", ")}`);
}
// Validate reviewLevel if provided (must be integer 0-3)
if (reviewLevel !== undefined && reviewLevel !== null) {
if (typeof reviewLevel !== "number" || !Number.isInteger(reviewLevel) || reviewLevel < 0 || reviewLevel > 3) {
throw badRequest("reviewLevel must be an integer between 0 and 3");
}
}
const executorModel = normalizeModelSelectionPair(validatedModelProvider, validatedModelId);
const validatorModel = normalizeModelSelectionPair(validatedValidatorModelProvider, validatedValidatorModelId);
const planningModel = normalizeModelSelectionPair(validatedPlanningModelProvider, validatedPlanningModelId);
@@ -3891,6 +3899,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
planningModelId: planningModel.modelId,
thinkingLevel: thinkingLevel || undefined,
summarize,
reviewLevel: reviewLevel ?? undefined,
},
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } }
);
@@ -5086,7 +5095,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.patch("/tasks/:id", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId } = req.body;
const { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel } = req.body;
const hasBodyField = (field: string) => Object.prototype.hasOwnProperty.call(req.body, field);
// Validate model fields are strings or undefined/null
@@ -5113,6 +5122,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw new Error(`thinkingLevel must be one of: ${validThinkingLevels.join(", ")}`);
}
// Validate reviewLevel if provided (must be integer 0-3)
if (reviewLevel !== undefined && reviewLevel !== null) {
if (typeof reviewLevel !== "number" || !Number.isInteger(reviewLevel) || reviewLevel < 0 || reviewLevel > 3) {
throw new Error("reviewLevel must be an integer between 0 and 3");
}
}
if (enabledWorkflowSteps !== undefined) {
if (!Array.isArray(enabledWorkflowSteps) || !enabledWorkflowSteps.every((id: unknown) => typeof id === "string")) {
throw new Error("enabledWorkflowSteps must be an array of strings");
@@ -5133,6 +5149,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (hasBodyField("planningModelId")) updates.planningModelId = validatedPlanningModelId;
if (hasBodyField("thinkingLevel")) updates.thinkingLevel = thinkingLevel === null ? null : thinkingLevel;
if (hasBodyField("assigneeUserId")) updates.assigneeUserId = validatedAssigneeUserId;
if (hasBodyField("reviewLevel")) updates.reviewLevel = reviewLevel;
const task = await scopedStore.updateTask(req.params.id, updates);
res.json(task);
@@ -5140,7 +5157,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (err instanceof ApiError) {
throw err;
}
const status = (err instanceof Error ? err.message : String(err)).includes("must be a string") || (err instanceof Error ? err.message : String(err)).includes("must be an array of strings") || (err instanceof Error ? err.message : String(err)).includes("thinkingLevel must be one of") ? 400 : 500;
const status = (err instanceof Error ? err.message : String(err)).includes("must be a string") || (err instanceof Error ? err.message : String(err)).includes("must be an array of strings") || (err instanceof Error ? err.message : String(err)).includes("thinkingLevel must be one of") || (err instanceof Error ? err.message : String(err)).includes("reviewLevel must be an integer") ? 400 : 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
}
});