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:
5
.changeset/fn-5775-ai-merge-logs.md
Normal file
5
.changeset/fn-5775-ai-merge-logs.md
Normal 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.
|
||||||
@@ -3,6 +3,25 @@ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "no
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { execSync } from "node:child_process";
|
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 {
|
import {
|
||||||
runAiMerge,
|
runAiMerge,
|
||||||
landSquash,
|
landSquash,
|
||||||
@@ -179,6 +198,49 @@ describe("runAiMerge", () => {
|
|||||||
expect(emitted.some((e) => e.event === "task:merged")).toBe(true);
|
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 () => {
|
it("includes the lineage trailer when the task has a lineageId", async () => {
|
||||||
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
|
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
|
||||||
const { store } = makeStore(dir, { lineageId: "lin-abc123" });
|
const { store } = makeStore(dir, { lineageId: "lin-abc123" });
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
buildTaskLineageTrailer,
|
buildTaskLineageTrailer,
|
||||||
getTaskMergeBlocker,
|
getTaskMergeBlocker,
|
||||||
resolveAgentPrompt,
|
resolveAgentPrompt,
|
||||||
|
resolvePersistAgentThinkingLog,
|
||||||
resolveTaskMergeTarget,
|
resolveTaskMergeTarget,
|
||||||
resolveValidatorSettingsModel,
|
resolveValidatorSettingsModel,
|
||||||
type AgentPromptsConfig,
|
type AgentPromptsConfig,
|
||||||
@@ -50,6 +51,7 @@ import { resolveIntegrationBranch } from "./integration-branch.js";
|
|||||||
import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js";
|
import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js";
|
||||||
import { createResolvedAgentSession, resolveMergerSessionModel } from "./agent-session-helpers.js";
|
import { createResolvedAgentSession, resolveMergerSessionModel } from "./agent-session-helpers.js";
|
||||||
import { promptWithFallback } from "./pi.js";
|
import { promptWithFallback } from "./pi.js";
|
||||||
|
import { AgentLogger } from "./agent-logger.js";
|
||||||
import { withRateLimitRetry } from "./rate-limit-retry.js";
|
import { withRateLimitRetry } from "./rate-limit-retry.js";
|
||||||
import { checkSessionError } from "./usage-limit-detector.js";
|
import { checkSessionError } from "./usage-limit-detector.js";
|
||||||
import { accumulateSessionTokenUsage } from "./session-token-usage.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) {
|
function makeMutatingAgent(store: TaskStore, settings: Settings, taskId: string, options: MergerOptions, audit: RunAuditor, systemPrompt: string) {
|
||||||
return async (cwd: string, prompt: string): Promise<void> => {
|
return async (cwd: string, prompt: string): Promise<void> => {
|
||||||
const model = resolveMergerSessionModel(settings);
|
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({
|
const { session } = await createResolvedAgentSession({
|
||||||
sessionPurpose: "merger",
|
sessionPurpose: "merger",
|
||||||
pluginRunner: options.pluginRunner,
|
pluginRunner: options.pluginRunner,
|
||||||
cwd,
|
cwd,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
tools: "coding",
|
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,
|
defaultProvider: model.provider,
|
||||||
defaultModelId: model.modelId,
|
defaultModelId: model.modelId,
|
||||||
fallbackProvider: settings.fallbackProvider,
|
fallbackProvider: settings.fallbackProvider,
|
||||||
@@ -436,6 +454,7 @@ function makeMutatingAgent(store: TaskStore, settings: Settings, taskId: string,
|
|||||||
}, { signal: options.signal });
|
}, { signal: options.signal });
|
||||||
await accumulateSessionTokenUsage(store, taskId, session);
|
await accumulateSessionTokenUsage(store, taskId, session);
|
||||||
} finally {
|
} finally {
|
||||||
|
await logger.flush();
|
||||||
session.dispose();
|
session.dispose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -449,6 +468,19 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o
|
|||||||
const validator = resolveValidatorSettingsModel(settings);
|
const validator = resolveValidatorSettingsModel(settings);
|
||||||
const model = validator.provider && validator.modelId ? validator : resolveMergerSessionModel(settings);
|
const model = validator.provider && validator.modelId ? validator : resolveMergerSessionModel(settings);
|
||||||
let captured = "";
|
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({
|
const { session } = await createResolvedAgentSession({
|
||||||
sessionPurpose: "merger",
|
sessionPurpose: "merger",
|
||||||
pluginRunner: options.pluginRunner,
|
pluginRunner: options.pluginRunner,
|
||||||
@@ -457,8 +489,11 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o
|
|||||||
tools: "coding",
|
tools: "coding",
|
||||||
onText: (delta: string) => {
|
onText: (delta: string) => {
|
||||||
captured += delta;
|
captured += delta;
|
||||||
options.onAgentText?.(delta);
|
logger.onText(delta);
|
||||||
},
|
},
|
||||||
|
onThinking: logger.onThinking,
|
||||||
|
onToolStart: logger.onToolStart,
|
||||||
|
onToolEnd: logger.onToolEnd,
|
||||||
defaultProvider: model.provider,
|
defaultProvider: model.provider,
|
||||||
defaultModelId: model.modelId,
|
defaultModelId: model.modelId,
|
||||||
fallbackProvider: settings.fallbackProvider,
|
fallbackProvider: settings.fallbackProvider,
|
||||||
@@ -476,6 +511,7 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o
|
|||||||
}, { signal: options.signal });
|
}, { signal: options.signal });
|
||||||
await accumulateSessionTokenUsage(store, taskId, session);
|
await accumulateSessionTokenUsage(store, taskId, session);
|
||||||
} finally {
|
} finally {
|
||||||
|
await logger.flush();
|
||||||
session.dispose();
|
session.dispose();
|
||||||
}
|
}
|
||||||
return captured;
|
return captured;
|
||||||
|
|||||||
Reference in New Issue
Block a user