FN-7899: add thinking-level editing to Agent Detail, Onboarding, and bulk task model selectors

Bring thinking-level (reasoning effort) editing to every remaining model selector surface that previously lacked it, so operators can set it consistently from Agent Detail, Agent Onboarding, and the List view's bulk task editor, in addition to the batch-update-models API and route that back them.

- Agent Detail config tab: persist/edit a built-in agent's runtimeConfig.thinkingLevel inline via the shared model dropdown, with dirty-state and reset tracking.
- Agent Onboarding modal: replace the read-only thinking-level input with an editable control wired into the same model dropdown used for creation.
- List view bulk edit toolbar: add a "no change" / "use default" / explicit-level thinking selector alongside executor/reviewer model and node overrides, wired through to the bulk apply action.
- Dashboard API client (`batchUpdateTaskModels`) and `/api/tasks/batch-update-models` route: accept and validate an optional `thinkingLevel` field (against `THINKING_LEVELS`), applying it per task alongside existing model/node updates.
- Update dashboard-guide.md docs and add regression tests across AgentDetailView, AgentOnboardingModal, ListView, and the batch-update-models route.
- Add a minor changeset documenting the feature for release notes.

Files changed:
 .changeset/thinking-level-selector-parity.md       |  7 ++
 docs/dashboard-guide.md                            |  5 +-
 packages/dashboard/app/api/legacy.ts               |  3 +
 .../dashboard/app/components/AgentDetailView.tsx   | 20 +++++-
 .../app/components/AgentOnboardingModal.tsx        | 11 +++-
 packages/dashboard/app/components/ListView.tsx     | 54 +++++++++++++---
 .../__tests__/AgentDetailView.settings.test.tsx    | 45 +++++++++++++
 .../__tests__/AgentDetailView.test-helpers.ts      | 19 +++++-
 .../__tests__/AgentOnboardingModal.test.tsx        | 41 +++++++++++-
 .../app/components/__tests__/ListView.test.tsx     | 43 ++++++++++++-
 .../src/__tests__/routes-tasks-ops.test.ts         | 75 ++++++++++++++++++++++
 .../src/routes/register-task-workflow-routes.ts    | 22 +++++--
 12 files changed, 323 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-7899

Fusion-Task-Lineage: fd584ce4-42b3-4c20-8de5-4d3c8963f593

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 19:18:25 -07:00
parent 02fdb4c089
commit b98314923c
12 changed files with 323 additions and 22 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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 } : {}),
}),
});
}

View File

@@ -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" ? (
<div className="config-field">
{/*
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.
*/}
<CustomModelDropdown
models={availableModels}
value={modelValue}
@@ -4737,6 +4750,11 @@ function ConfigTab({
onToggleFavorite={toggleFavoriteProvider}
favoriteModels={favoriteModels}
onToggleModelFavorite={toggleFavoriteModel}
thinkingLevel={formValues.thinkingLevel ?? ""}
onThinkingLevelChange={(level) => {
setFormValues((prev) => ({ ...prev, thinkingLevel: level as ThinkingLevel }));
void scheduleAutoSave();
}}
/>
</div>
) : (

View File

@@ -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
<div className="agent-onboarding-summary">
<p><strong>{t("agents.name", "Name")}:</strong> {summary.name}</p>
<p><strong>{t("agents.role", "Role")}:</strong> {summary.role}</p>
<label htmlFor="thinking-level">{t("agents.thinkingLevel", "Thinking level")}</label>
<input id="thinking-level" className="input" value={summary.thinkingLevel} onChange={() => {}} readOnly />
<label htmlFor="max-turns">{t("agents.maxTurns", "Max turns")}</label>
<input id="max-turns" className="input" type="number" value={summary.maxTurns} onChange={() => {}} readOnly />
<label htmlFor="runtime-mode">{t("agents.runtimeMode", "Runtime mode")}</label>
@@ -239,6 +238,10 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro
{runtimeMode === "model" && (
<>
<label>{t("agents.selectModel", "Model")}</label>
{/*
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.
*/}
<CustomModelDropdown
id="agent-onboarding-model"
label={t("agents.selectModel", "Model")}
@@ -246,6 +249,10 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro
onChange={setModel}
models={availableModels}
placeholder={t("agents.selectModelPlaceholder", "Select a model…")}
thinkingLevel={summary.thinkingLevel}
onThinkingLevelChange={(level) => {
setSummary((current) => current ? { ...current, thinkingLevel: level as ThinkingLevel } : current);
}}
/>
</>
)}

View File

@@ -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<string>("__no_change__");
const [validatorModel, setValidatorModel] = useState<string>("__no_change__");
const [bulkThinkingLevel, setBulkThinkingLevel] = useState<string>("__no_change__");
const [nodeOverride, setNodeOverride] = useState<string>("__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<string>("__no_change__");
const [validatorModel, setValidatorModel] = useState<string>("__no_change__");
const [nodeOverride, setNodeOverride] = useState<string>("__no_change__");
const [availableNodes, setAvailableNodes] = useState<NodeInfo[]>([]);
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({
</div>
{availableModels && availableModels.length > 0 ? (
<div className="bulk-edit-toolbar">
<span className="bulk-edit-label">{t("listView.bulkEditModelsLabel", "Bulk Edit Models & Node:")}</span>
<span className="bulk-edit-label">{t("listView.bulkEditModelsLabel", "Bulk Edit Models, Thinking & Node:")}</span>
<div className="bulk-edit-dropdown">
<CustomModelDropdown
models={availableModels}
@@ -2374,6 +2392,26 @@ export function ListView({
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
<div className="bulk-edit-dropdown">
{/*
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.
*/}
<select
className="select bulk-thinking-select"
value={bulkThinkingLevel}
onChange={(e) => setBulkThinkingLevel(e.target.value)}
aria-label={t("listView.thinkingLevel", "Thinking Level")}
>
<option value="__no_change__">{t("listView.noChange", "No change")}</option>
<option value="">{t("models.useDefault", "Use default")}</option>
{THINKING_LEVELS.map((level) => (
<option key={level} value={level}>
{t(`models.options.${level}`, level === "xhigh" ? "Very High" : level.charAt(0).toUpperCase() + level.slice(1))}
</option>
))}
</select>
</div>
<div className="bulk-edit-dropdown bulk-edit-node-wrap">
<select
className="select bulk-node-select"
@@ -2395,7 +2433,7 @@ export function ListView({
<button
className="btn btn-primary btn-sm bulk-edit-apply-btn"
onClick={handleApplyBulkUpdate}
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__" && nodeOverride === "__no_change__")}
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__" && bulkThinkingLevel === "__no_change__" && nodeOverride === "__no_change__")}
>
{isApplying ? t("listView.applying", "Applying...") : t("listView.apply", "Apply")}
</button>

View File

@@ -146,6 +146,51 @@ describe("Budget Settings", () => {
});
});
it("renders editable built-in-model thinking control and saves runtimeConfig.thinkingLevel", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {
modelProvider: "openai",
modelId: "gpt-4o",
model: "openai/gpt-4o",
thinkingLevel: "medium",
},
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await navigateToSettings(user);
const thinkingSelect = await screen.findByLabelText("Agent Model thinking level");
expect(thinkingSelect).toHaveValue("medium");
expect(screen.getByTestId("custom-model-dropdown")).toHaveAttribute("data-default-thinking-level", "");
fireEvent.change(thinkingSelect, { target: { value: "high" } });
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
runtimeConfig: expect.objectContaining({
modelProvider: "openai",
modelId: "gpt-4o",
model: "openai/gpt-4o",
thinkingLevel: "high",
}),
}),
undefined,
);
});
});
it("calls updateAgent with correct budgetConfig in runtimeConfig on save", async () => {
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);

View File

@@ -109,7 +109,7 @@ vi.mock("../AgentLogViewer", () => ({
}));
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({ models, value, onChange, disabled, label, placeholder, id, favoriteProviders = [], onToggleFavorite, favoriteModels = [], onToggleModelFavorite }: {
CustomModelDropdown: ({ models, value, onChange, disabled, label, placeholder, id, favoriteProviders = [], onToggleFavorite, favoriteModels = [], onToggleModelFavorite, thinkingLevel, onThinkingLevelChange, defaultThinkingLevel }: {
models: Array<{ provider: string; id: string }>;
value: string;
onChange: (v: string) => void;
@@ -121,14 +121,19 @@ vi.mock("../CustomModelDropdown", () => ({
onToggleFavorite?: (provider: string) => void;
favoriteModels?: string[];
onToggleModelFavorite?: (modelId: string) => void;
thinkingLevel?: string;
onThinkingLevelChange?: (level: string) => void;
defaultThinkingLevel?: string;
}) => {
const selectId = id ?? "custom-model-dropdown";
const thinkingSelectId = `${selectId}-thinking-level`;
return createElement(
"div",
{
"data-testid": "custom-model-dropdown",
"data-favorite-providers": favoriteProviders.join(","),
"data-favorite-models": favoriteModels.join(","),
"data-default-thinking-level": defaultThinkingLevel ?? "",
},
createElement("label", { htmlFor: selectId }, label),
createElement(
@@ -146,6 +151,18 @@ vi.mock("../CustomModelDropdown", () => ({
return createElement("option", { key: modelValue, value: modelValue }, modelValue);
}),
),
onThinkingLevelChange
? createElement(
"select",
{
id: thinkingSelectId,
"aria-label": `${label} thinking level`,
value: thinkingLevel ?? "",
onChange: (e: Event) => onThinkingLevelChange((e.target as HTMLSelectElement).value),
},
...["off", "minimal", "low", "medium", "high", "xhigh"].map((level) => createElement("option", { key: level, value: level }, level)),
)
: null,
...Array.from(new Set(models.map((model) => model.provider))).map((provider) => createElement(
"button",
{

View File

@@ -2,14 +2,35 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { AgentOnboardingModal } from "../AgentOnboardingModal";
import { createAgent } from "../../api";
let streamHandlers: any;
let respondCount = 0;
const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "scrollHeight");
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({ value, onChange, label, id, models, thinkingLevel, onThinkingLevelChange }: any) => (
<div data-testid="custom-model-dropdown" data-default-thinking-level="">
<label htmlFor={id}>{label}</label>
<select id={id} aria-label={label} value={value} onChange={(event) => onChange(event.target.value)}>
<option value="">Use default</option>
{models.map((model: any) => {
const modelValue = `${model.provider}/${model.id}`;
return <option key={modelValue} value={modelValue}>{modelValue}</option>;
})}
</select>
{onThinkingLevelChange ? (
<select aria-label={`${label} thinking level`} value={thinkingLevel ?? ""} onChange={(event) => onThinkingLevelChange(event.target.value)}>
{["off", "minimal", "low", "medium", "high", "xhigh"].map((level) => <option key={level} value={level}>{level}</option>)}
</select>
) : null}
</div>
),
}));
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();
});

View File

@@ -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(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
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 },

View File

@@ -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<typeof vi.fn>).mockResolvedValueOnce(task1).mockResolvedValueOnce(task2);
(store.updateTask as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce(task1);
(store.updateTask as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce(task1);
(store.updateTask as ReturnType<typeof vi.fn>).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" };

View File

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