feat(FN-2947): merge fusion/fn-2947

- Add AI-powered merge commit summarization with toggle in Settings (`packages/core/src/ai-summarize.ts`, `packages/dashboard/app/components/SettingsModal.tsx`, `packages/engine/src/merger.ts`)
- Introduce `generateMergeCommitSummary` utility in `@fusion/core` that calls the configured model with a diff prompt; results appear in the commit log for merge commits
- Add `aiMergeSummaryEnabled` project setting and `useAiMergeSummaryEnabled` hook in dashboard
- Update `@runfusion/fusion` CLI extension to surface the new setting
- Add `POST /api/tasks/:id/workflow/pre-merge` and `POST /api/tasks/:id/planning-subtask` routes (`packages/dashboard/src/routes/`)
- Rework InsightsView with two-pane layout for better readability (`packages/dashboard/app/components/InsightsView.tsx`, `packages/dashboard/app/components/InsightsView.css`)
- Fix `task plan` command to surface errors when planning fails (`packages/cli/src/commands/task.ts`)
- Add tests for `ai-summarize`, `agent-heartbeat`, `agent-tools`, and dashboard API routes; refresh onboarding and agents-view mobile tests
- Minor CSS polish: reduce AgentsView panel hover gap, improve CommitDiffTab layout, add ScriptsModal styles
- Changelog and version bumps for `@runfusion/fusion` v0.9.1, CLI alias, and all workspace packages

Commits merged:
- feat(FN-2947): complete Step 7 — add changeset and docs deliverables
- fix(FN-2947): clean unused aiSummary path
- test(FN-2947): complete Step 5 — cover AI merge summary flow
- feat(FN-2947): complete Step 4 — add merge summary settings toggle
- feat(FN-2947): complete Step 3 — wire AI merge summaries into merger
- feat(FN-2947): complete Step 2 — add merge summary setting
- feat(FN-2947): complete Step 1 — add merge commit summarizer
- feat(FN-2970): merge fusion/fn-2970
- feat(FN-2956): merge fusion/fn-2956
- feat(FN-2923): merge fusion/fn-2923
- feat(FN-2945): merge fusion/fn-2945
- chore(release): v0.9.1
- fix(FN-XXX): improve git manager diff layout
- fix(dashboard): rework Insights view with two-pane layout
- fix(FN-XXX): keep experimental views off by default

Files changed:
.changeset/active-agents-no-stuck-connecting.md    |  13 --
 .changeset/active-agents-panel-hoist-heartbeat.md  |  13 --
 .changeset/add-ai-merge-commit-summary.md          |   5 +
 .changeset/fix-agent-heartbeat-terminal-links.md   |   5 -
 .changeset/show-planning-tasks-immediately.md      |   5 -
 CHANGELOG.md                                       |  92 ++++++++++
 docs/settings-reference.md                         |   3 +
 package.json                                       |   2 +-
 packages/cli-alias/CHANGELOG.md                    |  15 ++
 packages/cli-alias/package.json                    |   2 +-
 packages/cli/CHANGELOG.md                          |  12 ++
 packages/cli/package.json                          |   2 +-
 packages/cli/src/__tests__/task-plan.test.ts       |   1 +
 packages/cli/src/commands/__tests__/task.test.ts   |  15 +-
 packages/cli/src/commands/task.ts                  |  15 +-
 packages/cli/src/extension.ts                      |   9 +
 packages/core/CHANGELOG.md                         |   7 +
 packages/core/package.json                         |   2 +-
 packages/core/src/__tests__/ai-summarize.test.ts   |  64 +++++++
 packages/core/src/ai-summarize.ts                  | 114 ++++++++++++
 packages/core/src/index.ts                         |   3 +
 packages/core/src/settings-schema.ts               |   1 +
 packages/core/src/types.ts                         |   4 +
 packages/dashboard/CHANGELOG.md                    |  14 ++
 packages/dashboard/app/App.tsx                     |  28 ++-
 .../app/__tests__/agent-css-classes.test.ts        |   3 +-
 packages/dashboard/app/__tests__/api.test.ts       |  13 ++
 packages/dashboard/app/api/legacy.ts               |   2 +
 packages/dashboard/app/components/AgentsView.css   |  22 +--
 .../dashboard/app/components/CommitDiffTab.tsx     |   2 +-
 .../dashboard/app/components/GitManagerModal.tsx   |  71 ++++----
 packages/dashboard/app/components/InsightsView.css | 200 ++++++++++++++++++---
 packages/dashboard/app/components/InsightsView.tsx | 111 ++++++++----
 .../app/components/ModelOnboardingModal.tsx        |   5 +-
 packages/dashboard/app/components/ScriptsModal.css |  89 +++++++++
 .../dashboard/app/components/SettingsModal.css     |  11 +-
 .../dashboard/app/components/SettingsModal.tsx     |  22 ++-
 .../dashboard/app/components/TaskDetailModal.css   |   5 +
 packages/dashboard/app/components/TodoView.tsx     |   2 +
 .../app/components/__tests__/App.test.tsx          |  33 ++++
 .../app/components/__tests__/InsightsView.test.tsx |  25 ++-
 .../__tests__/ModelOnboardingModal.test.tsx        |   4 +-
 .../app/components/__tests__/QuickChatFAB.test.tsx |  14 +-
 .../__tests__/SettingsModalNodeRouting.test.tsx    |  12 +-
 .../app/components/__tests__/TodoView.test.tsx     |   4 +-
 .../__tests__/agents-view-mobile.test.tsx          |   5 +-
 .../components/__tests__/onboarding-flow.test.tsx  |   2 +-
 .../app/hooks/__tests__/useAppSettings.test.ts     |   1 +
 .../app/hooks/__tests__/useTaskHandlers.test.ts    |   4 +-
 packages/dashboard/app/hooks/useAppSettings.ts     |  12 ++
 packages/dashboard/app/hooks/useTaskHandlers.ts    |   4 +-
 packages/dashboard/package.json                    |   2 +-
 packages/dashboard/src/__tests__/routes.test.ts    |  14 ++
 packages/dashboard/src/routes.ts                   |   4 +
 .../dashboard/src/routes/register-git-github.ts    |  12 ++
 .../src/routes/register-planning-subtask-routes.ts |   3 +
 .../src/routes/register-task-workflow-routes.ts    |   7 +
 packages/desktop/CHANGELOG.md                      |   7 +
 packages/desktop/package.json                      |   2 +-
 packages/engine/CHANGELOG.md                       |  11 ++
 packages/engine/package.json                       |   2 +-
 .../engine/src/__tests__/agent-heartbeat.test.ts   |  10 ++
 .../src/__tests__/agent-tools-delegation.test.ts   |   2 +
 packages/engine/src/__tests__/agent-tools.test.ts  |  37 ++++
 packages/engine/src/__tests__/cron-runner.test.ts  |   4 +
 packages/engine/src/__tests__/merger.test.ts       |  75 ++++++++
 .../src/__tests__/node-routing-policy.test.ts      |  25 ++-
 .../src/__tests__/pr-comment-handler.test.ts       |   8 +
 .../src/__tests__/scheduler-node-routing.test.ts   |  18 +-
 packages/engine/src/__tests__/triage.test.ts       |   2 +
 packages/engine/src/agent-heartbeat.ts             |  13 +-
 packages/engine/src/agent-tools.ts                 |  13 +-
 packages/engine/src/cron-runner.ts                 |   7 +-
 packages/engine/src/executor.ts                    |   2 +-
 packages/engine/src/merger.ts                      | 181 ++++++++-----------
 packages/engine/src/mission-execution-loop.ts      |   8 +
 packages/engine/src/pr-comment-handler.ts          |   5 +
 packages/engine/src/project-engine.ts              |   8 +
 packages/engine/src/routine-runner.ts              |   4 +
 packages/engine/src/scheduler.ts                   |   8 +-
 packages/engine/src/triage.ts                      |   4 +
 packages/mobile/CHANGELOG.md                       |   7 +
 packages/mobile/package.json                       |   2 +-
 packages/pi-claude-cli/CHANGELOG.md                |   7 +
 packages/pi-claude-cli/package.json                |   2 +-
 packages/plugin-sdk/CHANGELOG.md                   |  10 ++
 packages/plugin-sdk/package.json                   |   2 +-
 .../examples/fusion-plugin-auto-label/CHANGELOG.md |   8 +
 .../examples/fusion-plugin-auto-label/package.json |   2 +-
 .../examples/fusion-plugin-ci-status/CHANGELOG.md  |   8 +
 .../examples/fusion-plugin-ci-status/package.json  |   2 +-
 .../fusion-plugin-notification/CHANGELOG.md        |   8 +
 .../fusion-plugin-notification/package.json        |   2 +-
 .../fusion-plugin-settings-demo/CHANGELOG.md       |   8 +
 .../fusion-plugin-settings-demo/package.json       |   2 +-
 plugins/fusion-plugin-hermes-runtime/CHANGELOG.md  |   8 +
 plugins/fusion-plugin-hermes-runtime/package.json  |   2 +-
 .../fusion-plugin-openclaw-runtime/CHANGELOG.md    |   8 +
 .../fusion-plugin-openclaw-runtime/package.json    |   2 +-
 .../fusion-plugin-paperclip-runtime/CHANGELOG.md   |   8 +
 .../fusion-plugin-paperclip-runtime/package.json   |   2 +-
 101 files changed, 1388 insertions(+), 334 deletions(-)

Fusion-Task-Id: FN-2947
This commit is contained in:
Fusion
2026-04-29 17:36:20 -07:00
committed by gsxdsm
parent 42276db737
commit 9e5ac3c676
10 changed files with 360 additions and 110 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add an optional `useAiMergeCommitSummary` project setting that enables AI-generated merge commit summaries using the title summarizer model lane, with deterministic fallback when disabled or unavailable.

View File

@@ -206,6 +206,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `autoBackupRetention` | `number` | `7` | Number of backups to retain. | | `autoBackupRetention` | `number` | `7` | Number of backups to retain. |
| `autoBackupDir` | `string` | `".fusion/backups"` | Relative backup directory path. | | `autoBackupDir` | `string` | `".fusion/backups"` | Relative backup directory path. |
| `autoSummarizeTitles` | `boolean` | `false` | Auto-generate titles for long untitled descriptions. | | `autoSummarizeTitles` | `boolean` | `false` | Auto-generate titles for long untitled descriptions. |
| `useAiMergeCommitSummary` | `boolean` | `false` | Use AI-generated merge commit summaries instead of raw step-commit subject lists. |
| `titleSummarizerProvider` | `string` | `undefined` | Provider for title summarization. | | `titleSummarizerProvider` | `string` | `undefined` | Provider for title summarization. |
| `titleSummarizerModelId` | `string` | `undefined` | Model ID for title summarization. | | `titleSummarizerModelId` | `string` | `undefined` | Model ID for title summarization. |
| `titleSummarizerFallbackProvider` | `string` | `undefined` | Fallback provider for title summarization. | | `titleSummarizerFallbackProvider` | `string` | `undefined` | Fallback provider for title summarization. |
@@ -390,6 +391,8 @@ Fusion uses a dual-scope model settings system with five lanes. Global settings
### Title summarization model ### Title summarization model
Used for task title auto-summarization and (when enabled) AI merge commit summaries.
1. Project `titleSummarizerProvider` + `titleSummarizerModelId` 1. Project `titleSummarizerProvider` + `titleSummarizerModelId`
2. Global `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` 2. Global `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId`
3. Project `planningProvider` + `planningModelId` 3. Project `planningProvider` + `planningModelId`

View File

@@ -1,6 +1,16 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
const { getFnAgentMock } = vi.hoisted(() => ({
getFnAgentMock: vi.fn(),
}));
vi.mock("../ai-engine-loader.js", () => ({
getFnAgent: getFnAgentMock,
}));
import { import {
summarizeTitle, summarizeTitle,
summarizeMergeCommit,
summarizeCommitBody, summarizeCommitBody,
sanitizeCommitSubject, sanitizeCommitSubject,
MAX_COMMIT_SUBJECT_LENGTH, MAX_COMMIT_SUBJECT_LENGTH,
@@ -8,10 +18,12 @@ import {
getRateLimitResetTime, getRateLimitResetTime,
validateDescription, validateDescription,
SUMMARIZE_SYSTEM_PROMPT, SUMMARIZE_SYSTEM_PROMPT,
MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT,
COMMIT_BODY_SYSTEM_PROMPT, COMMIT_BODY_SYSTEM_PROMPT,
MAX_DESCRIPTION_LENGTH, MAX_DESCRIPTION_LENGTH,
MIN_DESCRIPTION_LENGTH, MIN_DESCRIPTION_LENGTH,
MAX_TITLE_LENGTH, MAX_TITLE_LENGTH,
MAX_MERGE_COMMIT_SUMMARY_LENGTH,
MAX_COMMIT_BODY_INPUT_LENGTH, MAX_COMMIT_BODY_INPUT_LENGTH,
MAX_COMMIT_BODY_LENGTH, MAX_COMMIT_BODY_LENGTH,
DEFAULT_COMMIT_BODY_TIMEOUT_MS, DEFAULT_COMMIT_BODY_TIMEOUT_MS,
@@ -25,6 +37,8 @@ import {
describe("ai-summarize", () => { describe("ai-summarize", () => {
beforeEach(() => { beforeEach(() => {
__resetSummarizeState(); __resetSummarizeState();
getFnAgentMock.mockReset();
getFnAgentMock.mockResolvedValue(null);
}); });
// ── Constants ────────────────────────────────────────────────────────────── // ── Constants ──────────────────────────────────────────────────────────────
@@ -169,6 +183,56 @@ describe("ai-summarize", () => {
}); });
}); });
describe("summarizeMergeCommit", () => {
it("returns null when commit log and diff stat are empty", async () => {
expect(await summarizeMergeCommit("", "", "/tmp")).toBeNull();
expect(await summarizeMergeCommit(" ", "\n\n", "/tmp")).toBeNull();
});
it("returns summary text when AI responds", async () => {
const prompt = vi.fn().mockResolvedValue(undefined);
getFnAgentMock.mockResolvedValue(() =>
Promise.resolve({
session: {
prompt,
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: "Updated merger and settings wiring for AI commit summaries.",
},
],
},
},
})
);
const summary = await summarizeMergeCommit(
"- feat: add summary\n- test: add coverage",
"merger.ts | 20 ++++++++++-----",
"/tmp"
);
expect(summary).toBe("Updated merger and settings wiring for AI commit summaries.");
expect(prompt).toHaveBeenCalledTimes(1);
});
it("throws AiServiceError when AI engine is unavailable", async () => {
await expect(
summarizeMergeCommit("- feat: add summary", "merger.ts | 2 ++", "/tmp")
).rejects.toThrow(AiServiceError);
await expect(
summarizeMergeCommit("- feat: add summary", "merger.ts | 2 ++", "/tmp")
).rejects.toThrow("AI engine not available");
});
it("exposes merge summary constants", () => {
expect(MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT).toContain("1-3 concise sentences");
expect(MAX_MERGE_COMMIT_SUMMARY_LENGTH).toBe(300);
});
});
describe("summarizeCommitBody", () => { describe("summarizeCommitBody", () => {
it("returns null for empty diff stat (nothing to summarize)", async () => { it("returns null for empty diff stat (nothing to summarize)", async () => {
expect(await summarizeCommitBody("", "/tmp")).toBeNull(); expect(await summarizeCommitBody("", "/tmp")).toBeNull();

View File

@@ -36,6 +36,9 @@ export const MIN_DESCRIPTION_LENGTH = 201;
/** Maximum title length in characters */ /** Maximum title length in characters */
export const MAX_TITLE_LENGTH = 60; export const MAX_TITLE_LENGTH = 60;
/** Maximum merge commit summary length in characters */
export const MAX_MERGE_COMMIT_SUMMARY_LENGTH = 300;
/** Rate limit: max requests per IP per hour */ /** Rate limit: max requests per IP per hour */
export const MAX_REQUESTS_PER_HOUR = 10; export const MAX_REQUESTS_PER_HOUR = 10;
@@ -311,6 +314,117 @@ export async function summarizeTitle(
} }
} }
/** System prompt for AI merge commit summary generation. */
export const MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT = `You summarize merge commits for a task management system.
Your job is to describe what the merge accomplishes based on step commit subjects and file-change stats.
## Guidelines
- Return only summary text, no markdown or bullet list
- Write 1-3 concise sentences
- Mention the most meaningful modules or behaviors touched
- Be factual and avoid inventing details
- Keep it readable and professional`;
/**
* Generate a concise natural-language merge summary from commit subjects and
* diff stats. Returns null for empty inputs.
*/
export async function summarizeMergeCommit(
commitLog: string,
diffStat: string,
rootDir: string,
provider?: string,
modelId?: string
): Promise<string | null> {
const trimmedCommitLog = (commitLog ?? "").trim();
const trimmedDiffStat = (diffStat ?? "").trim();
if (trimmedCommitLog.length === 0 && trimmedDiffStat.length === 0) {
return null;
}
const createFnAgent = await getFnAgent();
if (!createFnAgent) {
throw new AiServiceError("AI engine not available");
}
const agentOptions: {
cwd: string;
systemPrompt: string;
tools: "readonly";
defaultProvider?: string;
defaultModelId?: string;
} = {
cwd: rootDir,
systemPrompt: MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT,
tools: "readonly",
};
if (provider && modelId) {
agentOptions.defaultProvider = provider;
agentOptions.defaultModelId = modelId;
}
const agentResult = await createFnAgent(agentOptions);
if (!agentResult?.session) {
throw new AiServiceError("Failed to initialize AI agent");
}
try {
const promptParts: string[] = [
"Step commits being merged (most recent first):",
trimmedCommitLog || "(none provided)",
"",
"Files changed (`git diff --stat`):",
trimmedDiffStat || "(none provided)",
"",
"Write the merge summary now.",
];
await agentResult.session.prompt(promptParts.join("\n"));
if (agentResult.session.state?.error) {
throw new AiServiceError(`AI session error: ${agentResult.session.state.error}`);
}
const messages: AgentMessage[] = agentResult.session.state?.messages ?? [];
const lastMessage = messages.filter((m: AgentMessage) => m.role === "assistant").pop();
let summary = "";
if (typeof lastMessage?.content === "string") {
summary = lastMessage.content.trim();
} else if (Array.isArray(lastMessage?.content)) {
summary = lastMessage.content
.filter((c: { type: string; text?: string }): c is { type: "text"; text: string } =>
c.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join("")
.trim();
}
if (!summary) {
throw new AiServiceError("AI returned empty response");
}
if (summary.length > MAX_MERGE_COMMIT_SUMMARY_LENGTH) {
summary = summary.slice(0, MAX_MERGE_COMMIT_SUMMARY_LENGTH).trim();
}
return summary;
} catch (err) {
if (err instanceof AiServiceError) {
throw err;
}
const message = err instanceof Error ? err.message : "AI processing failed";
throw new AiServiceError(message);
} finally {
try {
agentResult.session.dispose?.();
} catch {
// Ignore disposal errors
}
}
}
// ── Commit Body Summarization ──────────────────────────────────────────── // ── Commit Body Summarization ────────────────────────────────────────────
/** System prompt for fallback merge commit body generation. */ /** System prompt for fallback merge commit body generation. */

View File

@@ -184,6 +184,7 @@ export type {
export { export {
summarizeTitle, summarizeTitle,
summarizeMergeCommit,
summarizeCommitBody, summarizeCommitBody,
summarizeCommitSubject, summarizeCommitSubject,
sanitizeCommitSubject, sanitizeCommitSubject,
@@ -191,6 +192,7 @@ export {
getRateLimitResetTime, getRateLimitResetTime,
validateDescription, validateDescription,
SUMMARIZE_SYSTEM_PROMPT, SUMMARIZE_SYSTEM_PROMPT,
MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT,
COMMIT_BODY_SYSTEM_PROMPT, COMMIT_BODY_SYSTEM_PROMPT,
COMMIT_SUBJECT_SYSTEM_PROMPT, COMMIT_SUBJECT_SYSTEM_PROMPT,
MAX_COMMIT_SUBJECT_LENGTH, MAX_COMMIT_SUBJECT_LENGTH,
@@ -198,6 +200,7 @@ export {
MAX_DESCRIPTION_LENGTH, MAX_DESCRIPTION_LENGTH,
MIN_DESCRIPTION_LENGTH, MIN_DESCRIPTION_LENGTH,
MAX_TITLE_LENGTH, MAX_TITLE_LENGTH,
MAX_MERGE_COMMIT_SUMMARY_LENGTH,
MAX_COMMIT_BODY_INPUT_LENGTH, MAX_COMMIT_BODY_INPUT_LENGTH,
MAX_COMMIT_BODY_LENGTH, MAX_COMMIT_BODY_LENGTH,
DEFAULT_COMMIT_BODY_TIMEOUT_MS, DEFAULT_COMMIT_BODY_TIMEOUT_MS,

View File

@@ -147,6 +147,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
autoBackupRetention: 7, autoBackupRetention: 7,
autoBackupDir: ".fusion/backups", autoBackupDir: ".fusion/backups",
autoSummarizeTitles: false, autoSummarizeTitles: false,
useAiMergeCommitSummary: false,
titleSummarizerProvider: undefined, titleSummarizerProvider: undefined,
titleSummarizerModelId: undefined, titleSummarizerModelId: undefined,
titleSummarizerFallbackProvider: undefined, titleSummarizerFallbackProvider: undefined,

View File

@@ -1718,6 +1718,10 @@ export interface ProjectSettings {
* characters will automatically receive an AI-generated title (max 60 chars). * characters will automatically receive an AI-generated title (max 60 chars).
* Default: false. */ * Default: false. */
autoSummarizeTitles?: boolean; autoSummarizeTitles?: boolean;
/** When true, merge commit messages include an AI-generated summary of the
* changes instead of just listing step commit subjects. Uses the title
* summarizer model. Default: false. */
useAiMergeCommitSummary?: boolean;
/** AI model provider for title summarization (when autoSummarizeTitles is enabled). /** AI model provider for title summarization (when autoSummarizeTitles is enabled).
* Must be set together with `titleSummarizerModelId`. Falls back to planningProvider, * Must be set together with `titleSummarizerModelId`. Falls back to planningProvider,
* then defaultProvider if not specified. */ * then defaultProvider if not specified. */

View File

@@ -1171,7 +1171,7 @@ export function SettingsModal({
globalModelKey: "titleSummarizerGlobalModelId", globalModelKey: "titleSummarizerGlobalModelId",
projectProviderKey: "titleSummarizerProvider", projectProviderKey: "titleSummarizerProvider",
projectModelKey: "titleSummarizerModelId", projectModelKey: "titleSummarizerModelId",
helperText: "AI model used for auto-generating task titles from descriptions and for synthesizing fallback merge commit message bodies when the branch's commit log is empty.", helperText: "AI model used for auto-generating task titles and merge commit summaries.",
fallbackOrder: "Project override → Global summarization lane → Project planning lane → Project default lane → Global default lane → Automatic resolution", fallbackOrder: "Project override → Global summarization lane → Project planning lane → Project default lane → Global default lane → Automatic resolution",
}, },
]; ];
@@ -2342,8 +2342,7 @@ export function SettingsModal({
<p className="settings-description"> <p className="settings-description">
Configures the model used for two short-summary jobs: Configures the model used for two short-summary jobs:
auto-generating task titles from long descriptions, and auto-generating task titles from long descriptions, and
synthesizing fallback merge commit message bodies when the generating merge commit summaries from step commits and diff stats.
branch's commit log is empty.
</p> </p>
<div className="form-group"> <div className="form-group">
<label htmlFor="autoSummarizeTitles" className="checkbox-label"> <label htmlFor="autoSummarizeTitles" className="checkbox-label">
@@ -2363,7 +2362,22 @@ export function SettingsModal({
</small> </small>
</div> </div>
{(form.autoSummarizeTitles || false) && ( <div className="form-group">
<label htmlFor="useAiMergeCommitSummary" className="checkbox-label">
<input
id="useAiMergeCommitSummary"
type="checkbox"
checked={form.useAiMergeCommitSummary || false}
onChange={(e) => setForm((f) => ({ ...f, useAiMergeCommitSummary: e.target.checked }))}
/>
AI merge commit summaries
</label>
<small>
When enabled, merge commit messages will include an AI-generated summary of the changes instead of just listing step commit subjects. Uses the title summarization model.
</small>
</div>
{(form.autoSummarizeTitles || form.useAiMergeCommitSummary || false) && (
<> <>
<div className="form-group"> <div className="form-group">
<label>Title and commit message summarization model</label> <label>Title and commit message summarization model</label>

View File

@@ -118,6 +118,7 @@ import {
import { mergerLog } from "../logger.js"; import { mergerLog } from "../logger.js";
import { createFnAgent } from "../pi.js"; import { createFnAgent } from "../pi.js";
import { execSync, exec } from "node:child_process"; import { execSync, exec } from "node:child_process";
import * as core from "@fusion/core";
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core"; import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core";
const mockedCreateFnAgent = vi.mocked(createFnAgent); const mockedCreateFnAgent = vi.mocked(createFnAgent);
@@ -5077,6 +5078,80 @@ describe("aiMergeTask — merge details collection", () => {
expect(mergeDetails.attemptsMade).toBe(1); expect(mergeDetails.attemptsMade).toBe(1);
}); });
it("stores AI summary in mergeDetails when useAiMergeCommitSummary is enabled", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
useAiMergeCommitSummary: true,
});
vi.spyOn(core, "summarizeMergeCommit").mockResolvedValue("AI summary of merged work.");
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123456789";
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("show --shortstat")) return "1 file changed, 1 insertion(+)";
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
const updateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls;
const mergeDetailsCall = updateCalls.find((call: any[]) => call[1]?.mergeDetails !== undefined);
expect(mergeDetailsCall?.[1].mergeDetails.mergeCommitMessage).toBe("AI summary of merged work.");
});
it("falls back to raw commit log when AI merge summary returns null", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
useAiMergeCommitSummary: true,
});
vi.spyOn(core, "summarizeMergeCommit").mockResolvedValue(null);
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123456789";
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("show --shortstat")) return "1 file changed, 1 insertion(+)";
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
const updateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls;
const mergeDetailsCall = updateCalls.find((call: any[]) => call[1]?.mergeDetails !== undefined);
expect(mergeDetailsCall?.[1].mergeDetails.mergeCommitMessage).toBe("- feat: something");
});
it("stores partial mergeDetails when branch is not found", async () => { it("stores partial mergeDetails when branch is not found", async () => {
const store = createMockStore( const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" }, { id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },

View File

@@ -137,9 +137,10 @@ import {
getTaskMergeBlocker, getTaskMergeBlocker,
normalizeMergeConflictStrategy, normalizeMergeConflictStrategy,
resolveProjectDefaultModel, resolveProjectDefaultModel,
resolveTitleSummarizerSettingsModel,
resolveAgentPrompt, resolveAgentPrompt,
summarizeCommitBody, summarizeCommitBody,
summarizeCommitSubject, summarizeMergeCommit,
type TaskStore, type TaskStore,
type MergeResult, type MergeResult,
type MergeDetails, type MergeDetails,
@@ -1054,19 +1055,31 @@ function resetMergeWithWarn(rootDir: string, taskId: string, label: string): voi
} }
} }
async function generateAiMergeSummary(
commitLog: string,
diffStat: string,
settings: Settings,
rootDir: string,
): Promise<string | null> {
try {
const resolved = resolveTitleSummarizerSettingsModel(settings);
return await summarizeMergeCommit(
commitLog,
diffStat,
rootDir,
resolved.provider,
resolved.modelId,
);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
mergerLog.warn(`AI merge summary failed; using deterministic fallback (${message})`);
return null;
}
}
/** /**
* Build the canonical merge commit message from the branch's step commits. * Build the canonical merge commit message from the branch's step commits.
* Subject is always `feat[(taskId)]: merge <branch>`. Body has three parts so * Subject is always `feat[(taskId)]: merge <branch>`.
* `git log` shows what actually landed instead of a bare "merge":
* 1. AI-generated summary (via the title-summarizer model lane), built from
* the branch's step-commit subjects + diffstat. Best-effort — bounded by
* timeout, falls through silently on any failure.
* 2. The raw step-commit list (always included as ground truth so a reader
* can verify the AI summary against the actual commits).
* 3. The diffstat block so file-level changes are visible inline.
*
* The AI summary is additive context, never the sole source of truth — the
* step commits and diffstat below it are deterministic.
*/ */
async function buildDeterministicMergeMessage(params: { async function buildDeterministicMergeMessage(params: {
taskId: string; taskId: string;
@@ -1074,13 +1087,11 @@ async function buildDeterministicMergeMessage(params: {
commitLog: string; commitLog: string;
diffStat?: string; diffStat?: string;
includeTaskId: boolean; includeTaskId: boolean;
rootDir?: string; aiSummary?: string | null;
settings?: Settings;
signal?: AbortSignal;
}): Promise<{ subjectArg: string; bodyArg: string }> { }): Promise<{ subjectArg: string; bodyArg: string }> {
const { taskId, branch, commitLog, diffStat, includeTaskId, rootDir, settings, signal } = params; const { taskId, branch, commitLog, diffStat, includeTaskId, aiSummary } = params;
const prefix = includeTaskId ? `feat(${taskId})` : "feat"; const prefix = includeTaskId ? `feat(${taskId})` : "feat";
const fallbackSubject = `${prefix}: merge ${branch}`; const subject = `${prefix}: merge ${branch}`;
const trimmedCommitLog = commitLog?.trim() ?? ""; const trimmedCommitLog = commitLog?.trim() ?? "";
const trimmedDiffStat = diffStat?.trim() ?? ""; const trimmedDiffStat = diffStat?.trim() ?? "";
@@ -1089,64 +1100,12 @@ async function buildDeterministicMergeMessage(params: {
? trimmedCommitLog ? trimmedCommitLog
: `- merge ${branch}`; : `- merge ${branch}`;
// Best-effort AI summary using the title-summarizer lane (small/fast model). const body = aiSummary?.trim().length
// Falls back to the project default when not configured. Any failure (no ? aiSummary.trim()
// runtime, timeout, empty response) returns null and we skip the summary. : [
// Subject and body are generated in parallel so the extra subject call `Commits merged:\n${commitsSection}`,
// doesn't serialize merge time. trimmedDiffStat.length > 0 ? `Files changed:\n${trimmedDiffStat}` : "",
let aiSummary: string | null = null; ].filter(Boolean).join("\n\n");
let aiSubject: string | null = null;
if (rootDir && settings && (trimmedCommitLog.length > 0 || trimmedDiffStat.length > 0)) {
const useTitleSummarizer =
!!settings.titleSummarizerProvider && !!settings.titleSummarizerModelId;
const provider = useTitleSummarizer
? settings.titleSummarizerProvider!
: (settings.defaultProviderOverride && settings.defaultModelIdOverride
? settings.defaultProviderOverride
: settings.defaultProvider);
const modelId = useTitleSummarizer
? settings.titleSummarizerModelId!
: (settings.defaultProviderOverride && settings.defaultModelIdOverride
? settings.defaultModelIdOverride
: settings.defaultModelId);
const [bodyResult, subjectResult] = await Promise.all([
summarizeCommitBody(trimmedDiffStat, rootDir, provider, modelId, {
branch,
taskId,
commitLog: trimmedCommitLog,
signal,
}).catch(() => null),
summarizeCommitSubject(trimmedDiffStat, rootDir, provider, modelId, {
branch,
taskId,
commitLog: trimmedCommitLog,
signal,
}).catch(() => null),
]);
aiSummary = bodyResult;
aiSubject = subjectResult;
}
// Compose subject: prefer the AI summary; fall back to the legacy
// `merge <branch>` form on any failure so a wedged summarizer can never
// block a merge. Hard cap at 72 chars (subject + prefix) — git's soft
// limit is 72; the AI is already capped at 60 by sanitizeCommitSubject.
let subject = fallbackSubject;
if (aiSubject && aiSubject.length > 0) {
const candidate = `${prefix}: ${aiSubject}`;
subject = candidate.length > 72 ? candidate.slice(0, 72).trimEnd() : candidate;
}
const sections: string[] = [];
if (aiSummary && aiSummary.trim().length > 0) {
sections.push(aiSummary.trim());
}
sections.push(`Commits merged:\n${commitsSection}`);
if (trimmedDiffStat.length > 0) {
sections.push(`Files changed:\n${trimmedDiffStat}`);
}
const body = sections.join("\n\n");
// -m args are double-quoted in the shell command, so escape backslashes, // -m args are double-quoted in the shell command, so escape backslashes,
// double quotes, dollar signs, and backticks. // double quotes, dollar signs, and backticks.
@@ -1185,6 +1144,7 @@ async function commitOrAmendMergeWithFixes(
diffStat?: string, diffStat?: string,
settings?: Settings, settings?: Settings,
signal?: AbortSignal, signal?: AbortSignal,
aiSummary?: string | null,
): Promise<boolean> { ): Promise<boolean> {
try { try {
// Stage everything (squash state + verification fixes the agent left // Stage everything (squash state + verification fixes the agent left
@@ -1259,9 +1219,7 @@ async function commitOrAmendMergeWithFixes(
commitLog: messageCommitLog, commitLog: messageCommitLog,
diffStat: messageDiffStat, diffStat: messageDiffStat,
includeTaskId, includeTaskId,
rootDir, aiSummary,
settings,
signal,
}); });
const trailerArg = buildTaskIdTrailerArg(taskId); const trailerArg = buildTaskIdTrailerArg(taskId);
@@ -2002,6 +1960,7 @@ async function resolveSafeCommitBody(opts: {
const cleanStat = opts.diffStat.trim(); const cleanStat = opts.diffStat.trim();
if (cleanStat.length > 0) { if (cleanStat.length > 0) {
if (opts.settings.useAiMergeCommitSummary) {
// Prefer the dedicated title-summarization model — a small, fast tier // Prefer the dedicated title-summarization model — a small, fast tier
// intended for short summarization. Falls back to the project / global // intended for short summarization. Falls back to the project / global
// default model when the summarizer lane isn't configured. The core // default model when the summarizer lane isn't configured. The core
@@ -2027,6 +1986,7 @@ async function resolveSafeCommitBody(opts: {
timeoutMs: opts.aiTimeoutMs, timeoutMs: opts.aiTimeoutMs,
}).catch(() => null); }).catch(() => null);
if (ai && ai.trim().length > 0) return ai.trim(); if (ai && ai.trim().length > 0) return ai.trim();
}
return `Files changed:\n\n${cleanStat}`; return `Files changed:\n\n${cleanStat}`;
} }
@@ -3220,6 +3180,10 @@ export async function aiMergeTask(
diffStat = "(unable to read diff)"; diffStat = "(unable to read diff)";
} }
const aiMergeSummary = settings.useAiMergeCommitSummary
? await generateAiMergeSummary(commitLog, diffStat, settings, rootDir)
: null;
// 4b. Validate diff scope against task's declared File Scope // 4b. Validate diff scope against task's declared File Scope
try { try {
const scopeResult = await validateDiffScope(store, taskId, diffStat, settings.strictScopeEnforcement); const scopeResult = await validateDiffScope(store, taskId, diffStat, settings.strictScopeEnforcement);
@@ -3308,6 +3272,7 @@ export async function aiMergeTask(
branch, branch,
commitLog, commitLog,
diffStat, diffStat,
aiSummary: aiMergeSummary,
includeTaskId, includeTaskId,
sourceIssueRef, sourceIssueRef,
smartConflictResolution, smartConflictResolution,
@@ -3452,6 +3417,7 @@ export async function aiMergeTask(
diffStat, diffStat,
settings, settings,
options.signal, options.signal,
aiMergeSummary,
); );
if (!finalized) { if (!finalized) {
// Phantom-merge guard: refused to fabricate a commit. Reset // Phantom-merge guard: refused to fabricate a commit. Reset
@@ -3557,6 +3523,7 @@ export async function aiMergeTask(
diffStat, diffStat,
settings, settings,
options.signal, options.signal,
aiMergeSummary,
); );
if (!finalized) { if (!finalized) {
// Phantom-merge guard: the verification fix passed but no // Phantom-merge guard: the verification fix passed but no
@@ -3746,7 +3713,7 @@ export async function aiMergeTask(
filesChanged: recordedFilesChanged, filesChanged: recordedFilesChanged,
insertions: recordedInsertions, insertions: recordedInsertions,
deletions: recordedDeletions, deletions: recordedDeletions,
mergeCommitMessage: commitLog, mergeCommitMessage: aiMergeSummary || commitLog,
mergedAt: new Date().toISOString(), mergedAt: new Date().toISOString(),
mergeConfirmed: true, mergeConfirmed: true,
resolutionStrategy: result.resolutionStrategy, resolutionStrategy: result.resolutionStrategy,
@@ -4063,6 +4030,7 @@ interface MergeAttemptParams {
branch: string; branch: string;
commitLog: string; commitLog: string;
diffStat: string; diffStat: string;
aiSummary?: string | null;
includeTaskId: boolean; includeTaskId: boolean;
sourceIssueRef?: string; sourceIssueRef?: string;
smartConflictResolution: boolean; smartConflictResolution: boolean;
@@ -4110,6 +4078,7 @@ async function executeMergeAttempt(
branch, branch,
commitLog, commitLog,
diffStat, diffStat,
aiSummary,
includeTaskId, includeTaskId,
sourceIssueRef, sourceIssueRef,
smartConflictResolution, smartConflictResolution,
@@ -4441,9 +4410,7 @@ async function executeMergeAttempt(
commitLog: actualContext.commitLog || commitLog, commitLog: actualContext.commitLog || commitLog,
diffStat: actualContext.diffStat || diffStat, diffStat: actualContext.diffStat || diffStat,
includeTaskId, includeTaskId,
rootDir, aiSummary,
settings: params.settings,
signal: options.signal,
}); });
const trailerArg = buildTaskIdTrailerArg(taskId); const trailerArg = buildTaskIdTrailerArg(taskId);
await execAsync( await execAsync(