diff --git a/packages/core/src/__tests__/ai-summarize.test.ts b/packages/core/src/__tests__/ai-summarize.test.ts index 8a1b07ad2..5fe03fdd5 100644 --- a/packages/core/src/__tests__/ai-summarize.test.ts +++ b/packages/core/src/__tests__/ai-summarize.test.ts @@ -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", () => { diff --git a/packages/core/src/ai-summarize.ts b/packages/core/src/ai-summarize.ts index 3d15e64e8..6f5db4ff7 100644 --- a/packages/core/src/ai-summarize.ts +++ b/packages/core/src/ai-summarize.ts @@ -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 { + 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>>>>["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 ─────────────────────────────────────────────────────────── /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 34aca9f95..85bc14c34 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 362ee8bd3..80fec2147 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -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 ` 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 { - 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>["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}`; } /**