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 { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
summarizeTitle,
|
summarizeTitle,
|
||||||
|
summarizeCommitBody,
|
||||||
checkRateLimit,
|
checkRateLimit,
|
||||||
getRateLimitResetTime,
|
getRateLimitResetTime,
|
||||||
validateDescription,
|
validateDescription,
|
||||||
SUMMARIZE_SYSTEM_PROMPT,
|
SUMMARIZE_SYSTEM_PROMPT,
|
||||||
|
COMMIT_BODY_SYSTEM_PROMPT,
|
||||||
MAX_DESCRIPTION_LENGTH,
|
MAX_DESCRIPTION_LENGTH,
|
||||||
MIN_DESCRIPTION_LENGTH,
|
MIN_DESCRIPTION_LENGTH,
|
||||||
MAX_TITLE_LENGTH,
|
MAX_TITLE_LENGTH,
|
||||||
|
MAX_COMMIT_BODY_INPUT_LENGTH,
|
||||||
|
MAX_COMMIT_BODY_LENGTH,
|
||||||
|
DEFAULT_COMMIT_BODY_TIMEOUT_MS,
|
||||||
MAX_REQUESTS_PER_HOUR,
|
MAX_REQUESTS_PER_HOUR,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
RateLimitError,
|
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 ───────────────────────────────────────────────────────────
|
// ── Error Classes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("error classes", () => {
|
describe("error classes", () => {
|
||||||
|
|||||||
@@ -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<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 ───────────────────────────────────────────────────────────
|
// ── Test Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -184,13 +184,18 @@ export type {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
summarizeTitle,
|
summarizeTitle,
|
||||||
|
summarizeCommitBody,
|
||||||
checkRateLimit,
|
checkRateLimit,
|
||||||
getRateLimitResetTime,
|
getRateLimitResetTime,
|
||||||
validateDescription,
|
validateDescription,
|
||||||
SUMMARIZE_SYSTEM_PROMPT,
|
SUMMARIZE_SYSTEM_PROMPT,
|
||||||
|
COMMIT_BODY_SYSTEM_PROMPT,
|
||||||
MAX_DESCRIPTION_LENGTH,
|
MAX_DESCRIPTION_LENGTH,
|
||||||
MIN_DESCRIPTION_LENGTH,
|
MIN_DESCRIPTION_LENGTH,
|
||||||
MAX_TITLE_LENGTH,
|
MAX_TITLE_LENGTH,
|
||||||
|
MAX_COMMIT_BODY_INPUT_LENGTH,
|
||||||
|
MAX_COMMIT_BODY_LENGTH,
|
||||||
|
DEFAULT_COMMIT_BODY_TIMEOUT_MS,
|
||||||
MAX_REQUESTS_PER_HOUR,
|
MAX_REQUESTS_PER_HOUR,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
RateLimitError,
|
RateLimitError,
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ import {
|
|||||||
normalizeMergeConflictStrategy,
|
normalizeMergeConflictStrategy,
|
||||||
resolveProjectDefaultModel,
|
resolveProjectDefaultModel,
|
||||||
resolveAgentPrompt,
|
resolveAgentPrompt,
|
||||||
|
summarizeCommitBody,
|
||||||
type TaskStore,
|
type TaskStore,
|
||||||
type MergeResult,
|
type MergeResult,
|
||||||
type MergeDetails,
|
type MergeDetails,
|
||||||
@@ -1882,9 +1883,9 @@ function quoteArg(value: string): string {
|
|||||||
* Cascade — most informative first, deterministic fallback at the end so
|
* Cascade — most informative first, deterministic fallback at the end so
|
||||||
* the function NEVER returns an empty string and NEVER throws:
|
* the function NEVER returns an empty string and NEVER throws:
|
||||||
* 1. The branch's commit log if non-empty.
|
* 1. The branch's commit log if non-empty.
|
||||||
* 2. AI-generated body, summarized from the diff stat. Bounded by
|
* 2. AI-generated body via `summarizeCommitBody` from `@fusion/core`,
|
||||||
* `aiTimeoutMs` (default 30s); any failure / timeout / empty response
|
* using the title-summarizer model lane when configured. Bounded by
|
||||||
* falls through.
|
* a timeout; any failure / timeout / empty response falls through.
|
||||||
* 3. The diff stat formatted as a "Files changed" listing.
|
* 3. The diff stat formatted as a "Files changed" listing.
|
||||||
* 4. A synthetic `- merge <branch>` placeholder.
|
* 4. A synthetic `- merge <branch>` placeholder.
|
||||||
*/
|
*/
|
||||||
@@ -1903,57 +1904,11 @@ async function resolveSafeCommitBody(opts: {
|
|||||||
|
|
||||||
const cleanStat = opts.diffStat.trim();
|
const cleanStat = opts.diffStat.trim();
|
||||||
if (cleanStat.length > 0) {
|
if (cleanStat.length > 0) {
|
||||||
const ai = await aiGenerateCommitBody({
|
// Prefer the dedicated title-summarization model — a small, fast tier
|
||||||
rootDir: opts.rootDir,
|
// intended for short summarization. Falls back to the project / global
|
||||||
taskId: opts.taskId,
|
// default model when the summarizer lane isn't configured. The core
|
||||||
branch: opts.branch,
|
// `summarizeCommitBody` helper handles missing-runtime / timeout / empty
|
||||||
diffStat: cleanStat,
|
// response gracefully and returns null.
|
||||||
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. ` +
|
|
||||||
`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<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.
|
|
||||||
const useTitleSummarizer =
|
const useTitleSummarizer =
|
||||||
!!opts.settings.titleSummarizerProvider && !!opts.settings.titleSummarizerModelId;
|
!!opts.settings.titleSummarizerProvider && !!opts.settings.titleSummarizerModelId;
|
||||||
const provider = useTitleSummarizer
|
const provider = useTitleSummarizer
|
||||||
@@ -1967,52 +1922,17 @@ async function aiGenerateCommitBody(opts: {
|
|||||||
? opts.settings.defaultModelIdOverride
|
? opts.settings.defaultModelIdOverride
|
||||||
: opts.settings.defaultModelId);
|
: opts.settings.defaultModelId);
|
||||||
|
|
||||||
const created = await createResolvedAgentSession({
|
const ai = await summarizeCommitBody(cleanStat, opts.rootDir, provider, modelId, {
|
||||||
sessionPurpose: "merger",
|
branch: opts.branch,
|
||||||
cwd: opts.rootDir,
|
taskId: opts.taskId,
|
||||||
systemPrompt,
|
signal: opts.signal,
|
||||||
tools: "readonly",
|
timeoutMs: opts.aiTimeoutMs,
|
||||||
defaultProvider: provider,
|
}).catch(() => null);
|
||||||
defaultModelId: modelId,
|
if (ai && ai.trim().length > 0) return ai.trim();
|
||||||
});
|
return `Files changed:\n\n${cleanStat}`;
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return `- merge ${opts.branch}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user