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", () => {
|
||||
|
||||
Reference in New Issue
Block a user