diff --git a/.changeset/fn-8169-group-fallback-lanes.md b/.changeset/fn-8169-group-fallback-lanes.md new file mode 100644 index 0000000000..fff09d55bd --- /dev/null +++ b/.changeset/fn-8169-group-fallback-lanes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Group each workflow model fallback lane directly under its primary lane in Settings. +category: fix +dev: Reorder WORKFLOW_MODEL_PAIRS (ProjectModelsSection) and WORKFLOW_MODEL_LANE_CATALOG (WorkflowSettingsPanel) to planning, planning-fallback, execution, execution-fallback, validator, validator-fallback. No key/persistence/resolution change. diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx index 7404d71a45..8433f3213d 100644 --- a/packages/dashboard/app/__tests__/settings-sections.test.tsx +++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx @@ -708,6 +708,53 @@ describe("ProjectModelsSection", () => { expect(dropdown).toHaveAttribute("data-thinking-value", ""); }); + it("renders default workflow model lanes with each fallback directly after its primary", async () => { + vi.mocked(fetchWorkflow).mockResolvedValue({ + id: "builtin:coding", + name: "Coding", + ir: { + settings: [ + "planning", + "planningFallback", + "execution", + "executionFallback", + "validator", + "validatorFallback", + ].flatMap((lane) => [ + { id: `${lane}Provider`, name: `${lane} provider`, type: "string" }, + { id: `${lane}ModelId`, name: `${lane} model`, type: "string" }, + { id: `${lane}ThinkingLevel`, name: `${lane} thinking`, type: "enum" }, + ]), + }, + } as never); + vi.mocked(fetchWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: {}, orphaned: [] }); + + render( + , + ); + + await screen.findByTestId("workflow-model-lane-validator-fallback"); + const workflowLaneIds = Array.from(document.querySelectorAll("[data-testid^='workflow-model-lane-']")) + .map((element) => element.dataset.testid?.replace("workflow-model-lane-", "")); + expect(workflowLaneIds).toEqual([ + "planning", + "planning-fallback", + "execution", + "execution-fallback", + "validator", + "validator-fallback", + ]); + }); + it("wires workflow fallback lane thinking render, persist, and reset", async () => { let saver: (() => Promise) | null = null; vi.mocked(fetchWorkflow).mockResolvedValue({ diff --git a/packages/dashboard/app/components/WorkflowSettingsPanel.tsx b/packages/dashboard/app/components/WorkflowSettingsPanel.tsx index 00af9cff91..336aed36da 100644 --- a/packages/dashboard/app/components/WorkflowSettingsPanel.tsx +++ b/packages/dashboard/app/components/WorkflowSettingsPanel.tsx @@ -515,6 +515,9 @@ Title summarization is owned by project/global Settings → Project Models, not FNXC:Settings-ThinkingLevel 2026-07-10-12:03: Workflow Values renders thinking controls inline with declared primary and fallback model lanes. 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. + +FNXC:SettingsModels 2026-07-16-00:00: +FN-8169 requires each workflow fallback lane to render directly under its primary lane so operators configure a model and its retry model together. Keep this catalog interleaved while preserving every lane key and declaration filter. */ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [ { @@ -525,6 +528,14 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [ label: "Plan/Triage Model", help: "Provider and model used when planning or triaging tasks. Leave unset to inherit from the default lane.", }, + { + id: "planning-fallback", + providerId: "planningFallbackProvider", + modelId: "planningFallbackModelId", + thinkingId: "planningFallbackThinkingLevel", + label: "Planning Fallback Model", + help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.", + }, { id: "execution", providerId: "executionProvider", @@ -533,14 +544,6 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [ label: "Executor Model", help: "Provider and model used by task implementation agents. Leave unset to inherit from the default lane.", }, - { - 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.", - }, { id: "execution-fallback", providerId: "executionFallbackProvider", @@ -550,12 +553,12 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [ help: "Fallback provider and model used when the primary Executor model cannot be used.", }, { - id: "planning-fallback", - providerId: "planningFallbackProvider", - modelId: "planningFallbackModelId", - thinkingId: "planningFallbackThinkingLevel", - label: "Planning Fallback Model", - help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.", + 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.", }, { id: "validator-fallback", diff --git a/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx index d0953a8568..5d9442dc86 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx @@ -588,6 +588,9 @@ describe("WorkflowSettingsPanel — Values tab", () => { { 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: "executionFallbackProvider", name: "Execution fallback provider", type: "string" }, + { id: "executionFallbackModelId", name: "Execution fallback model", type: "string" }, + { id: "executionFallbackThinkingLevel", name: "Execution fallback thinking level", type: "enum", options: [{ value: "high", label: "High" }] }, { 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" }] }, @@ -607,6 +610,43 @@ describe("WorkflowSettingsPanel — Values tab", () => { { id: "titleSummarizerFallbackModelId", name: "Title summarizer fallback model", type: "string" }, ]; + it("keeps the workflow model lane catalog interleaved by primary and fallback", () => { + expect(WORKFLOW_MODEL_LANE_CATALOG.map((pair) => pair.id)).toEqual([ + "planning", + "planning-fallback", + "execution", + "execution-fallback", + "validator", + "validator-fallback", + ]); + }); + + it("renders workflow model lane rows with each fallback directly after its primary", async () => { + render(); + + await screen.findByTestId("wf-settings-value-validator-fallback"); + const laneTestIds = new Set([ + "wf-settings-value-planning", + "wf-settings-value-planning-fallback", + "wf-settings-value-execution", + "wf-settings-value-execution-fallback", + "wf-settings-value-validator", + "wf-settings-value-validator-fallback", + ]); + const renderedLaneTestIds = Array.from(document.querySelectorAll("[data-testid^='wf-settings-value-']")) + .map((element) => element.dataset.testid) + .filter((testId): testId is string => laneTestIds.has(testId)); + + expect(renderedLaneTestIds).toEqual([ + "wf-settings-value-planning", + "wf-settings-value-planning-fallback", + "wf-settings-value-execution", + "wf-settings-value-execution-fallback", + "wf-settings-value-validator", + "wf-settings-value-validator-fallback", + ]); + }); + it("does not catalog title summarization as a workflow model lane", async () => { expect(WORKFLOW_MODEL_LANE_CATALOG.map((pair) => pair.id)).not.toEqual( expect.arrayContaining(["title-summarizer", "title-summarizer-fallback"]), diff --git a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx index d0044cf2b1..15993f765a 100644 --- a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx @@ -29,6 +29,9 @@ Fallback model lanes must be configurable in all Settings surfaces: General uses FNXC:Settings-ThinkingLevel 2026-07-10-12:08: Workflow fallback lanes may expose an inline thinking selector only when the active workflow declares the matching companion setting, while the title-summarizer fallback uses project-scoped keys below. Reset must clear both the model pair and the thinking companion; undeclared rows intentionally render no orphan thinking shell. + +FNXC:SettingsModels 2026-07-16-00:00: +FN-8169 requires each workflow fallback lane to render directly under its primary lane so operators configure a model and its retry model together. Keep this catalog interleaved while preserving every lane key and declaration filter. */ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [ { @@ -39,6 +42,14 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [ label: "Plan/Triage Model", help: "Provider and model used when planning or triaging tasks. Leave unset to inherit from the workflow default.", }, + { + id: "planning-fallback", + providerId: "planningFallbackProvider", + modelId: "planningFallbackModelId", + thinkingId: "planningFallbackThinkingLevel", + label: "Planning Fallback Model", + help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.", + }, { id: "execution", providerId: "executionProvider", @@ -47,14 +58,6 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [ label: "Executor Model", help: "Provider and model used while executing workflow steps. Leave unset to inherit from the workflow default.", }, - { - 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.", - }, { id: "execution-fallback", providerId: "executionFallbackProvider", @@ -64,12 +67,12 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [ help: "Fallback provider and model used when the primary Executor model cannot be used.", }, { - id: "planning-fallback", - providerId: "planningFallbackProvider", - modelId: "planningFallbackModelId", - thinkingId: "planningFallbackThinkingLevel", - label: "Planning Fallback Model", - help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.", + 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.", }, { id: "validator-fallback", diff --git a/packages/dashboard/app/components/workflow-setting-display.ts b/packages/dashboard/app/components/workflow-setting-display.ts index 6ad76451ee..55a5c882cc 100644 --- a/packages/dashboard/app/components/workflow-setting-display.ts +++ b/packages/dashboard/app/components/workflow-setting-display.ts @@ -41,6 +41,18 @@ const DISPLAY: Record = { label: "Executor model", description: "Model used by task implementation agents.", }, + /* + * FNXC:SettingsModels 2026-07-16-00:00: + * FN-8169 requires the Executor fallback to remain in the Models group so the shared lane catalog can render it immediately after Executor. Without these existing key classifications, pair filtering suppresses the fallback row entirely. + */ + executionFallbackProvider: { + group: "models", + label: "Executor fallback provider", + }, + executionFallbackModelId: { + group: "models", + label: "Executor fallback model", + }, validatorProvider: { group: "models", label: "Reviewer provider",