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

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