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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user