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:
@@ -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`
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user