refactor(core,merger): consolidate AI commit-body summarization into ai-summarize.ts
Moves the commit-body AI helper out of merger.ts into the existing
core/ai-summarize.ts module so all short-summary AI work (titles,
chat titles, fallback merge commit bodies) shares one home with
consistent dispatch semantics, error handling, and session lifecycle.
Core (ai-summarize.ts):
- New `summarizeCommitBody(diffStat, rootDir, provider, modelId, opts)`
exported alongside `summarizeTitle`. Same shape (provider/modelId
args), same get-engine-or-bail dynamic loading via `getFnAgent`,
same readonly-tools session, same disposal-in-finally pattern.
- Differs from `summarizeTitle` in three deliberate ways suited to the
commit-body job:
1. Returns null on any failure instead of throwing — the caller is
always the merger, which has a deterministic fallback chain
behind it. Throwing would force the merger to wrap every call
in try/catch.
2. Accepts an optional `signal` to forward engine-pause / shutdown
cancellation, plus a configurable `timeoutMs` (default 30s)
so a wedged AI session can't stall a merge indefinitely.
3. Larger output ceiling (2000 chars vs title's 60) and larger
input ceiling (4000 chars truncated diff) — commit bodies are
multi-line and need more room than a 60-char title.
- Exported alongside `summarizeTitle` from `@fusion/core`. Constants
(`COMMIT_BODY_SYSTEM_PROMPT`, `MAX_COMMIT_BODY_INPUT_LENGTH`,
`MAX_COMMIT_BODY_LENGTH`, `DEFAULT_COMMIT_BODY_TIMEOUT_MS`) re-exported
for callers that want to override behavior.
Engine (merger.ts):
- Dropped the local `aiGenerateCommitBody` function (~70 lines) — it
duplicated the session-creation pattern from `summarizeTitle` while
living in a place where future maintainers wouldn't think to look.
- `resolveSafeCommitBody` now imports `summarizeCommitBody` from
`@fusion/core` and delegates. The cascade behavior is unchanged
(commitLog → AI → diff stat → synthetic) and the title-summarizer
model preference is preserved (provider/modelId resolved here, then
passed through).
Tests:
- 6 new test cases in `ai-summarize.test.ts` covering:
empty input → null, missing engine → null (graceful, never throws),
missing engine + model selection → null, pre-aborted signal → null,
custom timeout (returns quickly under 1s ceiling), exposed constants.
- Core: 3136/3136 pass (was 3130 — +6 new). Engine: 2887/2887 pass.
- Typecheck clean, workspace lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
summarizeTitle,
|
||||
summarizeCommitBody,
|
||||
checkRateLimit,
|
||||
getRateLimitResetTime,
|
||||
validateDescription,
|
||||
SUMMARIZE_SYSTEM_PROMPT,
|
||||
COMMIT_BODY_SYSTEM_PROMPT,
|
||||
MAX_DESCRIPTION_LENGTH,
|
||||
MIN_DESCRIPTION_LENGTH,
|
||||
MAX_TITLE_LENGTH,
|
||||
MAX_COMMIT_BODY_INPUT_LENGTH,
|
||||
MAX_COMMIT_BODY_LENGTH,
|
||||
DEFAULT_COMMIT_BODY_TIMEOUT_MS,
|
||||
MAX_REQUESTS_PER_HOUR,
|
||||
ValidationError,
|
||||
RateLimitError,
|
||||
@@ -162,6 +167,73 @@ describe("ai-summarize", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeCommitBody", () => {
|
||||
it("returns null for empty diff stat (nothing to summarize)", async () => {
|
||||
expect(await summarizeCommitBody("", "/tmp")).toBeNull();
|
||||
expect(await summarizeCommitBody(" \n ", "/tmp")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when AI engine is unavailable (graceful, never throws)", async () => {
|
||||
const result = await summarizeCommitBody(
|
||||
"src/foo.ts | 5 +++--\n1 file changed",
|
||||
"/tmp",
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when AI engine is unavailable even with model selection", async () => {
|
||||
// Should NOT throw — the contract is fail-soft so the merger can fall
|
||||
// back to its deterministic body cascade without losing the merge.
|
||||
const result = await summarizeCommitBody(
|
||||
"src/foo.ts | 5 +++--\n1 file changed",
|
||||
"/tmp",
|
||||
"anthropic",
|
||||
"claude-sonnet-4-5",
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when caller's abort signal is already aborted", async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const result = await summarizeCommitBody(
|
||||
"src/foo.ts | 5 +++--\n1 file changed",
|
||||
"/tmp",
|
||||
undefined,
|
||||
undefined,
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("respects custom timeout (returns null on timeout, never hangs)", async () => {
|
||||
// Timeout is 1ms — faster than any real AI call, and the helper aborts
|
||||
// the in-flight session and returns null instead of hanging the merge.
|
||||
const start = Date.now();
|
||||
const result = await summarizeCommitBody(
|
||||
"src/foo.ts | 5 +++--\n1 file changed",
|
||||
"/tmp",
|
||||
undefined,
|
||||
undefined,
|
||||
{ timeoutMs: 1 },
|
||||
);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(result).toBeNull();
|
||||
// Belt-and-braces: even if the engine isn't available the call should
|
||||
// return very quickly. The 1s ceiling guards against future regressions
|
||||
// that might accidentally block.
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it("exposes sensible constants", () => {
|
||||
expect(COMMIT_BODY_SYSTEM_PROMPT.length).toBeGreaterThan(0);
|
||||
expect(COMMIT_BODY_SYSTEM_PROMPT).toContain("commit message");
|
||||
expect(MAX_COMMIT_BODY_INPUT_LENGTH).toBeGreaterThan(1000);
|
||||
expect(MAX_COMMIT_BODY_LENGTH).toBeGreaterThan(100);
|
||||
expect(DEFAULT_COMMIT_BODY_TIMEOUT_MS).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error Classes ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("error classes", () => {
|
||||
|
||||
@@ -311,6 +311,174 @@ export async function summarizeTitle(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commit Body Summarization ────────────────────────────────────────────
|
||||
|
||||
/** System prompt for fallback merge commit body generation. */
|
||||
export const COMMIT_BODY_SYSTEM_PROMPT = `You write concise commit message bodies for merge commits.
|
||||
|
||||
Your job is to summarize the changes described in a \`git diff --stat\` into a short, useful body.
|
||||
|
||||
## Guidelines
|
||||
- Output ONLY the body text — no code fences, no preamble, no subject line
|
||||
- 2–6 short bullet points starting with "- "
|
||||
- Be specific about what changed; reference filenames where helpful
|
||||
- Keep total output under 600 characters
|
||||
- Do not invent details that aren't in the input — if uncertain, stay general`;
|
||||
|
||||
/**
|
||||
* Maximum input length for commit body summarization. Diff stats can be
|
||||
* large; we truncate before sending so the prompt stays bounded.
|
||||
*/
|
||||
export const MAX_COMMIT_BODY_INPUT_LENGTH = 4000;
|
||||
|
||||
/**
|
||||
* Maximum output length for the generated commit body, in characters.
|
||||
* Bounded so a runaway response doesn't bloat the commit message.
|
||||
*/
|
||||
export const MAX_COMMIT_BODY_LENGTH = 2000;
|
||||
|
||||
/**
|
||||
* Default timeout for commit body summarization, in milliseconds. Bounded
|
||||
* so a slow / wedged AI session can't stall a merge indefinitely.
|
||||
*/
|
||||
export const DEFAULT_COMMIT_BODY_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Summarize a `git diff --stat` (and optional context) into a short
|
||||
* commit body via AI.
|
||||
*
|
||||
* Used by the merger as a fallback when the branch's commit log is empty
|
||||
* (no unique commits, or `git log` failed) and we need to commit on the
|
||||
* AI agent's behalf with a non-empty body.
|
||||
*
|
||||
* Best-effort: returns null on any failure (no AI runtime, timeout, empty
|
||||
* response, error). Caller is expected to have a deterministic fallback
|
||||
* (e.g. the diff stat itself or a synthetic placeholder) ready.
|
||||
*
|
||||
* Bounded by `timeoutMs` (default 30s) so it can't stall a merge
|
||||
* indefinitely. The optional `signal` lets callers (engine pause / shutdown)
|
||||
* tear down the AI session promptly.
|
||||
*
|
||||
* @param diffStat - Output of `git diff --stat` describing what changed.
|
||||
* @param rootDir - Project root directory for AI agent context.
|
||||
* @param provider - AI model provider (typically the title-summarizer lane).
|
||||
* @param modelId - AI model ID.
|
||||
* @param opts - Optional context (branch, taskId), abort signal, timeout.
|
||||
* @returns The generated body, or null on any failure.
|
||||
*/
|
||||
export async function summarizeCommitBody(
|
||||
diffStat: string,
|
||||
rootDir: string,
|
||||
provider?: string,
|
||||
modelId?: string,
|
||||
opts?: {
|
||||
branch?: string;
|
||||
taskId?: string;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<string | null> {
|
||||
const trimmedStat = (diffStat ?? "").trim();
|
||||
if (trimmedStat.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const truncatedStat = trimmedStat.length > MAX_COMMIT_BODY_INPUT_LENGTH
|
||||
? trimmedStat.slice(0, MAX_COMMIT_BODY_INPUT_LENGTH) + "\n…(truncated)"
|
||||
: trimmedStat;
|
||||
|
||||
const userPromptParts: string[] = [];
|
||||
if (opts?.branch) userPromptParts.push(`Branch: ${opts.branch}`);
|
||||
if (opts?.taskId) userPromptParts.push(`Task: ${opts.taskId}`);
|
||||
if (userPromptParts.length > 0) userPromptParts.push("");
|
||||
userPromptParts.push("Files changed (`git diff --stat`):");
|
||||
userPromptParts.push(truncatedStat);
|
||||
userPromptParts.push("");
|
||||
userPromptParts.push("Write the commit body now.");
|
||||
const userPrompt = userPromptParts.join("\n");
|
||||
|
||||
const timeoutMs = opts?.timeoutMs ?? DEFAULT_COMMIT_BODY_TIMEOUT_MS;
|
||||
const aborter = new AbortController();
|
||||
const timer = setTimeout(() => aborter.abort(), timeoutMs);
|
||||
if (opts?.signal) {
|
||||
if (opts.signal.aborted) aborter.abort();
|
||||
else opts.signal.addEventListener("abort", () => aborter.abort(), { once: true });
|
||||
}
|
||||
|
||||
let session: Awaited<ReturnType<NonNullable<Awaited<ReturnType<typeof getFnAgent>>>>>["session"] | undefined;
|
||||
try {
|
||||
const createFnAgent = await getFnAgent();
|
||||
if (!createFnAgent) {
|
||||
if (DEBUG) console.log("[ai-summarize] AI engine not available for commit body");
|
||||
return null;
|
||||
}
|
||||
|
||||
const agentOptions: {
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
tools: "readonly";
|
||||
defaultProvider?: string;
|
||||
defaultModelId?: string;
|
||||
} = {
|
||||
cwd: rootDir,
|
||||
systemPrompt: COMMIT_BODY_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
};
|
||||
if (provider && modelId) {
|
||||
agentOptions.defaultProvider = provider;
|
||||
agentOptions.defaultModelId = modelId;
|
||||
}
|
||||
|
||||
const agentResult = await createFnAgent(agentOptions);
|
||||
if (!agentResult?.session) return null;
|
||||
session = agentResult.session;
|
||||
|
||||
await session.prompt(userPrompt);
|
||||
if (aborter.signal.aborted) return null;
|
||||
|
||||
if (session.state?.error) {
|
||||
if (DEBUG) console.log(`[ai-summarize] Commit-body session error: ${session.state.error}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages: AgentMessage[] = session.state?.messages ?? [];
|
||||
const assistant = messages.filter((m: AgentMessage) => m.role === "assistant").pop();
|
||||
if (!assistant?.content) return null;
|
||||
|
||||
let body = "";
|
||||
if (typeof assistant.content === "string") {
|
||||
body = assistant.content;
|
||||
} else if (Array.isArray(assistant.content)) {
|
||||
body = assistant.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("");
|
||||
}
|
||||
body = body.trim();
|
||||
if (!body) return null;
|
||||
|
||||
if (body.length > MAX_COMMIT_BODY_LENGTH) {
|
||||
body = body.slice(0, MAX_COMMIT_BODY_LENGTH).trim();
|
||||
}
|
||||
return body;
|
||||
} catch (err) {
|
||||
if (DEBUG) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.log(`[ai-summarize] Commit-body generation failed: ${message}`);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
session?.dispose?.();
|
||||
} catch {
|
||||
// ignore disposal errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -184,13 +184,18 @@ export type {
|
||||
|
||||
export {
|
||||
summarizeTitle,
|
||||
summarizeCommitBody,
|
||||
checkRateLimit,
|
||||
getRateLimitResetTime,
|
||||
validateDescription,
|
||||
SUMMARIZE_SYSTEM_PROMPT,
|
||||
COMMIT_BODY_SYSTEM_PROMPT,
|
||||
MAX_DESCRIPTION_LENGTH,
|
||||
MIN_DESCRIPTION_LENGTH,
|
||||
MAX_TITLE_LENGTH,
|
||||
MAX_COMMIT_BODY_INPUT_LENGTH,
|
||||
MAX_COMMIT_BODY_LENGTH,
|
||||
DEFAULT_COMMIT_BODY_TIMEOUT_MS,
|
||||
MAX_REQUESTS_PER_HOUR,
|
||||
ValidationError,
|
||||
RateLimitError,
|
||||
|
||||
@@ -138,6 +138,7 @@ import {
|
||||
normalizeMergeConflictStrategy,
|
||||
resolveProjectDefaultModel,
|
||||
resolveAgentPrompt,
|
||||
summarizeCommitBody,
|
||||
type TaskStore,
|
||||
type MergeResult,
|
||||
type MergeDetails,
|
||||
@@ -1882,9 +1883,9 @@ function quoteArg(value: string): string {
|
||||
* Cascade — most informative first, deterministic fallback at the end so
|
||||
* the function NEVER returns an empty string and NEVER throws:
|
||||
* 1. The branch's commit log if non-empty.
|
||||
* 2. AI-generated body, summarized from the diff stat. Bounded by
|
||||
* `aiTimeoutMs` (default 30s); any failure / timeout / empty response
|
||||
* falls through.
|
||||
* 2. AI-generated body via `summarizeCommitBody` from `@fusion/core`,
|
||||
* using the title-summarizer model lane when configured. Bounded by
|
||||
* a timeout; any failure / timeout / empty response falls through.
|
||||
* 3. The diff stat formatted as a "Files changed" listing.
|
||||
* 4. A synthetic `- merge <branch>` placeholder.
|
||||
*/
|
||||
@@ -1903,57 +1904,11 @@ async function resolveSafeCommitBody(opts: {
|
||||
|
||||
const cleanStat = opts.diffStat.trim();
|
||||
if (cleanStat.length > 0) {
|
||||
const ai = await aiGenerateCommitBody({
|
||||
rootDir: opts.rootDir,
|
||||
taskId: opts.taskId,
|
||||
branch: opts.branch,
|
||||
diffStat: cleanStat,
|
||||
settings: opts.settings,
|
||||
signal: opts.signal,
|
||||
timeoutMs: opts.aiTimeoutMs ?? 30_000,
|
||||
}).catch(() => null);
|
||||
if (ai && ai.trim().length > 0) return ai.trim();
|
||||
return `Files changed:\n\n${cleanStat}`;
|
||||
}
|
||||
|
||||
return `- merge ${opts.branch}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to summarize a diff stat into a short commit body via a fresh
|
||||
* readonly AI session. Returns null on any failure (no runtime,
|
||||
* timeout, empty response, error). Bounded so it can't stall a merge.
|
||||
*/
|
||||
async function aiGenerateCommitBody(opts: {
|
||||
rootDir: string;
|
||||
taskId: string;
|
||||
branch: string;
|
||||
diffStat: string;
|
||||
settings: Settings;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs: number;
|
||||
}): Promise<string | null> {
|
||||
const truncatedStat = truncateWithEllipsis(opts.diffStat, 4000);
|
||||
const systemPrompt =
|
||||
`You write concise commit message bodies. Output ONLY the body text — no code fences, no preamble, no subject line. ` +
|
||||
`2–6 short bullet points starting with "- ". Be specific about what changed; reference filenames where helpful.`;
|
||||
const userPrompt =
|
||||
`Branch: ${opts.branch}\nTask: ${opts.taskId}\n\nFiles changed (\`git diff --stat\`):\n${truncatedStat}\n\n` +
|
||||
`Write the commit body now.`;
|
||||
|
||||
const aborter = new AbortController();
|
||||
const timer = setTimeout(() => aborter.abort(), opts.timeoutMs);
|
||||
if (opts.signal) {
|
||||
if (opts.signal.aborted) aborter.abort();
|
||||
else opts.signal.addEventListener("abort", () => aborter.abort(), { once: true });
|
||||
}
|
||||
|
||||
let session: Awaited<ReturnType<typeof createResolvedAgentSession>>["session"] | undefined;
|
||||
try {
|
||||
// Prefer the dedicated title-summarization model from settings — it's a
|
||||
// small, fast model intended exactly for short summarization tasks like
|
||||
// this. Falls back to the merger's default model only when the
|
||||
// summarization model isn't configured.
|
||||
// Prefer the dedicated title-summarization model — a small, fast tier
|
||||
// intended for short summarization. Falls back to the project / global
|
||||
// default model when the summarizer lane isn't configured. The core
|
||||
// `summarizeCommitBody` helper handles missing-runtime / timeout / empty
|
||||
// response gracefully and returns null.
|
||||
const useTitleSummarizer =
|
||||
!!opts.settings.titleSummarizerProvider && !!opts.settings.titleSummarizerModelId;
|
||||
const provider = useTitleSummarizer
|
||||
@@ -1967,52 +1922,17 @@ async function aiGenerateCommitBody(opts: {
|
||||
? opts.settings.defaultModelIdOverride
|
||||
: opts.settings.defaultModelId);
|
||||
|
||||
const created = await createResolvedAgentSession({
|
||||
sessionPurpose: "merger",
|
||||
cwd: opts.rootDir,
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
defaultProvider: provider,
|
||||
defaultModelId: modelId,
|
||||
});
|
||||
session = created.session;
|
||||
await session.prompt(userPrompt);
|
||||
if (aborter.signal.aborted) return null;
|
||||
|
||||
const messages = (session.state?.messages ?? []) as Array<{
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
}>;
|
||||
const last = messages.filter((m) => m.role === "assistant").pop();
|
||||
if (!last || !last.content) return null;
|
||||
|
||||
let text = "";
|
||||
if (typeof last.content === "string") {
|
||||
text = last.content;
|
||||
} else if (Array.isArray(last.content)) {
|
||||
for (const block of last.content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
"text" in block &&
|
||||
typeof (block as { text: unknown }).text === "string"
|
||||
) {
|
||||
text += (block as { text: string }).text;
|
||||
}
|
||||
}
|
||||
}
|
||||
text = text.trim();
|
||||
return text.length > 0 ? text : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
session?.dispose?.();
|
||||
} catch {
|
||||
// ignore disposal errors
|
||||
}
|
||||
const ai = await summarizeCommitBody(cleanStat, opts.rootDir, provider, modelId, {
|
||||
branch: opts.branch,
|
||||
taskId: opts.taskId,
|
||||
signal: opts.signal,
|
||||
timeoutMs: opts.aiTimeoutMs,
|
||||
}).catch(() => null);
|
||||
if (ai && ai.trim().length > 0) return ai.trim();
|
||||
return `Files changed:\n\n${cleanStat}`;
|
||||
}
|
||||
|
||||
return `- merge ${opts.branch}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user