Files
fusion/packages/engine/src/__tests__/merger.test.ts
Fusion ff3412637b feat(FN-3539): remove duplicate todos entry and update navigation docs
Merged branch removes the duplicate desktop "Todos" nav entry from the Header, syncs the `allowParallelExecution` runtime toggle into `AgentDetailView`, and updates the corresponding docs (agents.md, settings-reference.md, todo-view.md, dashboard-guide.md) to reflect the navigation change. Tests wer

Fusion-Task-Id: FN-3539
2026-05-06 03:52:18 -07:00

7435 lines
286 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock external dependencies
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
describeModel: vi.fn(() => "mock-provider/mock-model"),
promptWithFallback: vi.fn(async (session, prompt, options) => {
if (options === undefined) {
await session.prompt(prompt);
} else {
await session.prompt(prompt, options);
}
}),
compactSessionContext: vi.fn(),
}));
// Route async `exec` through the `execSync` mock so existing tests that set up
// mockedExecSync.mockImplementation for verification commands (vitest run,
// pnpm build, etc.) keep working unchanged. `promisify(exec)` in merger.ts
// resolves/rejects based on the callback wired here.
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const { EventEmitter } = await import("node:events");
const execSyncFn = vi.fn();
const spawnFn = vi.fn((cmd: string, opts?: any) => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 12345;
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn();
queueMicrotask(() => {
try {
const out = execSyncFn(cmd, opts);
const stdout = out === undefined ? "" : out.toString();
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
child.exitCode = 0;
child.emit("close", 0, null);
} catch (err) {
const error = err as { stdout?: string; stderr?: string; status?: number; code?: number };
const stdout = error?.stdout?.toString?.() ?? "";
const stderr = error?.stderr?.toString?.() ?? "";
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
if (stderr) child.stderr.emit("data", Buffer.from(stderr));
child.exitCode = error.status ?? error.code ?? 1;
child.emit("close", child.exitCode, null);
}
});
return child;
});
const execFn: any = vi.fn((cmd: any, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"] });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
execFn[promisify.custom] = (cmd: any, opts?: any) =>
new Promise((resolve, reject) => {
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
// execFile(file, args, opts, cb) — reassemble a shell-equivalent command and
// delegate to execSyncFn so the same mock infrastructure handles both exec and execFile.
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
// Normalize overloads: (file, args, cb) or (file, args, opts, cb)
const callback = typeof opts === "function" ? opts : cb;
const options = typeof opts === "function" ? {} : opts;
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
new Promise((resolve, reject) => {
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
});
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn(),
}));
vi.mock("../rate-limit-retry.js", () => ({
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
}));
vi.mock("../context-limit-detector.js", () => ({
isContextLimitError: vi.fn(),
}));
import {
aiMergeTask,
pushToRemoteAfterMerge,
findWorktreeUser,
detectResolvableConflicts,
autoResolveFile,
resolveConflicts,
classifyConflict,
getConflictedFiles,
isTrivialWhitespaceConflict,
resolveWithOurs,
resolveWithTheirs,
resolveTrivialWhitespace,
LOCKFILE_PATTERNS,
GENERATED_PATTERNS,
parseDiffStat,
extractFileScope,
validateDiffScope,
shouldSyncDependenciesForMerge,
summarizeVerificationOutput,
inferDefaultTestCommand,
resolveTaskDiffBaseRef,
MergeAbortedError,
type ConflictCategory,
} from "../merger.js";
import { mergerLog } from "../logger.js";
import { createFnAgent } from "../pi.js";
import { execSync, exec } from "node:child_process";
import * as core from "@fusion/core";
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core";
const mockedCreateFnAgent = vi.mocked(createFnAgent);
const mockedExecSync = vi.mocked(execSync);
const mockedExec = vi.mocked(exec);
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
const mockedReadFileSync = vi.mocked(mockedReadFileSyncRaw);
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
const baseTask: Task = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...taskOverrides,
};
return {
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
listTasks: vi.fn().mockResolvedValue(allTasks),
updateTask: vi.fn().mockResolvedValue(baseTask),
moveTask: vi.fn().mockResolvedValue(baseTask),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getActiveMergingTask: vi.fn().mockReturnValue(null),
emit: vi.fn(),
on: vi.fn(),
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
getVerificationCacheHit: vi.fn().mockReturnValue(null),
recordVerificationCachePass: vi.fn(),
} as unknown as TaskStore;
}
/**
* Set up execSync to handle the standard merge flow:
* rev-parse, log, diff, merge --squash, diff --cached --quiet (squash check),
* diff --cached (post-agent verify), branch -d
*
* Both `-X ours` and `-X theirs` final-fallback merges return success — the
* default settings strategy is "smart-prefer-main" (-X ours), but a few tests
* still exercise -X theirs explicitly via `mergeConflictStrategy: "smart-prefer-branch"`.
*
* For tests that want the merge to fail after 3 attempts, call
* setupFailingFallbackStrategy() instead.
*/
function setupHappyPathExecSync() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
return Buffer.from("");
}
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "did agent commit?" → "0" = yes
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
/**
* Same as setupHappyPathExecSync but makes the final fallback merge fail
* (both `-X theirs` and `-X ours`). Use this for tests that expect the merge
* to throw after 3 attempts fail.
*/
function setupFailingFallbackStrategy() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// -X theirs / -X ours should fail for these tests (they expect merge to throw)
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
const err = new Error("fatal: git merge -X fallback failed with unresolved conflicts");
err.name = "ExecSyncError";
throw err;
}
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "did agent commit?" → "0" = yes
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
/** @deprecated Renamed to setupFailingFallbackStrategy. */
const setupFailingTheirsStrategy = setupFailingFallbackStrategy;
describe("findWorktreeUser", () => {
it("returns null when no other task uses the worktree", async () => {
const store = createMockStore({}, [
{ id: "FN-050", worktree: "/tmp/wt", column: "done" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "FN-050");
expect(result).toBeNull();
});
it("returns task ID when another non-done task uses the worktree", async () => {
const store = createMockStore({}, [
{ id: "FN-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "FN-051", worktree: "/tmp/wt", column: "in-progress" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "FN-050");
expect(result).toBe("FN-051");
});
it("ignores done tasks", async () => {
const store = createMockStore({}, [
{ id: "FN-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "FN-051", worktree: "/tmp/wt", column: "done" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "FN-050");
expect(result).toBeNull();
});
});
describe("aiMergeTask pre-merge fetch + fast-forward (smart strategies)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
function setupSyncMock({
behind,
ahead,
fetchFails = false,
}: {
behind: number;
ahead: number;
fetchFails?: boolean;
}) {
let fetchCalled = false;
let ffCalled = false;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --abbrev-ref HEAD")) return "main" as any;
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("rev-list --left-right --count")) {
return `${behind}\t${ahead}` as any;
}
if (cmdStr.includes("git fetch origin")) {
fetchCalled = true;
if (fetchFails) throw new Error("fatal: unable to access remote");
return Buffer.from("");
}
if (cmdStr.includes("merge --ff-only")) {
ffCalled = true;
return Buffer.from("");
}
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
return Buffer.from("");
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
return {
get fetchCalled() { return fetchCalled; },
get ffCalled() { return ffCalled; },
};
}
it("fast-forwards local main when origin is strictly ahead (default smart-prefer-main)", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const probe = setupSyncMock({ behind: 2, ahead: 0 });
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(probe.fetchCalled).toBe(true);
expect(probe.ffCalled).toBe(true);
});
it("skips fast-forward when local main has unpushed commits (divergent)", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const probe = setupSyncMock({ behind: 1, ahead: 1 });
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(probe.fetchCalled).toBe(true);
expect(probe.ffCalled).toBe(false);
});
it("continues merge when fetch fails (graceful degrade)", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const probe = setupSyncMock({ behind: 0, ahead: 0, fetchFails: true });
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(probe.fetchCalled).toBe(true);
expect(probe.ffCalled).toBe(false);
expect(result.merged).toBe(true);
});
it("does not fetch for ai-only strategy", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "ai-only",
});
const probe = setupSyncMock({ behind: 5, ahead: 0 });
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(probe.fetchCalled).toBe(false);
});
it("normalizes legacy 'smart' setting and still fetches", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart" as any,
});
const probe = setupSyncMock({ behind: 1, ahead: 0 });
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(probe.fetchCalled).toBe(true);
expect(probe.ffCalled).toBe(true);
});
});
describe("aiMergeTask abort handling", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("throws immediately when signal is already aborted", async () => {
const store = createMockStore();
const controller = new AbortController();
controller.abort();
await expect(
aiMergeTask(store, "/tmp/root", "FN-050", { signal: controller.signal }),
).rejects.toBeInstanceOf(MergeAbortedError);
expect(store.getTask).not.toHaveBeenCalled();
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
});
it("throws MergeAbortedError when aborted during deterministic verification", async () => {
const controller = new AbortController();
const store = createMockStore();
store.getSettings = vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "pnpm test",
});
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === "pnpm test") {
controller.abort();
return "" as any;
}
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("merge -X theirs --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
await expect(
aiMergeTask(store, "/tmp/root", "FN-050", { signal: controller.signal }),
).rejects.toBeInstanceOf(MergeAbortedError);
});
});
describe("aiMergeTask — conditional worktree cleanup", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("does NOT remove worktree when another task references the same path", async () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "FN-050", worktree: worktreePath },
[
{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "FN-051", worktree: worktreePath, column: "in-progress" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Worktree should NOT be removed
const removeCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("worktree remove"),
);
expect(removeCall).toBeUndefined();
expect(result.worktreeRemoved).toBe(false);
});
it("removes worktree when no other task references it", async () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "FN-050", worktree: worktreePath },
[
{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
const removeCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("worktree remove"),
);
expect(removeCall).toBeDefined();
expect(result.worktreeRemoved).toBe(true);
});
it("clears task.worktree/branch after the worktree is removed", async () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "FN-050", worktree: worktreePath },
[
{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task,
],
);
await aiMergeTask(store, "/tmp/root", "FN-050");
// The dashboard's diff endpoint reads task.worktree to decide whether to
// run a live git diff; leaving it set after removal would point at a
// foreign branch (when the path is recycled) and surface other tasks'
// commits as if they belonged to FN-050.
const updateCalls = (store.updateTask as any).mock.calls;
const cleared = updateCalls.find(
([id, patch]: [string, any]) =>
id === "FN-050" && patch && patch.worktree === null && patch.branch === null,
);
expect(cleared).toBeDefined();
});
it("always deletes the branch regardless of worktree sharing", async () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "FN-050", worktree: worktreePath },
[
{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "FN-051", worktree: worktreePath, column: "in-progress" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Branch should be deleted even though worktree is shared
const branchDeleteCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("branch -d") || String(call[0]).includes("branch -D"),
);
expect(branchDeleteCall).toBeDefined();
expect(result.branchDeleted).toBe(true);
});
it("result.worktreeRemoved is false when worktree is retained", async () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "FN-050", worktree: worktreePath },
[
{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "FN-051", worktree: worktreePath, column: "todo" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.worktreeRemoved).toBe(false);
expect(result.merged).toBe(true);
});
});
describe("aiMergeTask — pre-merge rebase abort observability", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("attempts rebase abort in the task worktree and continues merge when abort succeeds", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Fall-through is only allowed for prefer-branch; prefer-main hard-fails (see test below).
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart-prefer-branch",
});
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
if (cmdStr.includes("git rev-parse --abbrev-ref origin/HEAD")) return "origin/main" as any;
if (cmdStr === "git rev-parse --abbrev-ref HEAD") return "main" as any;
if (cmdStr.includes("git config --get branch.main.remote")) return "origin" as any;
if (cmdStr === 'git fetch "origin"') return Buffer.from("");
if (cmdStr === 'git rebase "origin/main"') {
throw new Error("pre-merge rebase conflict");
}
if (cmdStr === "git rebase --abort") return Buffer.from("");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "" as any;
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "1 file changed, 1 insertion(+), 0 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(
mockedExec.mock.calls.some(
([command, options]) => command === "git rebase --abort"
&& typeof options === "object"
&& options !== null
&& "cwd" in options
&& (options as { cwd?: string }).cwd === "/tmp/root/.worktrees/KB-050",
),
).toBe(true);
expect(mockedExecSync.mock.calls.some(([command]) => String(command).includes("merge --squash"))).toBe(true);
});
it("logs abort cleanup failure details but still falls through to merge", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart-prefer-branch",
});
const warnSpy = vi.spyOn(mergerLog, "warn");
const abortFailureMessage = "fatal: no rebase in progress";
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
if (cmdStr.includes("git rev-parse --abbrev-ref origin/HEAD")) return "origin/main" as any;
if (cmdStr === "git rev-parse --abbrev-ref HEAD") return "main" as any;
if (cmdStr.includes("git config --get branch.main.remote")) return "origin" as any;
if (cmdStr === 'git fetch "origin"') return Buffer.from("");
if (cmdStr === 'git rebase "origin/main"') {
throw new Error("pre-merge rebase conflict");
}
if (cmdStr === "git rebase --abort") {
const error = new Error("rebase abort failed") as Error & { stderr?: string };
error.stderr = abortFailureMessage;
throw error;
}
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "" as any;
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "1 file changed, 1 insertion(+), 0 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(
warnSpy.mock.calls.some(([message]) => String(message).includes(`FN-050: failed to abort pre-merge rebase: ${abortFailureMessage}`)),
).toBe(true);
expect(mockedExecSync.mock.calls.some(([command]) => String(command).includes("merge --squash"))).toBe(true);
warnSpy.mockRestore();
});
it("hard-fails when prefer-main is paired with both rebase stages disabled", async () => {
// After the rebase block was split into two independent stages
// (remote + local-base), prefer-main is satisfied by either stage running
// successfully. Only when BOTH are explicitly disabled is the
// configuration incoherent.
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart-prefer-main",
worktreeRebaseBeforeMerge: false,
worktreeRebaseLocalBase: false,
});
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git rev-parse --abbrev-ref")) return "main" as any;
return Buffer.from("");
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
/Incompatible settings.*smart-prefer-main.*worktreeRebase/i,
);
});
it("local-base rebase still runs when remote is unresolvable (independent stages)", async () => {
// Regression: previously the local-base rebase was nested inside the
// remote-rebase success path, so a missing remote would skip BOTH. Now
// the stages are independent — local-base runs even when remote can't
// resolve, providing prefer-main with a usable safety net.
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart-prefer-main",
});
let localBaseRebaseRan = false;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
if (cmdStr === "git rev-parse --abbrev-ref HEAD") return "main" as any;
// No remote configured: empty config + empty remote list
if (cmdStr.includes("git config --get branch.")) return "" as any;
if (cmdStr === "git remote") return "" as any;
// Local HEAD lookup for Stage 2
if (cmdStr === "git rev-parse HEAD") return "localhead123";
// Stage 2 ancestor check fails (not contained), so rebase runs
if (cmdStr.includes("merge-base --is-ancestor")) {
const err: any = new Error("not an ancestor");
err.status = 1;
throw err;
}
// Stage 2 rebase command
if (cmdStr.includes('git rebase "localhead123"')) {
localBaseRebaseRan = true;
return Buffer.from("");
}
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("show --shortstat")) return "1 file changed, 1 insertion(+), 0 deletions(-)";
return Buffer.from("");
});
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(localBaseRebaseRan).toBe(true);
});
it("does not silently fall through to -X ours when smart-prefer-main rebase aborts and recovery layers 1+2 fail", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart-prefer-main",
});
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
if (cmdStr.includes("git rev-parse --abbrev-ref origin/HEAD")) return "origin/main" as any;
if (cmdStr === "git rev-parse --abbrev-ref HEAD") return "main" as any;
if (cmdStr.includes("git config --get branch.main.remote")) return "origin" as any;
if (cmdStr === 'git fetch "origin"') return Buffer.from("");
if (cmdStr === 'git rebase "origin/main"') {
throw new Error("pre-merge rebase conflict");
}
if (cmdStr === "git rebase --abort") return Buffer.from("");
return Buffer.from("");
});
// Layers 1+2 require successful exec calls we don't stub here, so they
// fail-soft. After fall-through, AI attempts 1+2 fail (no AI mock) and
// the merge cascade exhausts. The contract under test: -X ours must
// NEVER run, even after fall-through, because that would silently
// re-introduce content main has deleted.
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow();
expect(
mockedExec.mock.calls.some(([command]) => String(command).includes("merge -X ours")),
).toBe(false);
// The fall-through must be visible in the task log so the user can see
// the recovery attempt and why we declined to silently merge.
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.map(
(args: unknown[]) => String(args[1] ?? ""),
);
expect(
logCalls.some((msg: string) => msg.includes("Pre-merge recovery (Layer 3)")),
).toBe(true);
expect(
logCalls.some((msg: string) => msg.includes("Attempt 3 (-X ours fallback) suppressed")),
).toBe(true);
});
it("Layer 1 recovery: surgically drops dep commits via rebase --onto when executionStartBranch is set and primary rebase aborted", async () => {
// Scenario: FN-2849 declared baseBranch=fusion/fn-2729 (a dep). The
// worktree was forked off FN-2729's tip and inherited its raw commits.
// FN-2729 was later squash-merged to main. Now the primary rebase onto
// main aborts because FN-2729's raw commits conflict with their own
// squashed equivalent + later main commits. Layer 1 detects baseBranch
// and runs `git rebase --onto <main> <dep-tip> <branch>` to peel off
// the dep's commits cleanly so the merge can proceed.
const store = createMockStore(
{
id: "FN-2849",
executionStartBranch: "fusion/fn-2729",
branch: "fusion/fn-2849",
worktree: "/tmp/root/.worktrees/coral-stone",
},
[{ id: "FN-2849", worktree: "/tmp/root/.worktrees/coral-stone", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart-prefer-main",
});
// Layer happy-path mocks first, then override only what this test needs.
setupHappyPathExecSync();
const happyPath = mockedExecSync.getMockImplementation()!;
const DEP_TIP = "8f54a0e66b419a43703f996df5206d82bb4832e1";
let primaryRebaseAttempted = false;
let layer1OntoRebaseRan = false;
mockedExecSync.mockImplementation((cmd: any, opts?: any) => {
const cmdStr = String(cmd);
// Set up a resolvable origin remote so Stage 1 actually runs.
if (cmdStr === "git remote") return "origin\n" as any;
if (cmdStr.includes("git config --get branch.main.remote")) return "origin" as any;
if (cmdStr.includes("git rev-parse --abbrev-ref origin/HEAD")) return "origin/main" as any;
if (cmdStr === "git rev-parse --abbrev-ref HEAD") return "main" as any;
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
if (cmdStr === 'git fetch "origin"') return Buffer.from("");
// Resolve dep tip when Layer 1 looks it up via baseBranch.
if (cmdStr.includes('rev-parse --verify "fusion/fn-2729^{commit}"')) {
return Buffer.from(DEP_TIP);
}
// Stage 1 rebase aborts — this is what triggers Layer 1 recovery.
if (cmdStr === 'git rebase "origin/main"') {
primaryRebaseAttempted = true;
const err: any = new Error(
'could not apply ca5674d43... feat(FN-2729): Step 2 — adopt NodeHealthDot in InlineCreateCard\nadvice.mergeConflict false\nrebase conflict manually',
);
throw err;
}
if (cmdStr === "git rebase --abort") return Buffer.from("");
// Layer 1's surgical rebase succeeds.
if (
cmdStr.startsWith("git rebase --onto") &&
cmdStr.includes(DEP_TIP) &&
cmdStr.includes("fusion/fn-2849")
) {
layer1OntoRebaseRan = true;
return Buffer.from("");
}
return happyPath(cmd, opts);
});
await aiMergeTask(store, "/tmp/root", "FN-2849");
expect(primaryRebaseAttempted).toBe(true);
expect(layer1OntoRebaseRan).toBe(true);
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.map(
(args: unknown[]) => String(args[1] ?? ""),
);
expect(
logCalls.some((msg: string) => msg.includes("Pre-merge recovery (Layer 1)")),
).toBe(true);
// Layer 3 fall-through must NOT have triggered — Layer 1 unblocked.
expect(
logCalls.some((msg: string) => msg.includes("Pre-merge recovery (Layer 3)")),
).toBe(false);
});
});
describe("aiMergeTask — task.branch field", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("uses task.branch when set instead of deriving from task ID", async () => {
const store = createMockStore(
{ id: "FN-050", branch: "fusion/fn-050-2", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Should use fusion/fn-050-2, not fusion/fn-050
expect(result.branch).toBe("fusion/fn-050-2");
// Verify the suffixed branch was verified and deleted
const revParseCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("rev-parse --verify") && String(call[0]).includes("fusion/fn-050-2"),
);
expect(revParseCall).toBeDefined();
const branchDeleteCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("branch -d") && String(call[0]).includes("fusion/fn-050-2"),
);
expect(branchDeleteCall).toBeDefined();
});
it("falls back to conventional branch name when task.branch is not set", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.branch).toBe("fusion/fn-050");
});
});
describe("aiMergeTask — empty squash merge (branch already merged via dep)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
it("skips agent and still completes when squash stages nothing", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// Squash staged nothing → "0"
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore();
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// Agent should NOT have been spawned
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
// Task should still be moved to done
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("does not record commitSha when squash is empty (would be pre-merge HEAD)", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "premergehead999";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return " 5 files changed, 12 insertions(+), 3 deletions(-)\n" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore();
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// mergeDetails should be stored without commitSha (which would be the
// pre-merge HEAD, unrelated to this task).
const updateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls;
const mergeDetailsCall = updateCalls.find((call: any[]) => call[1]?.mergeDetails !== undefined);
expect(mergeDetailsCall).toBeDefined();
expect(mergeDetailsCall![1].mergeDetails.commitSha).toBeUndefined();
});
it("still cleans up branch and worktree when squash is empty", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore();
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Branch should be deleted
const branchDeleteCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("branch -d"),
);
expect(branchDeleteCall).toBeDefined();
expect(result.branchDeleted).toBe(true);
// Worktree should be removed
const worktreeRemoveCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("worktree remove"),
);
expect(worktreeRemoveCall).toBeDefined();
expect(result.worktreeRemoved).toBe(true);
});
});
describe("push-after-merge", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
function setupAiMergeExecSyncWithPush(pushBehavior?: (attempt: number) => void) {
let pushAttempts = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify REBASE_HEAD")) {
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
if (cmdStr.includes("git rev-parse --abbrev-ref origin/HEAD")) return "origin/main" as any;
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123" as any;
if (cmdStr.includes("git log HEAD..")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("git merge --squash")) return Buffer.from("");
if (cmdStr.includes("git diff --name-only --diff-filter=U")) return "" as any;
if (cmdStr.includes("git diff --cached --quiet")) {
// First call: squash-empty check, second call: post-agent commit verification.
return "1" as any;
}
if (cmdStr.startsWith("git commit ")) return Buffer.from("");
if (cmdStr.includes("git show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr.startsWith("git pull --rebase")) return Buffer.from("");
if (cmdStr.startsWith("git push ")) {
pushAttempts += 1;
pushBehavior?.(pushAttempts);
return Buffer.from("");
}
return Buffer.from("");
});
}
it("pushes merged result when pushAfterMerge is enabled", async () => {
setupAiMergeExecSyncWithPush();
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
pushAfterMerge: true,
pushRemote: "origin",
mergeStrategy: "direct",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.pushedToRemote).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).includes('git pull --rebase "origin" "main"')),
).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).includes('git push "origin" "main"')),
).toBe(true);
});
it("does not push when pushAfterMerge is disabled (default)", async () => {
setupAiMergeExecSyncWithPush();
const store = createMockStore();
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.pushedToRemote).toBeUndefined();
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git pull --rebase")),
).toBe(false);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git push ")),
).toBe(false);
});
it("does not push for pull-request merge strategy", async () => {
setupAiMergeExecSyncWithPush();
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeStrategy: "pull-request",
pushAfterMerge: true,
pushRemote: "origin",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.pushedToRemote).toBeUndefined();
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git pull --rebase")),
).toBe(false);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git push ")),
).toBe(false);
});
it("records push error but still completes merge when push fails", async () => {
setupAiMergeExecSyncWithPush((attempt) => {
if (attempt === 1) {
const err = new Error("failed to push some refs");
(err as Error & { stderr?: string }).stderr = "remote: permission denied";
throw err;
}
});
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
pushAfterMerge: true,
pushRemote: "origin",
mergeStrategy: "direct",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.pushedToRemote).toBe(false);
expect(result.pushError).toContain("permission denied");
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("uses custom remote and branch when configured", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith("git pull --rebase \"upstream\" \"main\"")) return Buffer.from("");
if (cmdStr.startsWith("git push \"upstream\" \"main\"")) return Buffer.from("");
if (cmdStr.includes("rev-parse --verify REBASE_HEAD")) {
const err = new Error("fatal: Needed a single revision");
throw err;
}
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
pushAfterMerge: true,
pushRemote: "upstream main",
});
expect(result.pushed).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith('git pull --rebase "upstream" "main"')),
).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith('git push "upstream" "main"')),
).toBe(true);
});
it("auto-resolves lock-file rebase conflicts and continues", async () => {
let rebaseInProgress = false;
let hasConflicts = false;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith('git pull --rebase "origin" "main"')) {
hasConflicts = true;
rebaseInProgress = true;
const err = new Error("rebase conflict") as Error & { stderr?: string };
err.stderr = "CONFLICT (content): Merge conflict in pnpm-lock.yaml";
throw err;
}
if (cmdStr.includes("git diff --name-only --diff-filter=U")) {
return hasConflicts ? "pnpm-lock.yaml" as any : "" as any;
}
if (cmdStr.includes("checkout --ours") && cmdStr.includes("pnpm-lock.yaml")) return Buffer.from("");
if (cmdStr.includes("git add") && cmdStr.includes("pnpm-lock.yaml")) {
hasConflicts = false;
return Buffer.from("");
}
if (cmdStr.includes("git rev-parse --verify REBASE_HEAD")) {
if (rebaseInProgress) return "rebasehead" as any;
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
if (cmdStr.startsWith("GIT_EDITOR=true git rebase --continue")) {
rebaseInProgress = false;
return Buffer.from("");
}
if (cmdStr.startsWith('git push "origin" "main"')) return Buffer.from("");
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
pushAfterMerge: true,
pushRemote: "origin",
});
expect(result.pushed).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).includes("checkout --ours") && String(call[0]).includes("pnpm-lock.yaml")),
).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("GIT_EDITOR=true git rebase --continue")),
).toBe(true);
});
it("uses AI to resolve complex rebase conflicts", async () => {
let rebaseInProgress = false;
let hasConflicts = false;
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(async () => {
hasConflicts = false;
}),
dispose: vi.fn(),
},
} as any);
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith('git pull --rebase "origin" "main"')) {
hasConflicts = true;
rebaseInProgress = true;
const err = new Error("rebase conflict") as Error & { stderr?: string };
err.stderr = "CONFLICT (content): Merge conflict in src/app.ts";
throw err;
}
if (cmdStr.includes("git diff --name-only --diff-filter=U")) {
return hasConflicts ? "src/app.ts" as any : "" as any;
}
if (cmdStr.startsWith("git diff-tree -p -w")) return "@@\n-foo\n+bar" as any;
if (cmdStr.includes("git rev-parse --verify REBASE_HEAD")) {
if (rebaseInProgress) return "rebasehead" as any;
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
if (cmdStr.startsWith("GIT_EDITOR=true git rebase --continue")) {
rebaseInProgress = false;
return Buffer.from("");
}
if (cmdStr.startsWith('git push "origin" "main"')) return Buffer.from("");
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
pushAfterMerge: true,
pushRemote: "origin",
});
expect(result.pushed).toBe(true);
expect(mockedCreateFnAgent).toHaveBeenCalled();
});
it("retries push once after non-fast-forward rejection", async () => {
let pushAttempts = 0;
let pullAttempts = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith('git pull --rebase "origin" "main"')) {
pullAttempts += 1;
return Buffer.from("");
}
if (cmdStr.startsWith('git push "origin" "main"')) {
pushAttempts += 1;
if (pushAttempts === 1) {
const err = new Error("non-fast-forward") as Error & { stderr?: string };
err.stderr = "[rejected] main -> main (non-fast-forward)";
throw err;
}
return Buffer.from("");
}
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
if (cmdStr.includes("git rev-parse --verify REBASE_HEAD")) {
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
pushAfterMerge: true,
pushRemote: "origin",
});
expect(result.pushed).toBe(true);
expect(pullAttempts).toBe(2);
expect(pushAttempts).toBe(2);
});
it("aborts rebase when conflicts remain unresolved", async () => {
let rebaseInProgress = false;
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith('git pull --rebase "origin" "main"')) {
rebaseInProgress = true;
const err = new Error("rebase conflict") as Error & { stderr?: string };
err.stderr = "CONFLICT (content): Merge conflict in src/app.ts";
throw err;
}
if (cmdStr.includes("git diff --name-only --diff-filter=U")) return "src/app.ts" as any;
if (cmdStr.startsWith("git diff-tree -p -w")) return "@@\n-foo\n+bar" as any;
if (cmdStr.includes("git rev-parse --verify REBASE_HEAD")) {
if (rebaseInProgress) return "rebasehead" as any;
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
if (cmdStr.startsWith("git rebase --abort")) {
rebaseInProgress = false;
return Buffer.from("");
}
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
pushAfterMerge: true,
pushRemote: "origin",
});
expect(result.pushed).toBe(false);
expect(result.error).toContain("unable to resolve rebase conflicts");
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git rebase --abort")),
).toBe(true);
});
});
describe("aiMergeTask — includeTaskIdInCommit setting", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("includes task ID in system prompt by default (includeTaskIdInCommit: true)", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "FN-050");
const agentCall = mockedCreateFnAgent.mock.calls[0][0] as any;
expect(agentCall.systemPrompt).toContain("<type>(<scope>): <summary>");
expect(agentCall.systemPrompt).toContain("the task ID");
});
it("omits task ID scope in system prompt when includeTaskIdInCommit is false", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
includeTaskIdInCommit: false,
});
await aiMergeTask(store, "/tmp/root", "FN-050");
const agentCall = mockedCreateFnAgent.mock.calls[0][0] as any;
expect(agentCall.systemPrompt).toContain("<type>: <summary>");
expect(agentCall.systemPrompt).not.toContain("<type>(<scope>): <summary>");
expect(agentCall.systemPrompt).toContain("Do NOT include a scope");
});
it("fallback commit includes task ID when includeTaskIdInCommit is true", async () => {
// Make staged check return "1" so fallback is triggered
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached")) return "1" as any;
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "FN-050");
const commitCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("git commit"),
);
expect(commitCall).toBeDefined();
expect(String(commitCall![0])).toContain("feat(FN-050):");
});
it("fallback commit omits task ID when includeTaskIdInCommit is false", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached")) return "1" as any;
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
includeTaskIdInCommit: false,
});
await aiMergeTask(store, "/tmp/root", "FN-050");
const commitCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("git commit"),
);
expect(commitCall).toBeDefined();
// Subject must use bare `feat:` prefix (no task-id scope) when
// includeTaskIdInCommit=false. The summary portion is derived from the
// step commit log or AI subject, so we don't pin its exact text — just
// assert the prefix shape.
expect(String(commitCall![0])).toMatch(/git commit -m "feat: \S/);
expect(String(commitCall![0])).not.toContain("feat(KB-050)");
expect(String(commitCall![0])).not.toContain("feat(FN-050)");
});
});
describe("aiMergeTask — model settings threading", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("passes defaultProvider and defaultModelId from settings to createFnAgent", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
defaultProvider: "openai",
defaultModelId: "gpt-4o",
});
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateFnAgent.mock.calls[0][0] as any;
expect(opts.defaultProvider).toBe("openai");
expect(opts.defaultModelId).toBe("gpt-4o");
});
it("does not set model fields when settings omit them", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "FN-050");
const opts = mockedCreateFnAgent.mock.calls[0][0] as any;
expect(opts.defaultProvider).toBeUndefined();
expect(opts.defaultModelId).toBeUndefined();
});
});
describe("aiMergeTask — agent log persistence", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
});
it("logs text deltas to store.appendAgentLog", async () => {
let capturedOnText: ((delta: string) => void) | undefined;
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
capturedOnText = opts.onText;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
capturedOnText?.("Hello ");
capturedOnText?.("merge");
}),
dispose: vi.fn(),
},
} as any;
});
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "FN-050", worktree: worktreePath },
[{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "Hello merge", "text", undefined, "merger");
});
it("logs tool invocations to store.appendAgentLog", async () => {
let capturedOnToolStart: ((name: string, args: any) => void) | undefined;
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
capturedOnToolStart = opts.onToolStart;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
capturedOnToolStart?.("Bash", { command: "git status" });
}),
dispose: vi.fn(),
},
} as any;
});
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "FN-050", worktree: worktreePath },
[{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "Bash", "tool", "git status", "merger");
});
it("still fires onAgentText callback alongside logging", async () => {
const onAgentText = vi.fn();
let capturedOnText: ((delta: string) => void) | undefined;
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
capturedOnText = opts.onText;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
capturedOnText?.("hi");
}),
dispose: vi.fn(),
},
} as any;
});
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "FN-050", worktree: worktreePath },
[{ id: "FN-050", worktree: worktreePath, column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "FN-050", { onAgentText });
expect(onAgentText).toHaveBeenCalledWith("hi");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "hi", "text", undefined, "merger");
});
});
// ── Usage limit detection in merger ──────────────────────────────────
import { UsageLimitPauser } from "../usage-limit-detector.js";
describe("aiMergeTask — usage limit detection", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
// Use setupFailingTheirsStrategy so -X theirs merge fails,
// allowing tests that expect throws to pass
setupFailingTheirsStrategy();
});
it("triggers global pause when merger catches a usage-limit error", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const pauser = new UsageLimitPauser(store);
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error("rate_limit_error: Rate limit exceeded")),
dispose: vi.fn(),
},
} as any);
await expect(
aiMergeTask(store, "/tmp/root", "FN-050", { usageLimitPauser: pauser }),
).rejects.toThrow("AI merge failed");
expect(onUsageLimitHitSpy).toHaveBeenCalledWith(
"merger",
"FN-050",
"rate_limit_error: Rate limit exceeded",
);
expect(store.updateSettings).toHaveBeenCalledWith({
globalPause: true,
globalPauseReason: "rate-limit",
});
});
it("triggers global pause when session.prompt() resolves with exhausted-retry error on state.error", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const pauser = new UsageLimitPauser(store);
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
// session.prompt() resolves normally, but session.state.error is set
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: { error: "429 Too Many Requests" },
};
mockedCreateFnAgent.mockResolvedValue({ session: mockSession } as any);
await expect(
aiMergeTask(store, "/tmp/root", "FN-050", { usageLimitPauser: pauser }),
).rejects.toThrow("AI merge failed");
// UsageLimitPauser should be called with "merger" agent type
expect(onUsageLimitHitSpy).toHaveBeenCalledWith(
"merger",
"FN-050",
"429 Too Many Requests",
);
// git reset --merge should be called to abort the merge
const resetCalls = mockedExecSync.mock.calls.filter(
(c) => String(c[0]).includes("reset --merge"),
);
expect(resetCalls.length).toBeGreaterThan(0);
});
it("does NOT trigger global pause for non-usage-limit errors", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const pauser = new UsageLimitPauser(store);
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error("connection refused")),
dispose: vi.fn(),
},
} as any);
await expect(
aiMergeTask(store, "/tmp/root", "FN-050", { usageLimitPauser: pauser }),
).rejects.toThrow("AI merge failed");
expect(onUsageLimitHitSpy).not.toHaveBeenCalled();
});
it("works without usageLimitPauser (backward compatible)", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error("rate_limit_error: Rate limit exceeded")),
dispose: vi.fn(),
},
} as any);
// Should not crash — just re-throw
await expect(
aiMergeTask(store, "/tmp/root", "FN-050"),
).rejects.toThrow("AI merge failed");
});
it("triggers global pause for overloaded error", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const pauser = new UsageLimitPauser(store);
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error("overloaded_error: Overloaded")),
dispose: vi.fn(),
},
} as any);
await expect(
aiMergeTask(store, "/tmp/root", "FN-050", { usageLimitPauser: pauser }),
).rejects.toThrow("AI merge failed");
expect(onUsageLimitHitSpy).toHaveBeenCalledWith(
"merger",
"FN-050",
"overloaded_error: Overloaded",
);
});
});
describe("aiMergeTask — onSession callback", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
});
it("calls onSession with the session object after creation", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
};
mockedCreateFnAgent.mockResolvedValue({
session: mockSession,
} as any);
const onSession = vi.fn();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "FN-050", { onSession });
expect(onSession).toHaveBeenCalledTimes(1);
expect(onSession).toHaveBeenCalledWith(mockSession);
});
it("works without onSession callback (backward compatible)", async () => {
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Should not crash without onSession
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).resolves.toBeDefined();
});
});
// ── Conflict Detection & Auto-Resolution ─────────────────────────────────
describe("detectResolvableConflicts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty array when no conflicts exist", async () => {
mockedExecSync.mockReturnValue(""); // Empty output = no conflicts
const result = await detectResolvableConflicts("/tmp/root");
expect(result).toEqual([]);
});
it("detects package-lock.json as auto-resolvable with 'theirs' strategy", async () => {
mockedExecSync.mockReturnValue("package-lock.json\n");
const result = await detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
filePath: "package-lock.json",
autoResolvable: true,
strategy: "ours",
reason: "lock-file",
});
});
it("detects pnpm-lock.yaml as lock file with 'ours' strategy", async () => {
mockedExecSync.mockReturnValue("pnpm-lock.yaml\n");
const result = await detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
filePath: "pnpm-lock.yaml",
autoResolvable: true,
strategy: "ours",
reason: "lock-file",
});
});
it("detects yarn.lock as lock file with 'ours' strategy", async () => {
mockedExecSync.mockReturnValue("yarn.lock\n");
const result = await detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "ours",
reason: "lock-file",
});
});
it("detects Gemfile.lock as lock file with 'ours' strategy", async () => {
mockedExecSync.mockReturnValue("Gemfile.lock\n");
const result = await detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "ours",
reason: "lock-file",
});
});
it("detects .gen.ts files as generated files with 'theirs' strategy", async () => {
mockedExecSync.mockReturnValue("src/types.gen.ts\n");
const result = await detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "theirs",
reason: "generated-file",
});
});
it("detects dist/ paths as generated files with 'theirs' strategy", async () => {
mockedExecSync.mockReturnValue("dist/index.js\n");
const result = await detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "theirs",
reason: "generated-file",
});
});
it("detects coverage/ paths as generated files with 'theirs' strategy", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only")) return "coverage/lcov.info\n";
return Buffer.from("");
});
const result = await detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "theirs",
reason: "generated-file",
});
});
it("marks regular source files as complex conflicts", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only")) return "src/components/App.tsx\n";
// git diff-tree for trivial detection — return real diff content to indicate non-trivial
if (cmdStr.includes("diff-tree")) return "+real change\n-old line\n";
return Buffer.from("");
});
const result = await detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
filePath: "src/components/App.tsx",
autoResolvable: false,
reason: "complex",
});
});
it("handles multiple conflicted files with mixed categories", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only"))
return "package-lock.json\nsrc/components/App.tsx\ndist/bundle.js\n";
// git diff-tree for trivial detection — return real diff for source files
if (cmdStr.includes("diff-tree")) return "+real change\n-old line\n";
return Buffer.from("");
});
const result = await detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(3);
const lockFile = result.find((r) => r.filePath === "package-lock.json");
const sourceFile = result.find((r) => r.filePath === "src/components/App.tsx");
const distFile = result.find((r) => r.filePath === "dist/bundle.js");
expect(lockFile).toMatchObject({ autoResolvable: true, reason: "lock-file" });
expect(sourceFile).toMatchObject({ autoResolvable: false, reason: "complex" });
expect(distFile).toMatchObject({ autoResolvable: true, reason: "generated-file" });
});
it("returns empty array on git command failure", async () => {
mockedExecSync.mockImplementation(() => {
throw new Error("git command failed");
});
const result = await detectResolvableConflicts("/tmp/root");
expect(result).toEqual([]);
});
});
describe("autoResolveFile", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock returns empty buffer for all git commands
mockedExecSync.mockReturnValue(Buffer.from(""));
});
it("calls git checkout --theirs for 'theirs' resolution", async () => {
await autoResolveFile("package-lock.json", "theirs", "/tmp/root");
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("git checkout --theirs"),
);
expect(checkoutCall).toBeDefined();
expect(String(checkoutCall![0])).toContain("package-lock.json");
});
it("calls git checkout --ours for 'ours' resolution", async () => {
await autoResolveFile("config.json", "ours", "/tmp/root");
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("git checkout --ours"),
);
expect(checkoutCall).toBeDefined();
expect(String(checkoutCall![0])).toContain("config.json");
});
it("stages the resolved file with git add", async () => {
await autoResolveFile("package-lock.json", "theirs", "/tmp/root");
const addCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("git add"),
);
expect(addCall).toBeDefined();
expect(String(addCall![0])).toContain("package-lock.json");
});
it("throws error when git checkout fails", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd).includes("checkout")) {
throw new Error("checkout failed");
}
return Buffer.from("");
});
await expect(autoResolveFile("file.ts", "theirs", "/tmp/root")).rejects.toThrow(
"Failed to auto-resolve",
);
});
});
describe("resolveConflicts", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock - success
mockedExecSync.mockReturnValue(Buffer.from(""));
});
it("resolves lock files and returns remaining complex conflicts", async () => {
const categories: ConflictCategory[] = [
{ filePath: "package-lock.json", autoResolvable: true, strategy: "ours", reason: "lock-file" },
{ filePath: "src/App.tsx", autoResolvable: false, reason: "complex" },
{ filePath: "dist/bundle.js", autoResolvable: true, strategy: "ours", reason: "generated-file" },
];
const remaining = await resolveConflicts(categories, "/tmp/root");
// Should have resolved package-lock.json and dist/bundle.js
expect(remaining).toEqual(["src/App.tsx"]);
// Should have called checkout and add for resolved files
const checkoutCalls = mockedExecSync.mock.calls.filter((call) =>
String(call[0]).includes("checkout"),
);
expect(checkoutCalls).toHaveLength(2);
});
it("returns all files when none are auto-resolvable", async () => {
const categories: ConflictCategory[] = [
{ filePath: "src/App.tsx", autoResolvable: false, reason: "complex" },
{ filePath: "src/utils.ts", autoResolvable: false, reason: "complex" },
];
const remaining = await resolveConflicts(categories, "/tmp/root");
expect(remaining).toEqual(["src/App.tsx", "src/utils.ts"]);
// No checkout calls should be made
const checkoutCalls = mockedExecSync.mock.calls.filter((call) =>
String(call[0]).includes("checkout"),
);
expect(checkoutCalls).toHaveLength(0);
});
it("returns empty array when all conflicts are resolved", async () => {
const categories: ConflictCategory[] = [
{ filePath: "package-lock.json", autoResolvable: true, strategy: "ours", reason: "lock-file" },
{ filePath: "yarn.lock", autoResolvable: true, strategy: "ours", reason: "lock-file" },
];
const remaining = await resolveConflicts(categories, "/tmp/root");
expect(remaining).toEqual([]);
});
});
// ── Trivial Conflict Detection Tests ──────────────────────────────────────
describe("trivial conflict detection (isTrivialWhitespaceConflict via detectResolvableConflicts)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("detects whitespace-only conflicts as trivial", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
// git diff-tree with -w returns empty = trivial whitespace
if (cmdStr.includes("diff-tree")) return "";
return Buffer.from("");
});
const result = await detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
filePath: "src/utils.ts",
autoResolvable: true,
strategy: "ours",
reason: "trivial",
});
});
it("marks conflicts with actual content differences as complex", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
// git diff-tree returns real content changes = non-trivial
if (cmdStr.includes("diff-tree")) return "+return 2;\n-return 1;\n";
return Buffer.from("");
});
const result = await detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
filePath: "src/utils.ts",
autoResolvable: false,
reason: "complex",
});
});
it("handles multiple conflict sections - one non-trivial makes complex", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
// Real diff content = non-trivial
if (cmdStr.includes("diff-tree")) return "+const x = 999;\n-const x = 2;\n";
return Buffer.from("");
});
const result = await detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
autoResolvable: false,
reason: "complex",
});
});
it("handles git command errors as complex conflicts", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
if (cmdStr.includes("diff-tree")) throw new Error("git error");
return Buffer.from("");
});
const result = await detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
autoResolvable: false,
reason: "complex",
});
});
});
// ── Retry Logic Tests ───────────────────────────────────────────────────
describe("aiMergeTask — retry logic with escalating strategies", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
// Default mock: successful happy path
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash") || cmdStr.includes("merge -X")) return Buffer.from("");
// Post-squash check: "1" = has staged changes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "0" = committed
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("attempt 1 success: sets resolutionStrategy to 'ai' and attemptsMade to 1", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Clean merge with no conflicts - simulate empty diff for conflicts
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// No conflicts
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
// Has staged changes that need committing
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("ai");
expect(result.attemptsMade).toBe(1);
});
it("with autoResolveConflicts disabled: only makes 1 attempt on conflict", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
autoResolveConflicts: false, // Disabled
});
let agentCallCount = 0;
// Simulate: merge succeeds but leaves conflicts, agent is called but fails
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) {
// Merge command succeeds but leaves conflict markers
return Buffer.from("");
}
// Conflict detection returns conflicts
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
return "src/file.ts\n";
}
// git diff-tree for trivial whitespace detection - return real changes (non-trivial)
if (cmdStr.includes("diff-tree")) {
const error = new Error("exit code 1") as any;
error.stdout = "+const x = 2;\n-const x = 1;";
throw error;
}
// Staged changes check after merge (conflicts present but not staged)
if (cmdStr.includes("diff --cached --quiet")) {
return "1"; // Has staged changes from the merge
}
if (cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
// Agent will be called and will fail
mockedCreateFnAgent.mockImplementation(() => {
agentCallCount++;
return Promise.resolve({
session: {
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
dispose: vi.fn(),
},
} as any);
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow();
// Should have called agent exactly once (no retries since autoResolve is disabled)
expect(agentCallCount).toBe(1);
});
it("attempt 2 throws when squash fails for a non-conflict reason (no U files)", async () => {
// Regression: previously any squash error was treated as conflicts. If
// the failure was non-conflict (hook, IO, lock) and no U files existed,
// the cascade fell into "all conflicts auto-resolved" and returned true,
// recording merge metadata for a merge that never happened.
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
let mergeCallCount = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) {
mergeCallCount++;
// Simulate a non-conflict failure on every squash attempt
// (e.g. pre-commit hook rejected, repo locked).
throw new Error("fatal: pre-commit hook returned non-zero status");
}
// Critical: no conflicted files surface
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
/failed without producing conflicts/i,
);
expect(mergeCallCount).toBeGreaterThanOrEqual(1);
});
it("attempt 1 fails, attempt 2 auto-resolves lock files: sets resolutionStrategy to 'auto-resolve'", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
let mergeCallCount = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) {
mergeCallCount++;
if (mergeCallCount === 1) {
// First attempt: conflict
throw new Error("Merge conflict");
}
// Second attempt succeeds after auto-resolution
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
// First time: return lock file, second time: empty
if (mergeCallCount === 1) return "package-lock.json\n";
return "";
}
if (cmdStr.includes("checkout --ours")) return Buffer.from("");
if (cmdStr.includes("git add")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "0"; // All resolved
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
// Agent should not be called since all conflicts are auto-resolved
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("auto-resolve");
expect(result.attemptsMade).toBe(2);
});
it("attempt 3 uses -X theirs strategy: sets resolutionStrategy to 'theirs'", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Pin the strategy: default is now "smart-prefer-main" (-X ours), but
// this test specifically exercises the -X theirs fallback path.
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart-prefer-branch",
});
let squashCallCount = 0;
let theirsCallCount = 0;
let hasConflicts = true;
let agentCallCount = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
// First two regular squash merges fail with conflicts
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) {
squashCallCount++;
throw new Error("Merge conflict");
}
// Third attempt with -X theirs succeeds (no conflicts)
if (cmdStr.includes("merge -X theirs --squash")) {
theirsCallCount++;
hasConflicts = false;
return Buffer.from("");
}
// After -X theirs, no conflicts
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
return hasConflicts ? "src/complex.ts\n" : "";
}
// git diff-tree for trivial whitespace detection - return real changes (non-trivial)
if (cmdStr.includes("diff-tree")) {
const error = new Error("exit code 1") as any;
error.stdout = "+const x = 2;\n-const x = 1;";
throw error;
}
if (cmdStr.includes("diff --cached --quiet")) return hasConflicts ? "1" : "0";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
// Agent fails on attempt 2 (when called to resolve complex conflicts)
mockedCreateFnAgent.mockImplementation(() => {
agentCallCount++;
if (agentCallCount === 1) {
// First agent call (attempt 2) fails
return Promise.resolve({
session: {
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
dispose: vi.fn(),
},
} as any);
}
// Should not reach here
return Promise.resolve({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("theirs");
expect(result.attemptsMade).toBe(3);
expect(theirsCallCount).toBe(1); // -X theirs was used once
expect(agentCallCount).toBe(1); // Agent was called once (on attempt 2, which failed)
});
it("final cleanup reset succeeds after all 3 attempts fail", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const resetCalls: string[] = [];
const warnSpy = vi.spyOn(mergerLog, "warn");
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) {
// AI merge attempts fail with conflicts
throw new Error("Merge conflict");
}
if (cmdStr.includes("merge -X theirs")) {
// -X theirs also fails (some conflicts can't be auto-resolved)
const err = new Error("Merge conflict");
err.name = "ExecSyncError";
throw err;
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
return "src/always-conflicts.ts\n"; // Always has conflicts
}
// Make auto-resolution fail by making git add fail
if (cmdStr.includes("git add")) {
const err = new Error("git add failed");
err.name = "ExecSyncError";
throw err;
}
if (cmdStr.includes("reset --merge")) {
resetCalls.push(cmdStr);
return Buffer.from("");
}
return Buffer.from("");
});
// Agent will also fail
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
dispose: vi.fn(),
},
} as any);
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
"all 3 attempts exhausted",
);
// Should have cleanup calls after each failed attempt plus final cleanup
expect(resetCalls.length).toBeGreaterThanOrEqual(3);
expect(
warnSpy.mock.calls.some(([message]) => String(message).includes("git reset --merge cleanup failed")),
).toBe(false);
warnSpy.mockRestore();
});
it("final cleanup reset failure is logged but does not change thrown error", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const resetFailureMessage = "reset failed: dirty worktree";
const warnSpy = vi.spyOn(mergerLog, "warn");
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) {
throw new Error("Merge conflict");
}
if (cmdStr.includes("merge -X theirs")) {
const err = new Error("Merge conflict");
err.name = "ExecSyncError";
throw err;
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
return "src/always-conflicts.ts\n";
}
if (cmdStr.includes("git add")) {
const err = new Error("git add failed");
err.name = "ExecSyncError";
throw err;
}
if (cmdStr.includes("reset --merge")) {
throw new Error(resetFailureMessage);
}
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
dispose: vi.fn(),
},
} as any);
let thrown: unknown;
try {
await aiMergeTask(store, "/tmp/root", "FN-050");
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toContain("all 3 attempts exhausted");
expect((thrown as Error).message).not.toContain(resetFailureMessage);
const cleanupWarnMessages = warnSpy.mock.calls
.map(([message]) => String(message))
.filter((message) => message.includes("git reset --merge cleanup failed"));
expect(cleanupWarnMessages.length).toBeGreaterThan(0);
expect(cleanupWarnMessages.some((message) => message.includes(resetFailureMessage))).toBe(true);
warnSpy.mockRestore();
});
it("retry-cleanup reset failure after attempt 1 is logged and merge continues to attempt 2", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const warnSpy = vi.spyOn(mergerLog, "warn");
const resetFailureMessage = "attempt-1 cleanup reset failed";
let mergeSquashCalls = 0;
let resetCalls = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("show --shortstat")) return "1 file changed, 1 insertion(+), 0 deletions(-)";
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) {
mergeSquashCalls++;
if (mergeSquashCalls === 1) {
throw new Error("Merge conflict");
}
return Buffer.from("");
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
if (cmdStr.includes("diff --cached --quiet")) return "0";
if (cmdStr.includes("reset --merge")) {
resetCalls++;
if (resetCalls === 1) {
throw new Error(resetFailureMessage);
}
return Buffer.from("");
}
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.attemptsMade).toBe(2);
expect(mergeSquashCalls).toBe(2);
const cleanupWarnMessages = warnSpy.mock.calls
.map(([message]) => String(message))
.filter((message) => message.includes("git reset --merge cleanup failed"));
expect(cleanupWarnMessages.some((message) => message.includes("merge-cleanup, attempt 1"))).toBe(true);
expect(cleanupWarnMessages.some((message) => message.includes(resetFailureMessage))).toBe(true);
warnSpy.mockRestore();
});
it("build-retry reset failure is logged when build verification fails", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
buildRetryCount: 1,
verificationFixRetries: 0,
});
const warnSpy = vi.spyOn(mergerLog, "warn");
const buildFailureMessage = "Build verification failed: tsc error";
const resetFailureMessage = "build-retry reset failed";
let resetCalls = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("reset --merge")) {
resetCalls++;
if (resetCalls === 1) {
throw new Error(resetFailureMessage);
}
return Buffer.from("");
}
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error(buildFailureMessage)),
dispose: vi.fn(),
},
} as any);
let thrown: unknown;
try {
await aiMergeTask(store, "/tmp/root", "FN-050");
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toContain(buildFailureMessage);
expect((thrown as Error).message).not.toContain(resetFailureMessage);
const cleanupWarnMessages = warnSpy.mock.calls
.map(([message]) => String(message))
.filter((message) => message.includes("git reset --merge cleanup failed"));
expect(cleanupWarnMessages.some((message) => message.includes("build-retry"))).toBe(true);
expect(cleanupWarnMessages.some((message) => message.includes(resetFailureMessage))).toBe(true);
warnSpy.mockRestore();
});
it("error-path retry cleanup reset failure is logged and merge still retries", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const warnSpy = vi.spyOn(mergerLog, "warn");
const resetFailureMessage = "retry cleanup reset failed";
let mergeSquashCalls = 0;
let resetCalls = 0;
let usedFallbackStrategy = false;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)";
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) {
mergeSquashCalls++;
if (mergeSquashCalls === 1) {
throw new Error("Merge conflict");
}
return Buffer.from("");
}
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
usedFallbackStrategy = true;
return Buffer.from("");
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
if (!usedFallbackStrategy && mergeSquashCalls === 2) {
return "src/complex.ts\n";
}
return "";
}
if (cmdStr.includes("diff-tree")) {
const error = new Error("exit code 1") as any;
error.stdout = "+const value = 2;\n-const value = 1;";
throw error;
}
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("reset --merge")) {
resetCalls++;
if (resetCalls === 2) {
throw new Error(resetFailureMessage);
}
return Buffer.from("");
}
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error("Agent failed on attempt 2")),
dispose: vi.fn(),
},
} as any);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.attemptsMade).toBe(3);
expect(mergeSquashCalls).toBe(2);
const cleanupWarnMessages = warnSpy.mock.calls
.map(([message]) => String(message))
.filter((message) => message.includes("git reset --merge cleanup failed"));
expect(cleanupWarnMessages.some((message) => message.includes("merge-retry, attempt 2"))).toBe(true);
expect(cleanupWarnMessages.some((message) => message.includes(resetFailureMessage))).toBe(true);
warnSpy.mockRestore();
});
it("tracks resolutionStrategy as 'ai' when attempt 1 succeeds even with autoResolve enabled", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Clean merge with no conflicts
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return ""; // No conflicts
if (cmdStr.includes("diff --cached --quiet")) return "1"; // Has staged changes
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("ai");
expect(result.attemptsMade).toBe(1);
});
});
describe("aiMergeTask — reset cleanup failure diagnostics", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
it("retry-cleanup reset failure after failed attempt is logged", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const warnSpy = vi.spyOn(mergerLog, "warn");
const resetFailureMessage = "dirty worktree";
let resetCalls = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("show --shortstat")) return "1 file changed, 1 insertion(+), 0 deletions(-)";
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) {
throw new Error("Merge conflict");
}
if (cmdStr.includes("merge -X theirs --squash")) {
throw new Error("Merge conflict");
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
return "src/always-conflicts.ts\n";
}
if (cmdStr.includes("diff-tree")) {
const error = new Error("exit code 1") as any;
error.stdout = "+const x = 2;\n-const x = 1;";
throw error;
}
if (cmdStr.includes("git add")) {
const err = new Error("git add failed");
err.name = "ExecSyncError";
throw err;
}
if (cmdStr.includes("reset --merge")) {
resetCalls++;
if (resetCalls === 1) {
throw new Error(resetFailureMessage);
}
return Buffer.from("");
}
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
dispose: vi.fn(),
},
} as any);
let thrown: unknown;
try {
await aiMergeTask(store, "/tmp/root", "FN-050");
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toContain("all 3 attempts exhausted");
expect((thrown as Error).message).not.toContain(resetFailureMessage);
const cleanupWarnMessages = warnSpy.mock.calls
.map(([message]) => String(message))
.filter((message) => message.includes("git reset --merge cleanup failed"));
expect(cleanupWarnMessages.some((message) => message.includes("merge-cleanup"))).toBe(true);
expect(cleanupWarnMessages.some((message) => message.includes(resetFailureMessage))).toBe(true);
warnSpy.mockRestore();
});
it("error-path retry cleanup reset failure is logged", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const warnSpy = vi.spyOn(mergerLog, "warn");
const resetFailureMessage = "retry cleanup reset failed";
let mergeSquashCalls = 0;
let resetCalls = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) {
mergeSquashCalls++;
if (mergeSquashCalls === 1) {
throw new Error("git merge failed: exit code 128");
}
return Buffer.from("");
}
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
throw new Error("Merge conflict");
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("reset --merge")) {
resetCalls++;
if (resetCalls === 2) {
throw new Error(resetFailureMessage);
}
return Buffer.from("");
}
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error("git merge failed: exit code 128")),
dispose: vi.fn(),
},
} as any);
let thrown: unknown;
try {
await aiMergeTask(store, "/tmp/root", "FN-050");
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toContain("all 3 attempts exhausted");
expect((thrown as Error).message).not.toContain(resetFailureMessage);
const cleanupWarnMessages = warnSpy.mock.calls
.map(([message]) => String(message))
.filter((message) => message.includes("git reset --merge cleanup failed"));
expect(cleanupWarnMessages.some((message) => message.includes("merge-retry"))).toBe(true);
expect(cleanupWarnMessages.some((message) => message.includes(resetFailureMessage))).toBe(true);
warnSpy.mockRestore();
});
it("build-verification reset failure is logged in executeMergeAttempt", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
buildCommand: "pnpm build",
buildRetryCount: 0,
verificationFixRetries: 0,
});
const warnSpy = vi.spyOn(mergerLog, "warn");
const resetFailureMessage = "lock file busy";
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
const reportTool = opts.customTools?.find((t: any) => t.name === "fn_report_build_failure");
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
if (reportTool) {
await reportTool.execute("tool-call-1", { message: "build failed" });
}
}),
dispose: vi.fn(),
},
} as any;
});
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("reset --merge")) {
throw new Error(resetFailureMessage);
}
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
let thrown: unknown;
try {
await aiMergeTask(store, "/tmp/root", "FN-050");
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toContain("Build verification failed");
expect((thrown as Error).message).not.toContain(resetFailureMessage);
const cleanupWarnMessages = warnSpy.mock.calls
.map(([message]) => String(message))
.filter((message) => message.includes("git reset --merge cleanup failed"));
// Reset cleanup now runs from mergeAttempt's catch handler (after the
// squash state is preserved across the build-failure throw site for the
// in-merge fix path). With both verificationFixRetries=0 and
// buildRetryCount=0 the rollback fires from the "no retries left" branch.
expect(cleanupWarnMessages.some((message) => message.includes("build-verification rollback"))).toBe(true);
expect(cleanupWarnMessages.some((message) => message.includes(resetFailureMessage))).toBe(true);
warnSpy.mockRestore();
});
});
// ── New Smart Conflict Resolution API Tests ────────────────────────────
describe("classifyConflict", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("classifies package-lock.json as 'lockfile-ours'", async () => {
const result = await classifyConflict("package-lock.json", "/tmp/root");
expect(result).toBe("lockfile-ours");
});
it("classifies pnpm-lock.yaml as 'lockfile-ours'", async () => {
const result = await classifyConflict("pnpm-lock.yaml", "/tmp/root");
expect(result).toBe("lockfile-ours");
});
it("classifies yarn.lock as 'lockfile-ours'", async () => {
const result = await classifyConflict("yarn.lock", "/tmp/root");
expect(result).toBe("lockfile-ours");
});
it("classifies Gemfile.lock as 'lockfile-ours'", async () => {
const result = await classifyConflict("Gemfile.lock", "/tmp/root");
expect(result).toBe("lockfile-ours");
});
it("classifies bun.lockb as 'lockfile-ours'", async () => {
const result = await classifyConflict("bun.lockb", "/tmp/root");
expect(result).toBe("lockfile-ours");
});
it("classifies go.sum as 'lockfile-ours'", async () => {
const result = await classifyConflict("go.sum", "/tmp/root");
expect(result).toBe("lockfile-ours");
});
it("classifies *.gen.ts files as 'generated-theirs'", async () => {
const result = await classifyConflict("src/types.gen.ts", "/tmp/root");
expect(result).toBe("generated-theirs");
});
it("classifies dist/* files as 'generated-theirs'", async () => {
const result = await classifyConflict("dist/bundle.js", "/tmp/root");
expect(result).toBe("generated-theirs");
});
it("classifies build/* files as 'generated-theirs'", async () => {
const result = await classifyConflict("build/index.html", "/tmp/root");
expect(result).toBe("generated-theirs");
});
it("classifies *.min.js files as 'generated-theirs'", async () => {
const result = await classifyConflict("app.min.js", "/tmp/root");
expect(result).toBe("generated-theirs");
});
it("classifies regular source files as 'complex'", async () => {
// Mock git diff-tree to return actual content changes (non-trivial)
mockedExecSync.mockImplementation(() => {
const error = new Error("exit code 1") as any;
error.stdout = `diff --git a/src/components/App.tsx b/src/components/App.tsx
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -1 +1 @@
-const x = 1;
+const x = 2;`;
throw error;
});
mockedReadFileSync.mockReturnValue("const x = 1;");
const result = await classifyConflict("src/components/App.tsx", "/tmp/root");
expect(result).toBe("complex");
});
});
describe("getConflictedFiles", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns array of conflicted file paths", async () => {
mockedExecSync.mockReturnValue("package-lock.json\nsrc/index.ts\n");
const result = await getConflictedFiles("/tmp/root");
expect(result).toEqual(["package-lock.json", "src/index.ts"]);
});
it("returns empty array when no conflicts", async () => {
mockedExecSync.mockReturnValue("");
const result = await getConflictedFiles("/tmp/root");
expect(result).toEqual([]);
});
it("returns empty array on git error", async () => {
mockedExecSync.mockImplementation(() => {
throw new Error("git error");
});
const result = await getConflictedFiles("/tmp/root");
expect(result).toEqual([]);
});
});
describe("resolveWithOurs", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExecSync.mockReturnValue(Buffer.from(""));
});
it("calls git checkout --ours and git add", async () => {
await resolveWithOurs("package-lock.json", "/tmp/root");
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("checkout --ours"),
);
const addCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("git add"),
);
expect(checkoutCall).toBeDefined();
expect(addCall).toBeDefined();
expect(String(checkoutCall![0])).toContain("package-lock.json");
});
it("throws on git error", async () => {
mockedExecSync.mockImplementation(() => {
throw new Error("checkout failed");
});
await expect(resolveWithOurs("file.ts", "/tmp/root")).rejects.toThrow(
"Failed to auto-resolve",
);
});
});
describe("resolveWithTheirs", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExecSync.mockReturnValue(Buffer.from(""));
});
it("calls git checkout --theirs and git add", async () => {
await resolveWithTheirs("dist/bundle.js", "/tmp/root");
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("checkout --theirs"),
);
const addCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("git add"),
);
expect(checkoutCall).toBeDefined();
expect(addCall).toBeDefined();
expect(String(checkoutCall![0])).toContain("dist/bundle.js");
});
it("throws on git error", async () => {
mockedExecSync.mockImplementation(() => {
throw new Error("checkout failed");
});
await expect(resolveWithTheirs("file.ts", "/tmp/root")).rejects.toThrow(
"Failed to auto-resolve",
);
});
});
describe("resolveTrivialWhitespace", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExecSync.mockReturnValue(Buffer.from(""));
});
it("calls git add to resolve trivial whitespace conflict", async () => {
await resolveTrivialWhitespace("src/utils.ts", "/tmp/root");
const addCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("git add"),
);
expect(addCall).toBeDefined();
expect(String(addCall![0])).toContain("src/utils.ts");
});
it("throws on git error", async () => {
mockedExecSync.mockImplementation(() => {
throw new Error("add failed");
});
await expect(resolveTrivialWhitespace("file.ts", "/tmp/root")).rejects.toThrow(
"Failed to auto-resolve",
);
});
});
describe("LOCKFILE_PATTERNS and GENERATED_PATTERNS", () => {
it("LOCKFILE_PATTERNS contains expected lock file patterns", () => {
expect(LOCKFILE_PATTERNS).toContain("package-lock.json");
expect(LOCKFILE_PATTERNS).toContain("pnpm-lock.yaml");
expect(LOCKFILE_PATTERNS).toContain("yarn.lock");
expect(LOCKFILE_PATTERNS).toContain("Gemfile.lock");
expect(LOCKFILE_PATTERNS).toContain("bun.lockb");
expect(LOCKFILE_PATTERNS).toContain("go.sum");
expect(LOCKFILE_PATTERNS).toContain("composer.lock");
expect(LOCKFILE_PATTERNS).toContain("poetry.lock");
expect(LOCKFILE_PATTERNS).not.toContain("Cargo.lock"); // Not in task spec
});
it("GENERATED_PATTERNS contains expected generated file patterns", () => {
expect(GENERATED_PATTERNS).toContain("*.gen.ts");
expect(GENERATED_PATTERNS).toContain("*.gen.js");
expect(GENERATED_PATTERNS).toContain("*.min.js");
expect(GENERATED_PATTERNS).toContain("*.min.css");
expect(GENERATED_PATTERNS).toContain("dist/*");
expect(GENERATED_PATTERNS).toContain("build/*");
expect(GENERATED_PATTERNS).toContain("coverage/*");
expect(GENERATED_PATTERNS).toContain("out/*");
});
});
describe("isTrivialWhitespaceConflict", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns true when diff contains only whitespace changes", async () => {
// Mock git diff-tree to return empty diff (no content changes)
mockedExecSync.mockReturnValue(
"diff --git a/file.ts b/file.ts\nindex 123..456 100644\n--- a/file.ts\n+++ b/file.ts\n"
);
const result = await isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
expect(result).toBe(true);
});
it("returns false when diff contains content changes", async () => {
// Mock git diff-tree to return diff with actual content changes
mockedExecSync.mockImplementation(() => {
const error = new Error("exit code 1") as any;
error.stdout = `diff --git a/file.ts b/file.ts
--- a/file.ts
+++ b/file.ts
@@ -1 +1 @@
-const x = 1;
+const x = 2;`;
throw error;
});
const result = await isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
expect(result).toBe(false);
});
it("returns true when only line endings differ (CRLF vs LF)", async () => {
// Mock git diff-tree -w to show no content changes (whitespace ignored)
mockedExecSync.mockReturnValue(
"diff --git a/file.ts b/file.ts\nindex 123..456 100644\n--- a/file.ts\n+++ b/file.ts\n"
);
const result = await isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
expect(result).toBe(true);
});
it("returns false when git diff-tree fails unexpectedly", async () => {
mockedExecSync.mockImplementation(() => {
throw new Error("fatal: not a git repository");
});
// Mock readFileSync for the fallback
mockedReadFileSync.mockReturnValue("content without conflict markers");
const result = await isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
expect(result).toBe(false);
});
it("calls git diff-tree with correct index references (:2: and :3:)", async () => {
mockedExecSync.mockReturnValue("");
await isTrivialWhitespaceConflict("src/utils.ts", "/tmp/root");
const call = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("git diff-tree")
);
expect(call).toBeDefined();
const cmdStr = String(call![0]);
expect(cmdStr).toContain("-w"); // whitespace ignored
expect(cmdStr).toContain(':2:"src/utils.ts"');
expect(cmdStr).toContain(':3:"src/utils.ts"');
});
});
// ── Build Verification Tests ─────────────────────────────────────────
describe("aiMergeTask — build verification", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
// Default happy path exec mock
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
});
it("system prompt contains build verification section", async () => {
let capturedSystemPrompt: string | undefined;
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
capturedSystemPrompt = opts.systemPrompt;
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore();
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(capturedSystemPrompt).toContain("## Build verification");
expect(capturedSystemPrompt).toContain("build verification is a hard gate");
expect(capturedSystemPrompt).toContain("Do not assume the build passes");
expect(capturedSystemPrompt).toContain("fn_report_build_failure");
});
it("includes build command in merge prompt when configured", async () => {
let capturedArgs: any;
let capturedPrompt: string | undefined;
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
capturedArgs = opts;
// Simulate agent committing by returning session that results in clean state
return {
session: {
prompt: vi.fn().mockImplementation(async (prompt: string) => {
capturedPrompt = prompt;
// Simulate commit happening by making staged check return "0" (clean)
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// After commit, diff shows clean
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
buildCommand: "pnpm build",
});
await aiMergeTask(store, "/tmp/root", "FN-050");
// Verify custom tool was passed
expect(capturedArgs.customTools).toBeDefined();
expect(capturedArgs.customTools.some((t: any) => t.name === "fn_report_build_failure")).toBe(true);
expect(capturedPrompt).toContain("Build command: `pnpm build`");
expect(capturedPrompt).toContain("This command is mandatory before commit.");
expect(capturedPrompt).toContain("Only commit if it exits 0.");
expect(capturedPrompt).toContain("call `fn_report_build_failure`");
});
it("merge succeeds when build passes (agent reports success)", async () => {
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// Simulate commit happening by making staged check return "0" (clean)
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// After commit, diff shows clean
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
buildCommand: "pnpm build",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("merge aborts when build fails via fn_report_build_failure tool", async () => {
// Mock agent that calls the fn_report_build_failure tool execute method
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
const reportTool = opts.customTools?.find((t: any) => t.name === "fn_report_build_failure");
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// Simulate the agent calling the tool when session.prompt() is called
if (reportTool) {
await reportTool.execute("tool-call-123", { message: "Type error in src/utils.ts" });
}
}),
dispose: vi.fn(),
},
} as any;
});
const resetCalls: string[] = [];
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("reset --merge")) {
resetCalls.push(cmdStr);
return Buffer.from("");
}
// Default happy path for other commands
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// Staged changes present (agent didn't commit due to build failure)
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
buildCommand: "pnpm build",
verificationFixRetries: 0, // Disable in-merge fix for this test
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
"Build verification failed for FN-050: Type error in src/utils.ts",
);
// Verify git reset --merge was called
expect(resetCalls.length).toBeGreaterThan(0);
// Verify task was NOT moved to done
expect(store.moveTask).not.toHaveBeenCalled();
// Verify log entry was made
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
"Build verification failed during merge",
"Type error in src/utils.ts",
);
});
it("logs warning when git reset --merge fails during build-verification rollback", async () => {
const store = createMockStore(
{ id: "FN-099", worktree: "/tmp/root/.worktrees/KB-099" },
[{ id: "FN-099", worktree: "/tmp/root/.worktrees/KB-099", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
buildCommand: "pnpm build",
verificationFixRetries: 0,
});
const warnSpy = vi.spyOn(mergerLog, "warn");
const resetFailureMessage = "reset failed: dirty working tree";
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
const reportTool = opts.customTools?.find((t: any) => t.name === "fn_report_build_failure");
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
await reportTool?.execute("tool-call-1", {
message: "Type error in src/utils.ts",
});
}),
dispose: vi.fn(),
},
} as any;
});
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git reset --merge")) throw new Error(resetFailureMessage);
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
await expect(aiMergeTask(store, "/tmp/root", "FN-099")).rejects.toThrow(
"Build verification failed for FN-099: Type error in src/utils.ts",
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("FN-099: git reset --merge cleanup failed during build-verification rollback"),
);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(resetFailureMessage));
warnSpy.mockRestore();
});
it("merge proceeds normally when no build command is configured", async () => {
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// buildCommand is undefined by default in DEFAULT_SETTINGS
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("merge proceeds when buildCommand is empty string (treated as undefined)", async () => {
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
buildCommand: " ", // whitespace-only, should be treated as undefined
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("syncs dependencies before build verification when install state is missing", async () => {
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
mockedExistsSync.mockImplementation((path: any) => {
const pathStr = String(path);
if (pathStr.includes("node_modules") || pathStr.endsWith(".pnp.cjs")) return false;
return true;
});
let cachedQuietChecks = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "2 files changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "" as any;
if (cmdStr.includes("git diff --cached --name-only")) {
return "package.json\npackages/desktop/package.json" as any;
}
if (cmdStr.includes("pnpm install --frozen-lockfile")) return "Lockfile is up to date" as any;
if (cmdStr.includes("diff --cached --quiet")) {
cachedQuietChecks += 1;
return cachedQuietChecks === 1 ? "1" as any : "0" as any;
}
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
buildCommand: "pnpm build",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
const installCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("pnpm install --frozen-lockfile"),
);
expect(installCall).toBeDefined();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
"Syncing dependencies before merge build verification: pnpm install --frozen-lockfile",
);
});
});
// ── Deterministic Merge Verification Tests ──────────────────────────────
describe("aiMergeTask — deterministic merge verification", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
// Default happy path exec mock
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
});
it("runs testCommand before buildCommand when both are configured", async () => {
const verificationOrder: string[] = [];
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// Record verification command calls
if (cmdStr.includes("vitest run")) {
verificationOrder.push("test");
return Buffer.from("");
}
if (cmdStr.includes("pnpm build")) {
verificationOrder.push("build");
return Buffer.from("");
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// Simulate commit
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) {
verificationOrder.push("test");
return Buffer.from("");
}
if (cmdStr.includes("pnpm build")) {
verificationOrder.push("build");
return Buffer.from("");
}
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
buildCommand: "pnpm build",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(verificationOrder).toEqual(["test", "build"]);
});
it("writes verification start/success entries to agent log", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("show --shortstat")) return "1 file changed, 1 insertion(+)" as any;
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-050",
"Running deterministic merge verification (test: vitest run)",
"text",
undefined,
"merger",
);
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-050",
"Running test command",
"tool",
"vitest run",
"merger",
);
const appendAgentLogCalls = (store.appendAgentLog as ReturnType<typeof vi.fn>).mock.calls;
const successCall = appendAgentLogCalls.find(
([task, message, type]) => task === "FN-050"
&& message === "test command succeeded (exit 0)"
&& type === "tool_result",
);
expect(successCall).toBeTruthy();
expect(successCall?.[3]).toMatch(/^\d+ms$/);
expect(successCall?.[4]).toBe("merger");
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-050",
"Deterministic merge verification passed",
"text",
undefined,
"merger",
);
});
it("writes verification failure output summaries to agent log", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) {
const error = new Error("Test failed") as any;
error.status = 1;
error.stdout = "FAIL: some test failed";
error.stderr = "";
throw error;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
verificationFixRetries: 0,
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
"Deterministic test verification failed",
);
const appendAgentLogCalls = (store.appendAgentLog as ReturnType<typeof vi.fn>).mock.calls;
const failureCall = appendAgentLogCalls.find(
([task, message, type]) => task === "FN-050"
&& message === "test command failed (exit 1)"
&& type === "tool_error",
);
expect(failureCall).toBeTruthy();
expect(failureCall?.[3]).toContain("full output available in engine logs");
expect(failureCall?.[4]).toBe("merger");
});
it("fails merge when testCommand fails and does not move task to done", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// Fail the test command
if (cmdStr.includes("vitest run")) {
const error = new Error("Test failed") as any;
error.status = 1;
error.stdout = "FAIL: some test failed";
error.stderr = "";
throw error;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
});
try {
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
"Deterministic test verification failed",
);
const consoleErrors = errorSpy.mock.calls.flat().join("\n");
expect(consoleErrors).not.toContain("FAIL: some test failed");
} finally {
errorSpy.mockRestore();
}
// Verify task was NOT moved to done
expect(store.moveTask).not.toHaveBeenCalled();
// Verify log entry was made
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("Deterministic test verification failed"),
"VerificationError",
);
// Verify log entry contains summary (not raw output) with engine logs reference
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
const verificationFailCall = logCalls.find((call: any[]) =>
typeof call[1] === "string" && call[1].includes("[verification] test command failed"),
);
expect(verificationFailCall).toBeTruthy();
expect(verificationFailCall![1]).toContain("full output available in engine logs");
});
it("does not fail verification when verbose test output exceeds buffer after exit 0", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) {
const error = new Error("stdout maxBuffer length exceeded") as any;
error.code = "ENOBUFS";
error.status = 0;
error.stdout = "tests passed but output was verbose";
error.stderr = "";
throw error;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("show --shortstat")) return "1 file changed, 1 insertion(+)" as any;
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
}) as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringMatching(/^\[timing\] \[verification\] test command succeeded \(exit 0(?:, output exceeded buffer)?\) in \d+ms$/),
);
});
it("fails merge when buildCommand fails and does not move task to done", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Setup exec mock that will be updated after agent commits
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// Initial diff check - staged changes exist
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// After agent "commits", update mock to handle verification commands
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// test passes
if (cmdStr.includes("vitest run")) return Buffer.from("");
// Fail the build command
if (cmdStr.includes("pnpm build")) {
const error = new Error("Build failed") as any;
error.status = 1;
error.stdout = "";
error.stderr = "Type error in src/utils.ts";
throw error;
}
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
buildCommand: "pnpm build",
});
try {
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
"Deterministic build verification failed",
);
const consoleErrors = errorSpy.mock.calls.flat().join("\n");
expect(consoleErrors).not.toContain("Type error in src/utils.ts");
} finally {
errorSpy.mockRestore();
}
// Verify task was NOT moved to done
expect(store.moveTask).not.toHaveBeenCalled();
});
it("enforces verification when merge uses fallback commit", async () => {
const verificationCalls: string[] = [];
// Initial exec mock - will be updated after agent commits
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// After agent "commits", update mock for verification
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// Track verification commands
if (cmdStr.includes("vitest run")) {
verificationCalls.push("test");
return Buffer.from("");
}
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// Verification should have run
expect(verificationCalls).toContain("test");
});
it("skips verification when neither testCommand nor buildCommand is configured", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// Simulate commit
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Neither testCommand nor buildCommand configured
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// Verify no verification commands were called
const verificationCalls = mockedExecSync.mock.calls.filter(
(call) => String(call[0]).includes("vitest") || String(call[0]).includes("pnpm build"),
);
expect(verificationCalls).toHaveLength(0);
});
it("skips test and build commands when a cache hit is found for the current tree sha", async () => {
const treeSha = "cachedtreeshaabc1234567890";
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
// Return the fake tree sha when rev-parse HEAD^{tree} is called
if (cmdStr.includes("HEAD^{tree}")) return Buffer.from(treeSha + "\n");
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Simulate a cache hit for this tree sha
const cacheHit = { recordedAt: "2026-05-01T00:00:00.000Z", taskId: "FN-049" };
(store.getVerificationCacheHit as ReturnType<typeof vi.fn>).mockReturnValue(cacheHit);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
buildCommand: "pnpm build",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// No actual test/build commands should have run
const runCalls = mockedExecSync.mock.calls.filter(
(call) => String(call[0]).includes("vitest run") || String(call[0]).includes("pnpm build"),
);
expect(runCalls).toHaveLength(0);
// The cache skip message should appear in the task log
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
const cacheMsg = logCalls.find((call: any[]) =>
typeof call[1] === "string" && call[1].includes("Skipping deterministic verification — cached pass"),
);
expect(cacheMsg).toBeTruthy();
expect(cacheMsg![1]).toContain(treeSha.slice(0, 7));
expect(cacheMsg![1]).toContain("FN-049");
// getVerificationCacheHit should have been called with the tree sha and commands
expect(store.getVerificationCacheHit).toHaveBeenCalledWith(treeSha, "vitest run", "pnpm build");
});
it("runs commands and records a cache pass when no cache hit exists", async () => {
const treeSha = "freshtreedead0000beef";
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr.includes("HEAD^{tree}")) return Buffer.from(treeSha + "\n");
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// No cache hit — returns null (default mock)
(store.getVerificationCacheHit as ReturnType<typeof vi.fn>).mockReturnValue(null);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// The test command should have been executed
const testRuns = mockedExecSync.mock.calls.filter(
(call) => String(call[0]).includes("vitest run"),
);
expect(testRuns.length).toBeGreaterThan(0);
// recordVerificationCachePass should have been called with the tree sha
expect(store.recordVerificationCachePass).toHaveBeenCalledWith(
treeSha, "vitest run", "", "FN-050",
);
});
it("gracefully skips cache lookup when git rev-parse HEAD^{tree} fails", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
// Simulate git failure for tree sha resolution
if (cmdStr.includes("HEAD^{tree}")) {
const err = new Error("not a git repository") as any;
err.status = 128;
throw err;
}
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
});
// Should not throw — merge should complete normally
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// Cache methods should never have been called
expect(store.getVerificationCacheHit).not.toHaveBeenCalled();
expect(store.recordVerificationCachePass).not.toHaveBeenCalled();
// The test command should still have run
const testRuns = mockedExecSync.mock.calls.filter(
(call) => String(call[0]).includes("vitest run"),
);
expect(testRuns.length).toBeGreaterThan(0);
});
});
describe("shouldSyncDependenciesForMerge", () => {
it("returns true when install state is missing", () => {
expect(shouldSyncDependenciesForMerge([], false)).toBe(true);
});
it("returns true when staged files change package manifests or lockfiles", () => {
expect(shouldSyncDependenciesForMerge(["packages/desktop/package.json"], true)).toBe(true);
expect(shouldSyncDependenciesForMerge(["pnpm-lock.yaml"], true)).toBe(true);
});
it("returns false for regular source-only changes when install state exists", () => {
expect(shouldSyncDependenciesForMerge(["packages/engine/src/merger.ts"], true)).toBe(false);
});
});
// ── Pre-merge diffstat scope validation tests ────────────────────────
describe("parseDiffStat", () => {
it("parses standard diffstat output", () => {
const stat = [
" packages/core/src/types.ts | 9 ++--",
" packages/engine/src/notifier.ts | 46 +-----",
" 2 files changed, 10 insertions(+), 45 deletions(-)",
].join("\n");
const entries = parseDiffStat(stat);
expect(entries).toHaveLength(2);
expect(entries[0].file).toBe("packages/core/src/types.ts");
// Rounding may shift total by ±1, so check approximate range
expect(entries[0].insertions + entries[0].deletions).toBeGreaterThanOrEqual(9);
expect(entries[0].insertions + entries[0].deletions).toBeLessThanOrEqual(10);
expect(entries[1].file).toBe("packages/engine/src/notifier.ts");
expect(entries[1].deletions).toBeGreaterThan(entries[1].insertions);
});
it("handles pure-deletion lines", () => {
const stat = " packages/engine/src/usage.ts | 527 ---";
const entries = parseDiffStat(stat);
expect(entries).toHaveLength(1);
expect(entries[0].insertions).toBe(0);
expect(entries[0].deletions).toBe(527);
});
it("handles pure-insertion lines", () => {
const stat = " packages/engine/src/new.ts | 100 +++";
const entries = parseDiffStat(stat);
expect(entries).toHaveLength(1);
expect(entries[0].insertions).toBe(100);
expect(entries[0].deletions).toBe(0);
});
it("returns empty for unreadable stat", () => {
expect(parseDiffStat("(unable to read diff)")).toEqual([]);
expect(parseDiffStat("")).toEqual([]);
});
it("skips summary line", () => {
const stat = " 1 file changed, 5 insertions(+)";
expect(parseDiffStat(stat)).toEqual([]);
});
});
describe("extractFileScope", () => {
it("extracts file patterns from PROMPT.md", () => {
const prompt = [
"# Task: FN-100 - Add feature",
"",
"## File Scope",
"",
"- `packages/core/src/types.ts`",
"- `packages/engine/src/notifier.ts`",
"- `packages/dashboard/app/components/*`",
"",
"## Steps",
"",
"### Step 1: Do things",
].join("\n");
const scope = extractFileScope(prompt);
expect(scope).toEqual([
"packages/core/src/types.ts",
"packages/engine/src/notifier.ts",
"packages/dashboard/app/components/*",
]);
});
it("handles patterns with artifact annotations", () => {
const prompt = [
"## File Scope",
"",
"- `src/foo.ts` (new)",
"- `src/bar.ts` (modified)",
"",
"## Steps",
].join("\n");
const scope = extractFileScope(prompt);
expect(scope).toEqual(["src/foo.ts", "src/bar.ts"]);
});
it("returns empty for missing File Scope section", () => {
const prompt = "# Task\n\n## Steps\n### Step 1\n";
expect(extractFileScope(prompt)).toEqual([]);
});
});
describe("validateDiffScope", () => {
it("returns warnings for large deletions outside scope", async () => {
const store = {
getTask: vi.fn().mockResolvedValue({
prompt: [
"## File Scope",
"",
"- `packages/dashboard/app/components/Header.tsx`",
"",
"## Steps",
].join("\n"),
}),
logEntry: vi.fn(),
} as unknown as TaskStore;
const diffStat = [
" packages/dashboard/app/components/Header.tsx | 20 ++--",
" packages/engine/src/usage.ts | 527 ---",
" packages/engine/src/usage.test.ts | 524 ---",
" 3 files changed, 5 insertions(+), 1066 deletions(-)",
].join("\n");
const result = await validateDiffScope(store, "FN-100", diffStat);
expect(result.outOfScopeFiles).toContain("packages/engine/src/usage.ts");
expect(result.outOfScopeFiles).toContain("packages/engine/src/usage.test.ts");
expect(result.largeOutOfScopeDeletions).toHaveLength(2);
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.warnings[0]).toContain("SCOPE WARNING");
});
it("allows changeset files outside scope", async () => {
const store = {
getTask: vi.fn().mockResolvedValue({
prompt: "## File Scope\n\n- `src/foo.ts`\n\n## Steps",
}),
} as unknown as TaskStore;
const diffStat = [
" src/foo.ts | 10 +++",
" .changeset/my-change.md | 5 +++",
" 2 files changed, 15 insertions(+)",
].join("\n");
const result = await validateDiffScope(store, "FN-100", diffStat);
expect(result.outOfScopeFiles).not.toContain(".changeset/my-change.md");
expect(result.warnings).toHaveLength(0);
});
it("returns empty result when no scope is declared", async () => {
const store = {
getTask: vi.fn().mockResolvedValue({
prompt: "# Task\n\n## Steps\n",
}),
} as unknown as TaskStore;
const result = await validateDiffScope(store, "FN-100", " foo.ts | 500 ---");
expect(result.warnings).toHaveLength(0);
});
it("does not warn for in-scope changes", async () => {
const store = {
getTask: vi.fn().mockResolvedValue({
prompt: "## File Scope\n\n- `packages/engine/src/*`\n\n## Steps",
}),
} as unknown as TaskStore;
const diffStat = [
" packages/engine/src/executor.ts | 50 +++---",
" packages/engine/src/triage.ts | 30 +++---",
" 2 files changed, 40 insertions(+), 40 deletions(-)",
].join("\n");
const result = await validateDiffScope(store, "FN-100", diffStat);
expect(result.outOfScopeFiles).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
});
});
describe("resolveTaskDiffBaseRef", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("prefers merge-base when it differs from head", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git merge-base "HEAD" "main"') return "merge-base-123" as any;
if (cmdStr === 'git rev-parse "HEAD"') return "head-456" as any;
throw new Error(`Unexpected command: ${cmdStr}`);
});
const diffBase = await resolveTaskDiffBaseRef({
cwd: "/tmp/root",
headRef: "HEAD",
baseBranch: "main",
baseCommitSha: "task-base-789",
});
expect(diffBase).toBe("merge-base-123");
expect(
mockedExecSync.mock.calls.some(([cmd]) =>
String(cmd).includes("merge-base --is-ancestor"),
),
).toBe(false);
});
it("uses baseCommitSha when merge-base equals head", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git merge-base "HEAD" "main"') return "head-456" as any;
if (cmdStr === 'git rev-parse "HEAD"') return "head-456" as any;
if (cmdStr === 'git merge-base --is-ancestor "task-base-789" "HEAD"') return "" as any;
throw new Error(`Unexpected command: ${cmdStr}`);
});
const diffBase = await resolveTaskDiffBaseRef({
cwd: "/tmp/root",
headRef: "HEAD",
baseBranch: "main",
baseCommitSha: "task-base-789",
});
expect(diffBase).toBe("task-base-789");
});
it("falls back to HEAD~1 when merge-base is unavailable and baseCommitSha is stale", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git merge-base "HEAD" "main"') {
throw new Error("missing local main");
}
if (cmdStr === 'git merge-base "HEAD" "origin/main"') {
throw new Error("missing remote main");
}
if (cmdStr === 'git merge-base --is-ancestor "stale-base" "HEAD"') {
throw new Error("stale base sha");
}
if (cmdStr === 'git rev-parse "HEAD~1"') return "parent-123" as any;
throw new Error(`Unexpected command: ${cmdStr}`);
});
const diffBase = await resolveTaskDiffBaseRef({
cwd: "/tmp/root",
headRef: "HEAD",
baseBranch: "main",
baseCommitSha: "stale-base",
});
expect(diffBase).toBe("parent-123");
});
it("returns undefined when no merge base, no valid baseCommitSha, and no parent commit are available", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git merge-base "HEAD" "main"') {
throw new Error("missing local main");
}
if (cmdStr === 'git merge-base "HEAD" "origin/main"') {
throw new Error("missing remote main");
}
if (cmdStr === 'git merge-base --is-ancestor "stale-base" "HEAD"') {
throw new Error("stale base sha");
}
if (cmdStr === 'git rev-parse "HEAD~1"') {
throw new Error("single commit repo");
}
throw new Error(`Unexpected command: ${cmdStr}`);
});
const diffBase = await resolveTaskDiffBaseRef({
cwd: "/tmp/root",
headRef: "HEAD",
baseBranch: "main",
baseCommitSha: "stale-base",
});
expect(diffBase).toBeUndefined();
});
});
describe("aiMergeTask — post-merge workflow steps", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
},
} as any);
});
it("runs post-merge workflow steps after successful merge", async () => {
const store = createMockStore();
// Add getWorkflowStep to mock
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Notify",
description: "Send notifications after merge",
prompt: "Check the merged code and confirm all is well.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Override getTask to include enabledWorkflowSteps
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// getWorkflowStep should have been called for the post-merge step
expect((store as any).getWorkflowStep).toHaveBeenCalledWith("WS-001");
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
);
expect(postMergeAgentCall).toBeDefined();
expect(postMergeAgentCall?.[0]?.cwd).toMatch(/\.worktrees\/post-merge-FN-050-[a-z0-9]+/);
expect(postMergeAgentCall?.[0]?.cwd).not.toBe("/tmp/root");
// Task should still move to done even though post-merge step ran
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("does not run pre-merge workflow steps in merger", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Pre-merge Check",
description: "Check before merge",
prompt: "Run pre-merge checks.",
phase: "pre-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
await aiMergeTask(store, "/tmp/root", "FN-050");
// getWorkflowStep may be called but pre-merge steps should not trigger agent creation
// beyond the merge agent itself. We verify createFnAgent was called only once (merge agent)
// since pre-merge steps are skipped in the merger
const mergeAgentCalls = mockedCreateFnAgent.mock.calls.filter(
(c: any) => c[0]?.systemPrompt?.includes("You are a merge agent")
);
const postMergeCalls = mockedCreateFnAgent.mock.calls.filter(
(c: any) => c[0]?.systemPrompt?.includes("post-merge")
);
// No post-merge agent should be created for a pre-merge step
expect(postMergeCalls).toHaveLength(0);
});
it("appends post-merge results to existing pre-merge results", async () => {
const existingPreMergeResults = [{
workflowStepId: "WS-001",
workflowStepName: "Pre-merge Check",
phase: "pre-merge",
status: "passed",
output: "All good",
}];
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-002",
name: "Post-merge Verify",
description: "Verify after merge",
prompt: "Check merged state.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001", "WS-002"],
workflowStepResults: existingPreMergeResults,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
await aiMergeTask(store, "/tmp/root", "FN-050");
// Should have called updateTask with workflow results containing both pre and post
const updateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls;
const resultsCall = updateCalls.find((c: any) =>
Array.isArray(c[1]?.workflowStepResults) && c[1].workflowStepResults.length > 1
);
if (resultsCall) {
const results = resultsCall[1].workflowStepResults;
// Should contain both pre-merge and post-merge results
expect(results.some((r: any) => r.phase === "pre-merge")).toBe(true);
expect(results.some((r: any) => r.phase === "post-merge")).toBe(true);
}
});
it("moves task to done even when post-merge step fails", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Fail",
description: "Will fail",
prompt: "Fail this check.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
// Make the post-merge agent throw
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
if (opts.systemPrompt?.includes("post-merge")) {
return {
session: {
prompt: vi.fn().mockRejectedValue(new Error("Post-merge agent failed")),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
},
};
}
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
},
};
}) as any);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Merge should succeed regardless of post-merge step failure
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("runs script-mode post-merge steps", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Build",
description: "Verify build passes",
phase: "post-merge",
mode: "script",
scriptName: "build",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
// Override settings to include scripts
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
scripts: { build: "pnpm build" },
});
// Mock execSync to handle the script execution
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "pnpm build") return "Build successful" as any;
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
const scriptExecCall = mockedExec.mock.calls.find((call: any) => String(call[0]) === "pnpm build");
expect(scriptExecCall).toBeDefined();
expect(scriptExecCall?.[1]?.cwd).toMatch(/\.worktrees\/post-merge-FN-050-[a-z0-9]+/);
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("creates temporary worktree for post-merge steps", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Notify",
description: "Send notifications after merge",
prompt: "Check the merged code and confirm all is well.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
await aiMergeTask(store, "/tmp/root", "FN-050");
const worktreeAddCall = mockedExec.mock.calls.find((call: any) =>
String(call[0]).includes("git worktree add") && String(call[0]).includes("post-merge-FN-050-"),
);
const worktreeRemoveCall = mockedExec.mock.calls.find((call: any) =>
String(call[0]).includes("git worktree remove --force") && String(call[0]).includes("post-merge-FN-050-"),
);
expect(worktreeAddCall).toBeDefined();
expect(worktreeRemoveCall).toBeDefined();
});
it("falls back to rootDir when worktree creation fails", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Notify",
description: "Send notifications after merge",
prompt: "Check the merged code and confirm all is well.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
const baseExecImpl = mockedExecSync.getMockImplementation();
mockedExecSync.mockImplementation((cmd: any, opts: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git worktree add") && cmdStr.includes("post-merge-FN-050-")) {
const error: any = new Error("cannot create worktree");
error.stderr = "cannot create worktree";
throw error;
}
return baseExecImpl ? baseExecImpl(cmd, opts) : Buffer.from("");
});
const warnSpy = vi.spyOn(mergerLog, "warn");
await aiMergeTask(store, "/tmp/root", "FN-050");
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
);
expect(postMergeAgentCall?.[0]?.cwd).toBe("/tmp/root");
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("could not create post-merge worktree — falling back to rootDir"));
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("cleans up temporary worktree even when post-merge step fails", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Fail",
description: "Will fail",
prompt: "Fail this check.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
if (opts.systemPrompt?.includes("post-merge")) {
throw new Error("Post-merge agent creation failed");
}
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
},
};
}) as any);
await aiMergeTask(store, "/tmp/root", "FN-050");
const worktreeRemoveCall = mockedExec.mock.calls.find((call: any) =>
String(call[0]).includes("git worktree remove --force") && String(call[0]).includes("post-merge-FN-050-"),
);
expect(worktreeRemoveCall).toBeDefined();
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("does not create post-merge worktree when no post-merge steps exist", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Pre-merge Check",
description: "Check before merge",
prompt: "Run pre-merge checks.",
phase: "pre-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
await aiMergeTask(store, "/tmp/root", "FN-050");
const worktreeAddCall = mockedExec.mock.calls.find((call: any) =>
String(call[0]).includes("git worktree add") && String(call[0]).includes("post-merge-FN-050-"),
);
expect(worktreeAddCall).toBeUndefined();
});
});
// ── Merge Details Collection Tests ─────────────────────────────────────
describe("aiMergeTask — merge details collection", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("stores mergeDetails with commitSha and stats after successful merge", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD "))
return "mergedcommit123456789"; // encoding: utf-8 → string
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("show --shortstat"))
return "3 files changed, 10 insertions(+), 2 deletions(-)";
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// Find the updateTask call that set mergeDetails
const updateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls;
const mergeDetailsCall = updateCalls.find(
(call: any[]) => call[1]?.mergeDetails !== undefined,
);
expect(mergeDetailsCall).toBeDefined();
const mergeDetails = mergeDetailsCall![1].mergeDetails;
expect(mergeDetails.commitSha).toBe("mergedcommit123456789");
expect(mergeDetails.filesChanged).toBe(3);
expect(mergeDetails.insertions).toBe(10);
expect(mergeDetails.deletions).toBe(2);
expect(mergeDetails.mergeCommitMessage).toBe("- feat: something");
expect(mergeDetails.mergedAt).toBeDefined();
expect(mergeDetails.mergeConfirmed).toBe(true);
expect(mergeDetails.resolutionStrategy).toBe("ai");
expect(mergeDetails.resolutionMethod).toBe("ai");
expect(mergeDetails.attemptsMade).toBe(1);
});
it("stores AI summary in mergeDetails when useAiMergeCommitSummary is enabled", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
useAiMergeCommitSummary: true,
});
vi.spyOn(core, "summarizeMergeCommit").mockResolvedValue("AI summary of merged work.");
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123456789";
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("show --shortstat")) return "1 file changed, 1 insertion(+)";
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
const updateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls;
const mergeDetailsCall = updateCalls.find((call: any[]) => call[1]?.mergeDetails !== undefined);
expect(mergeDetailsCall?.[1].mergeDetails.mergeCommitMessage).toBe("AI summary of merged work.");
});
it("falls back to raw commit log when AI merge summary returns null", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
useAiMergeCommitSummary: true,
});
vi.spyOn(core, "summarizeMergeCommit").mockResolvedValue(null);
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123456789";
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("show --shortstat")) return "1 file changed, 1 insertion(+)";
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
const updateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls;
const mergeDetailsCall = updateCalls.find((call: any[]) => call[1]?.mergeDetails !== undefined);
expect(mergeDetailsCall?.[1].mergeDetails.mergeCommitMessage).toBe("- feat: something");
});
it("recovers owned landed commit when branch is not found", async () => {
const store = createMockStore(
{
id: "FN-3469",
worktree: "/tmp/root/.worktrees/FN-3469",
baseCommitSha: "base3469",
mergeDetails: { commitSha: "a47b1e5d78d626f8b480f1e90d3d64be2625ff6a" } as any,
},
[{ id: "FN-3469", worktree: "/tmp/root/.worktrees/FN-3469", column: "in-review" } as Task],
);
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) throw new Error("not found");
if (cmdStr.includes("merge-base --is-ancestor a47b1e5d78d626f8b480f1e90d3d64be2625ff6a HEAD")) return Buffer.from("");
if (cmdStr.includes("log -1 --format=%H%x1f%s%x1f%b a47b1e5d78d626f8b480f1e90d3d64be2625ff6a")) {
return "a47b1e5d78d626f8b480f1e90d3d64be2625ff6a\u001ffix(FN-3469): title\u001fFusion-Task-Id: FN-3469" as any;
}
if (cmdStr.includes("show --shortstat --format= a47b1e5d78d626f8b480f1e90d3d64be2625ff6a")) {
return "2 files changed, 84 insertions(+), 2 deletions(-)" as any;
}
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-3469");
expect(result.merged).toBe(false);
const mergeDetailsCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls.find(
(call: any[]) => call[1]?.mergeDetails !== undefined,
);
expect(mergeDetailsCall?.[1].mergeDetails).toEqual(expect.objectContaining({
commitSha: "a47b1e5d78d626f8b480f1e90d3d64be2625ff6a",
mergeCommitMessage: "fix(FN-3469): title",
mergeConfirmed: true,
}));
});
it("does not persist misleading mergeDetails when branch is not found and no owned commit exists", async () => {
const store = createMockStore(
{ id: "FN-3373", worktree: "/tmp/root/.worktrees/FN-3373" },
[{ id: "FN-3373", worktree: "/tmp/root/.worktrees/FN-3373", column: "in-review" } as Task],
);
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) throw new Error("not found");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-3373");
expect(result.merged).toBe(false);
const mergeDetailsCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls.find(
(call: any[]) => call[1]?.mergeDetails !== undefined,
);
expect(mergeDetailsCall).toBeUndefined();
});
it("completes merge even when git commands fail during merge details collection", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Use prefer-branch: this test simulates `git rev-parse HEAD` failing to
// exercise merge-details fallback. The local-base rebase stage hits the
// same command first; under prefer-main its failure would hard-fail. This
// test isn't about prefer-main semantics, so opt out.
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart-prefer-branch",
});
let revParseHeadCalled = false;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) {
revParseHeadCalled = true;
throw new Error("git rev-parse HEAD failed");
}
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Merge should still succeed even though merge details collection failed
expect(result.merged).toBe(true);
expect(revParseHeadCalled).toBe(true);
// No mergeDetails should have been stored
const updateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls;
const mergeDetailsCall = updateCalls.find(
(call: any[]) => call[1]?.mergeDetails !== undefined,
);
expect(mergeDetailsCall).toBeUndefined();
// Task should still be moved to done
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("handles missing shortstat gracefully when show --shortstat fails", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD "))
return "mergedcommit123"; // encoding: utf-8 → string
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
// show --shortstat fails
if (cmdStr.includes("show --shortstat")) throw new Error("show failed");
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// mergeDetails should still be stored with commitSha but without stats
const updateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls;
const mergeDetailsCall = updateCalls.find(
(call: any[]) => call[1]?.mergeDetails !== undefined,
);
expect(mergeDetailsCall).toBeDefined();
const mergeDetails = mergeDetailsCall![1].mergeDetails;
expect(mergeDetails.commitSha).toBe("mergedcommit123");
// Stats should be undefined since show --shortstat failed (inner catch sets them as undefined)
expect(mergeDetails.filesChanged).toBeUndefined();
expect(mergeDetails.insertions).toBeUndefined();
expect(mergeDetails.deletions).toBeUndefined();
});
});
describe("aiMergeTask — fresh session and compaction recovery", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
function setupFreshSessionExecSync() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
it("creates a fresh session for merge agent via createFnAgent", async () => {
setupFreshSessionExecSync();
const sessionInstances: any[] = [];
mockedCreateFnAgent.mockImplementation(async () => {
const session = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
};
sessionInstances.push(session);
// Use type assertion to match expected return type
return { session } as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "FN-050");
// Session should be created once for the merge agent
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
expect(sessionInstances.length).toBe(1);
});
it("disposes session after merge agent completes (finally block)", async () => {
setupFreshSessionExecSync();
const mockDispose = vi.fn();
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: mockDispose,
};
mockedCreateFnAgent.mockResolvedValue({ session: mockSession } as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "FN-050");
// Session should be disposed via finally block
expect(mockDispose).toHaveBeenCalledTimes(1);
});
it("imports compactSessionContext and isContextLimitError from respective modules", async () => {
// This test verifies the imports are present in merger.ts
// The actual functionality is tested via behavior verification
const mergerModule = await import("../merger.js");
expect(mergerModule).toBeDefined();
});
});
// ── Merge Prompt Truncation Tests ─────────────────────────────────────
describe("buildSourceIssueRef", () => {
it("returns owner/repo#number for GitHub source issues", async () => {
const { buildSourceIssueRef } = await import("../merger.js");
expect(buildSourceIssueRef({
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "123",
issueNumber: 123,
})).toBe("runfusion/fusion#123");
});
it("falls back to externalIssueId when issueNumber is missing", async () => {
const { buildSourceIssueRef } = await import("../merger.js");
expect(buildSourceIssueRef({
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "321",
} as any)).toBe("runfusion/fusion#321");
});
it("returns empty string for non-GitHub providers", async () => {
const { buildSourceIssueRef } = await import("../merger.js");
expect(buildSourceIssueRef({
provider: "gitlab",
repository: "group/project",
externalIssueId: "123",
issueNumber: 123,
})).toBe("");
});
it("returns empty string for nullish source issue", async () => {
const { buildSourceIssueRef } = await import("../merger.js");
expect(buildSourceIssueRef(undefined)).toBe("");
expect(buildSourceIssueRef(null)).toBe("");
expect(buildSourceIssueRef({
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "not-a-number",
} as any)).toBe("");
});
});
describe("buildMergePrompt — truncation behavior", () => {
it("truncates commit log when exceeding MERGE_COMMIT_LOG_MAX_CHARS", async () => {
const { buildMergePrompt } = await import("../merger.js");
// Create a commit log that exceeds 5000 characters
const longCommitLog = "- " + "a".repeat(6000);
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: longCommitLog,
diffStat: "1 file changed",
hasConflicts: false,
});
// The prompt should contain truncation indicator
expect(prompt).toContain("... (truncated)");
// The truncated version should be shorter than original
expect(prompt.indexOf("- " + "a".repeat(5000))).toBe(-1);
});
it("truncates diff stat when exceeding MERGE_DIFF_STAT_MAX_CHARS", async () => {
const { buildMergePrompt } = await import("../merger.js");
// Create a diff stat that exceeds 3000 characters
const longDiffStat = "file.ts | " + " ".repeat(10) + "x".repeat(4000);
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: longDiffStat,
hasConflicts: false,
});
// The prompt should contain truncation indicator
expect(prompt).toContain("... (truncated)");
// The diff stat section should not contain the full long content
expect(prompt.indexOf("x".repeat(3000))).toBe(-1);
});
it("preserves short content unchanged (under limits)", async () => {
const { buildMergePrompt } = await import("../merger.js");
const shortCommitLog = "- feat: add login\n- fix: correct typo";
const shortDiffStat = "src/login.ts | 5 +++\n1 file changed";
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: shortCommitLog,
diffStat: shortDiffStat,
hasConflicts: false,
});
// Should not contain truncation markers
expect(prompt).not.toContain("... (truncated)");
// Should contain original content
expect(prompt).toContain(shortCommitLog);
expect(prompt).toContain(shortDiffStat);
});
it("truncates commit log but not diff stat when only commit log is over limit", async () => {
const { buildMergePrompt } = await import("../merger.js");
const longCommitLog = "- " + "b".repeat(6000);
const shortDiffStat = "1 file changed";
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: longCommitLog,
diffStat: shortDiffStat,
hasConflicts: false,
});
// Should contain truncation for commit log
expect(prompt).toContain("... (truncated)");
// Diff stat should be unchanged
expect(prompt).toContain(shortDiffStat);
});
it("includes author arg in no-conflicts commit instruction", async () => {
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: "1 file changed",
hasConflicts: false,
authorArg: ' --author="Fusion <noreply@runfusion.ai>"',
});
expect(prompt).toContain('Be sure to include `--author="Fusion <noreply@runfusion.ai>"` in the commit command');
});
it("includes author arg in conflicts commit instruction", async () => {
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: "1 file changed",
hasConflicts: true,
authorArg: ' --author="CustomBot <bot@example.com>"',
});
expect(prompt).toContain('Be sure to include `--author="CustomBot <bot@example.com>"` in the commit command');
});
it("omits author instruction when authorArg is not provided", async () => {
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: "1 file changed",
hasConflicts: false,
});
expect(prompt).not.toContain("Be sure to include");
expect(prompt).toContain("Write and run the `git commit` command with a good message summarizing the work");
});
it("handles empty authorArg gracefully", async () => {
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: "1 file changed",
hasConflicts: false,
authorArg: "",
});
expect(prompt).not.toContain("Be sure to include");
});
it("includes source issue reference guidance when provided", async () => {
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: "1 file changed",
hasConflicts: false,
sourceIssueRef: "runfusion/fusion#2915",
});
expect(prompt).toContain("Include this in the commit message body:");
expect(prompt).toContain("Ref: runfusion/fusion#2915");
});
it("omits source issue reference guidance when not provided", async () => {
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: "1 file changed",
hasConflicts: false,
});
expect(prompt).not.toContain("Include this in the commit message body:");
expect(prompt).not.toContain("Ref: runfusion/fusion#2915");
});
});
// ── Context Limit Recovery Tests ─────────────────────────────────────
describe("aiMergeTask — context limit recovery with truncation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
function setupContextLimitExecSync() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
it("retries with minimal prompt when context limit hit after auto-compaction", async () => {
const { isContextLimitError } = await import("../context-limit-detector.js");
vi.mocked(isContextLimitError).mockReturnValue(true);
// Track prompt calls
const promptCalls: string[] = [];
let firstCall = true;
mockedCreateFnAgent.mockImplementation(async () => {
const session = {
prompt: vi.fn().mockImplementation(async (prompt: string) => {
promptCalls.push(prompt);
if (firstCall) {
firstCall = false;
throw new Error("context window exceeds limit (2013)");
}
// Second call succeeds
}),
dispose: vi.fn(),
};
return { session } as any;
});
setupContextLimitExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Merge should succeed after truncated retry
expect(result.merged).toBe(true);
// Should have made 2 prompt calls
expect(promptCalls).toHaveLength(2);
// First call had original prompt, second call should have simplified prompt
expect(promptCalls[0]).toContain("## Branch commits");
// Second call uses simplifiedContext=true, so it should NOT contain "## Files changed"
expect(promptCalls[1]).not.toContain("## Files changed");
// Second call should have the minimal placeholder
expect(promptCalls[1]).toContain("(see git log)");
// Note: Compaction is now handled by promptWithFallback, not by the merger directly
});
it("throws when truncated retry also fails with context limit", async () => {
const { isContextLimitError } = await import("../context-limit-detector.js");
vi.mocked(isContextLimitError).mockReturnValue(true);
// Track prompt calls to verify both original and truncated prompts were tried
const promptCalls: string[] = [];
mockedCreateFnAgent.mockImplementation(async () => {
const session = {
prompt: vi.fn().mockImplementation(async (prompt: string) => {
promptCalls.push(prompt);
// Both calls fail with context limit error
throw new Error("context window exceeds limit (2013)");
}),
dispose: vi.fn(),
};
return { session } as any;
});
// Setup that simulates both attempts failing (first fails, attempts 2 and 3 also fail)
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
// All merge attempts fail
if (cmdStr.includes("merge --squash") || cmdStr.includes("merge -X")) {
throw new Error("merge conflict");
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "src/file.ts";
// git diff-tree for trivial whitespace detection - return real changes (non-trivial)
if (cmdStr.includes("diff-tree")) {
const error = new Error("exit code 1") as any;
error.stdout = "+const x = 2;\n-const x = 1;";
throw error;
}
if (cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
smartConflictResolution: true, // Enable all 3 attempts
});
// Should throw after all attempts exhausted
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow("all 3 attempts exhausted");
// Verify both original and truncated prompts were attempted (2 attempts for attempt 1)
// Each merge attempt calls promptWithFallback twice (original + truncated when compaction fails)
// With 3 merge attempts, this means we should have at least 6 prompt calls total
expect(promptCalls.length).toBeGreaterThan(0);
// Note: Compaction is now handled by promptWithFallback, not by the merger directly
});
it("succeeds when prompt succeeds on retry after context error", async () => {
const { isContextLimitError } = await import("../context-limit-detector.js");
vi.mocked(isContextLimitError).mockReturnValue(true);
// Track prompt calls
const promptCalls: string[] = [];
let firstCall = true;
mockedCreateFnAgent.mockImplementation(async () => {
const session = {
prompt: vi.fn().mockImplementation(async (prompt: string) => {
promptCalls.push(prompt);
if (firstCall) {
firstCall = false;
throw new Error("context window exceeds limit (2013)");
}
// Second call succeeds
}),
dispose: vi.fn(),
};
return { session } as any;
});
setupContextLimitExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Merge should succeed after retry
expect(result.merged).toBe(true);
// Should have made 2 prompt calls
expect(promptCalls).toHaveLength(2);
// Note: Compaction is now handled by promptWithFallback, not by the merger directly
});
it("does not attempt truncation retry for non-context errors", async () => {
const { compactSessionContext } = await import("../pi.js");
const { isContextLimitError } = await import("../context-limit-detector.js");
// Non-context error should not trigger recovery path
vi.mocked(compactSessionContext).mockResolvedValue(null);
vi.mocked(isContextLimitError).mockReturnValue(false);
// Mock non-context error
mockedCreateFnAgent.mockImplementation(async () => {
const session = {
prompt: vi.fn().mockRejectedValue(new Error("connection refused")),
dispose: vi.fn(),
};
return { session } as any;
});
// Setup that simulates merge failing - make merge --squash throw so auto-resolution isn't triggered
// Also make commit fail so all attempts exhaust
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
// All merge attempts fail - make merge --squash throw with conflicts
if (cmdStr.includes("merge --squash") || cmdStr.includes("merge -X")) {
const err = new Error("merge conflict");
err.name = "ExecSyncError";
throw err;
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "src/file.ts";
if (cmdStr.includes("reset --merge")) return Buffer.from("");
// Make commit fail so attempt 2's auto-resolution also fails
if (cmdStr.includes("git commit")) {
const err = new Error("commit failed");
err.name = "ExecSyncError";
throw err;
}
return Buffer.from("");
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
smartConflictResolution: true,
});
// Should throw without attempting compaction or truncation
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow("all 3 attempts exhausted");
// Compaction should NOT have been called for non-context errors
expect(vi.mocked(compactSessionContext)).not.toHaveBeenCalled();
});
});
describe("summarizeVerificationOutput", () => {
it("extracts vitest-style test summary with failure names", () => {
const output = [
"some setup output...",
"FAIL src/utils.test.ts",
" ✗ should validate input",
" ✗ should handle edge case",
"Tests: 2 failed, 48 passed, 50 total",
].join("\n");
const result = summarizeVerificationOutput(output, "test");
expect(result).toContain("Tests: 2 failed, 48 passed, 50 total");
expect(result).toContain("should validate input");
expect(result).toContain("full output available in engine logs");
});
it("limits failure names to 5 with overflow indicator", () => {
// Build output with 7 FAIL lines
const output = [
"FAIL test1",
"FAIL test2",
"FAIL test3",
"FAIL test4",
"FAIL test5",
"FAIL test6",
"FAIL test7",
"Tests: 7 failed, 0 passed, 7 total",
].join("\n");
const result = summarizeVerificationOutput(output, "test");
expect(result).toContain("test5");
expect(result).toContain("... and 2 more failures");
expect(result).not.toContain("test6");
});
it("falls back to first 500 chars for unstructured output", () => {
const output = "A".repeat(1000);
const result = summarizeVerificationOutput(output, "build");
expect(result.length).toBeLessThan(600);
expect(result).toContain("full output available in engine logs");
});
it("returns generic message for empty output", () => {
const result = summarizeVerificationOutput("", "test");
expect(result).toContain("no output");
expect(result).toContain("full output available in engine logs");
});
it("deduplicates identical failure names", () => {
const output = [
"FAIL src/a.test.ts",
"FAIL src/a.test.ts", // duplicate
"FAIL src/b.test.ts",
"Tests: 3 failed, 0 passed",
].join("\n");
const result = summarizeVerificationOutput(output, "test");
// Should contain only unique names (src/a.test.ts once, src/b.test.ts)
const bulletMatches = result.match(/• /g);
expect(bulletMatches?.length).toBe(2);
});
});
// ── Default Test Command Inference Tests ──────────────────────────────────
describe("inferDefaultTestCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: no lock files present
mockedExistsSync.mockReturnValue(false);
});
it("returns null when no package manager lock files exist", () => {
mockedExistsSync.mockReturnValue(false);
const result = inferDefaultTestCommand("/tmp/root");
expect(result).toBeNull();
});
it("returns pnpm test for pnpm-lock.yaml", () => {
mockedExistsSync.mockImplementation((path: any) => {
return String(path).includes("pnpm-lock.yaml");
});
const result = inferDefaultTestCommand("/tmp/root");
expect(result).toEqual({
command: "pnpm test",
testSource: "inferred",
});
});
it("returns npm test for package-lock.json", () => {
mockedExistsSync.mockImplementation((path: any) => {
return String(path).includes("package-lock.json");
});
const result = inferDefaultTestCommand("/tmp/root");
expect(result).toEqual({
command: "npm test",
testSource: "inferred",
});
});
it("returns yarn test for yarn.lock", () => {
mockedExistsSync.mockImplementation((path: any) => {
return String(path).includes("yarn.lock");
});
const result = inferDefaultTestCommand("/tmp/root");
expect(result).toEqual({
command: "yarn test",
testSource: "inferred",
});
});
it("returns bun test for bun.lock", () => {
mockedExistsSync.mockImplementation((path: any) => {
return String(path).includes("bun.lock");
});
const result = inferDefaultTestCommand("/tmp/root");
expect(result).toEqual({
command: "bun test",
testSource: "inferred",
});
});
it("returns bun test for bun.lockb", () => {
mockedExistsSync.mockImplementation((path: any) => {
return String(path).includes("bun.lockb");
});
const result = inferDefaultTestCommand("/tmp/root");
expect(result).toEqual({
command: "bun test",
testSource: "inferred",
});
});
it("prefers pnpm over npm when both exist", () => {
mockedExistsSync.mockImplementation((path: any) => {
const pathStr = String(path);
return pathStr.includes("pnpm-lock.yaml") || pathStr.includes("package-lock.json");
});
const result = inferDefaultTestCommand("/tmp/root");
expect(result?.command).toBe("pnpm test");
});
it("uses explicit testCommand when provided", () => {
mockedExistsSync.mockImplementation((path: any) => {
return String(path).includes("pnpm-lock.yaml");
});
const result = inferDefaultTestCommand("/tmp/root", "vitest run", "pnpm build");
expect(result).toEqual({
command: "vitest run",
testSource: "explicit",
buildSource: "explicit",
});
});
it("ignores empty string explicit testCommand", () => {
mockedExistsSync.mockImplementation((path: any) => {
return String(path).includes("pnpm-lock.yaml");
});
const result = inferDefaultTestCommand("/tmp/root", "", "pnpm build");
expect(result?.command).toBe("pnpm test");
expect(result?.testSource).toBe("inferred");
expect(result?.buildSource).toBe("explicit");
});
it("ignores whitespace-only explicit testCommand", () => {
mockedExistsSync.mockImplementation((path: any) => {
return String(path).includes("pnpm-lock.yaml");
});
const result = inferDefaultTestCommand("/tmp/root", " ", "pnpm build");
expect(result?.command).toBe("pnpm test");
expect(result?.testSource).toBe("inferred");
});
it("returns build source even when test is inferred", () => {
mockedExistsSync.mockImplementation((path: any) => {
return String(path).includes("pnpm-lock.yaml");
});
const result = inferDefaultTestCommand("/tmp/root", undefined, "pnpm build");
expect(result).toEqual({
command: "pnpm test",
testSource: "inferred",
buildSource: "explicit",
});
});
});
// ── Inferred Test Command Merge Behavior ─────────────────────────────────
describe("aiMergeTask — inferred test command execution", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("runs inferred test command when settings.testCommand is not configured", async () => {
// pnpm-lock.yaml exists, testCommand is not set
mockedExistsSync.mockImplementation((path: any) => {
const pathStr = String(path);
if (pathStr.includes("pnpm-lock.yaml")) return true;
return true; // other files exist for worktree check
});
const verificationCalls: string[] = [];
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("pnpm test")) {
verificationCalls.push("pnpm test");
return Buffer.from("");
}
// Handle all other git commands - matching setupHappyPathExecSync
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// testCommand is not set (undefined in DEFAULT_SETTINGS)
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(verificationCalls).toContain("pnpm test");
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("logs that test command was inferred from project files", async () => {
mockedExistsSync.mockImplementation((path: any) => {
return String(path).includes("pnpm-lock.yaml");
});
// Setup happy path
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("pnpm test")) return Buffer.from("");
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
await aiMergeTask(store, "/tmp/root", "FN-050");
// Verify log entries include verification with test command mentioned
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
const verificationLogCall = logCalls.find((call: any[]) =>
typeof call[1] === "string" && call[1].includes("pnpm test")
);
expect(verificationLogCall).toBeTruthy();
});
it("failing inferred test command blocks merge and keeps task out of done", async () => {
mockedExistsSync.mockImplementation((path: any) => {
return String(path).includes("pnpm-lock.yaml");
});
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("pnpm test")) {
// Simulate test failure
const error = new Error("Test failed") as any;
error.status = 1;
error.stdout = "FAIL: test failed";
error.stderr = "";
throw error;
}
// Handle other commands normally
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
"Deterministic test verification failed",
);
// Task should NOT be moved to done
expect(store.moveTask).not.toHaveBeenCalledWith("FN-050", "done");
// Log entry should indicate failure
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("test verification failed"),
"VerificationError",
);
});
it("explicit settings.testCommand takes precedence over inferred command", async () => {
mockedExistsSync.mockImplementation((path: any) => {
return String(path).includes("pnpm-lock.yaml");
});
const verificationCalls: string[] = [];
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("vitest run")) {
verificationCalls.push("vitest run");
return Buffer.from("");
}
if (cmdStr.includes("pnpm test")) {
verificationCalls.push("pnpm test - SHOULD NOT BE CALLED");
return Buffer.from("");
}
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Explicit testCommand is set
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
});
await aiMergeTask(store, "/tmp/root", "FN-050");
// Explicit command should be used, not inferred
expect(verificationCalls).toContain("vitest run");
expect(verificationCalls).not.toContain("pnpm test - SHOULD NOT BE CALLED");
});
it("skips verification when no lock files exist and no explicit testCommand is set", async () => {
// No lock files exist
mockedExistsSync.mockReturnValue(false);
const verificationCalls: string[] = [];
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("pnpm test") || cmdStr.includes("npm test") || cmdStr.includes("yarn test") || cmdStr.includes("bun test")) {
verificationCalls.push(cmdStr);
}
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// No test verification should have run
expect(verificationCalls).toHaveLength(0);
// Merge should still succeed
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
});
describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)", () => {
// Mock session-skill-context to control skill selection behavior
vi.mock("../session-skill-context.js", () => ({
buildSessionSkillContext: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
});
it("passes skillSelection to createFnAgent when agentStore is provided", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["fusion"],
sessionPurpose: "merger",
},
resolvedSkillNames: ["fusion"],
skillSource: "role-fallback",
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateFnAgent).toHaveBeenCalled();
// Find the first createFnAgent call (main merger agent)
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection).toBeDefined();
expect(opts.skillSelection!.projectRootDir).toBe("/tmp/root");
expect(opts.skillSelection!.requestedSkillNames).toEqual(["fusion"]);
expect(opts.skillSelection!.sessionPurpose).toBe("merger");
});
it("uses assigned agent skills when available", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["custom-skill", "another-skill"],
sessionPurpose: "merger",
},
resolvedSkillNames: ["custom-skill", "another-skill"],
skillSource: "assigned-agent",
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", assignedAgentId: "agent-001" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection).toBeDefined();
expect(opts.skillSelection!.requestedSkillNames).toEqual(["custom-skill", "another-skill"]);
});
it("does not pass skillSelection when buildSessionSkillContext returns undefined context", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: undefined,
resolvedSkillNames: [],
skillSource: "none",
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
// skillSelection should not be present when context is undefined
expect("skillSelection" in opts).toBe(false);
});
it("does not pass skillSelection when agentStore is not provided", async () => {
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
// No agentStore provided
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect("skillSelection" in opts).toBe(false);
});
it("gracefully handles buildSessionSkillContext throwing", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockRejectedValue(new Error("Agent not found"));
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
// Should not throw - graceful fallback
const result = await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(result.merged).toBe(true);
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect("skillSelection" in opts).toBe(false);
});
it("records resolved skill names in skill context result", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
const resolvedNames = ["skill-a", "skill-b"];
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: resolvedNames,
sessionPurpose: "merger",
},
resolvedSkillNames: resolvedNames,
skillSource: "assigned-agent",
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", assignedAgentId: "agent-001" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection?.requestedSkillNames).toEqual(resolvedNames);
});
it("uses sessionPurpose='merger' in skill selection context", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["fusion"],
sessionPurpose: "merger",
},
resolvedSkillNames: ["fusion"],
skillSource: "role-fallback",
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection?.sessionPurpose).toBe("merger");
});
});
describe("aiMergeTask — skill selection non-fatal diagnostics (FN-1510/FN-1511)", () => {
// Mock session-skill-context to control skill selection behavior
vi.mock("../session-skill-context.js", () => ({
buildSessionSkillContext: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("merge continues when skill selection produces diagnostics", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
// Simulate diagnostics being logged - the resolver would produce these
// when requested skills are not found or filtered
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["nonexistent-skill"],
sessionPurpose: "merger",
},
resolvedSkillNames: [],
skillSource: "none",
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
// Merge should succeed even when skill diagnostics are present
const result = await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("records skill source in context result for debugging", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["custom-skill"],
sessionPurpose: "merger",
},
resolvedSkillNames: ["custom-skill"],
skillSource: "assigned-agent",
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", assignedAgentId: "agent-001" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
const result = await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
// Result should be successful regardless of skill source
expect(result.merged).toBe(true);
// Verify skillSelection was passed with the custom skill
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection?.requestedSkillNames).toEqual(["custom-skill"]);
});
});
describe("aiMergeTask — in-merge verification fix", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
mockedExistsSync.mockReturnValue(true);
});
it("verification fix is attempted when verification fails", async () => {
// Simple mock: always fail verification
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) {
const err = new Error("Test failed") as any;
err.status = 1;
err.stdout = "";
err.stderr = "";
throw err;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
const isFixAgent = opts.systemPrompt?.includes("verification fix agent");
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
verificationFixRetries: 1,
});
// With verificationFixRetries: 1, the merge should fail with VerificationError
// because the fix agent can't fix the verification (it's mocked to not actually fix anything)
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({
name: "VerificationError",
});
// Verify that fix agent was spawned (3 calls: summarizer + merger + fix)
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(3);
// Verify the fix agent was called with correct options
const fixAgentCall = mockedCreateFnAgent.mock.calls[1];
expect(fixAgentCall[0].tools).toBe("coding");
expect(fixAgentCall[0].cwd).toBe("/tmp/root");
});
it("logs fix-agent startup metadata, streams callbacks, and logs rerun lifecycle", async () => {
let capturedFixOptions: any;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) {
const err = new Error("Test failed") as any;
err.status = 1;
err.stdout = "";
err.stderr = "";
throw err;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
const isFixAgent = opts.systemPrompt?.includes("verification fix agent");
if (!isFixAgent) {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
}
capturedFixOptions = opts;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
opts.onText?.("working on fix");
opts.onThinking?.("diagnosing");
opts.onToolStart?.("Bash", { command: "vitest run" });
opts.onToolEnd?.("Bash", false, "still failing");
}),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
verificationFixRetries: 1,
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({
name: "VerificationError",
});
expect(capturedFixOptions).toBeDefined();
expect(capturedFixOptions.onText).toBeTypeOf("function");
expect(capturedFixOptions.onThinking).toBeTypeOf("function");
expect(capturedFixOptions.onToolStart).toBeTypeOf("function");
expect(capturedFixOptions.onToolEnd).toBeTypeOf("function");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "Bash", "tool", "vitest run", "merger");
const logMessages = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls
.map((call: any[]) => call[1])
.filter((message: unknown): message is string => typeof message === "string");
const startupLog = logMessages.find((message) =>
message.includes("In-merge verification fix agent started"),
);
expect(startupLog).toBeDefined();
expect(startupLog).toContain("model: mock-provider/mock-model");
expect(startupLog).toContain("agentId: merger");
expect(startupLog).toMatch(/runId: merge-FN-050-/);
const rerunIdx = logMessages.findIndex((message) =>
message.includes("Re-running deterministic merge verification (attempt 1)"),
);
expect(rerunIdx).toBeGreaterThan(-1);
const verificationAfterRerunIdx = logMessages.findIndex(
(message, index) =>
index > rerunIdx && message.includes("[verification] Running test command: vitest run"),
);
expect(verificationAfterRerunIdx).toBeGreaterThan(rerunIdx);
});
it("logs rerun lifecycle before verification in build-failure fix path", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("pnpm build")) {
const err = new Error("Build failed") as any;
err.status = 1;
err.stdout = "";
err.stderr = "";
throw err;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
const isFixAgent = opts.systemPrompt?.includes("verification fix agent");
if (isFixAgent) {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
}
const reportTool = opts.customTools?.find((t: any) => t.name === "fn_report_build_failure");
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
await reportTool?.execute("tool-call-build", { message: "Type error in src/build.ts" });
}),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
buildCommand: "pnpm build",
verificationFixRetries: 1,
buildRetryCount: 0,
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
"Build verification failed for FN-050: Type error in src/build.ts",
);
const logMessages = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls
.map((call: any[]) => call[1])
.filter((message: unknown): message is string => typeof message === "string");
const rerunIdx = logMessages.findIndex((message) =>
message.includes("Re-running deterministic merge verification (attempt 1)"),
);
expect(rerunIdx).toBeGreaterThan(-1);
const verificationAfterRerunIdx = logMessages.findIndex(
(message, index) =>
index > rerunIdx && message.includes("[verification] Running build command: pnpm build"),
);
expect(verificationAfterRerunIdx).toBeGreaterThan(rerunIdx);
});
it("verification fix is skipped when verificationFixRetries is 0", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) {
const err = new Error("Test failed") as any;
err.status = 1;
err.stdout = "";
err.stderr = "";
throw err;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
verificationFixRetries: 0,
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({
name: "VerificationError",
});
// Verify fix agent was NOT spawned (summarizer + merger only)
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
// Verify no fix attempt was logged
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
const fixAttempts = logCalls.filter((call: any[]) =>
typeof call[1] === "string" && call[1].includes("in-merge verification fix"),
);
expect(fixAttempts).toHaveLength(0);
});
it("fix agent uses same model settings as merger", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) {
const err = new Error("Test failed") as any;
err.status = 1;
err.stdout = "";
err.stderr = "";
throw err;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async (_opts: any) => {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
verificationFixRetries: 1,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({
name: "VerificationError",
});
// Verify fix agent uses same model settings
const fixAgentCall = mockedCreateFnAgent.mock.calls[1];
expect(fixAgentCall[0].defaultProvider).toBe("anthropic");
expect(fixAgentCall[0].defaultModelId).toBe("claude-sonnet-4-5");
});
it("uses project default override for merge agent model", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) {
const err = new Error("Test failed") as any;
err.status = 1;
err.stdout = "";
err.stderr = "";
throw err;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async (_opts: any) => {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
verificationFixRetries: 0,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({
name: "VerificationError",
});
const mergeAgentCall = mockedCreateFnAgent.mock.calls[0];
expect(mergeAgentCall[0].defaultProvider).toBe("openai");
expect(mergeAgentCall[0].defaultModelId).toBe("gpt-4o");
});
it("fix agent session is disposed", async () => {
const disposeMock = vi.fn();
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) {
const err = new Error("Test failed") as any;
err.status = 1;
err.stdout = "";
err.stderr = "";
throw err;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
const isFixAgent = opts.systemPrompt?.includes("verification fix agent");
if (isFixAgent) {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: disposeMock,
},
} as any;
}
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
verificationFixRetries: 1,
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({
name: "VerificationError",
});
expect(disposeMock).toHaveBeenCalled();
});
it("max fix retries capped at 3", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) {
const err = new Error("Test failed") as any;
err.status = 1;
err.stdout = "";
err.stderr = "";
throw err;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
verificationFixRetries: 10, // Exceeds max
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({
name: "VerificationError",
});
// Should have 3 fix attempts (capped at 3) + summarizer + merger = 5 calls
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(5);
});
it("default verificationFixRetries (omitted) results in 3 fix attempts", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) {
const err = new Error("Test failed") as any;
err.status = 1;
err.stdout = "";
err.stderr = "";
throw err;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
return Buffer.from("");
});
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Explicitly omit verificationFixRetries to test default behavior
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
// verificationFixRetries is NOT set — should default to 3
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({
name: "VerificationError",
});
// Should have 3 fix attempts (default) + summarizer + merger = 5 calls
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(5);
// Verify the log shows 3 fix attempts (2 log entries per attempt: start + failure)
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
const fixAttempts = logCalls.filter((call: any[]) =>
typeof call[1] === "string" && call[1].includes("In-merge verification fix attempt"),
);
// Each attempt produces 2 log entries: "attempt X/3" and "attempt X — verification still fails"
expect(fixAttempts).toHaveLength(6);
expect(fixAttempts[0][1]).toContain("attempt 1/3");
expect(fixAttempts[2][1]).toContain("attempt 2/3");
expect(fixAttempts[4][1]).toContain("attempt 3/3");
});
});