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) => 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 = {}, 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).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).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).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).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).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).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).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).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
` 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).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).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).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).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).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).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("(): "); 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).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(": "); expect(agentCall.systemPrompt).not.toContain("(): "); 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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).mockReturnValue(cacheHit); (store.getSettings as ReturnType).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).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).mockReturnValue(null); (store.getSettings as ReturnType).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).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).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).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).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).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).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).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).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).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).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).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).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).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 "', }); expect(prompt).toContain('Be sure to include `--author="Fusion "` 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 "', }); expect(prompt).toContain('Be sure to include `--author="CustomBot "` 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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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"); }); });