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:
gsxdsm
2026-04-29 08:30:34 -07:00
parent 635678e69b
commit 7ddc34048f
4 changed files with 264 additions and 99 deletions

View File

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

View File

@@ -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
- 26 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 ───────────────────────────────────────────────────────────
/**

View File

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

View File

@@ -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. ` +
`26 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}`;
}
/**