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:
5
.changeset/add-ai-merge-commit-summary.md
Normal file
5
.changeset/add-ai-merge-commit-summary.md
Normal 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.
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user