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:
7
.changeset/thinking-level-selector-parity.md
Normal file
7
.changeset/thinking-level-selector-parity.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user