From e623340e7136631af050b1e9b71358653f89460c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 8 Jun 2026 11:22:37 -0700 Subject: [PATCH] FN-5994: restore project summarization model settings Keep summarization and PR metadata model selection on project settings while leaving workflow lanes on the workflow. - restore the project summarization model lane in Project Models and replace per-phase workflow lanes with a workflow-settings redirect stub - remove moved workflow model keys from project settings defaults/save paths and update settings docs/tests for the restored project lane behavior - make PR metadata generation resolve its model from the title summarizer settings lane instead of task planning settings Files changed: docs/settings-reference.md | 4 +- .../core/src/__tests__/settings-migration.test.ts | 18 +- .../core/src/__tests__/settings-parity.test.ts | 17 +- packages/core/src/__tests__/store-settings.test.ts | 54 ++-- .../core/src/__tests__/task-creation-hook.test.ts | 10 +- packages/core/src/builtin-workflow-settings.ts | 24 -- packages/core/src/settings-schema.ts | 13 +- .../components/__tests__/SettingsModal.test.tsx | 11 +- .../app/components/settings/save-split.ts | 11 +- .../settings/sections/ProjectModelsSection.tsx | 330 +++------------------ .../src/__tests__/pr-metadata-generator.test.ts | 25 +- packages/dashboard/src/pr-metadata-generator.ts | 4 +- 12 files changed, 140 insertions(+), 381 deletions(-) Fusion-Task-Id: FN-5994 Fusion-Task-Lineage: f7f6f1ff-5d06-4d16-b46d-644eb219e7c1 --- docs/settings-reference.md | 4 +- .../src/__tests__/settings-migration.test.ts | 18 +- .../src/__tests__/settings-parity.test.ts | 17 +- .../core/src/__tests__/store-settings.test.ts | 54 +-- .../src/__tests__/task-creation-hook.test.ts | 10 +- .../core/src/builtin-workflow-settings.ts | 24 -- packages/core/src/settings-schema.ts | 13 +- .../__tests__/SettingsModal.test.tsx | 11 +- .../app/components/settings/save-split.ts | 11 +- .../sections/ProjectModelsSection.tsx | 330 +++--------------- .../__tests__/pr-metadata-generator.test.ts | 25 +- .../dashboard/src/pr-metadata-generator.ts | 4 +- 12 files changed, 140 insertions(+), 381 deletions(-) diff --git a/docs/settings-reference.md b/docs/settings-reference.md index a59581f677..70ca351211 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -228,7 +228,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** | `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries` | -| **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks), `titleSummarizerProvider`/`titleSummarizerModelId` (+ fallback) | +| **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks) | In the dashboard Settings modal, Project Models now exposes Plan/Triage, Executor, and Reviewer controls for the default workflow. Former locations for advanced @@ -822,7 +822,7 @@ For post-merge prompt workflow steps, explicit step-level `modelProvider` + `mod ### Title summarization model -Used for task title auto-summarization, GitHub tracking issue title summarization when tasks are untitled, and (when enabled) AI merge commit summaries. +Project-scoped model lane used for task title auto-summarization, GitHub tracking issue title summarization when tasks are untitled, PR title/body generation, and (when enabled) AI merge commit summaries. 1. Project `titleSummarizerProvider` + `titleSummarizerModelId` 2. Global `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` diff --git a/packages/core/src/__tests__/settings-migration.test.ts b/packages/core/src/__tests__/settings-migration.test.ts index 61ddd0838f..24735ea5f7 100644 --- a/packages/core/src/__tests__/settings-migration.test.ts +++ b/packages/core/src/__tests__/settings-migration.test.ts @@ -23,7 +23,7 @@ import { SETTINGS_MIGRATION_MARKER_KEY, } from "../moved-settings.js"; import { resolveEffectiveSettingsById, type WorkflowSettingsResolverStore } from "../workflow-settings-resolver.js"; -import { PROJECT_SETTINGS_KEYS } from "../settings-schema.js"; +import { DEFAULT_PROJECT_SETTINGS, PROJECT_SETTINGS_KEYS } from "../settings-schema.js"; // ── Test harness ──────────────────────────────────────────────────────────── @@ -143,8 +143,20 @@ describe("settings hard-move migration (U4)", () => { expect(MOVED_SETTINGS_KEYS).toContain("workflowStepTimeoutMs"); expect(MOVED_SETTINGS_KEYS).toContain("requirePrApproval"); expect(MOVED_SETTINGS_KEYS).toContain("executionProvider"); - // 30 keys after removing buildTimeoutMs from the catalog. - expect(MOVED_SETTINGS_KEYS.length).toBe(30); + expect(MOVED_SETTINGS_KEYS).not.toContain("titleSummarizerProvider"); + expect(MOVED_SETTINGS_KEYS).not.toContain("titleSummarizerModelId"); + expect(MOVED_SETTINGS_KEYS).not.toContain("titleSummarizerFallbackProvider"); + expect(MOVED_SETTINGS_KEYS).not.toContain("titleSummarizerFallbackModelId"); + expect(PROJECT_SETTINGS_KEYS).toContain("titleSummarizerProvider"); + expect(PROJECT_SETTINGS_KEYS).toContain("titleSummarizerModelId"); + expect(PROJECT_SETTINGS_KEYS).toContain("titleSummarizerFallbackProvider"); + expect(PROJECT_SETTINGS_KEYS).toContain("titleSummarizerFallbackModelId"); + expect(DEFAULT_PROJECT_SETTINGS).toHaveProperty("titleSummarizerProvider", undefined); + expect(DEFAULT_PROJECT_SETTINGS).toHaveProperty("titleSummarizerModelId", undefined); + expect(DEFAULT_PROJECT_SETTINGS).toHaveProperty("titleSummarizerFallbackProvider", undefined); + expect(DEFAULT_PROJECT_SETTINGS).toHaveProperty("titleSummarizerFallbackModelId", undefined); + // 26 keys after removing buildTimeoutMs plus the summarizer lane from the catalog. + expect(MOVED_SETTINGS_KEYS.length).toBe(26); }); it("fresh project post-init: marker set, effective values equal declaration defaults, no moved key in PROJECT_SETTINGS_KEYS", async () => { diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 14ce670f26..f860085c85 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -221,10 +221,6 @@ describe("settings key parity", () => { "validatorModelId", "validatorFallbackProvider", "validatorFallbackModelId", - "titleSummarizerProvider", - "titleSummarizerModelId", - "titleSummarizerFallbackProvider", - "titleSummarizerFallbackModelId", ]; for (const key of movedKeys) { expect(isProjectSettingsKey(key)).toBe(false); @@ -424,11 +420,10 @@ describe("eval settings parity regression (FN-3393)", () => { describe("model lane key parity regression (FN-1729)", () => { // All model lane provider/modelId pairs that should exist. // - // U4 hard-move: the per-PHASE project lanes (execution/planning/validator/ - // titleSummarizer provider+model, plus their fallbacks) MOVED to workflow - // settings and are no longer in either scope key list ("workflow" scope). The - // GLOBAL baseline lanes (`*GlobalProvider`) and the default/fallback baseline - // stay global. + // U4 hard-move moved the execution/planning/validator project lanes (plus + // their fallbacks) to workflow settings. The title-summarizer lane was later + // restored to project settings, while GLOBAL baseline lanes (`*GlobalProvider`) + // and the default/fallback baseline remain global. const allModelLanePairs = [ // Default baseline (global only) { provider: "defaultProvider", modelId: "defaultModelId", expectedScope: "global" }, @@ -446,9 +441,9 @@ describe("model lane key parity regression (FN-1729)", () => { { provider: "validatorGlobalProvider", modelId: "validatorGlobalModelId", expectedScope: "global" }, { provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId", expectedScope: "workflow" }, // Summarizer lane - { provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId", expectedScope: "workflow" }, + { provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId", expectedScope: "project" }, { provider: "titleSummarizerGlobalProvider", modelId: "titleSummarizerGlobalModelId", expectedScope: "global" }, - { provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId", expectedScope: "workflow" }, + { provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId", expectedScope: "project" }, ] as const; it.each(allModelLanePairs)( diff --git a/packages/core/src/__tests__/store-settings.test.ts b/packages/core/src/__tests__/store-settings.test.ts index a84b0fbc9f..fabe84ae5b 100644 --- a/packages/core/src/__tests__/store-settings.test.ts +++ b/packages/core/src/__tests__/store-settings.test.ts @@ -169,8 +169,9 @@ describe("TaskStore", () => { // ── Dual-Scope Lane Model Settings (FN-1710) ───────────────────── describe("dual-scope lane model settings", () => { - // U4 hard-move: the per-phase PROJECT lanes are dropped by updateSettings. - it("moved project lanes are dropped, not round-tripped through project config", async () => { + // U4 hard-move drops execution/planning/validator project lanes, while the + // title-summarizer lane stays project-scoped and round-trips normally. + it("only execution/planning/validator project lanes are dropped from project config", async () => { await harness.store().updateSettings({ planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5", @@ -183,12 +184,14 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); expect(settings.planningProvider).toBeUndefined(); expect(settings.validatorProvider).toBeUndefined(); - expect(settings.titleSummarizerProvider).toBeUndefined(); + expect(settings.titleSummarizerProvider).toBe("google"); + expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro"); const config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8")); expect((config.settings as any).planningProvider).toBeUndefined(); expect((config.settings as any).validatorProvider).toBeUndefined(); - expect((config.settings as any).titleSummarizerProvider).toBeUndefined(); + expect((config.settings as any).titleSummarizerProvider).toBe("google"); + expect((config.settings as any).titleSummarizerModelId).toBe("gemini-2.5-pro"); }); // New default override fields @@ -360,8 +363,8 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); expect(settings.titleSummarizerGlobalProvider).toBe("openai"); expect(settings.titleSummarizerGlobalModelId).toBe("gpt-4o-mini"); - expect(settings.titleSummarizerProvider).toBeUndefined(); - expect(settings.titleSummarizerModelId).toBeUndefined(); + expect(settings.titleSummarizerProvider).toBe("anthropic"); + expect(settings.titleSummarizerModelId).toBe("claude-haiku"); }); // Global-only key filtering tests @@ -418,24 +421,27 @@ describe("TaskStore", () => { // Table-driven test matrix: verifies all model lane fields persist correctly // Fields are split by their correct scope (global or project) // U4 hard-move: the per-PHASE project lanes (execution/planning/validator/ - // titleSummarizer + fallbacks) MOVED to workflow settings and no longer - // persist through `updateSettings` (the stale-writer guard drops them). They - // are covered by the workflow-settings store + settings-migration suites. - // Only `defaultProviderOverride`/`defaultModelIdOverride` remain project-scoped. + // Execution/planning/validator project lanes MOVED to workflow settings and + // no longer persist through `updateSettings` (the stale-writer guard drops + // them). The title-summarizer lane was restored to project settings, so it + // is covered below alongside the default override pair. const projectModelLanePairs = [ // Default override (project-level override of global defaults) — NOT moved. { provider: "defaultProviderOverride", modelId: "defaultModelIdOverride" }, + { provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId" }, + { + provider: "titleSummarizerFallbackProvider", + modelId: "titleSummarizerFallbackModelId", + }, ] as const; - // The moved lanes, asserted to be DROPPED from project settings (R8). + // The moved lanes, asserted to be DROPPED from project settings (U4 hard-move). const movedProjectModelLanePairs = [ { provider: "executionProvider", modelId: "executionModelId" }, { provider: "planningProvider", modelId: "planningModelId" }, { provider: "planningFallbackProvider", modelId: "planningFallbackModelId" }, { provider: "validatorProvider", modelId: "validatorModelId" }, { provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId" }, - { provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId" }, - { provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId" }, ] as const; it.each(movedProjectModelLanePairs)( @@ -835,7 +841,8 @@ describe("TaskStore", () => { expect(settings.executionGlobalModelId).toBe("claude-opus-4"); expect(settings.titleSummarizerGlobalProvider).toBe("anthropic"); expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku"); - // U4 hard-move: the per-phase PROJECT lanes are dropped by updateSettings. + // U4 hard-move drops execution/planning/validator project lanes, but the + // title-summarizer lane remains project-scoped. expect(settings.planningProvider).toBeUndefined(); expect(settings.planningModelId).toBeUndefined(); expect(settings.planningFallbackProvider).toBeUndefined(); @@ -846,10 +853,10 @@ describe("TaskStore", () => { expect(settings.validatorModelId).toBeUndefined(); expect(settings.validatorFallbackProvider).toBeUndefined(); expect(settings.validatorFallbackModelId).toBeUndefined(); - expect(settings.titleSummarizerProvider).toBeUndefined(); - expect(settings.titleSummarizerModelId).toBeUndefined(); - expect(settings.titleSummarizerFallbackProvider).toBeUndefined(); - expect(settings.titleSummarizerFallbackModelId).toBeUndefined(); + expect(settings.titleSummarizerProvider).toBe("google"); + expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro"); + expect(settings.titleSummarizerFallbackProvider).toBe("anthropic"); + expect(settings.titleSummarizerFallbackModelId).toBe("claude-haiku"); }); }); @@ -991,7 +998,8 @@ describe("TaskStore", () => { expect(settings.fallbackProvider).toBe("openai"); expect(settings.fallbackModelId).toBe("gpt-4o"); - // Global lanes stay; U4 hard-move drops every per-phase PROJECT lane. + // Global lanes stay; execution/planning/validator project lanes are still + // dropped, but the title-summarizer lane remains project-scoped. expect(settings.executionGlobalProvider).toBe("google"); expect(settings.executionGlobalModelId).toBe("gemini-2.5-pro"); expect(settings.planningGlobalProvider).toBe("anthropic"); @@ -1006,8 +1014,8 @@ describe("TaskStore", () => { expect(settings.planningFallbackProvider).toBeUndefined(); expect(settings.validatorProvider).toBeUndefined(); expect(settings.validatorFallbackProvider).toBeUndefined(); - expect(settings.titleSummarizerProvider).toBeUndefined(); - expect(settings.titleSummarizerFallbackProvider).toBeUndefined(); + expect(settings.titleSummarizerProvider).toBe("openai"); + expect(settings.titleSummarizerFallbackProvider).toBe("google"); }); }); @@ -1068,7 +1076,7 @@ describe("TaskStore", () => { // U4 hard-move: all per-phase PROJECT lanes are dropped from project settings. expect(settings.planningProvider).toBeUndefined(); expect(settings.validatorProvider).toBeUndefined(); - expect(settings.titleSummarizerProvider).toBeUndefined(); + expect(settings.titleSummarizerProvider).toBe("google"); expect(settings.executionProvider).toBeUndefined(); expect(settings.executionModelId).toBeUndefined(); @@ -1176,7 +1184,7 @@ describe("TaskStore", () => { expect(settings.executionModelId).toBeUndefined(); }); - it("clears titleSummarizerProvider/titleSummarizerModelId with null", async () => { + it("clears project-scoped titleSummarizerProvider/titleSummarizerModelId with null", async () => { await harness.store().updateSettings({ titleSummarizerProvider: "anthropic", titleSummarizerModelId: "claude-haiku", diff --git a/packages/core/src/__tests__/task-creation-hook.test.ts b/packages/core/src/__tests__/task-creation-hook.test.ts index 0558893eff..84e455d202 100644 --- a/packages/core/src/__tests__/task-creation-hook.test.ts +++ b/packages/core/src/__tests__/task-creation-hook.test.ts @@ -247,11 +247,11 @@ describe("task creation hook", () => { summarizeTitleMock.mockResolvedValue("Auto Generated Title"); setTaskCreatedHook(hook); - // autoSummarizeTitles stays a project setting; the summarizer model lanes - // MOVED to workflow settings (U4/KTD-7), so write them to the project's - // default workflow (builtin:coding) value store. - await store.updateSettings({ autoSummarizeTitles: true }); - await store.updateWorkflowSettingValues("builtin:coding", store.getWorkflowSettingsProjectId(), { + // autoSummarizeTitles and the summarizer model lane are both project + // settings, so the store should auto-attach summarization from project + // settings alone when explicit options are omitted. + await store.updateSettings({ + autoSummarizeTitles: true, titleSummarizerProvider: "openai", titleSummarizerModelId: "gpt-5-mini", }); diff --git a/packages/core/src/builtin-workflow-settings.ts b/packages/core/src/builtin-workflow-settings.ts index 423a6554d7..2d85801ebc 100644 --- a/packages/core/src/builtin-workflow-settings.ts +++ b/packages/core/src/builtin-workflow-settings.ts @@ -229,28 +229,4 @@ export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ type: "string", description: "Fallback model id for the validation phase.", }, - { - id: "titleSummarizerProvider", - name: "Title summarizer provider", - type: "string", - description: "Provider for summarizing task titles.", - }, - { - id: "titleSummarizerModelId", - name: "Title summarizer model", - type: "string", - description: "Model id for summarizing task titles.", - }, - { - id: "titleSummarizerFallbackProvider", - name: "Title summarizer fallback provider", - type: "string", - description: "Fallback provider for summarizing task titles.", - }, - { - id: "titleSummarizerFallbackModelId", - name: "Title summarizer fallback model", - type: "string", - description: "Fallback model id for summarizing task titles.", - }, ]; diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 5187b59b12..200126d39b 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -48,11 +48,7 @@ type MovedProjectSettingsKey = | "validatorProvider" | "validatorModelId" | "validatorFallbackProvider" - | "validatorFallbackModelId" - | "titleSummarizerProvider" - | "titleSummarizerModelId" - | "titleSummarizerFallbackProvider" - | "titleSummarizerFallbackModelId"; + | "validatorFallbackModelId"; type ProjectSettingsSchema = Omit; @@ -418,8 +414,11 @@ export const DEFAULT_PROJECT_SETTINGS = { memoryBackupScope: "all" as const, autoSummarizeTitles: false, useAiMergeCommitSummary: true, - // Title-summarizer model lanes MOVED to workflow settings (U4) — - // see MOVED_SETTINGS_KEYS. + // Title-summarizer model lanes stay project-scoped (not moved in U4). + titleSummarizerProvider: undefined, + titleSummarizerModelId: undefined, + titleSummarizerFallbackProvider: undefined, + titleSummarizerFallbackModelId: undefined, scripts: undefined, setupScript: undefined, insightExtractionEnabled: false, diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 33d1934428..57f64cb80d 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -1281,10 +1281,7 @@ describe("SettingsModal", () => { expect(screen.queryByText("Title, commit message, and GitHub tracking issue summarization model")).not.toBeInTheDocument(); }); - it("shows a moved-to-workflow note for the summarizer model when GitHub tracking defaults are on", async () => { - // The title-summarizer model lane was hard-moved (U4) onto workflow - // settings; the Project Models section now surfaces a moved-to-workflow - // note instead of an inline picker. + it("does not show a moved-to-workflow note for the summarizer model when GitHub tracking defaults are on", async () => { mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, githubTrackingEnabledByDefault: true, @@ -1295,10 +1292,8 @@ describe("SettingsModal", () => { await userEvent.click(screen.getByRole("button", { name: "Project Models" })); - expect( - screen.queryByText("Title, commit message, and GitHub tracking issue summarization model"), - ).not.toBeInTheDocument(); - expect(screen.getByText(/model used for summarization now lives on the workflow/i)).toBeInTheDocument(); + expect(screen.queryByText(/model used for summarization now lives on the workflow/i)).not.toBeInTheDocument(); + expect(screen.getByText(/per-phase model lanes \(execution, planning, reviewer, and their fallbacks\) now live on the workflow/i)).toBeInTheDocument(); }); it("picks a project repo suggestion and preserves label association", async () => { diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index 0687709b4d..f2db7c676f 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -29,14 +29,15 @@ import type { GlobalSettings, Settings } from "@fusion/core"; * Project-scoped model-override keys whose overrides track inheritance * explicitly (changed-only writes with null-as-delete in the project branch). * - * The per-phase model lanes (planning/validator/execution/titleSummarizer - * providers, models, and fallbacks) MOVED to workflow settings (U4) and are no - * longer project keys, so `isProjectSettingsKey` filters them out before the - * project branch is reached — listing them here would be dead. Only the two - * project-level default overrides remain. + * The title-summarizer lane was restored to project settings in FN-5994, so it + * needs the same changed-only/null-as-delete handling as the project default + * lane overrides. Execution/planning/validator lanes still live on workflow + * settings and are filtered out before the project branch is reached. */ export const MODEL_LANE_KEYS = [ "defaultProviderOverride", "defaultModelIdOverride", + "titleSummarizerProvider", "titleSummarizerModelId", + "titleSummarizerFallbackProvider", "titleSummarizerFallbackModelId", ] as const; const MODEL_LANE_KEY_SET = new Set(MODEL_LANE_KEYS); diff --git a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx index 7c249099a7..43b2611e6a 100644 --- a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx @@ -1,25 +1,26 @@ /** * Project Models section (U9 / KTD-10). * - * Project-scoped model configuration. The project DEFAULT model lane still saves - * as project settings. The common workflow model lanes (Plan/Triage, Executor, - * Reviewer) are now proxy-edited here for the active default workflow while - * persisting through workflow setting values, not tombstoned project keys. + * Project-scoped model configuration that survives the workflow hard-move: token + * cap, the project DEFAULT model lane, the restored title-summarizer lane, + * model presets (with the inline editor and size-based auto-selection), and the + * title/commit summarization toggles. The per-phase execution/planning/validator + * lanes still live on the workflow and render as a redirect stub. The + * model-lane helpers, preset draft state/handlers, available-model list, + * favorites, and the confirm dialog all live in the shell (they share state + * with the save flow and the global model lanes) and are relayed through a + * `models` prop bag — mirroring the Authentication/Remote section conventions. + * Keys, lane labels, and conditional rendering are preserved verbatim from the + * original inline JSX. */ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { ModelPreset, Settings } from "@fusion/core"; -import { - ApiRequestError, - fetchWorkflowSettingValues, - updateWorkflowSettingValues, - type ModelInfo, - type WorkflowSettingRejection, - type WorkflowSettingValuesPayload, -} from "../../../api"; +import type { ModelInfo } from "../../../api"; import { CustomModelDropdown } from "../../CustomModelDropdown"; import { applyPresetToSelection } from "../../../utils/modelPresets"; import type { ToastType } from "../../../hooks/useToast"; +import { MovedSettingsStub } from "./MovedSettingsStub"; import type { ModelLane, SectionBaseProps, SettingsFormState } from "./context"; type LaneStatus = "inherited" | "overridden"; @@ -52,52 +53,11 @@ export interface ProjectModelsSectionProps extends SectionBaseProps { onOpenWorkflowSettings?: () => void; } -interface WorkflowModelLane { - id: "planning" | "execution" | "validator"; - label: string; - providerKey: string; - modelKey: string; - help: string; -} - -const WORKFLOW_MODEL_LANES: WorkflowModelLane[] = [ - { - id: "planning", - label: "Plan/Triage Model", - providerKey: "planningProvider", - modelKey: "planningModelId", - help: "Used when Fusion plans, breaks down, or triages tasks for this workflow.", - }, - { - id: "execution", - label: "Executor Model", - providerKey: "executionProvider", - modelKey: "executionModelId", - help: "Used by implementation agents running this workflow.", - }, - { - id: "validator", - label: "Reviewer Model", - providerKey: "validatorProvider", - modelKey: "validatorModelId", - help: "Used by review and validation agents for this workflow.", - }, -]; - -function splitModelValue(value: string): { provider: string | null; modelId: string | null } { - if (!value) return { provider: null, modelId: null }; - const slashIdx = value.indexOf("/"); - if (slashIdx <= 0) return { provider: null, modelId: null }; - return { provider: value.slice(0, slashIdx), modelId: value.slice(slashIdx + 1) }; -} - export function ProjectModelsSection({ scopeBanner, form, setForm, models, - projectId, - addToast, onOpenWorkflowSettings, }: ProjectModelsSectionProps) { const { t } = useTranslation("app"); @@ -124,154 +84,29 @@ export function ProjectModelsSection({ const presets = form.modelPresets || []; const presetOptions = presets.map((preset) => ({ id: preset.id, name: preset.name })); const inUsePresetIds = new Set(Object.values(form.defaultPresetBySize || {}).filter(Boolean)); - const defaultWorkflowId = useMemo(() => { - const raw = typeof form.defaultWorkflowId === "string" ? form.defaultWorkflowId.trim() : ""; - return raw || "builtin:coding"; - }, [form.defaultWorkflowId]); - const [workflowPayload, setWorkflowPayload] = useState(null); - const [workflowPending, setWorkflowPending] = useState>({}); - const [workflowRejections, setWorkflowRejections] = useState>({}); - const [resolvedWorkflowId, setResolvedWorkflowId] = useState(defaultWorkflowId); - const [workflowLoading, setWorkflowLoading] = useState(false); - const [workflowSaving, setWorkflowSaving] = useState(false); - const reqSeq = useRef(0); - const loadWorkflowValues = useCallback(async () => { - const seq = ++reqSeq.current; - if (!projectId) { - setWorkflowPayload(null); - setWorkflowPending({}); - setWorkflowRejections({}); - setResolvedWorkflowId(defaultWorkflowId); - return; + // The project DEFAULT lane and restored title-summarizer lane remain editable + // here. Execution/planning/validator workflow-specific lanes still redirect to + // workflow settings below. + const projectModelLanes = modelLanes.filter((lane) => ["default", "summarization"].includes(lane.laneId)); + const getProjectLaneLabel = (lane: ModelLane) => { + if (lane.laneId === "default") { + return "Project Default Model"; } - setWorkflowLoading(true); - try { - let targetWorkflowId = defaultWorkflowId; - let payload: WorkflowSettingValuesPayload; - try { - payload = await fetchWorkflowSettingValues(targetWorkflowId, projectId); - } catch (err) { - if (targetWorkflowId === "builtin:coding" || !(err instanceof ApiRequestError) || err.status !== 404) { - throw err; - } - targetWorkflowId = "builtin:coding"; - payload = await fetchWorkflowSettingValues(targetWorkflowId, projectId); - } - if (reqSeq.current === seq) { - setWorkflowPayload(payload); - setWorkflowPending({}); - setWorkflowRejections({}); - setResolvedWorkflowId(targetWorkflowId); - } - } catch { - if (reqSeq.current === seq) { - setWorkflowPayload(null); - setWorkflowPending({}); - setWorkflowRejections({}); - setResolvedWorkflowId(defaultWorkflowId); - addToast(t("settings.models.workflowLanesLoadFailed", "Failed to load workflow model settings"), "error"); - } - } finally { - if (reqSeq.current === seq) setWorkflowLoading(false); + if (lane.laneId === "summarization") { + return "Project Summarization Model"; } - }, [addToast, defaultWorkflowId, projectId, t]); - - useEffect(() => { - void loadWorkflowValues(); - }, [loadWorkflowValues]); - - const workflowValueFor = useCallback( - (key: string): unknown => { - if (Object.prototype.hasOwnProperty.call(workflowPending, key)) { - return workflowPending[key]; - } - return workflowPayload?.effective?.[key]; - }, - [workflowPayload, workflowPending], - ); - - const workflowLaneValue = useCallback( - (lane: WorkflowModelLane): string => { - const provider = workflowValueFor(lane.providerKey); - const modelId = workflowValueFor(lane.modelKey); - return typeof provider === "string" && provider && typeof modelId === "string" && modelId - ? `${provider}/${modelId}` - : ""; - }, - [workflowValueFor], - ); - - const workflowLaneCustomized = useCallback( - (lane: WorkflowModelLane): boolean => { - const pendingProvider = workflowPending[lane.providerKey]; - const pendingModel = workflowPending[lane.modelKey]; - if (pendingProvider === null && pendingModel === null) return false; - if (pendingProvider !== undefined || pendingModel !== undefined) return true; - return Boolean( - workflowPayload?.stored && - (Object.prototype.hasOwnProperty.call(workflowPayload.stored, lane.providerKey) || - Object.prototype.hasOwnProperty.call(workflowPayload.stored, lane.modelKey)), - ); - }, - [workflowPayload, workflowPending], - ); - - const updateWorkflowLane = useCallback((lane: WorkflowModelLane, value: string) => { - const { provider, modelId } = splitModelValue(value); - setWorkflowPending((current) => ({ - ...current, - [lane.providerKey]: provider, - [lane.modelKey]: modelId, - })); - setWorkflowRejections((current) => { - if (!current[lane.providerKey] && !current[lane.modelKey]) return current; - const next = { ...current }; - delete next[lane.providerKey]; - delete next[lane.modelKey]; - return next; - }); - }, []); - - const saveWorkflowModelLanes = useCallback(async () => { - if (!projectId || Object.keys(workflowPending).length === 0) return; - setWorkflowSaving(true); - try { - const payload = await updateWorkflowSettingValues(resolvedWorkflowId, workflowPending, projectId); - setWorkflowPayload(payload); - setWorkflowPending({}); - setWorkflowRejections({}); - addToast(t("settings.models.workflowLanesSaved", "Workflow model settings saved"), "success"); - } catch (err) { - if (err instanceof ApiRequestError && err.status === 400 && err.details) { - const rejList = (err.details.rejections as WorkflowSettingRejection[] | undefined) ?? []; - if (rejList.length > 0) { - const byId: Record = {}; - for (const r of rejList) byId[r.settingId] = r; - setWorkflowRejections(byId); - addToast(t("settings.models.workflowLanesRejected", "Some workflow model settings were rejected"), "error"); - return; - } - } - addToast(t("settings.models.workflowLanesSaveFailed", "Failed to save workflow model settings"), "error"); - } finally { - setWorkflowSaving(false); + return lane.label; + }; + const getProjectLaneHelperText = (lane: ModelLane) => { + if (lane.laneId === "default") { + return "Project-wide default AI model used when no more specific task or project lane override is set."; } - }, [addToast, projectId, resolvedWorkflowId, t, workflowPending]); - - const workflowDirty = Object.keys(workflowPending).length > 0; - - // Only the project DEFAULT model lane survives in this modal. The - // per-phase execution/planning/validator lanes, their fallbacks, and the - // title-summarizer lane were hard-moved (U4) onto the workflow settings - // mechanism — they are no longer project settings keys and must never be - // renderable or savable here (redirect stub below). - const projectModelLanes = modelLanes.filter((lane) => lane.laneId === "default"); - const getProjectLaneLabel = (lane: ModelLane) => lane.laneId === "default" ? "Project Default Model" : lane.label; - const getProjectLaneHelperText = (lane: ModelLane) => - lane.laneId === "default" - ? "Project-wide default AI model used when no more specific task or project lane override is set." - : lane.helperText; + if (lane.laneId === "summarization") { + return "Model used for title auto-summarization, merge commit summaries, GitHub tracking issue titles, and PR title/body generation."; + } + return lane.helperText; + }; return ( <> @@ -375,94 +210,15 @@ export function ProjectModelsSection({ )} - {/* --- Default workflow model lanes (workflow setting values) --- */} -

Default workflow model lanes

-

- These controls edit model values on this project's default workflow ({resolvedWorkflowId}). - They use workflow settings as the source of truth. -

- {!projectId ? ( -
- Open a project to edit workflow model lanes. -
- ) : modelsLoading || workflowLoading ? ( -
Loading workflow model settings…
- ) : availableModels.length === 0 ? ( -
- No models available. Configure authentication first. -
- ) : ( - <> - {WORKFLOW_MODEL_LANES.map((lane) => { - const value = workflowLaneValue(lane); - const customized = workflowLaneCustomized(lane); - const rejection = workflowRejections[lane.providerKey] ?? workflowRejections[lane.modelKey]; - return ( -
-
- - - {customized ? "Override (Workflow)" : "Inherited"} - -
-
-
- updateWorkflowLane(lane, val)} - placeholder="Use workflow/global default" - defaultOptionLabel="Use workflow/global default" - favoriteProviders={favoriteProviders} - onToggleFavorite={onToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={onToggleModelFavorite} - /> -
- {customized && ( - - )} -
- {rejection ? ( - - {rejection.message} - - ) : null} - {lane.help} -
- ); - })} -
- - {onOpenWorkflowSettings && ( - - )} -
- - )} + {/* --- Per-phase model lanes (MOVED to workflow settings) --- */} +

Per-phase model lanes

+ {/* --- Model Presets --- */}

Model Presets

@@ -715,7 +471,7 @@ export function ProjectModelsSection({

{t( "settings.movedStub.summarizerModelInline", - "The model used for summarization now lives on the workflow (title summarizer lane). Open workflow settings to choose it.", + "The summarization model lane above controls title auto-summarization, merge commit summaries, GitHub tracking titles, and PR metadata generation.", )}

)} diff --git a/packages/dashboard/src/__tests__/pr-metadata-generator.test.ts b/packages/dashboard/src/__tests__/pr-metadata-generator.test.ts index f425d95a86..f0a8c64805 100644 --- a/packages/dashboard/src/__tests__/pr-metadata-generator.test.ts +++ b/packages/dashboard/src/__tests__/pr-metadata-generator.test.ts @@ -93,13 +93,24 @@ describe("generatePrMetadata", () => { vi.clearAllMocks(); }); - it("returns generated body without template", async () => { + it("uses the summarizer model lane for PR generation", async () => { const result = await generatePrMetadata({ task: createTask(), repoRoot, - settings: {} as never, + settings: { + titleSummarizerProvider: "anthropic", + titleSummarizerModelId: "claude-haiku", + planningProvider: "openai", + planningModelId: "gpt-4o", + } as never, }); + expect(vi.mocked(createFnAgent)).toHaveBeenCalledWith( + expect.objectContaining({ + defaultProvider: "anthropic", + defaultModelId: "claude-haiku", + }), + ); expect(result.title).toBe("feat: add routes"); expect(result.body).toContain("## Summary"); expect(result.body).toContain("## Changes"); @@ -119,7 +130,10 @@ describe("generatePrMetadata", () => { const result = await generatePrMetadata({ task: createTask(), repoRoot, - settings: {} as never, + settings: { + titleSummarizerProvider: "anthropic", + titleSummarizerModelId: "claude-haiku", + } as never, }); expect(result.templateUsed).toBe(true); @@ -142,7 +156,10 @@ describe("generatePrMetadata", () => { const result = await generatePrMetadata({ task: createTask(), repoRoot, - settings: {} as never, + settings: { + titleSummarizerProvider: "anthropic", + titleSummarizerModelId: "claude-haiku", + } as never, }); expect(result).toEqual({ diff --git a/packages/dashboard/src/pr-metadata-generator.ts b/packages/dashboard/src/pr-metadata-generator.ts index b7e1a08720..dbe994b3e2 100644 --- a/packages/dashboard/src/pr-metadata-generator.ts +++ b/packages/dashboard/src/pr-metadata-generator.ts @@ -3,7 +3,7 @@ import { exec as execCb } from "node:child_process"; import { access, readFile } from "node:fs/promises"; import { join } from "node:path"; import type { GlobalSettings, ProjectSettings, Settings, Task } from "@fusion/core"; -import { resolveTaskPlanningModel } from "@fusion/core"; +import { resolveTitleSummarizerSettingsModel } from "@fusion/core"; import { createFnAgent } from "@fusion/engine"; const execAsync = promisify(execCb); @@ -238,7 +238,7 @@ export async function generatePrMetadata(input: { const templateExists = await access(templatePath).then(() => true).catch(() => false); const template = templateExists ? await readFile(templatePath, "utf8") : ""; - const model = resolveTaskPlanningModel(task, settings as Partial); + const model = resolveTitleSummarizerSettingsModel(settings as Partial); let aiText = ""; const { session } = await createFnAgent({ cwd: repoRoot,