FN-5775: persist AI merge agent output in merger logs

Ensure AI merger runs persist agent output signals so merge activity is visible in task logs.

- Persist merger agent text, thinking, tool start, and tool result output via AgentLogger in AI merge/review sessions.
- Add regression coverage validating persisted merger agent logs and prompt/commit guidance behavior.
- Add a patch changeset for @runfusion/fusion describing AI merge log persistence.

Files changed:
 .changeset/fn-5775-ai-merge-logs.md             |  5 ++
 packages/engine/src/__tests__/merger-ai.test.ts | 62 +++++++++++++++++++++++++
 packages/engine/src/merger-ai.ts                | 40 +++++++++++++++-
 3 files changed, 105 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-5775

Fusion-Task-Lineage: 7e1b803f-135a-412e-81d9-bad0aa655855
This commit is contained in:
gsxdsm
2026-05-31 08:50:23 -07:00
parent b154844286
commit 93e8a5f78a
3 changed files with 105 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Persist AI merge agent text, thinking, and tool output to task agent logs in AI merger mode.

View File

@@ -3,6 +3,25 @@ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "no
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
const createResolvedAgentSessionMock = vi.hoisted(() => vi.fn());
vi.mock("../agent-session-helpers.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../agent-session-helpers.js")>();
return {
...actual,
createResolvedAgentSession: createResolvedAgentSessionMock,
};
});
vi.mock("../pi.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../pi.js")>();
return {
...actual,
promptWithFallback: vi.fn(async (session: { prompt: (prompt: string) => Promise<void> | void }, prompt: string) => {
await session.prompt(prompt);
}),
};
});
import {
runAiMerge,
landSquash,
@@ -179,6 +198,49 @@ describe("runAiMerge", () => {
expect(emitted.some((e) => e.event === "task:merged")).toBe(true);
});
it("persists AI merge agent text/thinking/tool output to agent logs", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { store } = makeStore(dir, {}, { persistAgentToolOutput: true, persistAgentThinkingLog: true });
createResolvedAgentSessionMock.mockImplementation(async (opts: any) => {
const isReview = String(opts.systemPrompt ?? "").includes(REVIEW_VERDICT_MARKER);
const session = {
async prompt(prompt: string) {
opts.onThinking?.("thinking-delta");
opts.onToolStart?.("read", { path: "feature.txt" });
opts.onToolEnd?.("read", false, "feature work");
opts.onText?.(isReview ? "REVIEW_VERDICT: approve" : "merge-agent-output");
if (!isReview) {
try {
execSync("git merge --squash fusion/fn-1", { cwd: opts.cwd, stdio: "pipe" });
} catch {
execSync("git checkout --theirs . || true", { cwd: opts.cwd, stdio: "pipe", shell: "/bin/bash" } as any);
execSync("git add -A", { cwd: opts.cwd, stdio: "pipe" });
}
execSync("git add -A", { cwd: opts.cwd, stdio: "pipe" });
execSync('git commit -q -m "squash: feature"', { cwd: opts.cwd, stdio: "pipe" });
}
},
dispose: vi.fn(),
getSessionStats: vi.fn(() => ({ tokens: { input: 1, output: 1 } })),
};
return { session };
});
await runAiMerge(store, dir, "FN-1", { manual: true });
const mergerLogCalls = store.appendAgentLog.mock.calls.filter(
([id, _text, _type, _detail, agent]: [string, string, string, string | undefined, string | undefined]) =>
id === "FN-1" && agent === "merger",
);
expect(mergerLogCalls.some(([, text, type]: [string, string, string]) => type === "tool" && text === "read")).toBe(true);
expect(mergerLogCalls.some(([, text, type]: [string, string, string]) => type === "tool_result" && text === "read")).toBe(true);
expect(mergerLogCalls.some(([, _text, type]: [string, string, string]) => type === "thinking")).toBe(true);
expect(mergerLogCalls.some(([, _text, type]: [string, string, string]) => type === "text")).toBe(true);
createResolvedAgentSessionMock.mockReset();
});
it("includes the lineage trailer when the task has a lineageId", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { store } = makeStore(dir, { lineageId: "lin-abc123" });

View File

@@ -37,6 +37,7 @@ import {
buildTaskLineageTrailer,
getTaskMergeBlocker,
resolveAgentPrompt,
resolvePersistAgentThinkingLog,
resolveTaskMergeTarget,
resolveValidatorSettingsModel,
type AgentPromptsConfig,
@@ -50,6 +51,7 @@ import { resolveIntegrationBranch } from "./integration-branch.js";
import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js";
import { createResolvedAgentSession, resolveMergerSessionModel } from "./agent-session-helpers.js";
import { promptWithFallback } from "./pi.js";
import { AgentLogger } from "./agent-logger.js";
import { withRateLimitRetry } from "./rate-limit-retry.js";
import { checkSessionError } from "./usage-limit-detector.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
@@ -412,13 +414,29 @@ interface AgentDeps {
function makeMutatingAgent(store: TaskStore, settings: Settings, taskId: string, options: MergerOptions, audit: RunAuditor, systemPrompt: string) {
return async (cwd: string, prompt: string): Promise<void> => {
const model = resolveMergerSessionModel(settings);
const logger = new AgentLogger({
store,
taskId,
agent: "merger",
persistAgentToolOutput: settings.persistAgentToolOutput,
persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: true }),
onAgentText: options.onAgentText
? (_id: string, delta: string) => options.onAgentText?.(delta)
: undefined,
onAgentTool: options.onAgentTool
? (_id: string, name: string) => options.onAgentTool?.(name)
: undefined,
});
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
pluginRunner: options.pluginRunner,
cwd,
systemPrompt,
tools: "coding",
onText: options.onAgentText ? (delta: string) => options.onAgentText?.(delta) : undefined,
onText: logger.onText,
onThinking: logger.onThinking,
onToolStart: logger.onToolStart,
onToolEnd: logger.onToolEnd,
defaultProvider: model.provider,
defaultModelId: model.modelId,
fallbackProvider: settings.fallbackProvider,
@@ -436,6 +454,7 @@ function makeMutatingAgent(store: TaskStore, settings: Settings, taskId: string,
}, { signal: options.signal });
await accumulateSessionTokenUsage(store, taskId, session);
} finally {
await logger.flush();
session.dispose();
}
};
@@ -449,6 +468,19 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o
const validator = resolveValidatorSettingsModel(settings);
const model = validator.provider && validator.modelId ? validator : resolveMergerSessionModel(settings);
let captured = "";
const logger = new AgentLogger({
store,
taskId,
agent: "merger",
persistAgentToolOutput: settings.persistAgentToolOutput,
persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: true }),
onAgentText: options.onAgentText
? (_id: string, delta: string) => options.onAgentText?.(delta)
: undefined,
onAgentTool: options.onAgentTool
? (_id: string, name: string) => options.onAgentTool?.(name)
: undefined,
});
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
pluginRunner: options.pluginRunner,
@@ -457,8 +489,11 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o
tools: "coding",
onText: (delta: string) => {
captured += delta;
options.onAgentText?.(delta);
logger.onText(delta);
},
onThinking: logger.onThinking,
onToolStart: logger.onToolStart,
onToolEnd: logger.onToolEnd,
defaultProvider: model.provider,
defaultModelId: model.modelId,
fallbackProvider: settings.fallbackProvider,
@@ -476,6 +511,7 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o
}, { signal: options.signal });
await accumulateSessionTokenUsage(store, taskId, session);
} finally {
await logger.flush();
session.dispose();
}
return captured;