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:
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user