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
This commit is contained in:
gsxdsm
2026-06-08 11:22:37 -07:00
parent 53ab22449f
commit e623340e71
12 changed files with 140 additions and 381 deletions

View File

@@ -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`

View File

@@ -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 () => {

View File

@@ -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)(

View File

@@ -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",

View File

@@ -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",
});

View File

@@ -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.",
},
];

View File

@@ -48,11 +48,7 @@ type MovedProjectSettingsKey =
| "validatorProvider"
| "validatorModelId"
| "validatorFallbackProvider"
| "validatorFallbackModelId"
| "titleSummarizerProvider"
| "titleSummarizerModelId"
| "titleSummarizerFallbackProvider"
| "titleSummarizerFallbackModelId";
| "validatorFallbackModelId";
type ProjectSettingsSchema = Omit<ProjectSettings, MovedProjectSettingsKey>;
@@ -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,

View File

@@ -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 () => {

View File

@@ -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<string>(MODEL_LANE_KEYS);

View File

@@ -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<WorkflowSettingValuesPayload | null>(null);
const [workflowPending, setWorkflowPending] = useState<Record<string, unknown>>({});
const [workflowRejections, setWorkflowRejections] = useState<Record<string, WorkflowSettingRejection>>({});
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<string, WorkflowSettingRejection> = {};
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) --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Default workflow model lanes</h4>
<p className="settings-description">
These controls edit model values on this project's default workflow ({resolvedWorkflowId}).
They use workflow settings as the source of truth.
</p>
{!projectId ? (
<div className="settings-empty-state settings-muted">
Open a project to edit workflow model lanes.
</div>
) : modelsLoading || workflowLoading ? (
<div className="settings-empty-state">Loading workflow model settings…</div>
) : availableModels.length === 0 ? (
<div className="settings-empty-state settings-muted">
No models available. Configure authentication first.
</div>
) : (
<>
{WORKFLOW_MODEL_LANES.map((lane) => {
const value = workflowLaneValue(lane);
const customized = workflowLaneCustomized(lane);
const rejection = workflowRejections[lane.providerKey] ?? workflowRejections[lane.modelKey];
return (
<div className="form-group" key={lane.id} data-testid={`workflow-model-lane-${lane.id}`}>
<div className="settings-model-lane-label-row">
<label htmlFor={`workflow-${lane.id}-model`}>{lane.label}</label>
<span
className={`settings-lane-badge ${customized ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`}
title={customized ? "Explicitly set on the default workflow" : "Inherited through workflow/global defaults"}
>
{customized ? "Override (Workflow)" : "Inherited"}
</span>
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<CustomModelDropdown
id={`workflow-${lane.id}-model`}
label={lane.label}
models={availableModels}
value={value}
onChange={(val) => updateWorkflowLane(lane, val)}
placeholder="Use workflow/global default"
defaultOptionLabel="Use workflow/global default"
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
{customized && (
<button
type="button"
className="btn btn-ghost btn-sm"
title="Reset to inherit"
onClick={() => updateWorkflowLane(lane, "")}
style={{ whiteSpace: "nowrap" }}
>
Reset
</button>
)}
</div>
{rejection ? (
<small className="field-error" role="alert" data-testid={`workflow-model-lane-error-${lane.id}`}>
{rejection.message}
</small>
) : null}
<small>{lane.help}</small>
</div>
);
})}
<div className="settings-model-lane-actions">
<button
type="button"
className="btn btn-primary btn-sm"
data-testid="save-workflow-model-lanes"
disabled={!workflowDirty || workflowSaving}
onClick={() => void saveWorkflowModelLanes()}
>
Save workflow models
</button>
{onOpenWorkflowSettings && (
<button type="button" className="btn btn-ghost btn-sm" onClick={onOpenWorkflowSettings}>
Advanced workflow policy
</button>
)}
</div>
</>
)}
{/* --- Per-phase model lanes (MOVED to workflow settings) --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Per-phase model lanes</h4>
<MovedSettingsStub
message={t(
"settings.movedStub.modelLanes",
"Per-phase model lanes (execution, planning, reviewer, and their fallbacks) now live on the workflow.",
)}
onOpenWorkflowSettings={onOpenWorkflowSettings}
/>
{/* --- Model Presets --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Model Presets</h4>
@@ -715,7 +471,7 @@ export function ProjectModelsSection({
<p className="settings-description">
{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.",
)}
</p>
)}

View File

@@ -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({

View File

@@ -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<Settings>);
const model = resolveTitleSummarizerSettingsModel(settings as Partial<Settings>);
let aiText = "";
const { session } = await createFnAgent({
cwd: repoRoot,