From 235ff4c65e925ceee4a5f31ddfb25dd60867952b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 01:49:15 -0700 Subject: [PATCH] FN-7771: add per-node thinking level for workflow model bindings Adds a per-node thinking-level override (config.thinkingLevel) for workflow IR model bindings so individual workflow nodes can set reasoning effort independently of the global default. - Extend workflow-ir types/schema and workflow-steps-to-ir conversion to carry config.thinkingLevel per node - Wire thinkingLevel through executor and step-session-executor so the engine applies the per-node override during model calls - Add a thinking-level control to WorkflowNodeEditor for authoring per-node overrides in the dashboard - Add/extend tests covering IR round-trip, steps-to-ir conversion, executor model binding, and the WorkflowNodeEditor UI - Document the new setting in docs/workflow-steps.md - Add changeset for @runfusion/fusion (minor) Files changed: .changeset/fn-7771-workflow-node-thinking.md | 7 +++ docs/workflow-steps.md | 10 ++- packages/core/src/__tests__/workflow-ir.test.ts | 42 +++++++++++++ .../src/__tests__/workflow-steps-to-ir.test.ts | 15 +++++ packages/core/src/store.ts | 1 + packages/core/src/types.ts | 9 +++ packages/core/src/workflow-ir.ts | 21 +++++++ packages/core/src/workflow-steps-to-ir.ts | 5 ++ .../app/components/WorkflowNodeEditor.tsx | 42 ++++++++++++- .../__tests__/WorkflowNodeEditor.test.tsx | 73 ++++++++++++++++++++++ .../engine/src/__tests__/executor-test-helpers.ts | 5 ++ .../__tests__/executor-workflow-step-model.test.ts | 31 +++++++++ .../src/__tests__/workflow-step-review.test.ts | 53 ++++++++++++++++ packages/engine/src/executor.ts | 61 ++++++++++++++++-- packages/engine/src/step-session-executor.ts | 12 +++- packages/engine/src/workflow-node-handlers.ts | 29 ++++++++- 16 files changed, 404 insertions(+), 12 deletions(-) Fusion-Task-Id: FN-7771 Fusion-Task-Lineage: 5dbe3efb-773d-47db-9412-b740eb1d7745 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7771-workflow-node-thinking.md | 7 ++ docs/workflow-steps.md | 10 ++- .../core/src/__tests__/workflow-ir.test.ts | 42 +++++++++++ .../__tests__/workflow-steps-to-ir.test.ts | 15 ++++ packages/core/src/store.ts | 1 + packages/core/src/types.ts | 9 +++ packages/core/src/workflow-ir.ts | 21 ++++++ packages/core/src/workflow-steps-to-ir.ts | 5 ++ .../app/components/WorkflowNodeEditor.tsx | 42 ++++++++++- .../__tests__/WorkflowNodeEditor.test.tsx | 73 +++++++++++++++++++ .../src/__tests__/executor-test-helpers.ts | 5 ++ .../executor-workflow-step-model.test.ts | 31 ++++++++ .../__tests__/workflow-step-review.test.ts | 53 ++++++++++++++ packages/engine/src/executor.ts | 61 ++++++++++++++-- packages/engine/src/step-session-executor.ts | 12 ++- packages/engine/src/workflow-node-handlers.ts | 29 +++++++- 16 files changed, 404 insertions(+), 12 deletions(-) create mode 100644 .changeset/fn-7771-workflow-node-thinking.md diff --git a/.changeset/fn-7771-workflow-node-thinking.md b/.changeset/fn-7771-workflow-node-thinking.md new file mode 100644 index 0000000000..4ddcee44ac --- /dev/null +++ b/.changeset/fn-7771-workflow-node-thinking.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add per-node workflow thinking-level controls for custom model bindings. +category: feature +dev: Workflow IR now round-trips config.thinkingLevel and runtime precedence is node/step > task > settings. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 014024bdd3..93b8dd6d5d 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -542,14 +542,20 @@ There is no longer a Settings → Workflow Steps manager or step CRUD form. To a Plugin palette templates (above) can be dropped in as a starting point instead of authoring a node from scratch. -## Model Overrides for Prompt Steps +## Model Overrides for Workflow Nodes + + A prompt-mode gate node can set its own model with: - `modelProvider` - `modelId` +- `thinkingLevel` (`"off" | "minimal" | "low" | "medium" | "high" | "xhigh"`) -If both are set, node execution uses that model; otherwise it falls back to default model selection. Dashboard node summaries show that unpinned prompt-node state as **Default model**. +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. ## Default-On Behavior for New Tasks diff --git a/packages/core/src/__tests__/workflow-ir.test.ts b/packages/core/src/__tests__/workflow-ir.test.ts index 9b5624250b..34dc43be0a 100644 --- a/packages/core/src/__tests__/workflow-ir.test.ts +++ b/packages/core/src/__tests__/workflow-ir.test.ts @@ -155,6 +155,48 @@ describe("parseWorkflowIr — v2 columns & placement", () => { expect(() => parseWorkflowIr(ir)).toThrow(/duplicate column id 'dup'/); }); + it("validates optional node thinkingLevel values", () => { + const valid = v2( + [{ id: "only", name: "Only", traits: [] }], + [ + { id: "start", kind: "start", column: "only" }, + { id: "a", kind: "prompt", column: "only", config: { thinkingLevel: "high" } }, + { id: "review", kind: "step-review", column: "only", config: { type: "code", thinkingLevel: "low" } }, + { id: "end", kind: "end", column: "only" }, + ], + [ + { from: "start", to: "a" }, + { from: "a", to: "review" }, + { from: "review", to: "end", condition: "outcome:approve" }, + { from: "review", to: "end", condition: "outcome:revise" }, + ], + ); + expect(() => parseWorkflowIr(valid)).not.toThrow(); + + const absent = v2( + [{ id: "only", name: "Only", traits: [] }], + [ + { id: "start", kind: "start", column: "only" }, + { id: "a", kind: "prompt", column: "only" }, + { id: "end", kind: "end", column: "only" }, + ], + [{ from: "start", to: "a" }, { from: "a", to: "end" }], + ); + expect(() => parseWorkflowIr(absent)).not.toThrow(); + + const invalid = v2( + [{ id: "only", name: "Only", traits: [] }], + [ + { id: "start", kind: "start", column: "only" }, + { id: "a", kind: "prompt", column: "only", config: { thinkingLevel: "ultra" } }, + { id: "end", kind: "end", column: "only" }, + ], + [{ from: "start", to: "a" }, { from: "a", to: "end" }], + ); + expect(() => parseWorkflowIr(invalid)).toThrow(WorkflowIrError); + expect(() => parseWorkflowIr(invalid)).toThrow(/Workflow node 'a' thinkingLevel must be one of/); + }); + it("rejects duplicate top-level node ids before Map de-duplication can mask them", () => { const ir = v2( [{ id: "only", name: "Only", traits: [] }], diff --git a/packages/core/src/__tests__/workflow-steps-to-ir.test.ts b/packages/core/src/__tests__/workflow-steps-to-ir.test.ts index 981c0cd1c0..0741986247 100644 --- a/packages/core/src/__tests__/workflow-steps-to-ir.test.ts +++ b/packages/core/src/__tests__/workflow-steps-to-ir.test.ts @@ -31,6 +31,7 @@ function step(overrides: Partial): WorkflowStep { defaultOn: overrides.defaultOn, modelProvider: overrides.modelProvider, modelId: overrides.modelId, + thinkingLevel: overrides.thinkingLevel, migratedFragmentId: overrides.migratedFragmentId, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -71,6 +72,20 @@ describe("stepsToWorkflowIr — produces valid IR structure", () => { expect(ir.nodes.find((n) => n.id === "step-1")?.config?.prompt).toBe("Implement the change"); }); + it("lowers thinkingLevel independently from the model pair", () => { + const thinkingOnly = step({ id: "WS-thinking", name: "Think", mode: "prompt", gateMode: "advisory", prompt: "x", thinkingLevel: "high" }); + const withoutThinking = step({ id: "WS-default", name: "Default", mode: "prompt", gateMode: "advisory", prompt: "y" }); + + const fragment = stepToFragmentIr(thinkingOnly); + expect(fragment.nodes.find((n) => n.id === "step-1")?.config).toMatchObject({ thinkingLevel: "high" }); + expect(fragment.nodes.find((n) => n.id === "step-1")?.config).not.toHaveProperty("modelProvider"); + expect(fragment.nodes.find((n) => n.id === "step-1")?.config).not.toHaveProperty("modelId"); + + const ir = stepsToWorkflowIr([thinkingOnly, withoutThinking], "Thinking"); + expect(ir.nodes.find((n) => n.id === "step-1")?.config).toMatchObject({ thinkingLevel: "high" }); + expect(ir.nodes.find((n) => n.id === "step-2")?.config).not.toHaveProperty("thinkingLevel"); + }); + it("empty step list yields a minimal valid IR (start + seams + end)", () => { const ir = stepsToWorkflowIr([], "Empty"); expect(() => parseWorkflowIr(ir)).not.toThrow(); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 90c1d8a027..5c2b1ee24f 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -15432,6 +15432,7 @@ ${stepsSection}`; defaultOn: entry.template.defaultOn, modelProvider: entry.template.modelProvider, modelId: entry.template.modelId, + thinkingLevel: entry.template.thinkingLevel, createdAt: now, updatedAt: now, }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d87c191bf5..2a97ce60a3 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -711,6 +711,11 @@ export interface WorkflowStep { * Must be set together with `modelProvider`. When both model fields are undefined, * the executor uses global settings defaults. Only used when mode is "prompt". */ modelId?: string; + /** + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Workflow IR nodes may pin reasoning effort independently from the model pair so authors can inherit the model while overriding thinking level. Runtime precedence is node/step `thinkingLevel` > task `thinkingLevel` > settings `defaultThinkingLevel`. + */ + thinkingLevel?: ThinkingLevel; /** (workflow-editor-consolidation U1/U2, KTD-1/KTD-3) when this legacy step has * been migrated into a fragment WorkflowDefinition, the fragment's id is stamped * here so the lazy step migration is idempotent (already-stamped rows are @@ -852,6 +857,8 @@ export interface WorkflowStepInput { modelProvider?: string; /** AI model ID override. Must be set together with modelProvider. Only used when mode is "prompt". */ modelId?: string; + /** Optional per-node reasoning-effort override; inherits from task/settings when omitted. */ + thinkingLevel?: ThinkingLevel; /** (workflow-editor-consolidation U2, KTD-3) fragment id stamped when this step * was migrated into a fragment WorkflowDefinition. Set by the migration only. */ migratedFragmentId?: string; @@ -1017,6 +1024,8 @@ export interface WorkflowStepTemplate { modelProvider?: string; /** AI model ID override for prompt-mode templates. */ modelId?: string; + /** Optional per-node reasoning-effort override for prompt-mode templates. */ + thinkingLevel?: ThinkingLevel; /** Grouping category (e.g., "Quality", "Security") */ category: string; /** Optional icon identifier for UI (e.g., "file-text", "shield") */ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 47f0adc173..ae18a4186b 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -17,6 +17,7 @@ import type { } from "./workflow-ir-types.js"; import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js"; import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js"; +import { THINKING_LEVELS } from "./types.js"; export class WorkflowIrError extends Error { constructor(message: string) { @@ -70,6 +71,8 @@ const FIELD_RENDER_WIDGETS: ReadonlySet = new Set([ "toggle", ]); +const THINKING_LEVEL_SET: ReadonlySet = new Set(THINKING_LEVELS); + /** Workflow-settings (U1) value-type whitelist (mirrors WORKFLOW_FIELD_TYPES). */ export const WORKFLOW_SETTING_TYPES: ReadonlySet = new Set([ "string", @@ -845,6 +848,23 @@ function validateStepReviewRouting( } } +function validateThinkingLevelConfig(nodes: WorkflowIrNode[]): void { + for (const node of nodes) { + const value = node.config?.thinkingLevel; + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Per-node thinking overrides are workflow model-binding config, so IR validation rejects unknown reasoning levels before editor-authored or imported graphs reach execution. + */ + if (value !== undefined && (typeof value !== "string" || !THINKING_LEVEL_SET.has(value))) { + throw new WorkflowIrError( + `Workflow node '${node.id}' thinkingLevel must be one of ${THINKING_LEVELS.join(", ")} when present`, + ); + } + const templateNodes = (node.config as { template?: { nodes?: unknown } } | undefined)?.template?.nodes; + if (Array.isArray(templateNodes)) validateThinkingLevelConfig(templateNodes as WorkflowIrNode[]); + } +} + /** Compute the set of node ids that lie strictly inside some split..join branch * region. Walks each split's branches forward to the join. Lightweight; used * for the step-review advisory-only rule. */ @@ -1469,6 +1489,7 @@ function validateV2(ir: WorkflowIrV2): void { // configs first, then structural rules. const topLevelIds = new Set(ir.nodes.map((n) => n.id)); validateStepExecutePlacement(ir.nodes); + validateThinkingLevelConfig(ir.nodes); for (const node of ir.nodes) { if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds); if (node.kind === "loop") validateLoop(node, topLevelIds, columnIds); diff --git a/packages/core/src/workflow-steps-to-ir.ts b/packages/core/src/workflow-steps-to-ir.ts index fee092090c..38f0d868f6 100644 --- a/packages/core/src/workflow-steps-to-ir.ts +++ b/packages/core/src/workflow-steps-to-ir.ts @@ -69,6 +69,11 @@ function stepInputToNode(step: WorkflowStep, id: string): WorkflowIrNode { config.modelProvider = step.modelProvider; config.modelId = step.modelId; } + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * A workflow step can pin reasoning effort while inheriting its model, so lower `thinkingLevel` independently from the model-provider/model-id pair. + */ + if (step.thinkingLevel) config.thinkingLevel = step.thinkingLevel; return { id, kind: "prompt", config }; } diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 9dfbf10cad..74e64f8287 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -37,6 +37,7 @@ import { fetchPluginWorkflowStepTemplates, fetchWorkflowPromptOverrides, updateWorkflowPromptOverrides, + fetchSettings, type ModelInfo, type WorkflowPromptOverridesPayload, } from "../api"; @@ -322,6 +323,11 @@ function stepTemplateToNode(tpl: WorkflowStepTemplate): { config.modelProvider = tpl.modelProvider; config.modelId = tpl.modelId; } + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Template-seeded prompt nodes preserve reasoning effort independently from model selection so authors can inherit a model while pinning per-node thinking. + */ + if (tpl.thinkingLevel) config.thinkingLevel = tpl.thinkingLevel; return { kind: "prompt", label: tpl.name, config }; } @@ -1734,7 +1740,17 @@ function InnerEditor({ config: typeof patch.config === "function" ? patch.config((n.data.config ?? {}) as Record) - : { ...(n.data.config ?? {}), ...patch.config }, + : (() => { + const nextConfig = { ...(n.data.config ?? {}), ...patch.config }; + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Inspector controls use `undefined` as the clear signal for optional config keys such as `thinkingLevel`; remove those keys so saved IR has absence, not an undefined shell. + */ + for (const key of Object.keys(nextConfig)) { + if (nextConfig[key] === undefined) delete nextConfig[key]; + } + return nextConfig; + })(), } : patch), }, @@ -2249,6 +2265,7 @@ function InnerEditor({ // summaries; the inspector selects reuse the same state. Failures are // non-fatal — summaries fall back to raw ids — so the prefetch is toastless. const [models, setModels] = useState([]); + const [projectDefaultThinkingLevel, setProjectDefaultThinkingLevel] = useState("off"); const [agents, setAgents] = useState([]); // The agent fetches are project-scoped, but this cache survives project // switches — both load paths short-circuit on agents.length > 0, which would @@ -2282,6 +2299,13 @@ function InnerEditor({ if (!cancelled && Array.isArray(res)) setSkills(res); }) .catch(() => {}); + Promise.resolve(fetchSettings(projectId)) + .then((res) => { + if (!cancelled) setProjectDefaultThinkingLevel(res?.defaultThinkingLevel ?? "off"); + }) + .catch(() => { + if (!cancelled) setProjectDefaultThinkingLevel("off"); + }); return () => { cancelled = true; }; @@ -3837,6 +3861,14 @@ function InnerEditor({ const { provider, modelId } = parseModelDropdownValue(value); updateSelectedData({ config: { modelProvider: provider || undefined, modelId: modelId || undefined } }); }} + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Prompt model nodes expose the shared inline thinking selector only for the model executor, persisting `config.thinkingLevel` with Default clearing the key. + */ + showThinkingLevel + thinkingLevel={String(selectedNode.data.config?.thinkingLevel ?? "")} + onThinkingLevelChange={(value) => updateSelectedData({ config: { thinkingLevel: value || undefined } })} + defaultThinkingLevel={projectDefaultThinkingLevel ?? "off"} /> )} @@ -4568,6 +4600,14 @@ function InnerEditor({ }, }); }} + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Step-review nodes share the model dropdown thinking selector so review sessions can pin reasoning effort with the same node > task > settings precedence as executor steps. + */ + showThinkingLevel + thinkingLevel={String(selectedNode.data.config?.thinkingLevel ?? "")} + onThinkingLevelChange={(value) => updateSelectedData({ config: { thinkingLevel: value || undefined } })} + defaultThinkingLevel={projectDefaultThinkingLevel ?? "off"} />

diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index f1d49cd5ac..74afaabc78 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -356,6 +356,40 @@ function scriptDef(): WorkflowDefinition { }; } +function thinkingModelDef(): WorkflowDefinition { + return { + id: "WF-THINKING", + kind: "workflow", + name: "Thinking workflow", + description: "", + ir: { + version: "v2", + name: "Thinking workflow", + columns: [ + { id: "triage", name: "Triage", traits: [{ trait: "intake" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "triage" }, + { id: "model", kind: "prompt", column: "triage", config: { name: "Model node", executor: "model", prompt: "run" } }, + { id: "review", kind: "step-review", column: "triage", config: { type: "code", thinkingLevel: "low" } }, + { id: "script", kind: "script", column: "triage", config: { scriptName: "lint" } }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "model" }, + { from: "model", to: "review" }, + { from: "review", to: "script", condition: "outcome:approve" }, + { from: "review", to: "model", condition: "outcome:revise", kind: "rework" }, + { from: "script", to: "end" }, + ], + }, + layout: { start: { x: 0, y: 20 }, model: { x: 120, y: 60 }, review: { x: 240, y: 120 }, script: { x: 360, y: 180 }, end: { x: 480, y: 240 } }, + createdAt: "2026-06-03T00:00:00.000Z", + updatedAt: "2026-06-03T00:00:00.000Z", + }; +} + function plainConnectDef(): WorkflowDefinition { return { id: "WF-PLAIN-CONNECT", @@ -1186,6 +1220,45 @@ describe("WorkflowNodeEditor", () => { await waitFor(() => expect(screen.queryByTestId("wf-node-inspector")).not.toBeInTheDocument()); }); + it("wires thinking controls only on workflow model pickers and persists clear semantics", async () => { + const source = thinkingModelDef(); + vi.mocked(fetchWorkflows).mockResolvedValue([source]); + vi.mocked(fetchModels).mockResolvedValue({ models: [{ provider: "anthropic", id: "claude-sonnet", name: "Claude Sonnet" }] }); + vi.mocked(fetchSettings).mockResolvedValue({ defaultThinkingLevel: "medium" } as Settings); + vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...source, ...(updates as object) })); + + render( {}} addToast={() => {}} />); + + await screen.findByText("Save"); + fireEvent.click(screen.getByTestId("wf-layout-toggle")); + + fireEvent.click(within(await screen.findByTestId("mobile-wf-node-model")).getAllByRole("button")[0]); + let inspector = await screen.findByTestId("wf-node-inspector"); + expect(within(inspector).getByTestId("custom-model-dropdown-thinking-badge")).toHaveTextContent("Default (medium)"); + fireEvent.click(within(inspector).getByRole("button", { name: "Model" })); + await screen.findByTestId("custom-model-dropdown-thinking"); + fireEvent.change(screen.getAllByTestId("custom-model-dropdown-thinking").at(-1)!, { target: { value: "high" } }); + fireEvent.click(screen.getByText("Save")); + await waitFor(() => expect(updateWorkflow).toHaveBeenCalled()); + let saved = vi.mocked(updateWorkflow).mock.calls.at(-1)?.[1] as { ir?: WorkflowDefinition["ir"] }; + expect(saved.ir?.nodes.find((node) => node.id === "model")?.config?.thinkingLevel).toBe("high"); + + fireEvent.click(within(await screen.findByTestId("mobile-wf-node-review")).getAllByRole("button")[0]); + inspector = await screen.findByTestId("wf-node-inspector"); + expect(within(inspector).getByTestId("custom-model-dropdown-thinking-badge")).toHaveTextContent("Low"); + fireEvent.click(within(inspector).getByRole("button", { name: "Review model (optional)" })); + await screen.findByTestId("custom-model-dropdown-thinking"); + fireEvent.change(screen.getAllByTestId("custom-model-dropdown-thinking").at(-1)!, { target: { value: "" } }); + fireEvent.click(screen.getByText("Save")); + await waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(2)); + saved = vi.mocked(updateWorkflow).mock.calls.at(-1)?.[1] as { ir?: WorkflowDefinition["ir"] }; + expect(saved.ir?.nodes.find((node) => node.id === "review")?.config).not.toHaveProperty("thinkingLevel"); + + fireEvent.click(within(await screen.findByTestId("mobile-wf-node-script")).getAllByRole("button")[0]); + inspector = await screen.findByTestId("wf-node-inspector"); + expect(within(inspector).queryByTestId("custom-model-dropdown-thinking-badge")).not.toBeInTheDocument(); + }); + it("opens selected edge details as a dismissible full-screen mobile stage", async () => { mockWorkflowEditorViewport("mobile"); vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]); diff --git a/packages/engine/src/__tests__/executor-test-helpers.ts b/packages/engine/src/__tests__/executor-test-helpers.ts index 0dcdc399f8..7b75605367 100644 --- a/packages/engine/src/__tests__/executor-test-helpers.ts +++ b/packages/engine/src/__tests__/executor-test-helpers.ts @@ -83,6 +83,11 @@ vi.mock("../agent-session-helpers.js", async () => { const hint = runtimeConfig?.runtimeHint; return typeof hint === "string" && hint.trim().length > 0 ? hint.trim() : undefined; }, + resolveExecutorThinkingLevel: (taskThinkingLevel: string | undefined, settings: Record | undefined) => + taskThinkingLevel + ?? (typeof settings?.executionGlobalThinkingLevel === "string" ? settings.executionGlobalThinkingLevel : undefined) + ?? (typeof settings?.defaultThinkingLevelOverride === "string" ? settings.defaultThinkingLevelOverride : undefined) + ?? (typeof settings?.defaultThinkingLevel === "string" ? settings.defaultThinkingLevel : undefined), resolveExecutorSessionModel: ( taskModelProvider: string | undefined, taskModelId: string | undefined, diff --git a/packages/engine/src/__tests__/executor-workflow-step-model.test.ts b/packages/engine/src/__tests__/executor-workflow-step-model.test.ts index f8e627d115..928d60c1af 100644 --- a/packages/engine/src/__tests__/executor-workflow-step-model.test.ts +++ b/packages/engine/src/__tests__/executor-workflow-step-model.test.ts @@ -11,6 +11,7 @@ import { type CapturedSession = { defaultProvider?: string; defaultModelId?: string; + defaultThinkingLevel?: string; }; function captureSession(output = '{"verdict":"APPROVE","notes":""}'): { last?: CapturedSession } { @@ -19,6 +20,7 @@ function captureSession(output = '{"verdict":"APPROVE","notes":""}'): { last?: C holder.last = { defaultProvider: opts.defaultProvider, defaultModelId: opts.defaultModelId, + defaultThinkingLevel: opts.defaultThinkingLevel, }; const listeners: Array<(event: any) => void> = []; @@ -232,6 +234,35 @@ describe("executor workflow-step model resolution", () => { }); }); + it("resolves workflow-step thinking level before task and settings defaults", async () => { + await expect( + runStepWithSettings( + { + defaultThinkingLevel: "low", + }, + { + task: { thinkingLevel: "medium" }, + step: { thinkingLevel: "high" }, + }, + ), + ).resolves.toMatchObject({ defaultThinkingLevel: "high" }); + + await expect( + runStepWithSettings( + { + defaultThinkingLevel: "low", + }, + { + task: { thinkingLevel: "medium" }, + }, + ), + ).resolves.toMatchObject({ defaultThinkingLevel: "medium" }); + + await expect( + runStepWithSettings({ defaultThinkingLevel: "low" }), + ).resolves.toMatchObject({ defaultThinkingLevel: "low" }); + }); + it("logs workflow-step model rows with thinking effort before override annotations", async () => { const primary = await runStepWithSettings( { diff --git a/packages/engine/src/__tests__/workflow-step-review.test.ts b/packages/engine/src/__tests__/workflow-step-review.test.ts index f5750b41da..e0365a34c7 100644 --- a/packages/engine/src/__tests__/workflow-step-review.test.ts +++ b/packages/engine/src/__tests__/workflow-step-review.test.ts @@ -98,6 +98,59 @@ describe("WorkflowGraphExecutor step-review (U5)", () => { expect(doneMarks).toEqual([{ index: 0, status: "done" }]); }); + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Regression for a node's own `config.thinkingLevel` being dropped by resolveStepReviewConfig + * before it reached `seams.stepReview` — the FN-7771 per-node override would silently never + * apply to review sessions even though the dashboard persisted it on the node. + */ + it("threads the review node's own config.thinkingLevel into the stepReview seam config", async () => { + const seenThinkingLevels: Array = []; + const seams = baseSeams({ + stepExecute: async () => ({ outcome: "success", value: "step-done" }), + stepReview: async (_t, _ctx, cfg) => { + seenThinkingLevels.push(cfg.thinkingLevel); + return { verdict: "APPROVE" }; + }, + }); + const executor = new WorkflowGraphExecutor({ seams }); + const result = await executor.run( + taskWithSteps(1), + settingsOn(), + reviewForeachIr({ config: {} }), + ); + void result; + + // Baseline: no thinkingLevel on the node -> undefined reaches the seam. + expect(seenThinkingLevels).toEqual([undefined]); + + // Now with a pinned node-level thinkingLevel on the review node itself. + const template = { + nodes: [ + { id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } }, + { id: "review", kind: "step-review" as const, config: { type: "code", thinkingLevel: "high" } }, + ] as WorkflowIrNode[], + edges: [{ from: "exec", to: "review", condition: "success" }], + }; + const ir: WorkflowIr = { + version: "v2", + name: "review-thinking-test", + columns: [{ id: "work", name: "Work", traits: [] }], + nodes: [ + { id: "start", kind: "start" }, + { id: "fe", kind: "foreach", config: { source: "task-steps", template } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "fe" }, + { from: "fe", to: "end", condition: "success" }, + ], + }; + seenThinkingLevels.length = 0; + await executor.run(taskWithSteps(1), settingsOn(), ir); + expect(seenThinkingLevels).toEqual(["high"]); + }); + it("REVISE routes a rework edge without triggering a reset", async () => { const resets: string[] = []; let reviewCalls = 0; diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index d15f1a33eb..8343b86ca8 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -6,12 +6,14 @@ import { setImmediate as setImmediateCb } from "node:timers"; // Internal git plumbing intentionally bypasses sandbox backends. const execAsync = promisify(exec); +const WORKFLOW_THINKING_LEVEL_SET: ReadonlySet = new Set(THINKING_LEVELS); + import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path"; import { existsSync, lstatSync, realpathSync } from "node:fs"; import { readFile, rm, writeFile } from "node:fs/promises"; -import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult } from "@fusion/core"; +import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult, ThinkingLevel } from "@fusion/core"; import { getUnmetSchedulingDependencies } from "./scheduler.js"; -import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON } from "@fusion/core"; +import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS } from "@fusion/core"; import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js"; import { mergeEffectiveSettings } from "./effective-settings.js"; import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core"; @@ -36,6 +38,7 @@ import { observeWorkflowParity, WORKFLOW_INTERPRETER_DUAL_OBSERVE_FLAG } from ". import { FOREACH_ACTIVE_CONTEXT_KEY, SEAM_GOVERNING_NODE_CONTEXT_KEY, + SEAM_THINKING_LEVEL_CONTEXT_KEY, SPLIT_ACTIVE_CONTEXT_KEY, type ForeachActiveContext, type WorkflowLegacySeams, @@ -4811,6 +4814,12 @@ export class TaskExecutor { * session build, and cleared by the seam afterward. Keyed by task id. */ private graphSeamGoverningNodeId = new Map(); + /** + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Execute and step-execute seam nodes can pin reasoning effort for the implementation session; keep it per graph run so session creation applies node/step > task > settings precedence. + */ + private graphSeamThinkingLevel = new Map(); + /** Tasks currently being orchestrated by the graph runner. Process-wide for * the same reason as executingTaskLock (FN-4811): duplicate execute() * invocations can arrive from different TaskExecutor instances in one @@ -5140,6 +5149,7 @@ export class TaskExecutor { this.graphColumnAgentResolver.delete(task.id); this.graphUnattendedRuns.delete(task.id); this.graphSeamGoverningNodeId.delete(task.id); + this.graphSeamThinkingLevel.delete(task.id); this.graphExecuteSelfRequeued.delete(task.id); // Per-instance keys: clear every instance slot owned by this task. const ctxPrefix = `${task.id}:`; @@ -5828,6 +5838,7 @@ export class TaskExecutor { stepIndex: number, instanceId?: string, governingNodeId?: string, + thinkingLevel?: ThinkingLevel, ): Promise<{ success: boolean; error?: string }> { const active = this.foreachActiveForTask(task.id, instanceId); /* @@ -5859,6 +5870,9 @@ export class TaskExecutor { if (typeof governingNodeId === "string") { this.graphSeamGoverningNodeId.set(task.id, governingNodeId); } + if (thinkingLevel) { + this.graphSeamThinkingLevel.set(task.id, thinkingLevel); + } phase = this.runImplementationPhase(task); this.graphStepRunOnce.set(task.id, phase); void phase @@ -5868,6 +5882,9 @@ export class TaskExecutor { if (typeof governingNodeId === "string" && this.graphSeamGoverningNodeId.get(task.id) === governingNodeId) { this.graphSeamGoverningNodeId.delete(task.id); } + if (thinkingLevel && this.graphSeamThinkingLevel.get(task.id) === thinkingLevel) { + this.graphSeamThinkingLevel.delete(task.id); + } }); } try { @@ -6436,11 +6453,16 @@ export class TaskExecutor { if (typeof governingNodeId === "string") { this.graphSeamGoverningNodeId.set(seamTask.id, governingNodeId); } + const seamThinkingLevel = context?.[SEAM_THINKING_LEVEL_CONTEXT_KEY]; + if (typeof seamThinkingLevel === "string" && WORKFLOW_THINKING_LEVEL_SET.has(seamThinkingLevel)) { + this.graphSeamThinkingLevel.set(seamTask.id, seamThinkingLevel as ThinkingLevel); + } let result: { taskDone: boolean; modifiedFiles: string[] }; try { result = await this.runImplementationPhase(seamTask); } finally { this.graphSeamGoverningNodeId.delete(seamTask.id); + this.graphSeamThinkingLevel.delete(seamTask.id); } if (result.taskDone) { return { outcome: "success", value: "implemented" }; @@ -6563,6 +6585,7 @@ export class TaskExecutor { // instance's; per-invocation set/delete here would race under parallel // foreach (overwrite mid-build, or clear while the shared pass is live). const stepGoverningNodeId = context[SEAM_GOVERNING_NODE_CONTEXT_KEY]; + const seamThinkingLevel = context[SEAM_THINKING_LEVEL_CONTEXT_KEY]; const result: Awaited> = await runTaskStep( { store: this.store, @@ -6578,6 +6601,9 @@ export class TaskExecutor { stepIndex, active.instanceId, typeof stepGoverningNodeId === "string" ? stepGoverningNodeId : undefined, + typeof seamThinkingLevel === "string" && WORKFLOW_THINKING_LEVEL_SET.has(seamThinkingLevel) + ? (seamThinkingLevel as ThinkingLevel) + : undefined, ), }, { id: seamTask.id, steps: live.steps }, @@ -6660,7 +6686,16 @@ export class TaskExecutor { defaultModelId: settings.defaultModelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: resolveExecutorThinkingLevel(detail.thinkingLevel, settings), + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Step-review model sessions honor per-node `config.thinkingLevel` before task and settings defaults. + */ + defaultThinkingLevel: resolveExecutorThinkingLevel( + typeof config.thinkingLevel === "string" && WORKFLOW_THINKING_LEVEL_SET.has(config.thinkingLevel) + ? (config.thinkingLevel as ThinkingLevel) + : detail.thinkingLevel, + settings, + ), taskValidatorProvider: detail.validatorModelProvider, taskValidatorModelId: detail.validatorModelId, projectValidatorProvider: settings.validatorProvider, @@ -7579,6 +7614,13 @@ export class TaskExecutor { const stepSkillName = executorKind === "skill" && typeof cfg.skillName === "string" && cfg.skillName.trim() ? cfg.skillName.trim() : undefined; + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Graph model nodes can pin reasoning effort independently from modelProvider/modelId; carry only validated THINKING_LEVELS into the synthesized WorkflowStep. + */ + const stepThinkingLevel = typeof cfg.thinkingLevel === "string" && WORKFLOW_THINKING_LEVEL_SET.has(cfg.thinkingLevel) + ? cfg.thinkingLevel as ThinkingLevel + : undefined; const step: WorkflowStep = { id: `graph:${node.id}`, name: typeof cfg.name === "string" && cfg.name.trim() ? cfg.name : node.id, @@ -7595,6 +7637,7 @@ export class TaskExecutor { ...(stepSkillName ? { skillName: stepSkillName } : {}), ...(cfg.requiresBrowser === true ? { requiresBrowser: true } : {}), ...(modelProvider && modelId ? { modelProvider, modelId } : {}), + ...(stepThinkingLevel ? { thinkingLevel: stepThinkingLevel } : {}), }; if (cfg.summaryTarget === "task") { (step as WorkflowStep & { summaryTarget?: "task" }).summaryTarget = "task"; @@ -9834,6 +9877,7 @@ export class TaskExecutor { permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, stepIdentityAgent, settings.defaultAgentPermissionPolicy), // FNXC:McpConfig 2026-06-25-23:03: Per-step workflow sessions are an executor lane, so they inherit the task's resolved MCP set from the effective step identity agent and never re-read or log plaintext secret values. mcpServers: await this.resolveMcpServers(stepIdentityAgent?.id), + workflowStepThinkingLevel: this.graphSeamThinkingLevel.get(task.id), // Pass skill selection context from the main executor session skillSelection: skillContext.skillSelectionContext, // Pass agentStore and messageStore for delegation and messaging tools @@ -10543,7 +10587,7 @@ export class TaskExecutor { ); const executorFallbackProvider = settings.fallbackProvider; const executorFallbackModelId = settings.fallbackModelId; - const executorThinkingLevel = resolveExecutorThinkingLevel(detail.thinkingLevel, settings); + const executorThinkingLevel = resolveExecutorThinkingLevel(this.graphSeamThinkingLevel.get(task.id) ?? detail.thinkingLevel, settings); // U1 telemetry: now that the session model/provider/node are resolved, // give the agent logger the context it needs to emit usage_events tool @@ -15069,6 +15113,11 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB ); } + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * WorkflowStep sessions resolve reasoning effort as node/step `thinkingLevel` first, then task override, then settings defaults/lane fallbacks. + */ + const workflowStepThinkingLevel = resolveExecutorThinkingLevel(workflowStep.thinkingLevel ?? task.thinkingLevel, settings); const { session } = await createResolvedAgentSession({ sessionPurpose: "executor", runtimeHint: workflowRuntimeHint, @@ -15080,7 +15129,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB defaultModelId: modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: resolveExecutorThinkingLevel(task.thinkingLevel, settings), + defaultThinkingLevel: workflowStepThinkingLevel, runAuditor: createRunAuditor(this.store, this.getRunContextFor(task.id)), settings, taskEnv: stepEnv, @@ -15098,7 +15147,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB const workflowModelDetails = formatModelMarkerDetails( describeModel(session), - resolveExecutorThinkingLevel(task.thinkingLevel, settings), + workflowStepThinkingLevel, [ useOverride && attemptLabel === "primary" ? "workflow step override" : "", attemptLabel === "fallback" ? "fallback after timeout" : "", diff --git a/packages/engine/src/step-session-executor.ts b/packages/engine/src/step-session-executor.ts index e2674cfac0..609ae5e21f 100644 --- a/packages/engine/src/step-session-executor.ts +++ b/packages/engine/src/step-session-executor.ts @@ -135,6 +135,8 @@ export interface StepSessionExecutorOptions { permanentAgentGating?: PermanentAgentGatingContext; /** Optional resolved MCP servers to forward into workflow step sessions. */ mcpServers?: ResolvedMcpServerDefinition[]; + /** Optional workflow node/step reasoning-effort override for all sessions in this graph-pinned step pass. */ + workflowStepThinkingLevel?: string; /** Task-scoped environment injected into non-git subprocesses. */ taskEnv?: NodeJS.ProcessEnv; /** @@ -1270,6 +1272,14 @@ export class StepSessionExecutor { let session: AgentSession | null = null; const localTelemetry = { agentLogger, trackingKey }; + /* + * 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. + */ + const stepThinkingLevel = this.options.workflowStepThinkingLevel + ?? (taskDetail.steps[stepIndex] as { thinkingLevel?: string } | undefined)?.thinkingLevel; + const effectiveThinkingLevel = resolveExecutorThinkingLevel(stepThinkingLevel ?? taskDetail.thinkingLevel, settings); + try { // Get plugin tools from plugin runner if available const pluginTools = this.options.pluginRunner?.getPluginTools() ?? []; @@ -1345,7 +1355,7 @@ Follow instructions precisely and avoid unrelated changes.`, defaultModelId: executorModelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: resolveExecutorThinkingLevel(taskDetail.thinkingLevel, settings), + defaultThinkingLevel: effectiveThinkingLevel, runAuditor: createRunAuditor(this.store, { runId: generateSyntheticRunId("workflow-step", taskDetail.id), // Column-agent attribution (U4): the effective column agent is the diff --git a/packages/engine/src/workflow-node-handlers.ts b/packages/engine/src/workflow-node-handlers.ts index b9a8386496..ab9d54e4d3 100644 --- a/packages/engine/src/workflow-node-handlers.ts +++ b/packages/engine/src/workflow-node-handlers.ts @@ -53,6 +53,8 @@ export { // node still declaring `config.seam: "workflow-step"` is no longer a recognized // seam: `resolveSeamName` throws a WorkflowIrError for it (fails loud, never a // silent no-op). +export const SEAM_THINKING_LEVEL_CONTEXT_KEY = "workflow:seamThinkingLevel"; + export type WorkflowSeamName = | "planning" | "execute" @@ -112,6 +114,8 @@ export interface WorkflowLegacySeams { export interface StepReviewConfig { type: "plan" | "code"; model?: string; + /** Optional per-node reasoning-effort override for the review session. */ + thinkingLevel?: string; /** Single-writer rule (KTD-4): true when the node is inside a split branch, so * the review is advisory-only — no projection write, no authoritative verdict. */ advisory?: boolean; @@ -296,6 +300,15 @@ export function createPromptLikeHandler( active.stepIndex, node.id, ); + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Step-execute nodes carry per-node reasoning effort through workflow context so the implementation session can resolve node/step > task > settings precedence. + */ + if (typeof node.config?.thinkingLevel === "string") { + context.context[SEAM_THINKING_LEVEL_CONTEXT_KEY] = node.config.thinkingLevel; + } else { + delete context.context[SEAM_THINKING_LEVEL_CONTEXT_KEY]; + } return seams.stepExecute(context.task, context.context); } if (seam) { @@ -303,6 +316,11 @@ export function createPromptLikeHandler( // IS the seam node, so its declared column drives the binding. (Other seams // — planning/review/merge/schedule — stamp it too; only execute reads it.) context.context[SEAM_GOVERNING_NODE_CONTEXT_KEY] = node.id; + if (typeof node.config?.thinkingLevel === "string") { + context.context[SEAM_THINKING_LEVEL_CONTEXT_KEY] = node.config.thinkingLevel; + } else { + delete context.context[SEAM_THINKING_LEVEL_CONTEXT_KEY]; + } return seams[seam]!(context.task, context.context); } if (!runCustomNode) { @@ -429,10 +447,17 @@ const STEP_REVIEW_UNAVAILABLE_RETRY_CAP = 2; /** Resolve a step-review node's config (KTD-4). Defaults `type` to `code` (the * enforcing review level — matches the legacy code-review authority). */ function resolveStepReviewConfig(node: WorkflowIrNode, advisory: boolean): StepReviewConfig { - const raw = (node.config ?? {}) as { type?: unknown; model?: unknown }; + const raw = (node.config ?? {}) as { type?: unknown; model?: unknown; thinkingLevel?: unknown }; const type = raw.type === "plan" ? "plan" : "code"; const model = typeof raw.model === "string" ? raw.model : undefined; - return { type, model, advisory }; + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * step-review nodes persist their own `config.thinkingLevel` (WorkflowNodeEditor); without + * reading it here the executor.ts seam's node-level-override precedence was dead code — the + * config object it receives never carried the node's pinned reasoning effort. + */ + const thinkingLevel = typeof raw.thinkingLevel === "string" ? raw.thinkingLevel : undefined; + return { type, model, thinkingLevel, advisory }; } /**