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 5c2240caab
commit 414cbcffda
4 changed files with 264 additions and 99 deletions

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}`;
}
/**