FN-7772: add per-lane thinking level for workflow model lanes

Adds a per-workflow-lane thinking-level setting so execution, planning, and
review model lanes can each specify their own reasoning-effort/thinking
level, threaded through engine phase precedence so lane-specific settings
override the workflow default.

- Add workflow lane thinking-level settings to builtin workflow settings and
  settings schema, with new types in core.
- Thread lane thinking-level precedence through model-resolution and engine
  phase execution (executor, step-session-executor, triage,
  agent-session-helpers).
- Surface per-lane thinking-level controls in the dashboard
  WorkflowSettingsPanel and ProjectModelsSection.
- Add/adjust tests for workflow settings, agent-session-helpers, and the
  dashboard settings panels.
- Update settings-reference.md and workflow-steps.md docs.
- Add changeset for the new feature (minor).

Files changed:
 .changeset/fn-7772-workflow-lane-thinking.md       |  7 ++++
 docs/settings-reference.md                         | 10 ++---
 docs/workflow-steps.md                             |  4 +-
 .../core/src/__tests__/workflow-settings.test.ts   | 41 ++++++++++++++++++++
 packages/core/src/builtin-workflow-settings.ts     | 27 ++++++++++++-
 packages/core/src/index.ts                         |  4 +-
 packages/core/src/model-resolution.ts              | 45 +++++++++++++++++++++-
 packages/core/src/settings-schema.ts               |  3 ++
 packages/core/src/types.ts                         |  6 +++
 .../app/components/WorkflowSettingsPanel.tsx       | 35 ++++++++++++++---
 .../__tests__/SettingsModal.models-auth.test.tsx   |  7 +++-
 .../__tests__/WorkflowSettingsPanel.test.tsx       | 31 +++++++++++++++
 .../settings/sections/ProjectModelsSection.tsx     | 39 ++++++++++++++++---
 .../src/__tests__/agent-session-helpers.test.ts    | 13 ++++---
 .../engine/src/__tests__/executor-test-helpers.ts  |  1 +
 packages/engine/src/agent-session-helpers.ts       | 19 +++++----
 packages/engine/src/executor.ts                    |  4 +-
 packages/engine/src/step-session-executor.ts       |  2 +-
 packages/engine/src/triage.ts                      |  8 +++-
 19 files changed, 264 insertions(+), 42 deletions(-)

Fusion-Task-Id: FN-7772

Fusion-Task-Lineage: 70aba3a9-66c2-4bb0-aff9-acabc7b98818

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 02:21:01 -07:00
parent 235ff4c65e
commit df8ad460af
19 changed files with 264 additions and 42 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add per-workflow model lane thinking-level controls for planning, execution, and review.
category: feature
dev: Adds execution/planning/validator workflow thinking settings and phase precedence threading.

View File

@@ -259,8 +259,7 @@ govern that execution belong to the workflow.
**Where to set them.** The common model lanes for a project's default workflow are
available directly in **Settings → Project Models → Default workflow model lanes**:
Plan/Triage, Executor, Reviewer, and the Planning/Reviewer fallback lanes declared
by the default workflow. Those dropdown controls use the shared model picker and
are persisted by the Settings modal's primary **Save** action, which writes
by the default workflow. Primary Plan/Triage, Executor, and Reviewer rows also show an inline Thinking Level control when the workflow declares `planningThinkingLevel`, `executionThinkingLevel`, or `validatorThinkingLevel`; unset means inherit. Those dropdown controls use the shared model picker and are persisted by the Settings modal's primary **Save** action, which writes
workflow setting values for the active project's default workflow; they do not
restore the old project settings keys. The global **Fallback Model** remains in
Settings → General Models, and workflow-specific fallbacks are also editable from
@@ -285,7 +284,8 @@ Actions. It has two tabs:
editable for any workflow, including built-ins. Common provider/model lane pairs
(Plan/Triage, Executor, Reviewer, and fallbacks declared by the workflow) use the
same model dropdown picker as Project Models so clearing or selecting a model
updates both keys together. Advanced/custom non-model settings still use typed
updates both keys together. Declared primary-lane thinking companions render inline
and clear with the lane reset instead of as separate enum fields. Advanced/custom non-model settings still use typed
controls. Built-in Plan Review/spec and Code Review revision caps also live here:
leave `planReviewMaxRevisions` or `codeReviewMaxRevisions` empty for unbounded
automatic revisions, enter a non-negative integer to cap attempts, or enter `0`
@@ -332,7 +332,7 @@ These groups moved out of project settings and into workflow settings (built-in
| **Step execution** | `workflowStepTimeoutMs`, `runStepsInNewSessions`, `maxParallelSteps`, `workflowStepScopeEnforcement`, `strictScopeEnforcement`, `verificationFixRetries`, `maxPostReviewFixes`, `buildRetryCount` |
| **Review / approval** | Workflow values: `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries`, `planReviewMaxRevisions`, `codeReviewMaxRevisions`; project override: `planApprovalMode` |
| **Planner oversight** | `plannerOversightLevel` (workflow-native; values: `off`, `observe`, `steer`, `autonomous`); `plannerOversightNotificationLevel` (workflow-native; values: `silent`, `errors`, `important`, `all`); `plannerOverseerExecutorStuckAfterMs` (workflow-native; number, default `7200000` = 2h) |
| **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks) |
| **Per-phase model lanes** | `executionProvider`/`executionModelId` + `executionThinkingLevel`, `planningProvider`/`planningModelId` + `planningThinkingLevel` (+ fallbacks), `validatorProvider`/`validatorModelId` + `validatorThinkingLevel` (+ fallbacks). Thinking values accept `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`; unset inherits. |
### Workflow-native triage policy settings
@@ -971,7 +971,7 @@ Short-lived token bounds are enforced server-side:
Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes (persisted by the Settings modal's primary Save) or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. General-scope fallback selection remains the global Fallback Model picker in Settings -> General Models.
Settings model lanes can also carry optional thinking/reasoning effort overrides in the same model dropdown. Empty thinking values inherit the global `defaultThinkingLevel`; explicit lane values are cleared by the lane reset action. Runtime thinking precedence is task `thinkingLevel` > lane thinking override > `defaultThinkingLevel`, and the value still flows through pi.ts' existing thinking/reasoning-conflict fallback (Fusion retries without the explicit level when a provider rejects conflicting thinking parameters).
Settings model lanes can also carry optional thinking/reasoning effort overrides in the same model dropdown. Primary workflow lanes declare `executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel` per `(workflow, project)`; empty thinking values inherit through the lane/global/default chain and explicit values are cleared by the lane reset action. Runtime thinking precedence is node/step `config.thinkingLevel` > task `thinkingLevel` > workflow lane thinking override > global lane thinking override > project default thinking override > global `defaultThinkingLevel`, and the value still flows through pi.ts' existing thinking/reasoning-conflict fallback (Fusion retries without the explicit level when a provider rejects conflicting thinking parameters).
When the planning lane has neither `planningFallback*` nor a global `fallback*` pair configured, triage now derives an **implicit fallback** from the resolved project/global default (execution) model (FN-7719). This lets a retryable primary planner-model failure (e.g. a provider 404/429) recover via one distinct swap instead of permanently failing triage with "no fallback configured" — the operator's chosen primary planner lane is unchanged, and the implicit fallback is skipped when it would equal the primary model or when test mode is active.

View File

@@ -546,7 +546,7 @@ Plugin palette templates (above) can be dropped in as a starting point instead o
<!--
FNXC:WorkflowModelBinding 2026-07-10-00:00:
FN-7771 lets workflow authors bind reasoning effort per session-running node, independently from provider/model. Node-level thinking is strongest so a custom workflow can pin a high-effort review or low-effort execution seam without changing task-wide or project/workflow lane defaults.
FN-7771 lets workflow authors bind reasoning effort per session-running node, independently from provider/model. FN-7772 adds workflow-declared primary lane thinking companions, but node-level thinking remains strongest so a custom workflow can pin a high-effort review or low-effort execution seam without changing task-wide or lane defaults.
-->
A prompt-mode gate node can set its own model with:
@@ -555,7 +555,7 @@ A prompt-mode gate node can set its own model with:
- `modelId`
- `thinkingLevel` (`"off" | "minimal" | "low" | "medium" | "high" | "xhigh"`)
If both model fields are set, node execution uses that provider/model pair; otherwise it falls back to default model selection. `thinkingLevel` is stored as `config.thinkingLevel` and can be set or cleared independently from the model pair. Runtime reasoning-effort precedence is **node/step `thinkingLevel` → task `thinkingLevel` → workflow/project lane thinking override → global `defaultThinkingLevel`**. This applies to prompt/gate custom nodes, the `execute` and `step-execute` seams, and `step-review` reviewer sessions. Dashboard node summaries show unpinned provider/model state as **Default model**; the inspector's inline thinking selector shows the resolved project default (e.g. "Default (off)") when no node-level thinking value is pinned.
If both model fields are set, node execution uses that provider/model pair; otherwise it falls back to default model selection. `thinkingLevel` is stored as `config.thinkingLevel` and can be set or cleared independently from the model pair. Runtime reasoning-effort precedence is **node/step `thinkingLevel` → task `thinkingLevel` → workflow lane thinking override (`executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel`) → global lane thinking override → project default thinking override → global `defaultThinkingLevel`**. The lane settings accept `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`; unset means inherit. This applies to prompt/gate custom nodes, the `execute` and `step-execute` seams, triage/planning, and `step-review` reviewer sessions. Dashboard node summaries show unpinned provider/model state as **Default model**; the inspector's inline thinking selector shows the resolved project default (e.g. "Default (off)") when no node-level thinking value is pinned.
## Default-On Behavior for New Tasks

View File

@@ -8,6 +8,7 @@ import {
} from "../workflow-settings.js";
import type { WorkflowSettingDefinition, WorkflowIrV2 } from "../workflow-ir-types.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
import { THINKING_LEVELS } from "../types.js";
import { createSharedTaskStoreTestHarness } from "./store-test-helpers.js";
const BUILTIN_CODING = "builtin:coding";
@@ -309,6 +310,46 @@ describe("TaskStore.updateWorkflowSettingValues", () => {
});
});
describe("workflow model-lane thinking settings", () => {
const harness = createSharedTaskStoreTestHarness();
beforeAll(harness.beforeAll);
afterAll(harness.afterAll);
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
it("round-trips primary lane thinking levels and clears them with null-as-delete", async () => {
const store = harness.store();
await store.updateWorkflowSettingValues(BUILTIN_CODING, PROJECT, {
executionThinkingLevel: "low",
planningThinkingLevel: "high",
validatorThinkingLevel: "minimal",
});
expect(store.getWorkflowSettingValues(BUILTIN_CODING, PROJECT)).toMatchObject({
executionThinkingLevel: "low",
planningThinkingLevel: "high",
validatorThinkingLevel: "minimal",
});
await store.updateWorkflowSettingValues(BUILTIN_CODING, PROJECT, { executionThinkingLevel: null });
expect(store.getWorkflowSettingValues(BUILTIN_CODING, PROJECT)).not.toHaveProperty("executionThinkingLevel");
});
it("declares thinking companions as THINKING_LEVELS enum settings and rejects invalid values", async () => {
const ids = ["executionThinkingLevel", "planningThinkingLevel", "validatorThinkingLevel"];
for (const id of ids) {
const decl = BUILTIN_WORKFLOW_SETTINGS.find((setting) => setting.id === id);
expect(decl?.type).toBe("enum");
expect(decl?.options?.map((option) => option.value)).toEqual([...THINKING_LEVELS]);
}
const store = harness.store();
await expect(store.updateWorkflowSettingValues(BUILTIN_CODING, PROJECT, { executionThinkingLevel: "turbo" })).rejects.toBeInstanceOf(WorkflowSettingRejectionError);
expect(store.getWorkflowSettingValues(BUILTIN_CODING, PROJECT)).not.toHaveProperty("executionThinkingLevel");
});
});
describe("TaskStore.getModelLaneDrift", () => {
const harness = createSharedTaskStoreTestHarness();

View File

@@ -1,4 +1,4 @@
import type { Settings } from "./types.js";
import { THINKING_LEVELS, type Settings } from "./types.js";
import type { WorkflowSettingDefinition } from "./workflow-ir-types.js";
/**
@@ -203,6 +203,10 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
// ── Per-phase model lanes ──────────────────────────────────────────────
// Legacy defaults are all `undefined`; `default` is omitted so resolution
// falls through to the global lane / project default (KTD-7).
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Workflow-declared primary model lanes may pin a thinking effort per (workflow, project). Empty values inherit through the lane/global/default chain, enum options are validated against THINKING_LEVELS, and fallback/title-summarizer lanes intentionally reuse their primary lane thinking level instead of adding separate companion settings.
*/
{
id: "executionProvider",
name: "Execution provider",
@@ -215,6 +219,13 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
type: "string",
description: "Model id for the execution phase. Empty falls through to the global lane.",
},
{
id: "executionThinkingLevel",
name: "Execution thinking level",
type: "enum",
options: THINKING_LEVELS.map((level) => ({ value: level, label: level })),
description: "Thinking effort for the execution phase. Empty inherits from the task or default thinking level.",
},
{
id: "planningProvider",
name: "Planning provider",
@@ -227,6 +238,13 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
type: "string",
description: "Model id for the planning phase. Empty falls through to the global lane.",
},
{
id: "planningThinkingLevel",
name: "Planning thinking level",
type: "enum",
options: THINKING_LEVELS.map((level) => ({ value: level, label: level })),
description: "Thinking effort for the planning phase. Empty inherits from the task or default thinking level.",
},
{
id: "planningFallbackProvider",
name: "Planning fallback provider",
@@ -251,6 +269,13 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
type: "string",
description: "Model id for the validation phase. Empty falls through to the global lane.",
},
{
id: "validatorThinkingLevel",
name: "Validator thinking level",
type: "enum",
options: THINKING_LEVELS.map((level) => ({ value: level, label: level })),
description: "Thinking effort for the validation/review phase. Empty inherits from the task or default thinking level.",
},
{
id: "validatorFallbackProvider",
name: "Validator fallback provider",

View File

@@ -1357,8 +1357,10 @@ export {
applyTestModeOverrides,
isTestModeActive,
resolveExecutionSettingsModel,
resolvePhaseThinkingLevel,
resolvePlanningSettingsModel,
resolveProjectDefaultModel,
resolveSettingsLaneThinkingLevel,
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
@@ -1369,7 +1371,7 @@ export {
routeTaskPlanningModel,
routeTaskValidatorModel,
} from "./model-resolution.js";
export type { ResolvedModelSelection, RouterLaneOptions } from "./model-resolution.js";
export type { ModelThinkingPhase, ResolvedModelSelection, RouterLaneOptions } from "./model-resolution.js";
export {
routeModel,
routeModelAndEmit,

View File

@@ -1,4 +1,4 @@
import type { Settings } from "./types.js";
import type { Settings, ThinkingLevel } from "./types.js";
import type {
ModelGovernancePredicate,
RouterDecision,
@@ -12,6 +12,8 @@ export interface ResolvedModelSelection {
modelId?: string;
}
export type ModelThinkingPhase = "execution" | "planning" | "validation";
export const TEST_MODE_RESOLVED: ResolvedModelSelection = { provider: "mock", modelId: "scripted" };
export function isTestModeActive(settings?: Partial<Settings>): boolean {
@@ -55,6 +57,47 @@ function pickFirstModelPair(...pairs: ModelPair[]): ResolvedModelSelection {
return {};
}
function firstThinkingLevel(...levels: Array<ThinkingLevel | string | undefined | null>): string | undefined {
for (const level of levels) {
if (typeof level === "string" && level.trim().length > 0) {
return level.trim();
}
}
return undefined;
}
/**
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Workflow model-lane thinking companions are workflow-declared settings whose unset state means inherit. Resolve them centrally so executor, reviewer, triage, step sessions, and merger-adjacent validation agree on precedence: node/step override > task thinking > workflow lane > global lane > project default thinking override > global default thinking level.
*/
export function resolveSettingsLaneThinkingLevel(
phase: ModelThinkingPhase,
settings?: Partial<Settings>,
): ThinkingLevel | undefined {
if (phase === "execution") return settings?.executionThinkingLevel;
if (phase === "planning") return settings?.planningThinkingLevel;
return settings?.validatorThinkingLevel;
}
export function resolvePhaseThinkingLevel(
phase: ModelThinkingPhase,
settings: Partial<Settings> | undefined,
nodeOrTaskThinkingLevel?: ThinkingLevel | string,
): string | undefined {
const globalLane = phase === "execution"
? settings?.executionGlobalThinkingLevel
: phase === "planning"
? settings?.planningGlobalThinkingLevel
: settings?.validatorGlobalThinkingLevel;
return firstThinkingLevel(
nodeOrTaskThinkingLevel,
resolveSettingsLaneThinkingLevel(phase, settings),
globalLane,
settings?.defaultThinkingLevelOverride,
settings?.defaultThinkingLevel,
);
}
export function resolveProjectDefaultModel(settings?: Partial<Settings>): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(

View File

@@ -42,12 +42,15 @@ type MovedProjectSettingsKey =
| "reflectionEnabled"
| "executionProvider"
| "executionModelId"
| "executionThinkingLevel"
| "planningProvider"
| "planningModelId"
| "planningThinkingLevel"
| "planningFallbackProvider"
| "planningFallbackModelId"
| "validatorProvider"
| "validatorModelId"
| "validatorThinkingLevel"
| "validatorFallbackProvider"
| "validatorFallbackModelId";

View File

@@ -4105,6 +4105,10 @@ export interface ProjectSettings {
/** Project-level AI model ID for task execution.
* Must be set together with `executionProvider`. */
executionModelId?: string;
/** Workflow-declared execution-lane thinking override. Inherits through task/default thinking when unset. */
executionThinkingLevel?: ThinkingLevel;
/** Workflow-declared planning-lane thinking override. Inherits through task/default thinking when unset. */
planningThinkingLevel?: ThinkingLevel;
/** AI model provider for validator/reviewer agent.
* Must be set together with `validatorModelId`. When both are undefined,
* falls back to `defaultProvider`/`defaultModelId`. */
@@ -4120,6 +4124,8 @@ export interface ProjectSettings {
/** Fallback model ID for validator/reviewer. When unset, falls back to the
* global fallback model. Must be set together with `validatorFallbackProvider`. */
validatorFallbackModelId?: string;
/** Workflow-declared validator-lane thinking override. Inherits through task/default thinking when unset. */
validatorThinkingLevel?: ThinkingLevel;
/** Reusable model configuration presets for task creation. */
modelPresets?: ModelPreset[];
/** When true, task creation UIs automatically recommend/apply a preset based on task size. */

View File

@@ -504,6 +504,7 @@ export interface WorkflowModelLanePair {
id: string;
providerId: string;
modelId: string;
thinkingId?: string;
label: string;
help: string;
}
@@ -511,12 +512,16 @@ export interface WorkflowModelLanePair {
/*
FNXC:WorkflowSettings 2026-06-17-09:13:
Title summarization is owned by project/global Settings → Project Models, not workflow Values. Keep this workflow-editor catalog limited to workflow-executed model lanes so custom declarations cannot create misleading title-summarizer dropdowns whose values are orphaned for built-in workflows.
FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
Workflow Values renders thinking controls inline with declared primary model lanes only. The companion setting ids are excluded from generic enum rendering so operators get one inherit/override/reset affordance and undeclared lanes leave no empty control shell.
*/
export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
{
id: "planning",
providerId: "planningProvider",
modelId: "planningModelId",
thinkingId: "planningThinkingLevel",
label: "Plan/Triage Model",
help: "Provider and model used when planning or triaging tasks. Leave unset to inherit from the default lane.",
},
@@ -524,6 +529,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
id: "execution",
providerId: "executionProvider",
modelId: "executionModelId",
thinkingId: "executionThinkingLevel",
label: "Executor Model",
help: "Provider and model used by task implementation agents. Leave unset to inherit from the default lane.",
},
@@ -531,6 +537,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
id: "validator",
providerId: "validatorProvider",
modelId: "validatorModelId",
thinkingId: "validatorThinkingLevel",
label: "Reviewer Model",
help: "Provider and model used by review and validation agents. Leave unset to inherit from the default lane.",
},
@@ -593,12 +600,13 @@ function ValuesTab({
WORKFLOW_MODEL_LANE_CATALOG.filter((pair) => {
const provider = settingsById.get(pair.providerId);
const model = settingsById.get(pair.modelId);
return provider?.type === "string" && model?.type === "string";
const thinking = pair.thinkingId ? settingsById.get(pair.thinkingId) : undefined;
return provider?.type === "string" && model?.type === "string" && (!pair.thinkingId || thinking?.type === "enum" || thinking?.type === "string");
}),
[settingsById],
);
const modelPairSettingIds = useMemo(
() => new Set(modelLanePairs.flatMap((pair) => [pair.providerId, pair.modelId])),
() => new Set(modelLanePairs.flatMap((pair) => [pair.providerId, pair.modelId, ...(pair.thinkingId ? [pair.thinkingId] : [])])),
[modelLanePairs],
);
@@ -716,6 +724,16 @@ function ValuesTab({
});
};
const setModelPairThinkingValue = (pair: WorkflowModelLanePair, value: string) => {
if (!pair.thinkingId) return;
setValue(pair.thinkingId, value || null);
};
const clearModelPairValue = (pair: WorkflowModelLanePair) => {
setModelPairValue(pair, "");
setModelPairThinkingValue(pair, "");
};
const dirty = Object.keys(pending).length > 0;
const save = useCallback(async () => {
@@ -773,8 +791,11 @@ function ValuesTab({
const providerValue = valueOfSettingId(pair.providerId);
const modelValue = valueOfSettingId(pair.modelId);
const value = typeof providerValue === "string" && typeof modelValue === "string" ? `${providerValue}/${modelValue}` : "";
const error = rejections[pair.providerId]?.message ?? rejections[pair.modelId]?.message;
const customized = isCustomizedId(pair.providerId) || isCustomizedId(pair.modelId);
const thinkingValue = pair.thinkingId && typeof valueOfSettingId(pair.thinkingId) === "string"
? (valueOfSettingId(pair.thinkingId) as string)
: "";
const error = rejections[pair.providerId]?.message ?? rejections[pair.modelId]?.message ?? (pair.thinkingId ? rejections[pair.thinkingId]?.message : undefined);
const customized = isCustomizedId(pair.providerId) || isCustomizedId(pair.modelId) || Boolean(pair.thinkingId && isCustomizedId(pair.thinkingId));
const dropdownDisabled = modelsLoading || availableModels.length === 0;
const emptyHelp =
!modelsLoading && availableModels.length === 0
@@ -791,7 +812,7 @@ function ValuesTab({
scope="project"
disabled={modelsLoading}
clearable={customized}
onClear={() => setModelPairValue(pair, "")}
onClear={() => clearModelPairValue(pair)}
>
<CustomModelDropdown
id={`wf-model-lane-${pair.id}`}
@@ -804,6 +825,10 @@ function ValuesTab({
disabled={dropdownDisabled}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
showThinkingLevel={Boolean(pair.thinkingId)}
thinkingLevel={thinkingValue}
onThinkingLevelChange={pair.thinkingId ? (level) => setModelPairThinkingValue(pair, level) : undefined}
defaultThinkingLevel="off"
/>
</SettingsFieldRow>
{customized && (

View File

@@ -354,14 +354,17 @@ describe("SettingsModal", () => {
}
});
const declaredWorkflowModelSettings = (ids: string[]) => ids.map((id) => ({ id, name: id, type: "string" as const }));
const declaredWorkflowModelSettings = (ids: string[]) => ids.map((id) => ({ id, name: id, type: id.endsWith("ThinkingLevel") ? "enum" as const : "string" as const, options: id.endsWith("ThinkingLevel") ? [{ value: "high", label: "High" }] : undefined }));
const primaryWorkflowModelSettingIds = [
"planningProvider",
"planningModelId",
"planningThinkingLevel",
"executionProvider",
"executionModelId",
"executionThinkingLevel",
"validatorProvider",
"validatorModelId",
"validatorThinkingLevel",
];
const fallbackWorkflowModelSettingIds = [
"planningFallbackProvider",
@@ -573,7 +576,7 @@ describe("SettingsModal", () => {
await waitFor(() => {
expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith(
"workflow-custom",
{ executionProvider: null, executionModelId: null },
{ executionProvider: null, executionModelId: null, executionThinkingLevel: null },
"proj-1",
);
});

View File

@@ -193,6 +193,7 @@ describe("WorkflowSettingsPanel — Values tab", () => {
initial={[
{ id: "planningProvider", name: "Planning provider", type: "string" },
{ id: "planningModelId", name: "Planning model", type: "string" },
{ id: "planningThinkingLevel", name: "Planning thinking level", type: "enum", options: [{ value: "high", label: "High" }] },
{ id: "validatorProvider", name: "Validator provider", type: "string" },
{ id: "requirePlanApproval", name: "Require plan approval", type: "boolean" },
{ id: "planReviewMaxRevisions", name: "Plan Review revision cap", type: "number" },
@@ -583,10 +584,13 @@ describe("WorkflowSettingsPanel — Values tab", () => {
const modelDecls: WorkflowSettingDefinition[] = [
{ id: "planningProvider", name: "Planning provider", type: "string" },
{ id: "planningModelId", name: "Planning model", type: "string" },
{ id: "planningThinkingLevel", name: "Planning thinking level", type: "enum", options: [{ value: "high", label: "High" }] },
{ id: "executionProvider", name: "Execution provider", type: "string" },
{ id: "executionModelId", name: "Execution model", type: "string" },
{ id: "executionThinkingLevel", name: "Execution thinking level", type: "enum", options: [{ value: "low", label: "Low" }] },
{ id: "validatorProvider", name: "Validator provider", type: "string" },
{ id: "validatorModelId", name: "Validator model", type: "string" },
{ id: "validatorThinkingLevel", name: "Validator thinking level", type: "enum", options: [{ value: "minimal", label: "Minimal" }] },
{ id: "planningFallbackProvider", name: "Planning fallback provider", type: "string" },
{ id: "planningFallbackModelId", name: "Planning fallback model", type: "string" },
{ id: "customModelProvider", name: "Custom model provider", type: "string" },
@@ -662,6 +666,33 @@ describe("WorkflowSettingsPanel — Values tab", () => {
);
});
it("edits workflow lane thinking inline and reset clears the companion setting", async () => {
mockFetchValues.mockResolvedValue(
payload({
stored: { planningThinkingLevel: "high" },
effective: { planningThinkingLevel: "high" },
}),
);
render(<Host initial={modelDecls} readOnly />);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalled());
await openPlanningDropdown();
fireEvent.change(screen.getByTestId("custom-model-dropdown-thinking"), { target: { value: "" } });
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
expect(mockUpdateValues).toHaveBeenCalledWith("wf-1", { planningThinkingLevel: null }, "proj-1");
});
it("does not render a thinking control when a lane lacks its companion declaration", async () => {
const withoutThinking = modelDecls.filter((setting) => setting.id !== "planningThinkingLevel");
render(<Host initial={withoutThinking} readOnly />);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalled());
expect(screen.queryByLabelText("Plan/Triage Model")).not.toBeInTheDocument();
expect(screen.getByLabelText("Planning Fallback Model")).toBeInTheDocument();
});
it("clearing a workflow model dropdown writes paired null values", async () => {
mockFetchValues.mockResolvedValue(
payload({

View File

@@ -13,6 +13,7 @@ type WorkflowModelPair = {
id: "planning" | "execution" | "validator" | "planning-fallback" | "validator-fallback" | "title-summarizer-fallback";
providerId: string;
modelId: string;
thinkingId?: string;
label: string;
help: string;
};
@@ -20,12 +21,16 @@ const DEFAULT_WORKFLOW_ID = "builtin:coding";
/*
FNXC:SettingsModels 2026-06-16-19:58:
Fallback model lanes must be configurable in all Settings surfaces: General uses the global Fallback Model, Workflow Values uses declared workflow settings, and Project Models exposes only fallback pairs declared by the active default workflow so saves never PATCH undeclared keys.
FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
Primary workflow model lanes may expose an inline thinking selector only when the active workflow declares the matching companion setting. Reset must clear both the model pair and the thinking companion; fallback/title-summarizer rows intentionally render no orphan thinking shell.
*/
const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
{
id: "planning",
providerId: "planningProvider",
modelId: "planningModelId",
thinkingId: "planningThinkingLevel",
label: "Plan/Triage Model",
help: "Provider and model used when planning or triaging tasks. Leave unset to inherit from the workflow default.",
},
@@ -33,6 +38,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
id: "execution",
providerId: "executionProvider",
modelId: "executionModelId",
thinkingId: "executionThinkingLevel",
label: "Executor Model",
help: "Provider and model used while executing workflow steps. Leave unset to inherit from the workflow default.",
},
@@ -40,6 +46,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
id: "validator",
providerId: "validatorProvider",
modelId: "validatorModelId",
thinkingId: "validatorThinkingLevel",
label: "Reviewer Model",
help: "Provider and model used for workflow review or validation lanes. Leave unset to inherit from the workflow default.",
},
@@ -70,7 +77,8 @@ function declaredWorkflowModelPairs(settings?: WorkflowSettingDefinition[]): Wor
return WORKFLOW_MODEL_PAIRS.filter((pair) => {
const provider = settingsById.get(pair.providerId);
const model = settingsById.get(pair.modelId);
return provider?.type === "string" && model?.type === "string";
const thinking = pair.thinkingId ? settingsById.get(pair.thinkingId) : undefined;
return provider?.type === "string" && model?.type === "string" && (!pair.thinkingId || thinking?.type === "enum" || thinking?.type === "string");
});
}
function modelPairValue(values: Record<string, unknown>, pair: WorkflowModelPair): string {
@@ -213,9 +221,22 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
};
});
}, []);
const setWorkflowThinkingValue = useCallback((pair: WorkflowModelPair, value: string) => {
if (!pair.thinkingId)
return;
setWorkflowRejections((current) => {
if (!pair.thinkingId || !current[pair.thinkingId])
return current;
const next = { ...current };
delete next[pair.thinkingId];
return next;
});
setWorkflowPending((current) => ({ ...current, [pair.thinkingId as string]: value || null }));
}, []);
const resetWorkflowPairValue = useCallback((pair: WorkflowModelPair) => {
setWorkflowPairValue(pair, "");
}, [setWorkflowPairValue]);
setWorkflowThinkingValue(pair, "");
}, [setWorkflowPairValue, setWorkflowThinkingValue]);
const saveWorkflowLanes = useCallback(async () => {
if (!projectId || !workflowDirty)
return;
@@ -329,11 +350,19 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
{!projectId ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.openAProjectToEditWorkflowModelLanes", "Open a project to edit workflow model lanes.")}</div>) : workflowLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.projectModels.loadingWorkflowModelLanes", "Loading workflow model lanes\u2026")} /></div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow", " No models available. Configure authentication before selecting workflow model lanes. ")}</div>) : (<>
{workflowModelPairs.map((pair) => {
const value = modelPairValue(effectiveWorkflowValues, pair);
const customized = Object.prototype.hasOwnProperty.call(workflowPending, pair.providerId)
const rawThinkingValue = pair.thinkingId ? effectiveWorkflowValues[pair.thinkingId] : undefined;
const thinkingValue: string = typeof rawThinkingValue === "string" ? rawThinkingValue : "";
const modelCustomized = Object.prototype.hasOwnProperty.call(workflowPending, pair.providerId)
? workflowPending[pair.providerId] !== null
: Boolean(workflowPayload?.stored && (Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.providerId)
|| Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.modelId)));
const error = workflowRejections[pair.providerId]?.message ?? workflowRejections[pair.modelId]?.message;
const thinkingCustomized = pair.thinkingId
? (Object.prototype.hasOwnProperty.call(workflowPending, pair.thinkingId)
? workflowPending[pair.thinkingId] !== null
: Boolean(workflowPayload?.stored && Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.thinkingId)))
: false;
const customized = modelCustomized || thinkingCustomized;
const error = workflowRejections[pair.providerId]?.message ?? workflowRejections[pair.modelId]?.message ?? (pair.thinkingId ? workflowRejections[pair.thinkingId]?.message : undefined);
return (<div className="form-group" key={pair.id} data-testid={`workflow-model-lane-${pair.id}`}>
<div className="settings-model-lane-label-row">
<label htmlFor={`workflow-${pair.id}-model`}>{pair.label}</label>
@@ -343,7 +372,7 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<CustomModelDropdown id={`workflow-${pair.id}-model`} label={pair.label} models={availableModels} value={value} onChange={(next) => setWorkflowPairValue(pair, next)} placeholder={t("settings.projectModels.useWorkflowDefault", "Use workflow default")} defaultOptionLabel="Use workflow default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable"/>
<CustomModelDropdown id={`workflow-${pair.id}-model`} label={pair.label} models={availableModels} value={value} onChange={(next) => setWorkflowPairValue(pair, next)} placeholder={t("settings.projectModels.useWorkflowDefault", "Use workflow default")} defaultOptionLabel="Use workflow default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={Boolean(pair.thinkingId)} thinkingLevel={thinkingValue} onThinkingLevelChange={pair.thinkingId ? (level) => setWorkflowThinkingValue(pair, level) : undefined} defaultThinkingLevel={typeof form.defaultThinkingLevel === "string" ? form.defaultThinkingLevel : "off"}/>
</div>
{customized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromWorkflow", "Reset to inherit from workflow")} onClick={() => resetWorkflowPairValue(pair)} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div>

View File

@@ -28,23 +28,26 @@ vi.mock("../runtime-resolution.js", async () => {
describe("resolve model-lane thinking levels", () => {
it("applies task > execution lane > project default lane > global default precedence", () => {
it("applies node/task > workflow execution lane > global lane > project default lane > global default precedence", () => {
const settings = {
defaultThinkingLevel: "low",
defaultThinkingLevelOverride: "medium",
executionGlobalThinkingLevel: "high",
executionThinkingLevel: "minimal",
} as const;
expect(resolveExecutorThinkingLevel("xhigh", settings)).toBe("xhigh");
expect(resolveExecutorThinkingLevel(undefined, settings)).toBe("high");
expect(resolveExecutorThinkingLevel(undefined, settings)).toBe("minimal");
expect(resolveExecutorThinkingLevel(undefined, { executionGlobalThinkingLevel: "high", defaultThinkingLevel: "low" })).toBe("high");
expect(resolveExecutorThinkingLevel(undefined, { defaultThinkingLevelOverride: "medium", defaultThinkingLevel: "low" })).toBe("medium");
expect(resolveExecutorThinkingLevel(undefined, { defaultThinkingLevel: "low" })).toBe("low");
});
it("resolves planning, reviewer, and summarization lane overrides before the global default", () => {
expect(resolvePlanningThinkingLevel({ planningGlobalThinkingLevel: "minimal", defaultThinkingLevel: "high" })).toBe("minimal");
expect(resolveValidatorThinkingLevel(undefined, { validatorGlobalThinkingLevel: "medium", defaultThinkingLevel: "low" })).toBe("medium");
expect(resolveValidatorThinkingLevel("xhigh", { validatorGlobalThinkingLevel: "medium", defaultThinkingLevel: "low" })).toBe("xhigh");
expect(resolvePlanningThinkingLevel({ planningThinkingLevel: "low", planningGlobalThinkingLevel: "minimal", defaultThinkingLevel: "high" })).toBe("low");
expect(resolvePlanningThinkingLevel({ planningThinkingLevel: "low", defaultThinkingLevel: "high" }, "xhigh")).toBe("xhigh");
expect(resolveValidatorThinkingLevel(undefined, { validatorThinkingLevel: "minimal", validatorGlobalThinkingLevel: "medium", defaultThinkingLevel: "low" })).toBe("minimal");
expect(resolveValidatorThinkingLevel("xhigh", { validatorThinkingLevel: "minimal", validatorGlobalThinkingLevel: "medium", defaultThinkingLevel: "low" })).toBe("xhigh");
expect(resolveTitleSummarizerThinkingLevel({
titleSummarizerThinkingLevel: "high",
titleSummarizerGlobalThinkingLevel: "medium",

View File

@@ -85,6 +85,7 @@ vi.mock("../agent-session-helpers.js", async () => {
},
resolveExecutorThinkingLevel: (taskThinkingLevel: string | undefined, settings: Record<string, unknown> | undefined) =>
taskThinkingLevel
?? (typeof settings?.executionThinkingLevel === "string" ? settings.executionThinkingLevel : undefined)
?? (typeof settings?.executionGlobalThinkingLevel === "string" ? settings.executionGlobalThinkingLevel : undefined)
?? (typeof settings?.defaultThinkingLevelOverride === "string" ? settings.defaultThinkingLevelOverride : undefined)
?? (typeof settings?.defaultThinkingLevel === "string" ? settings.defaultThinkingLevel : undefined),

View File

@@ -16,6 +16,7 @@ import {
isGrokApiKeyFusionVisible,
isTestModeActive,
resolveExecutionSettingsModel,
resolvePhaseThinkingLevel,
resolveProjectDefaultModel,
resolveTaskExecutionModel,
resolveTaskPlanningModel,
@@ -161,29 +162,27 @@ function firstThinkingLevel(...levels: Array<ThinkingLevel | string | undefined
/**
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Model-lane thinking overrides must resolve through the same session option (`defaultThinkingLevel`) pi.ts already guards with the thinking/reasoning conflict fallback. Keep task-level thinking first, then the lane override, then the global default.
* Model-lane thinking overrides must resolve through the same session option (`defaultThinkingLevel`) pi.ts already guards with the thinking/reasoning conflict fallback. Keep node/step thinking first when supplied by callers, then task, workflow lane, global lane, project default thinking override, and global default.
*/
export function resolveExecutorThinkingLevel(
taskThinkingLevel: ThinkingLevel | string | undefined,
settings: Partial<Settings> | undefined,
): string | undefined {
return firstThinkingLevel(
taskThinkingLevel,
settings?.executionGlobalThinkingLevel,
settings?.defaultThinkingLevelOverride,
settings?.defaultThinkingLevel,
);
return resolvePhaseThinkingLevel("execution", settings, taskThinkingLevel);
}
export function resolvePlanningThinkingLevel(settings: Partial<Settings> | undefined): string | undefined {
return firstThinkingLevel(settings?.planningGlobalThinkingLevel, settings?.defaultThinkingLevelOverride, settings?.defaultThinkingLevel);
export function resolvePlanningThinkingLevel(
settings: Partial<Settings> | undefined,
taskThinkingLevel?: ThinkingLevel | string,
): string | undefined {
return resolvePhaseThinkingLevel("planning", settings, taskThinkingLevel);
}
export function resolveValidatorThinkingLevel(
taskThinkingLevel: ThinkingLevel | string | undefined,
settings: Partial<Settings> | undefined,
): string | undefined {
return firstThinkingLevel(taskThinkingLevel, settings?.validatorGlobalThinkingLevel, settings?.defaultThinkingLevelOverride, settings?.defaultThinkingLevel);
return resolvePhaseThinkingLevel("validation", settings, taskThinkingLevel);
}
export function resolveTitleSummarizerThinkingLevel(settings: Partial<Settings> | undefined): string | undefined {

View File

@@ -6688,9 +6688,9 @@ export class TaskExecutor {
fallbackModelId: settings.fallbackModelId,
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Step-review model sessions honor per-node `config.thinkingLevel` before task and settings defaults.
* Step-review model sessions honor per-node `config.thinkingLevel` before task, validator workflow lane, global lane, and default thinking settings.
*/
defaultThinkingLevel: resolveExecutorThinkingLevel(
defaultThinkingLevel: resolveValidatorThinkingLevel(
typeof config.thinkingLevel === "string" && WORKFLOW_THINKING_LEVEL_SET.has(config.thinkingLevel)
? (config.thinkingLevel as ThinkingLevel)
: detail.thinkingLevel,

View File

@@ -1274,7 +1274,7 @@ export class StepSessionExecutor {
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Reusable primary step sessions resolve reasoning effort as workflow node/step override first, then parsed step metadata, then task and settings defaults.
* Reusable primary step sessions resolve reasoning effort as workflow node/step override first, then parsed step metadata, task thinking, workflow execution lane, global lane, and default thinking settings.
*/
const stepThinkingLevel = this.options.workflowStepThinkingLevel
?? (taskDetail.steps[stepIndex] as { thinkingLevel?: string } | undefined)?.thinkingLevel;

View File

@@ -1161,7 +1161,11 @@ export class TriageProcessor {
fallbackModelId: hasExplicitPlanningFallback
? settings.planningFallbackModelId
: (hasExplicitGlobalFallback ? settings.fallbackModelId : implicitPlanningFallback.modelId),
defaultThinkingLevel: resolvePlanningThinkingLevel(settings),
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Planning sessions carry task thinking first, then the workflow-declared planning lane, global planning lane, and default thinking settings into pi.ts' existing thinking fallback path.
*/
defaultThinkingLevel: resolvePlanningThinkingLevel(settings, task.thinkingLevel),
runAuditor,
settings,
// FNXC:McpConfig 2026-06-25-23:17: Primary triage planning is an AI lane, so it receives the store-resolved MCP set while the pi runtime-support guard decides whether to forward it without logging secret material.
@@ -1179,7 +1183,7 @@ export class TriageProcessor {
}),
});
const modelDesc = formatModelMarkerDetails(describeModel(session), resolvePlanningThinkingLevel(settings));
const modelDesc = formatModelMarkerDetails(describeModel(session), resolvePlanningThinkingLevel(settings, task.thinkingLevel));
planLog.log(`${task.id}: using model ${modelDesc}`);
await this.store.logEntry(task.id, `Triage using model: ${modelDesc}`);
await this.store.appendAgentLog(