diff --git a/.changeset/thinking-level-selector-parity.md b/.changeset/thinking-level-selector-parity.md new file mode 100644 index 0000000000..4546c90851 --- /dev/null +++ b/.changeset/thinking-level-selector-parity.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add thinking-level controls to agent and bulk task model selectors. +category: feature +dev: Dashboard agent detail/onboarding model pickers and List bulk model updates now persist thinkingLevel. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index b4e1c535ff..3c6f868e24 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -268,7 +268,7 @@ Features: - Sectioned task table grouped by lifecycle column - Sortable columns (ID/title/status/column) - Column visibility toggles and optional hide-done filtering -- Bulk selection + batch model updates +- Bulk selection + batch model, node, and task thinking-level updates - Bulk Pause / Unpause / Archive actions from the selection toolbar (`Pause selected`, `Unpause selected`, `Archive selected`) for fast batch task state management. - Bulk delete from the selection toolbar (`Delete selected`): archived selections are skipped automatically, and dependency-conflict failures can be force-deleted per task after a danger confirmation that removes dependency references. - Desktop List view keeps the two-pane table/detail split. Tablet-width and mobile viewports use the single-pane card layout so list controls and quick-add stay full-width; tapping a task opens detail instead of selecting an embedded split pane. @@ -500,7 +500,7 @@ Rules: - `auto-new` creates a branch after task creation using `fusion/{task-id}-{short-name}` (for example `fusion/fn-5671-branch-strategy-dropdown`). - `Merge target / base branch` stays optional for all modes and uses the same branch-dropdown + `Custom…` fallback behavior as Planning Mode. - In **More options → Model Configuration**, **Auto-merge** is a per-task override with three states: **Default** (follow project setting), **Enabled**, or **Disabled**. -- In **More options → Model Configuration**, task and agent model pickers expose **Thinking Level** inside the same model dropdown panel instead of as a separate adjacent selector. Task pickers offer **Default (project setting)** plus **Off**, **Minimal**, **Low**, **Medium**, **High**, and **Very High**; agent creation is concrete-only and starts at **Off**. +- In **More options → Model Configuration**, task and agent model pickers expose **Thinking Level** inside the same model dropdown panel instead of as a separate adjacent selector. Task pickers offer **Default (project setting)** plus **Off**, **Minimal**, **Low**, **Medium**, **High**, and **Very High**; agent creation, Agent Onboarding review, and Agent Detail built-in-model settings are concrete-only and start/fall back to **Off**. - In **More options → Model Configuration**, **Planner oversight** is a per-task override of the workflow-native `plannerOversightLevel` setting (FN-7508): **Inherit from workflow** (default) plus **Off**, **Observe**, **Steer**, and **Autonomous recovery**. This selector appears in both the New Task dialog and the Task Detail edit form (same shared control). Selecting **Inherit from workflow** clears the per-task override (sent as `null` on edit, omitted on create) so the task falls back to the effective `plannerOversightLevel` configured on its workflow — set project/global defaults for this in the **Workflow Editor → Values** tab, not in Project Settings; it is workflow-native, not a project setting. The dialog also exposes AI handoffs that quick-add no longer shows: **Plan** opens Planning Mode with the current description, and **Subtask** opens Subtask Breakdown with the current description when **Settings → Experimental Features → Subtask Breakdown** is enabled. The Subtask handoff is hidden by default; visible handoff buttons remain disabled until the description has content, matching the quick-add row behavior for Subtask. **Execution mode** and optional workflow-step selection are available in the New Task dialog as well as quick entry, so users can choose Fast or standard execution and opt into workflow-specific creation-time steps before creating a task from either surface. @@ -994,6 +994,7 @@ Features: - First-run setup asks whether to create an optional project agent after project registration. The default template is **CEO**; users can choose another preset, use the AI interview when `experimentalFeatures.agentOnboarding` is enabled, or skip it. Fusion can still build tasks without an agent by starting temporary agents to plan, code, review, and merge task work. - Start, pause, stop, and trigger agent runs from the view and from detail panels - In **Agent detail**, use the kebab **Bulk agent actions** button in the header utility cluster (next to **Refresh** and **Close**) to run project-wide lifecycle transitions for non-ephemeral agents in the current project — **Pause All Agents** targets agents in the `active` or `running` state, while **Resume All Agents** targets agents in the `paused` state only +- In **Agent detail → Settings → Configuration**, the built-in-model picker includes a concrete **Thinking Level** selector; changing it autosaves to the agent's `runtimeConfig.thinkingLevel` alongside the provider/model choice. - Bulk menu items stay disabled when nothing is eligible and show an inline hint (`Loading eligible agents...`, `No active agents eligible`, `No paused agents eligible`, or the current eligible count such as `Pause 2 active/running agents`) - Bulk lifecycle flow: open **Bulk agent actions**, review the eligibility hint, confirm the modal, then use the success or partial-failure toast to verify paused/resumed counts plus skipped/failed agents - Open agent detail tabs for runs, logs, read-only mail (agent inbox/outbox), settings/config, tasks, memory, and chain-of-command relationships diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 25db4c998b..f5a03f1c22 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -602,6 +602,7 @@ export function updateTask( * @param modelId - Executor model ID (optional, null to clear) * @param validatorModelProvider - Validator model provider (optional, null to clear) * @param validatorModelId - Validator model ID (optional, null to clear) + * @param thinkingLevel - Executor thinking level (optional, null to clear) * @returns Promise with updated tasks and count */ export function batchUpdateTaskModels( @@ -613,6 +614,7 @@ export function batchUpdateTaskModels( planningModelProvider?: string | null, planningModelId?: string | null, nodeId?: string | null, + thinkingLevel?: string | null, projectId?: string, ): Promise<{ updated: Task[]; count: number }> { return api<{ updated: Task[]; count: number }>(withProjectId("/tasks/batch-update-models", projectId), { @@ -626,6 +628,7 @@ export function batchUpdateTaskModels( planningModelProvider, planningModelId, nodeId, + ...(thinkingLevel !== undefined ? { thinkingLevel } : {}), }), }); } diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index bcfa62c2ca..edd2e495a5 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -15,7 +15,7 @@ import remarkGfm from "remark-gfm"; import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api"; import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api"; import type { Agent } from "../api"; -import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission } from "@fusion/core"; +import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel } from "@fusion/core"; import { AGENT_PERMISSIONS, getErrorMessage, isEphemeralAgent } from "@fusion/core"; import { AgentLogViewer } from "./AgentLogViewer"; import { LoadingSpinner } from "./LoadingSpinner"; @@ -3709,6 +3709,7 @@ function ConfigTab({ initial[field.key] = String(raw); } } + initial.thinkingLevel = typeof agent.runtimeConfig?.thinkingLevel === "string" ? agent.runtimeConfig.thinkingLevel : "off"; return initial; }); @@ -4084,6 +4085,7 @@ function ConfigTab({ if (runtimeMode !== (initialRuntimeHint ? "runtime" : "model")) return true; if (modelValue !== initialModelValue) return true; if (selectedRuntimeId !== initialRuntimeHint) return true; + if ((formValues.thinkingLevel || "off") !== (typeof rc.thinkingLevel === "string" ? rc.thinkingLevel : "off")) return true; return false; })(); @@ -4130,6 +4132,10 @@ function ConfigTab({ setSkipHeartbeatWhenIdle(deriveSkipHeartbeatWhenIdle(agent.runtimeConfig)); setHeartbeatScopeDiscipline(deriveHeartbeatScopeDiscipline(agent.runtimeConfig)); setBudgetValues(deriveBudgetValues(agent.runtimeConfig)); + setFormValues((prev) => ({ + ...prev, + thinkingLevel: typeof agent.runtimeConfig?.thinkingLevel === "string" ? agent.runtimeConfig.thinkingLevel : "off", + })); setModelValue(initialModelValue); setSelectedRuntimeId(initialRuntimeHint); setRuntimeMode(initialRuntimeHint ? "runtime" : "model"); @@ -4321,6 +4327,9 @@ function ConfigTab({ newRuntimeConfig.heartbeatPromptTemplate = heartbeatPromptTemplate; } + const selectedThinkingLevel = (formValues.thinkingLevel || "off") as ThinkingLevel; + newRuntimeConfig.thinkingLevel = selectedThinkingLevel; + if (runtimeMode === "runtime") { if (selectedRuntimeId.trim()) { newRuntimeConfig.runtimeHint = selectedRuntimeId.trim(); @@ -4723,6 +4732,10 @@ function ConfigTab({ {runtimeMode === "model" ? (
+ {/* + FNXC:Settings-ThinkingLevel 2026-07-12-00:00: + Agent Detail now lets operators change a built-in agent's persisted runtimeConfig.thinkingLevel after creation through the shared inline model-dropdown control, matching NewAgentDialog's concrete-only agent semantics. + */} { + setFormValues((prev) => ({ ...prev, thinkingLevel: level as ThinkingLevel })); + void scheduleAutoSave(); + }} />
) : ( diff --git a/packages/dashboard/app/components/AgentOnboardingModal.tsx b/packages/dashboard/app/components/AgentOnboardingModal.tsx index b268728d26..4b359a9f4f 100644 --- a/packages/dashboard/app/components/AgentOnboardingModal.tsx +++ b/packages/dashboard/app/components/AgentOnboardingModal.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import type { ThinkingLevel } from "@fusion/core"; import type { AgentCapability, ConversationHistoryEntry } from "../api"; import { startAgentOnboardingStreaming, @@ -227,8 +228,6 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro

{t("agents.name", "Name")}: {summary.name}

{t("agents.role", "Role")}: {summary.role}

- - {}} readOnly /> {}} readOnly /> @@ -239,6 +238,10 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro {runtimeMode === "model" && ( <> + {/* + FNXC:Settings-ThinkingLevel 2026-07-12-00:00: + Agent onboarding's model picker owns the generated runtimeConfig.thinkingLevel so operators can edit the concrete reasoning effort before creating the agent, matching NewAgentDialog without an inherit/default lane. + */} { + setSummary((current) => current ? { ...current, thinkingLevel: level as ThinkingLevel } : current); + }} /> )} diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 461381612d..4431c85ea2 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -4,8 +4,8 @@ import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive } from "lucide-react"; -import type { Task, TaskDetail, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction, PrInfo } from "@fusion/core"; -import { COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core"; +import type { Task, TaskDetail, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction, PrInfo, ThinkingLevel } from "@fusion/core"; +import { COLUMNS, DEFAULT_COLUMN, THINKING_LEVELS, getErrorMessage, isColumn } from "@fusion/core"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; import { useColumnLabel } from "../i18n/labels"; import { sortTasksForDisplayColumn } from "./taskSorting"; @@ -568,10 +568,20 @@ export function ListView({ }; }, [projectId, useSinglePaneList]); + // Bulk edit state and handlers (declared before clearSelection so every clear path resets pending lane edits) + const [executorModel, setExecutorModel] = useState("__no_change__"); + const [validatorModel, setValidatorModel] = useState("__no_change__"); + const [bulkThinkingLevel, setBulkThinkingLevel] = useState("__no_change__"); + const [nodeOverride, setNodeOverride] = useState("__no_change__"); + const toggleBulkEdit = useCallback(() => { setBulkEditEnabled((prev) => { if (prev) { setSelectedTaskIds(new Set()); + setExecutorModel("__no_change__"); + setValidatorModel("__no_change__"); + setBulkThinkingLevel("__no_change__"); + setNodeOverride("__no_change__"); } return !prev; }); @@ -593,6 +603,10 @@ export function ListView({ // Clear selection const clearSelection = useCallback(() => { setSelectedTaskIds(new Set()); + setExecutorModel("__no_change__"); + setValidatorModel("__no_change__"); + setBulkThinkingLevel("__no_change__"); + setNodeOverride("__no_change__"); }, []); // Toggle a column's visibility @@ -932,9 +946,6 @@ export function ListView({ }, [groupedTasks, isArchivedColumn, selectedTaskIds]); // Bulk edit state and handlers (must be after groupedTasks and clearSelection definition) - const [executorModel, setExecutorModel] = useState("__no_change__"); - const [validatorModel, setValidatorModel] = useState("__no_change__"); - const [nodeOverride, setNodeOverride] = useState("__no_change__"); const [availableNodes, setAvailableNodes] = useState([]); const [isLoadingNodes, setIsLoadingNodes] = useState(false); const selectedOverrideNode = useMemo( @@ -1388,6 +1399,7 @@ export function ListView({ validatorModelProvider?: string | null; validatorModelId?: string | null; nodeId?: string | null; + thinkingLevel?: ThinkingLevel | null; } = { taskIds }; if (executorModel !== "__no_change__") { @@ -1426,6 +1438,10 @@ export function ListView({ } } + if (bulkThinkingLevel !== "__no_change__") { + payload.thinkingLevel = bulkThinkingLevel === "" ? null : bulkThinkingLevel as ThinkingLevel; + } + // Check if any changes were made if (Object.keys(payload).length === 1) { addToast(t("listView.bulkNoChanges", "No changes to apply"), "info"); @@ -1443,6 +1459,7 @@ export function ListView({ undefined, undefined, payload.nodeId, + payload.thinkingLevel, projectId, ); @@ -1456,13 +1473,14 @@ export function ListView({ clearSelection(); setExecutorModel("__no_change__"); setValidatorModel("__no_change__"); + setBulkThinkingLevel("__no_change__"); setNodeOverride("__no_change__"); } catch (err) { addToast(getErrorMessage(err) || t("listView.bulkUpdateFailed", "Failed to update models"), "error"); } finally { setIsApplying(false); } - }, [selectedTaskIds, tasks, executorModel, validatorModel, nodeOverride, projectId, addToast, clearSelection, isArchivedColumn, onTasksUpdated]); + }, [selectedTaskIds, tasks, executorModel, validatorModel, bulkThinkingLevel, nodeOverride, projectId, addToast, clearSelection, isArchivedColumn, onTasksUpdated]); const closeContextMenu = useCallback(() => { setContextMenuState(null); @@ -2345,7 +2363,7 @@ export function ListView({
{availableModels && availableModels.length > 0 ? (
- {t("listView.bulkEditModelsLabel", "Bulk Edit Models & Node:")} + {t("listView.bulkEditModelsLabel", "Bulk Edit Models, Thinking & Node:")}
+
+ {/* + FNXC:Settings-ThinkingLevel 2026-07-12-00:00: + List bulk edit needs a no-change sentinel plus a clear-to-default lane for task.thinkingLevel so operators can update reasoning effort independently from executor/reviewer model overrides. + */} + +
onChange(event.target.value)}> + + {models.map((model: any) => { + const modelValue = `${model.provider}/${model.id}`; + return ; + })} + + {onThinkingLevelChange ? ( + + ) : null} +
+ ), +})); + vi.mock("../../api", () => ({ startAgentOnboardingStreaming: vi.fn().mockResolvedValue({ sessionId: "onb-1" }), - fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }), + fetchModels: vi.fn().mockResolvedValue({ models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }], favoriteProviders: [], favoriteModels: [] }), connectAgentOnboardingStream: vi.fn().mockImplementation((_sessionId, _projectId, handlers) => { streamHandlers = handlers; setTimeout(() => handlers.onQuestion?.({ id: "q1", type: "text", question: "What should this agent primarily help with?" }), 0); @@ -39,6 +60,7 @@ vi.mock("../../api", () => ({ afterEach(() => { respondCount = 0; streamHandlers = undefined; + vi.mocked(createAgent).mockClear(); if (originalScrollHeightDescriptor) { Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", originalScrollHeightDescriptor); } else { @@ -71,8 +93,25 @@ describe("AgentOnboardingModal", () => { fireEvent.click(screen.getByText("Continue")); await screen.findByText("Review generated configuration"); + expect(screen.queryByLabelText("Thinking level")).not.toBeInTheDocument(); + const thinkingSelect = screen.getByLabelText("Model thinking level"); + expect(thinkingSelect).toHaveValue("medium"); + fireEvent.change(thinkingSelect, { target: { value: "high" } }); + fireEvent.change(screen.getByLabelText("Model"), { target: { value: "openai/gpt-4o" } }); fireEvent.click(screen.getByText("Create agent")); + await waitFor(() => { + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeConfig: expect.objectContaining({ + thinkingLevel: "high", + model: "openai/gpt-4o", + }), + }), + undefined, + ); + }); + await waitFor(() => { expect(onCreated).toHaveBeenCalled(); }); diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 8c1e44184e..43ad336145 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -4044,7 +4044,7 @@ describe("ListView - Bulk Selection", () => { const checkbox = screen.getByLabelText("Select FN-001"); clickInAct(checkbox); - expect(screen.getByText("Bulk Edit Models & Node:")).toBeDefined(); + expect(screen.getByText("Bulk Edit Models, Thinking & Node:")).toBeDefined(); }); it("shows bulk edit toolbar when tasks are selected", () => { @@ -4067,7 +4067,7 @@ describe("ListView - Bulk Selection", () => { const checkbox = screen.getByLabelText("Select FN-001"); clickInAct(checkbox); - expect(screen.getByText("Bulk Edit Models & Node:")).toBeDefined(); + expect(screen.getByText("Bulk Edit Models, Thinking & Node:")).toBeDefined(); }); it("disables apply button when no model changes selected", () => { @@ -4129,7 +4129,7 @@ describe("ListView - Bulk Selection", () => { enterBulkEditMode(); await user.click(screen.getByLabelText("Select FN-001")); - expect(screen.getByText("Bulk Edit Models & Node:")).toBeInTheDocument(); + expect(screen.getByText("Bulk Edit Models, Thinking & Node:")).toBeInTheDocument(); const applyButton = screen.getByRole("button", { name: "Apply" }); expect(applyButton).toBeDisabled(); @@ -4665,6 +4665,7 @@ describe("ListView - Bulk Selection", () => { expect(firstApplyArgs?.[2]).toBe("gpt-4o"); expect(firstApplyArgs?.[3]).toBeUndefined(); expect(firstApplyArgs?.[4]).toBeUndefined(); + expect(firstApplyArgs?.[8]).toBeUndefined(); }); // After a successful apply, controls reset to No change and disable Apply again. @@ -4699,9 +4700,45 @@ describe("ListView - Bulk Selection", () => { expect(clearApplyArgs?.[2]).toBeNull(); expect(clearApplyArgs?.[3]).toBeUndefined(); expect(clearApplyArgs?.[4]).toBeUndefined(); + expect(clearApplyArgs?.[8]).toBeUndefined(); }); }); + it("forwards bulk thinking-level selections and omits the field for no-change", async () => { + const user = userEvent.setup(); + const availableModels = [ + { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, + ]; + const tasks = [createMockTask({ id: "FN-001" })]; + const mockedBatchUpdateTaskModels = vi.mocked(batchUpdateTaskModels); + mockedBatchUpdateTaskModels.mockResolvedValue({ updated: [{ ...tasks[0], thinkingLevel: "high" }], count: 1 }); + + render(); + enterBulkEditMode(); + await user.click(screen.getByLabelText("Select FN-001")); + + const thinkingSelect = screen.getByLabelText("Thinking Level"); + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + + await user.selectOptions(thinkingSelect, "high"); + expect(screen.getByRole("button", { name: "Apply" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Apply" })); + + await waitFor(() => { + const args = mockedBatchUpdateTaskModels.mock.calls.at(-1); + expect(args?.[0]).toEqual(["FN-001"]); + expect(args?.[1]).toBeUndefined(); + expect(args?.[2]).toBeUndefined(); + expect(args?.[7]).toBeUndefined(); + expect(args?.[8]).toBe("high"); + expect(args?.[9]).toBe(TEST_PROJECT_ID); + }); + + await user.click(screen.getByLabelText("Select FN-001")); + expect(screen.getByLabelText("Thinking Level")).toHaveValue("__no_change__"); + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + }); + describe("Bulk node override", () => { const availableModels = [ { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, diff --git a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts index 6333c8e268..d4c7c62295 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts @@ -2445,6 +2445,81 @@ describe("POST /tasks/batch-update-models", () => { expect(res.body.error).toContain("nodeId must be a string, null, or undefined"); }); + it("bulk sets thinkingLevel across selected tasks", async () => { + const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" }; + const task2 = { ...FAKE_TASK_DETAIL, id: "FN-002" }; + const updated1 = { ...task1, thinkingLevel: "high" }; + const updated2 = { ...task2, thinkingLevel: "high" }; + + (store.getTask as ReturnType).mockResolvedValueOnce(task1).mockResolvedValueOnce(task2); + (store.updateTask as ReturnType).mockResolvedValueOnce(updated1).mockResolvedValueOnce(updated2); + + const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({ + taskIds: ["FN-001", "FN-002"], + thinkingLevel: "high", + }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(2); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { thinkingLevel: "high" }); + expect(store.updateTask).toHaveBeenCalledWith("FN-002", { thinkingLevel: "high" }); + }); + + it("rejects invalid thinkingLevel values", async () => { + const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({ + taskIds: ["FN-001"], + thinkingLevel: "maximum", + }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("thinkingLevel must be one of"); + expect(store.updateTask).not.toHaveBeenCalled(); + }); + + it("omitted thinkingLevel leaves existing values untouched", async () => { + const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001", thinkingLevel: "medium" }; + const updated1 = { ...task1, modelProvider: "openai", modelId: "gpt-4o" }; + + (store.getTask as ReturnType).mockResolvedValueOnce(task1); + (store.updateTask as ReturnType).mockResolvedValueOnce(updated1); + + const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({ + taskIds: ["FN-001"], + modelProvider: "openai", + modelId: "gpt-4o", + }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { + modelProvider: "openai", + modelId: "gpt-4o", + }); + }); + + it("clears thinkingLevel when null is provided", async () => { + const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001", thinkingLevel: "medium" }; + const updated1 = { ...task1, thinkingLevel: undefined }; + + (store.getTask as ReturnType).mockResolvedValueOnce(task1); + (store.updateTask as ReturnType).mockResolvedValueOnce(updated1); + + const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({ + taskIds: ["FN-001"], + thinkingLevel: null, + }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { thinkingLevel: null }); + }); + it("updates nodeId across multiple tasks", async () => { const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" }; const task2 = { ...FAKE_TASK_DETAIL, id: "FN-002" }; diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 283b015e55..63b753a0ed 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -47,6 +47,7 @@ import { getPlannerInterventionTimeline, isBuiltinWorkflowId, type NearDuplicateCandidate, + type ThinkingLevel, } from "@fusion/core"; import { GitHubClient } from "../github.js"; import { githubRateLimiter } from "../github-poll.js"; @@ -2685,7 +2686,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork /** * POST /api/tasks/batch-update-models * Batch update AI model configuration for multiple tasks. - * Body: { taskIds: string[], modelProvider?: string | null, modelId?: string | null, validatorModelProvider?: string | null, validatorModelId?: string | null, planningModelProvider?: string | null, planningModelId?: string | null } + * Body: { taskIds: string[], modelProvider?: string | null, modelId?: string | null, validatorModelProvider?: string | null, validatorModelId?: string | null, planningModelProvider?: string | null, planningModelId?: string | null, thinkingLevel?: ThinkingLevel | null } * Returns: { updated: Task[], count: number } */ router.post("/tasks/batch-update-models", async (req, res) => { @@ -2700,6 +2701,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork planningModelProvider, planningModelId, nodeId, + thinkingLevel, } = req.body; // Validate taskIds @@ -2713,18 +2715,22 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork throw badRequest("taskIds must contain non-empty strings"); } - // Validate that at least one model field or node override is being updated + // Validate that at least one model field, thinking level, or node override is being updated const hasExecutorModel = modelProvider !== undefined || modelId !== undefined; const hasValidatorModel = validatorModelProvider !== undefined || validatorModelId !== undefined; const hasPlanningModel = planningModelProvider !== undefined || planningModelId !== undefined; const hasNodeId = nodeId !== undefined; - if (!hasExecutorModel && !hasValidatorModel && !hasPlanningModel && !hasNodeId) { - throw badRequest("At least one model field or nodeId must be provided"); + const hasThinkingLevel = thinkingLevel !== undefined; + if (!hasExecutorModel && !hasValidatorModel && !hasPlanningModel && !hasNodeId && !hasThinkingLevel) { + throw badRequest("At least one model field, thinkingLevel, or nodeId must be provided"); } if (nodeId !== undefined && nodeId !== null && typeof nodeId !== "string") { throw badRequest("nodeId must be a string, null, or undefined"); } + if (thinkingLevel !== undefined && thinkingLevel !== null && (typeof thinkingLevel !== "string" || !THINKING_LEVELS.includes(thinkingLevel as ThinkingLevel))) { + throw badRequest(`thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}, null, or undefined`); + } // Validate model field pairs (both provider and modelId must be provided together or neither) const validateModelPair = (provider: unknown, modelIdValue: unknown, name: string): { provider?: string | null; modelId?: string | null } => { @@ -2784,6 +2790,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork planningModelProvider?: string | null; planningModelId?: string | null; nodeId?: string | null; + thinkingLevel?: ThinkingLevel | null; } = {}; if (validatedExecutor.provider !== undefined) { updates.modelProvider = validatedExecutor.provider; @@ -2806,6 +2813,13 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork if (nodeId !== undefined) { updates.nodeId = nodeId; } + /* + FNXC:Settings-ThinkingLevel 2026-07-12-00:00: + Bulk task model edits can now set or clear one executor-scoped thinkingLevel across the selected tasks, reusing the existing batch route instead of inventing a dashboard-only control that persists nowhere. + */ + if (thinkingLevel !== undefined) { + updates.thinkingLevel = thinkingLevel as ThinkingLevel | null; + } // Update all tasks in parallel const updatePromises = taskIds.map(async (taskId) => {