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 05a00c2877
commit ffbe8cf59b
12 changed files with 473 additions and 66 deletions

View File

@@ -143,7 +143,7 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
});
}
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 }, 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 }, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),

View File

@@ -34,6 +34,13 @@ function getValidatorSelection(task: Task | TaskDetail): ModelSelection {
};
}
function getPlanningSelection(task: Task | TaskDetail): ModelSelection {
return {
provider: normalizeModelField(task.planningModelProvider),
modelId: normalizeModelField(task.planningModelId),
};
}
function parseModelValue(value: string): ModelSelection {
if (!value) {
return { provider: undefined, modelId: undefined };
@@ -56,8 +63,13 @@ function selectionsEqual(a: ModelSelection, b: ModelSelection): boolean {
return a.provider === b.provider && a.modelId === b.modelId;
}
function getSuccessToastMessage(target: "executor" | "validator", selection: ModelSelection): string {
const label = target === "executor" ? "Executor" : "Validator";
function getSuccessToastMessage(target: "executor" | "validator" | "planning", selection: ModelSelection): string {
const labels: Record<string, string> = {
executor: "Executor",
validator: "Validator",
planning: "Planning",
};
const label = labels[target] || target;
if (!selection.provider || !selection.modelId) {
return `${label} model set to default`;
@@ -77,7 +89,9 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
const [savedExecutor, setSavedExecutor] = useState<ModelSelection>(() => getExecutorSelection(task));
const [selectedValidator, setSelectedValidator] = useState<ModelSelection>(() => getValidatorSelection(task));
const [savedValidator, setSavedValidator] = useState<ModelSelection>(() => getValidatorSelection(task));
const [savingTarget, setSavingTarget] = useState<"executor" | "validator" | null>(null);
const [selectedPlanning, setSelectedPlanning] = useState<ModelSelection>(() => getPlanningSelection(task));
const [savedPlanning, setSavedPlanning] = useState<ModelSelection>(() => getPlanningSelection(task));
const [savingTarget, setSavingTarget] = useState<"executor" | "validator" | "planning" | null>(null);
const activeTaskIdRef = useRef(task.id);
@@ -142,23 +156,28 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
const nextExecutor = getExecutorSelection(task);
const nextValidator = getValidatorSelection(task);
const nextPlanning = getPlanningSelection(task);
setSelectedExecutor(nextExecutor);
setSavedExecutor(nextExecutor);
setSelectedValidator(nextValidator);
setSavedValidator(nextValidator);
setSelectedPlanning(nextPlanning);
setSavedPlanning(nextPlanning);
setSavingTarget(null);
}, [task.id, task.modelProvider, task.modelId, task.validatorModelProvider, task.validatorModelId]);
}, [task.id, task.modelProvider, task.modelId, task.validatorModelProvider, task.validatorModelId, task.planningModelProvider, task.planningModelId]);
const executorValue = useMemo(() => getDropdownValue(selectedExecutor), [selectedExecutor]);
const validatorValue = useMemo(() => getDropdownValue(selectedValidator), [selectedValidator]);
const planningValue = useMemo(() => getDropdownValue(selectedPlanning), [selectedPlanning]);
const isSaving = savingTarget !== null;
const saveSelection = useCallback(
async (target: "executor" | "validator", nextSelection: ModelSelection) => {
async (target: "executor" | "validator" | "planning", nextSelection: ModelSelection) => {
const requestTaskId = task.id;
const previousSavedExecutor = savedExecutor;
const previousSavedValidator = savedValidator;
const previousSavedPlanning = savedPlanning;
setSavingTarget(target);
@@ -176,6 +195,12 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
validatorModelId: target === "validator"
? nextSelection.modelId ?? null
: previousSavedValidator.modelId ?? null,
planningModelProvider: target === "planning"
? nextSelection.provider ?? null
: previousSavedPlanning.provider ?? null,
planningModelId: target === "planning"
? nextSelection.modelId ?? null
: previousSavedPlanning.modelId ?? null,
});
if (activeTaskIdRef.current !== requestTaskId) {
@@ -184,17 +209,23 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
const nextSavedExecutor = getExecutorSelection(updatedTask);
const nextSavedValidator = getValidatorSelection(updatedTask);
const nextSavedPlanning = getPlanningSelection(updatedTask);
setSavedExecutor(nextSavedExecutor);
setSelectedExecutor(nextSavedExecutor);
setSavedValidator(nextSavedValidator);
setSelectedValidator(nextSavedValidator);
setSavedPlanning(nextSavedPlanning);
setSelectedPlanning(nextSavedPlanning);
const targetSelections: Record<string, ModelSelection> = {
executor: nextSavedExecutor,
validator: nextSavedValidator,
planning: nextSavedPlanning,
};
addToast(
getSuccessToastMessage(
target,
target === "executor" ? nextSavedExecutor : nextSavedValidator,
),
getSuccessToastMessage(target, targetSelections[target]),
"success",
);
} catch (err: any) {
@@ -204,8 +235,10 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
if (target === "executor") {
setSelectedExecutor(previousSavedExecutor);
} else {
} else if (target === "validator") {
setSelectedValidator(previousSavedValidator);
} else {
setSelectedPlanning(previousSavedPlanning);
}
addToast(err.message || "Failed to save model settings", "error");
@@ -215,7 +248,7 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
}
}
},
[task.id, savedExecutor, savedValidator, addToast],
[task.id, savedExecutor, savedValidator, savedPlanning, addToast],
);
const handleExecutorChange = useCallback(
@@ -246,8 +279,23 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
[savedValidator, saveSelection],
);
const handlePlanningChange = useCallback(
(value: string) => {
const nextSelection = parseModelValue(value);
setSelectedPlanning(nextSelection);
if (selectionsEqual(nextSelection, savedPlanning)) {
return;
}
void saveSelection("planning", nextSelection);
},
[savedPlanning, saveSelection],
);
const executorUsingDefault = !savedExecutor.provider && !savedExecutor.modelId;
const validatorUsingDefault = !savedValidator.provider && !savedValidator.modelId;
const planningUsingDefault = !savedPlanning.provider && !savedPlanning.modelId;
return (
<div className="model-selector-tab">
@@ -342,8 +390,36 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
<small>The AI model used to review code and plans for this task.</small>
</div>
<div className="form-group">
<label htmlFor="planningModel">Planning Model</label>
<div className="model-selector-current">
{planningUsingDefault ? (
<span className="model-badge model-badge-default">Using default</span>
) : (
<span className="model-badge model-badge-custom">
{savedPlanning.provider && <ProviderIcon provider={savedPlanning.provider} size="sm" />}
{savedPlanning.provider}/{savedPlanning.modelId}
</span>
)}
</div>
<CustomModelDropdown
id="planningModel"
label="Planning Model"
value={planningValue}
onChange={handlePlanningChange}
models={availableModels}
disabled={isSaving}
placeholder="Select planning model…"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
<small>The AI model used for task specification (triage phase).</small>
</div>
<div className="model-selector-status">
{executorUsingDefault && validatorUsingDefault
{executorUsingDefault && validatorUsingDefault && planningUsingDefault
? "Using global default models."
: "Model settings are up to date."}
</div>

View File

@@ -229,6 +229,7 @@ export function TaskDetailModal({
const [editDependencies, setEditDependencies] = useState<string[]>(task.dependencies || []);
const [editExecutorModel, setEditExecutorModel] = useState("");
const [editValidatorModel, setEditValidatorModel] = useState("");
const [editPlanningModel, setEditPlanningModel] = useState("");
const [editPresetMode, setEditPresetMode] = useState<"default" | "preset" | "custom">("default");
const [editSelectedPresetId, setEditSelectedPresetId] = useState("");
const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
@@ -319,9 +320,11 @@ export function TaskDetailModal({
// Populate model overrides from task
const execModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "";
const valModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : "";
const planModel = task.planningModelProvider && task.planningModelId ? `${task.planningModelProvider}/${task.planningModelId}` : "";
setEditExecutorModel(execModel);
setEditValidatorModel(valModel);
setEditPresetMode(execModel || valModel ? "custom" : "default");
setEditPlanningModel(planModel);
setEditPresetMode(execModel || valModel || planModel ? "custom" : "default");
setEditSelectedPresetId("");
setEditSelectedWorkflowSteps(task.enabledWorkflowSteps || []);
setEditPendingImages([]);
@@ -342,6 +345,7 @@ export function TaskDetailModal({
// Build update payload with all changed fields
const executorSlashIdx = editExecutorModel.indexOf("/");
const validatorSlashIdx = editValidatorModel.indexOf("/");
const planningSlashIdx = editPlanningModel.indexOf("/");
const updates: Parameters<typeof updateTask>[1] = {
title: editTitle.trim() || undefined,
@@ -352,6 +356,8 @@ export function TaskDetailModal({
modelId: editExecutorModel && executorSlashIdx !== -1 ? editExecutorModel.slice(executorSlashIdx + 1) : null,
validatorModelProvider: editValidatorModel && validatorSlashIdx !== -1 ? editValidatorModel.slice(0, validatorSlashIdx) : null,
validatorModelId: editValidatorModel && validatorSlashIdx !== -1 ? editValidatorModel.slice(validatorSlashIdx + 1) : null,
planningModelProvider: editPlanningModel && planningSlashIdx !== -1 ? editPlanningModel.slice(0, planningSlashIdx) : null,
planningModelId: editPlanningModel && planningSlashIdx !== -1 ? editPlanningModel.slice(planningSlashIdx + 1) : null,
};
await updateTask(task.id, updates, projectId);
@@ -384,7 +390,7 @@ export function TaskDetailModal({
setIsSaving(false);
}
}
}, [task.id, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId]);
}, [task.id, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId]);
// Handle keyboard shortcuts for edit mode
const handleEditKeyDown = useCallback((e: KeyboardEvent) => {
@@ -796,6 +802,8 @@ export function TaskDetailModal({
onExecutorModelChange={setEditExecutorModel}
validatorModel={editValidatorModel}
onValidatorModelChange={setEditValidatorModel}
planningModel={editPlanningModel}
onPlanningModelChange={setEditPlanningModel}
presetMode={editPresetMode}
onPresetModeChange={setEditPresetMode}
selectedPresetId={editSelectedPresetId}

View File

@@ -31,6 +31,8 @@ export interface TaskFormProps {
onExecutorModelChange: (value: string) => void;
validatorModel: string;
onValidatorModelChange: (value: string) => void;
planningModel?: string;
onPlanningModelChange?: (value: string) => void;
presetMode: "default" | "preset" | "custom";
onPresetModeChange: (mode: "default" | "preset" | "custom") => void;
selectedPresetId: string;
@@ -71,6 +73,8 @@ export function TaskForm({
onExecutorModelChange,
validatorModel,
onValidatorModelChange,
planningModel,
onPlanningModelChange,
presetMode,
onPresetModeChange,
selectedPresetId,
@@ -641,6 +645,27 @@ export function TaskForm({
onToggleModelFavorite={handleToggleModelFavorite}
/>
</div>
{onPlanningModelChange && (
<div className="model-select-row">
<label htmlFor="planning-model" className="model-select-label">Planning</label>
<CustomModelDropdown
id="planning-model"
label="Planning Model"
value={planningModel || ""}
onChange={(value) => {
onPresetModeChange("custom");
onSelectedPresetIdChange("");
onPlanningModelChange(value);
}}
models={availableModels}
disabled={disabled || presetMode === "preset"}
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
</div>
)}
</>
)}
</div>

View File

@@ -81,6 +81,25 @@ describe("ModelSelectorTab", () => {
) ?? screen.getAllByText("Use default")[0];
}
/** Helper to build expected updateTask call with all model fields */
function expectedModelCall(overrides: {
modelProvider?: string | null;
modelId?: string | null;
validatorModelProvider?: string | null;
validatorModelId?: string | null;
planningModelProvider?: string | null;
planningModelId?: string | null;
} = {}) {
return {
modelProvider: overrides.modelProvider ?? null,
modelId: overrides.modelId ?? null,
validatorModelProvider: overrides.validatorModelProvider ?? null,
validatorModelId: overrides.validatorModelId ?? null,
planningModelProvider: overrides.planningModelProvider ?? null,
planningModelId: overrides.planningModelId ?? null,
};
}
beforeEach(() => {
vi.clearAllMocks();
mockFetchModels.mockResolvedValue(MOCK_MODELS_RESPONSE);
@@ -103,6 +122,7 @@ describe("ModelSelectorTab", () => {
await waitForSelectors();
expect(screen.getByLabelText("Validator Model")).toBeInTheDocument();
expect(screen.getByLabelText("Planning Model")).toBeInTheDocument();
expect(screen.queryByText("Save")).not.toBeInTheDocument();
expect(screen.queryByText("Reset")).not.toBeInTheDocument();
});
@@ -117,6 +137,9 @@ describe("ModelSelectorTab", () => {
const validatorSection = getSection("Validator Model");
expect(within(validatorSection!).getByText("Using default")).toBeInTheDocument();
const planningSection = getSection("Planning Model");
expect(within(planningSection!).getByText("Using default")).toBeInTheDocument();
});
it("shows current custom model when overrides are set", async () => {
@@ -214,23 +237,21 @@ describe("ModelSelectorTab", () => {
await selectOption("Executor Model", "Claude Sonnet 4.5");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenNthCalledWith(1, "FN-001", {
expect(mockUpdateTask).toHaveBeenNthCalledWith(1, "FN-001", expectedModelCall({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: null,
validatorModelId: null,
});
}));
});
await selectOption("Validator Model", "GPT-4o");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenNthCalledWith(2, "FN-001", {
expect(mockUpdateTask).toHaveBeenNthCalledWith(2, "FN-001", expectedModelCall({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
});
}));
});
});
@@ -251,12 +272,12 @@ describe("ModelSelectorTab", () => {
await selectOption("Executor Model", "Claude Sonnet 4.5");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
});
}));
});
});
@@ -280,12 +301,7 @@ describe("ModelSelectorTab", () => {
await user.click(getUseDefaultOption());
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
});
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall());
});
});
@@ -306,12 +322,12 @@ describe("ModelSelectorTab", () => {
await selectOption("Validator Model", "GPT-4o");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
});
}));
});
});
@@ -335,12 +351,7 @@ describe("ModelSelectorTab", () => {
await user.click(getUseDefaultOption());
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
});
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall());
});
});
@@ -366,7 +377,7 @@ describe("ModelSelectorTab", () => {
});
});
it("disables both selectors while saving", async () => {
it("disables all selectors while saving", async () => {
const user = userEvent.setup();
let resolveUpdate: ((value: Task) => void) | undefined;
mockUpdateTask.mockImplementation(
@@ -385,6 +396,7 @@ describe("ModelSelectorTab", () => {
await waitFor(() => {
expect(getSelector("Executor Model")).toBeDisabled();
expect(getSelector("Validator Model")).toBeDisabled();
expect(getSelector("Planning Model")).toBeDisabled();
});
resolveUpdate?.({
@@ -396,6 +408,7 @@ describe("ModelSelectorTab", () => {
await waitFor(() => {
expect(getSelector("Executor Model")).not.toBeDisabled();
expect(getSelector("Validator Model")).not.toBeDisabled();
expect(getSelector("Planning Model")).not.toBeDisabled();
});
});
@@ -658,12 +671,10 @@ describe("ModelSelectorTab", () => {
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: null,
validatorModelId: null,
});
}));
});
it("Use default option is always visible", async () => {
@@ -708,12 +719,151 @@ describe("ModelSelectorTab", () => {
await user.click(screen.getByText("GPT-4o"));
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall({
modelProvider: "openai",
modelId: "gpt-4o",
validatorModelProvider: null,
validatorModelId: null,
});
}));
});
});
});
describe("Planning model selector", () => {
it("renders planning model dropdown", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitForSelectors();
expect(screen.getByLabelText("Planning Model")).toBeInTheDocument();
});
it("shows 'Using default' badge when no planning model override is set", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitForSelectors();
const planningSection = getSection("Planning Model");
expect(within(planningSection!).getByText("Using default")).toBeInTheDocument();
});
it("shows custom badge when planning model override is set", async () => {
const taskWithPlanning = {
...FAKE_TASK,
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
};
render(<ModelSelectorTab task={taskWithPlanning} addToast={mockAddToast} />);
await waitForSelectors();
const planningSection = getSection("Planning Model");
const badge = within(planningSection!).getByText("google/gemini-2.5-pro", { selector: ".model-badge-custom" });
expect(badge).toBeInTheDocument();
});
it("auto-saves planning model selection correctly", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitForSelectors();
await selectOption("Planning Model", "Claude Sonnet 4.5");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall({
planningModelProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
}));
});
});
it("clears planning model override with 'Use default'", async () => {
const taskWithPlanning = {
...FAKE_TASK,
planningModelProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
};
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
...taskWithPlanning,
...updates,
}));
const user = userEvent.setup();
render(<ModelSelectorTab task={taskWithPlanning} addToast={mockAddToast} />);
await waitForSelectors();
await user.click(getSelector("Planning Model"));
await user.click(getUseDefaultOption());
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall());
});
});
it("preserves executor and validator overrides when saving planning model", async () => {
const taskWithModels = {
...FAKE_TASK,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
};
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
...taskWithModels,
...updates,
}));
render(<ModelSelectorTab task={taskWithModels} addToast={mockAddToast} />);
await waitForSelectors();
await selectOption("Planning Model", "Claude Opus 4");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expectedModelCall({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
planningModelProvider: "anthropic",
planningModelId: "claude-opus-4",
}));
});
});
it("shows planning model success toast with correct model name", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitForSelectors();
await selectOption("Planning Model", "GPT-4o");
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
"Planning model set to openai/gpt-4o",
"success",
);
});
});
it("shows 'set to default' toast when clearing planning model override", async () => {
const taskWithPlanning = {
...FAKE_TASK,
planningModelProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
};
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
...taskWithPlanning,
...updates,
}));
const user = userEvent.setup();
render(<ModelSelectorTab task={taskWithPlanning} addToast={mockAddToast} />);
await waitForSelectors();
await user.click(getSelector("Planning Model"));
await user.click(getUseDefaultOption());
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("Planning model set to default", "success");
});
});
});

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