/**
 * run-unit-via-swarm.test.mjs — Tests for the SF_AUTONOMOUS_VIA_SWARM opt-in path in runUnit.
 *
 * Purpose: verify that when SF_AUTONOMOUS_VIA_SWARM=1 is set, runUnit routes through
 * swarmDispatchAndWait instead of the legacy pi.sendMessage path, and that the swarm
 * result is mapped back to the correct UnitResult shape.
 *
 * When the flag is unset, the swarm path must NOT be called (default behavior unchanged).
 *
 * Consumer: CI unit-test suite (`npm run test:unit`).
 */

import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

// ─── Mock swarm-dispatch.js ───────────────────────────────────────────────────
// Stub swarmDispatchAndWait so tests never touch SQLite, swarm topology, or LLM.
// Uses vi.hoisted so the factory reference is available when vi.mock is hoisted.

const MOCK_REPLY = "swarm agent reply: unit complete";
const MOCK_REPLY_ID = "msg-swarm-001";
const MOCK_TARGET = "worker-1";

const { mockSwarmDispatchAndWait } = vi.hoisted(() => {
	const fn = vi.fn();
	return { mockSwarmDispatchAndWait: fn };
});

vi.mock("../uok/swarm-dispatch.js", () => ({
	swarmDispatchAndWait: mockSwarmDispatchAndWait,
	swarmDispatch: vi.fn(),
	SwarmDispatchLayer: vi.fn(),
}));

// ─── Mock preferences.js ─────────────────────────────────────────────────────
// resolveAutoSupervisorConfig is called in runUnitViaSwarm to compute timeoutMs.

vi.mock("../preferences.js", () => ({
	resolveAutoSupervisorConfig: vi.fn(() => ({ hard_timeout_minutes: 8 })),
	resolvePersistModelChanges: vi.fn(() => false),
}));

// ─── Mock autonomous-solver.js ────────────────────────────────────────────────
// appendAutonomousSolverCheckpoint is called in runUnitViaSwarm to synthesize a
// checkpoint so phases-unit.js's assessAutonomousSolverTurn does not fire the
// missing-checkpoint repair loop (Bug 2 fix).

const { mockAppendCheckpoint } = vi.hoisted(() => {
	const fn = vi.fn();
	return { mockAppendCheckpoint: fn };
});

vi.mock("../autonomous-solver.js", () => ({
	appendAutonomousSolverCheckpoint: mockAppendCheckpoint,
	MAX_CHECKPOINTS_PER_ITERATION: 5,
}));

// ─── Mock journal.js ──────────────────────────────────────────────────────────
// Stub emitJournalEvent so tests never touch the filesystem journal.

const { mockEmitJournalEvent } = vi.hoisted(() => {
	const fn = vi.fn();
	return { mockEmitJournalEvent: fn };
});

vi.mock("../journal.js", () => ({
	emitJournalEvent: mockEmitJournalEvent,
	queryJournal: vi.fn(async () => []),
}));

// ─── Mock everything runUnit imports that touches DB / session infrastructure ─

vi.mock("../debug-logger.js", () => ({ debugLog: vi.fn() }));
vi.mock("../error-utils.js", () => ({
	getErrorMessage: (e) => String(e?.message ?? e),
}));
vi.mock("../workflow-logger.js", () => ({ logWarning: vi.fn() }));
vi.mock("../constants.js", () => ({
	scopeActiveToolsForUnitType: vi.fn((_, tools) => tools),
}));
vi.mock("../uok/auto-runaway-guard.js", () => ({
	collectSessionTokenUsage: vi.fn(() => 0),
	collectWorktreeFingerprint: vi.fn(() => ""),
	countChangedFiles: vi.fn(() => 0),
	resetRunawayGuardState: vi.fn(),
}));
vi.mock("./resolve.js", () => ({
	_clearCurrentResolve: vi.fn(),
	_setCurrentResolve: vi.fn(),
	_setSessionSwitchInFlight: vi.fn(),
}));
vi.mock("./session.js", () => ({ NEW_SESSION_TIMEOUT_MS: 5000 }));
vi.mock("./turn-epoch.js", () => ({
	getCurrentTurnGeneration: vi.fn(() => 1),
	runWithTurnGeneration: vi.fn(async (_gen, fn) => fn()),
}));

// ─── Import runUnit AFTER mocks are set up ────────────────────────────────────

import { runUnit } from "../auto/run-unit.js";

// ─── Helpers ──────────────────────────────────────────────────────────────────

function makeCtx(basePath = "/tmp/test-project") {
	return {
		basePath,
		ui: { notify: vi.fn() },
		modelRegistry: null,
		model: null,
	};
}

function makePi() {
	return {
		sendMessage: vi.fn(async () => {}),
		getActiveTools: vi.fn(() => []),
		setActiveTools: vi.fn(),
		setModel: vi.fn(async () => true),
		setFallbackUnitContext: vi.fn(),
	};
}

function makeS(basePath = "/tmp/test-project") {
	return {
		basePath,
		active: true,
		verbose: false,
		currentUnitModel: null,
		cmdCtx: { newSession: vi.fn(async () => ({})), clearQueue: vi.fn() },
	};
}

// ─── Save / restore env ───────────────────────────────────────────────────────

let origEnv;
let origNoOutputEnv;
let origHeadlessEnv;
let origFanoutEnv;

beforeEach(() => {
	vi.clearAllMocks();
	origEnv = process.env.SF_AUTONOMOUS_VIA_SWARM;
	origNoOutputEnv = process.env.SF_SWARM_NO_OUTPUT_TIMEOUT_MS;
	origHeadlessEnv = process.env.SF_HEADLESS;
	origFanoutEnv = process.env.SF_SWARM_FANOUT;
	delete process.env.SF_AUTONOMOUS_VIA_SWARM;
	delete process.env.SF_SWARM_NO_OUTPUT_TIMEOUT_MS;
	delete process.env.SF_HEADLESS;
	delete process.env.SF_SWARM_FANOUT;

	// Default implementation for the happy-path tests: return a deterministic reply.
	mockSwarmDispatchAndWait.mockImplementation(
		async (_basePath, envelope, _opts) => ({
			messageId: "msg-dispatch-001",
			targetAgent: MOCK_TARGET,
			swarmName: "default",
			envelope,
			reply: MOCK_REPLY,
			replyMessageId: MOCK_REPLY_ID,
		}),
	);
});

afterEach(() => {
	if (origEnv === undefined) {
		delete process.env.SF_AUTONOMOUS_VIA_SWARM;
	} else {
		process.env.SF_AUTONOMOUS_VIA_SWARM = origEnv;
	}
	if (origNoOutputEnv === undefined) {
		delete process.env.SF_SWARM_NO_OUTPUT_TIMEOUT_MS;
	} else {
		process.env.SF_SWARM_NO_OUTPUT_TIMEOUT_MS = origNoOutputEnv;
	}
	if (origHeadlessEnv === undefined) {
		delete process.env.SF_HEADLESS;
	} else {
		process.env.SF_HEADLESS = origHeadlessEnv;
	}
	if (origFanoutEnv === undefined) {
		delete process.env.SF_SWARM_FANOUT;
	} else {
		process.env.SF_SWARM_FANOUT = origFanoutEnv;
	}
});

// ─── Flag ON — happy path ─────────────────────────────────────────────────────

describe("runUnit — SF_AUTONOMOUS_VIA_SWARM=1 — happy path", () => {
	test("routes through swarmDispatchAndWait and returns status=completed", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"unit-001",
			"implement the feature",
			{},
		);

		expect(result.status).toBe("completed");
		expect(result.requestDispatchedAt).toBeDefined();
		expect(result._via).toBe("swarm");
	});

	test("calls swarmDispatchAndWait with correct envelope shape", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const ctx = makeCtx("/myproject");
		const pi = makePi();
		const s = makeS("/myproject");

		await runUnit(ctx, pi, s, "execute-task", "task-42", "execute the task", {
			scope: "milestone-1",
			priority: 7,
		});

		expect(mockSwarmDispatchAndWait).toHaveBeenCalledOnce();
		const [basePath, envelope, opts] = mockSwarmDispatchAndWait.mock.calls[0];

		expect(basePath).toBe("/myproject");
		expect(envelope.unitId).toBe("task-42");
		expect(envelope.unitType).toBe("execute-task");
		expect(envelope.workMode).toBe("build"); // execute-task → build
		expect(envelope.payload).toBe("execute the task");
		expect(envelope.scope).toBe("milestone-1");
		expect(envelope.priority).toBe(7);
		expect(envelope.executorPermissionLevel).toBe("low");
		expect(opts.timeoutMs).toBeGreaterThan(0);
		expect(opts.noOutputTimeoutMs).toBe(180_000);
		expect(opts.noOutputTimeoutMs).toBeLessThanOrEqual(opts.timeoutMs);
	});

	test("uses configured no-output timeout without exceeding hard timeout", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";
		process.env.SF_SWARM_NO_OUTPUT_TIMEOUT_MS = "45000";

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		await runUnit(ctx, pi, s, "execute-task", "unit-timeout", "do work", {});

		const [, , opts] = mockSwarmDispatchAndWait.mock.calls[0];
		expect(opts.noOutputTimeoutMs).toBe(45_000);
		expect(opts.noOutputTimeoutMs).toBeLessThanOrEqual(opts.timeoutMs);
	});

	// ─── Round 7: executor system prompt + tools on the envelope ─────────────

	test("envelope includes executorSystemPrompt containing 'checkpoint'", async () => {
		// Round 7: runUnitViaSwarm must embed the checkpoint contract in the envelope
		// so the swarm worker knows it must call checkpoint before ending its turn.
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		await runUnit(ctx, pi, s, "execute-task", "t-chk-prompt", "build it", {});

		expect(mockSwarmDispatchAndWait).toHaveBeenCalledOnce();
		const [, envelope] = mockSwarmDispatchAndWait.mock.calls[0];

		expect(typeof envelope.executorSystemPrompt).toBe("string");
		expect(envelope.executorSystemPrompt.length).toBeGreaterThan(0);
		// Must mention "checkpoint" so the worker knows to call it
		expect(envelope.executorSystemPrompt.toLowerCase()).toContain("checkpoint");
		// Must mention the unit context so the worker can fill checkpoint fields correctly
		expect(envelope.executorSystemPrompt).toContain("execute-task");
		expect(envelope.executorSystemPrompt).toContain("t-chk-prompt");
	});

	test("executorSystemPrompt varies by unitType and unitId", async () => {
		// Different units must get different prompts so workers aren't confused about
		// what unit they're executing.
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		await runUnit(ctx, pi, s, "plan-slice", "ps-99", "plan the slice", {});

		const [, envelope] = mockSwarmDispatchAndWait.mock.calls[0];
		expect(envelope.executorSystemPrompt).toContain("plan-slice");
		expect(envelope.executorSystemPrompt).toContain("ps-99");
	});

	test("maps swarm reply into event.messages[last].content (array with text+tool blocks)", async () => {
		// content is now an array: [{ type:"tool_use", name:"swarm_unit_complete", ... }, { type:"text", text:reply }]
		// The tool_use block is the synthetic checkpoint marker; the text block carries the raw reply.
		// (When no real tool calls are emitted, the synthetic placeholder comes first.)
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"u-1",
			"do stuff",
			{},
		);

		const lastMsg = result.event.messages[result.event.messages.length - 1];
		expect(Array.isArray(lastMsg.content)).toBe(true);
		// When no real tool calls are emitted, a synthetic checkpoint is injected
		// so the solver pass sees a valid signal. It sits first, followed by text.
		expect(lastMsg.content[0]).toMatchObject({
			type: "tool_use",
			name: "checkpoint",
		});
		expect(lastMsg.content[0].input.outcome).toBe("continue");
		// Text block is second
		expect(lastMsg.content[1]).toMatchObject({
			type: "text",
			text: MOCK_REPLY,
		});
		expect(lastMsg.role).toBe("assistant");
		expect(lastMsg.replyMessageId).toBe(MOCK_REPLY_ID);
		expect(lastMsg.targetAgent).toBe(MOCK_TARGET);
	});

	test("does NOT call pi.sendMessage in the swarm path", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		await runUnit(ctx, pi, s, "execute-task", "u-2", "prompt", {});

		expect(pi.sendMessage).not.toHaveBeenCalled();
	});

	// ─── Bug 2: synthesized checkpoint prevents repair loop ───────────────────

	test("calls appendAutonomousSolverCheckpoint with outcome=continue on success", async () => {
		// Bug 2 fix: runUnitViaSwarm must write a synthetic checkpoint so that
		// phases-unit.js's assessAutonomousSolverTurn does not find an empty solver
		// state and fire the missing-checkpoint repair loop.
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		await runUnit(ctx, pi, s, "execute-task", "synth-chk-1", "do the work", {});

		expect(mockAppendCheckpoint).toHaveBeenCalledOnce();
		const [basePath, params] = mockAppendCheckpoint.mock.calls[0];

		expect(basePath).toBe("/proj");
		expect(params.unitType).toBe("execute-task");
		expect(params.unitId).toBe("synth-chk-1");
		expect(params.outcome).toBe("continue");
		expect(typeof params.summary).toBe("string");
		expect(params.summary.length).toBeGreaterThan(0);
		expect(Array.isArray(params.completedItems)).toBe(true);
		expect(Array.isArray(params.remainingItems)).toBe(true);
		expect(params.remainingItems.length).toBeGreaterThan(0);
		expect(params.remainingItems[0]).toContain(
			"Continue execute-task synth-chk-1",
		);
		expect(Array.isArray(params.verificationEvidence)).toBe(true);
	});

	test("synthesized checkpoint summary is truncated at 500 chars", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const longReply = "x".repeat(600);
		mockSwarmDispatchAndWait.mockResolvedValueOnce({
			messageId: "msg-long-1",
			targetAgent: MOCK_TARGET,
			swarmName: "default",
			envelope: {},
			reply: longReply,
			replyMessageId: "rmsg-long-1",
		});

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"synth-long",
			"long reply test",
			{},
		);

		const [, params] = mockAppendCheckpoint.mock.calls[0];
		expect(params.summary.length).toBeLessThanOrEqual(500);
		expect(params.summary.endsWith("...")).toBe(true);
	});

	test("event.messages[last].content includes swarm_unit_complete tool_use block", async () => {
		// Bug 2 fix: the synthetic tool-call block with name=swarm_unit_complete
		// prevents isNoOpExecutorTranscript from flagging the swarm turn as a no-op
		// (which would trigger a second repair path via "solver-noop-continue").
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"synth-tool-1",
			"build it",
			{},
		);

		const lastMsg = result.event.messages[result.event.messages.length - 1];
		expect(Array.isArray(lastMsg.content)).toBe(true);

		// When no real tool calls are emitted, runUnitViaSwarm injects a synthetic
		// checkpoint (not swarm_unit_complete) so the solver pass gets a valid signal.
		const toolBlock = lastMsg.content.find(
			(b) => b.type === "tool_use" && b.name === "checkpoint",
		);
		expect(toolBlock).toBeDefined();
		expect(toolBlock.input.outcome).toBe("continue");
		expect(typeof toolBlock.input.summary).toBe("string");
	});

	test("event.messages[last].content contains a text block with swarm reply", async () => {
		// In the fallback path (no real tool calls), content is [swarm_unit_complete, text].
		// In the real-tool-calls path, content is [...tool_uses, text].
		// Either way, a text block carrying the reply must be present.
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"synth-text-1",
			"build it",
			{},
		);

		const lastMsg = result.event.messages[result.event.messages.length - 1];
		const textBlock = lastMsg.content.find((b) => b.type === "text");
		expect(textBlock).toBeDefined();
		expect(textBlock.text).toBe(MOCK_REPLY);
	});

	test("appendAutonomousSolverCheckpoint failure is silently swallowed (fail-open)", async () => {
		// If checkpoint synthesis fails, the loop should still return status=completed
		// rather than crashing or returning cancelled.
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";
		mockAppendCheckpoint.mockImplementationOnce(() => {
			throw new Error("solver state write failed");
		});

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"synth-fail-1",
			"build it",
			{},
		);

		// Must still return completed — checkpoint synthesis failure is non-fatal
		expect(result.status).toBe("completed");
	});

	test("appendAutonomousSolverCheckpoint is NOT called when swarm returns no reply", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";
		mockSwarmDispatchAndWait.mockResolvedValueOnce({
			messageId: "m-noreply",
			targetAgent: "worker-1",
			swarmName: "default",
			reply: null,
			replyMessageId: null,
			error: "no reply",
		});

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		await runUnit(ctx, pi, s, "execute-task", "synth-noreply", "prompt", {});

		expect(mockAppendCheckpoint).not.toHaveBeenCalled();
	});

	test("accepts SF_AUTONOMOUS_VIA_SWARM=true (string)", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "true";

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"plan-slice",
			"ps-1",
			"plan it",
			{},
		);

		expect(result.status).toBe("completed");
		expect(mockSwarmDispatchAndWait).toHaveBeenCalledOnce();
	});

	test("workMode defaults to 'build' for unknown unitType", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		await runUnit(ctx, pi, s, "unknown-type-xyz", "u-3", "do it", {});

		const [, envelope] = mockSwarmDispatchAndWait.mock.calls[0];
		expect(envelope.workMode).toBe("build");
	});
});

describe("runUnit — swarm fan-out policy", () => {
	test("research-slice_when_swarm_enabled_fans_out_to_independent_read_only_lanes", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";
		mockSwarmDispatchAndWait.mockImplementation(
			async (_basePath, envelope, _opts) => ({
				messageId: `dispatch-${envelope.targetAgent}`,
				targetAgent: envelope.targetAgent,
				swarmName: "default",
				envelope,
				reply: `reply from ${envelope.targetAgent}`,
				replyMessageId: `reply-${envelope.targetAgent}`,
			}),
		);

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"research-slice",
			"M001/S01",
			"research provider routing",
			{},
		);

		expect(result.status).toBe("completed");
		expect(mockSwarmDispatchAndWait).toHaveBeenCalledTimes(3);
		const targetAgents = mockSwarmDispatchAndWait.mock.calls.map(
			(call) => call[1].targetAgent,
		);
		expect(targetAgents).toEqual(["scout", "reviewer", "verifier"]);
		expect(result._swarmResult.fanout).toBe(true);
		expect(result._swarmResult.lanes).toHaveLength(3);
		expect(result.event.messages[0].targetAgent).toBe("fanout");
		expect(result.event.messages[0].content.at(-1).text).toContain("## scout");
	});

	test("execute-task_when_no_disjoint_scope_remains_single_agent", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"M001/S01/T01",
			"implement a coupled change",
			{},
		);

		expect(result.status).toBe("completed");
		expect(mockSwarmDispatchAndWait).toHaveBeenCalledOnce();
		expect(
			mockSwarmDispatchAndWait.mock.calls[0][1].targetAgent,
		).toBeUndefined();
	});
});

// ─── Flag ON — error paths ────────────────────────────────────────────────────

describe("runUnit — SF_AUTONOMOUS_VIA_SWARM=1 — error paths", () => {
	test("returns cancelled when swarmDispatchAndWait throws", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";
		mockSwarmDispatchAndWait.mockRejectedValueOnce(new Error("bus failure"));

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"u-err-1",
			"prompt",
			{},
		);

		expect(result.status).toBe("cancelled");
		expect(result.errorContext.message).toContain("bus failure");
		expect(result.errorContext.category).toBe("session-failed");
		expect(result.requestDispatchedAt).toBeDefined();
	});

	test("returns cancelled when swarm returns reply=null", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";
		mockSwarmDispatchAndWait.mockResolvedValueOnce({
			messageId: "m-1",
			targetAgent: "worker-1",
			swarmName: "default",
			reply: null,
			replyMessageId: null,
			error: "no reply",
		});

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"u-err-2",
			"prompt",
			{},
		);

		expect(result.status).toBe("cancelled");
		expect(result.errorContext.message).toContain("no reply");
	});

	test("returns cancelled when swarm result has error field", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";
		mockSwarmDispatchAndWait.mockResolvedValueOnce({
			messageId: "m-2",
			targetAgent: "worker-1",
			swarmName: "default",
			reply: null,
			replyMessageId: null,
			error: "runAgentTurn threw: LLM timeout",
		});

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"u-err-3",
			"prompt",
			{},
		);

		expect(result.status).toBe("cancelled");
		expect(result.errorContext.message).toContain("LLM timeout");
	});

	test("notifies ctx.ui on swarm error", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";
		mockSwarmDispatchAndWait.mockRejectedValueOnce(
			new Error("network failure"),
		);

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		await runUnit(ctx, pi, s, "execute-task", "u-err-4", "prompt", {});

		expect(ctx.ui.notify).toHaveBeenCalledWith(
			expect.stringContaining("network failure"),
			"error",
		);
	});
});

// ─── Flag OFF — default path preserved ───────────────────────────────────────

describe("runUnit — SF_AUTONOMOUS_VIA_SWARM unset — default path", () => {
	test("does NOT call swarmDispatchAndWait when flag is absent", async () => {
		// Flag is deleted in beforeEach
		expect(process.env.SF_AUTONOMOUS_VIA_SWARM).toBeUndefined();

		const ctx = makeCtx("/tmp");
		const pi = makePi();
		const s = makeS("/tmp");
		// Make newSession() reject immediately so runUnit returns without hanging.
		s.cmdCtx.newSession = vi.fn(async () => {
			throw new Error("session-failed");
		});

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"u-default-1",
			"prompt",
			{},
		);

		// Swarm path was NOT taken
		expect(mockSwarmDispatchAndWait).not.toHaveBeenCalled();
		// Returns cancelled because newSession threw (legacy path was entered)
		expect(result.status).toBe("cancelled");
	});

	test("does NOT call swarmDispatchAndWait when flag is '0'", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "0";

		const ctx = makeCtx("/tmp");
		const pi = makePi();
		const s = makeS("/tmp");
		s.cmdCtx.newSession = vi.fn(async () => {
			throw new Error("session-failed");
		});

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"u-default-2",
			"prompt",
			{},
		);

		expect(mockSwarmDispatchAndWait).not.toHaveBeenCalled();
		expect(result.status).toBe("cancelled");
	});

	test("does NOT route checkpoint-only solver pass through swarm in headless mode", async () => {
		process.env.SF_HEADLESS = "1";
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const ctx = makeCtx("/tmp");
		const pi = makePi();
		const s = makeS("/tmp");
		s.cmdCtx.newSession = vi.fn(async () => {
			throw new Error("session-failed");
		});

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"u-solver-pass",
			"emit checkpoint only",
			{ activeToolsAllowlist: ["checkpoint"] },
		);

		expect(mockSwarmDispatchAndWait).not.toHaveBeenCalled();
		expect(result.status).toBe("cancelled");
	});
});

// ─── workMode derivation unit tests ──────────────────────────────────────────

describe("deriveWorkMode (via envelope.workMode in dispatch calls)", () => {
	const cases = [
		["research-slice", "research"],
		["research-project", "research"],
		["scout-context", "research"],
		["review-milestone", "review"],
		["gate-evaluate", "verify"],
		["run-uat", "verify"],
		["validate-milestone", "verify"],
		["repair-slice", "repair"],
		["fix-bug", "repair"],
		["execute-task", "build"],
		["implement-feature", "build"],
		["plan-milestone", "plan"],
		["plan-slice", "plan"],
		["refine-slice", "plan"],
		["replan-slice", "plan"],
		["rewrite-docs", "document"],
		["rewrite-spec", "document"], // contains "rewrite"
		["scribe-notes", "document"], // contains "scribe"
		["promote-spec", "build"], // no matching keyword → build (fallback)
		["adversary-review", "review"], // "review" check comes before "adversar"
		["challenge-mission", "challenge"], // "challenge" matches; no plan/slice/etc.
		["unknown-type", "build"], // fallback
	];

	for (const [unitType, expectedWorkMode] of cases) {
		test(`${unitType} → ${expectedWorkMode}`, async () => {
			process.env.SF_AUTONOMOUS_VIA_SWARM = "1";
			process.env.SF_SWARM_FANOUT = "0";

			const ctx = makeCtx("/proj");
			const pi = makePi();
			const s = makeS("/proj");

			await runUnit(ctx, pi, s, unitType, "u-wm-test", "prompt", {});

			const [, envelope] = mockSwarmDispatchAndWait.mock.calls[0];
			expect(envelope.workMode).toBe(expectedWorkMode);

			vi.clearAllMocks();
		});
	}
});

// ─── Round 8: swarmToolCallCount for zero-tool-call guard bypass ─────────────

describe("runUnit — Round 8: swarmToolCallCount in UnitResult", () => {
	test("swarmToolCallCount is 0 when no tool calls emitted (default mock)", async () => {
		// When the swarm worker emits no tool-call events, swarmToolCallCount must be
		// 0 so phases-unit.js still applies the zero-tool-call guard for real no-ops.
		// Note: the synthetic checkpoint is injected AFTER swarmToolCallCount is set
		// from collectedToolCalls.length, so it correctly reflects only real events.
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"r8-notc",
			"build",
			{},
		);

		expect(result.status).toBe("completed");
		expect(result._via).toBe("swarm");
		expect(result.swarmToolCallCount).toBe(0);
		// The synthetic checkpoint is injected into event.messages content but does
		// NOT affect swarmToolCallCount, which counts only real worker tool calls.
		const lastMsg = result.event.messages[result.event.messages.length - 1];
		const toolBlocks = lastMsg.content.filter((b) => b.type === "tool_use");
		expect(toolBlocks.length).toBeGreaterThanOrEqual(1);
		expect(toolBlocks[0].name).toBe("checkpoint");
	});

	test("swarmToolCallCount equals the number of tool calls emitted", async () => {
		// When the swarm worker emits 3 tool-call events, swarmToolCallCount must be 3
		// so phases-unit.js can bypass the zero-tool-call guard that fires because the
		// parent-session ledger entry has 0 (subagent tool calls don't appear there).
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		mockWithToolCallEvents([
			{ name: "Bash", arguments: { command: "npm test" } },
			{ name: "Read", arguments: { file_path: "/foo.ts" } },
			{
				name: "checkpoint",
				arguments: { outcome: "complete", summary: "done" },
			},
		]);

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"r8-tc3",
			"build",
			{},
		);

		expect(result.status).toBe("completed");
		expect(result._via).toBe("swarm");
		expect(result.swarmToolCallCount).toBe(3);
	});

	test("swarmToolCallCount is 1 when only checkpoint is emitted", async () => {
		// checkpoint counts as a tool call in collectedToolCalls even though it's
		// protocol (not work). The bypass check is > 0, and the no-op guard separately
		// handles the checkpoint-only case via isNoOpExecutorTranscript.
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		mockWithToolCallEvents([
			{
				name: "checkpoint",
				arguments: { outcome: "continue", summary: "partial" },
			},
		]);

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"r8-chkonly",
			"build",
			{},
		);

		expect(result.swarmToolCallCount).toBe(1);
	});
});

// ─── Round 6: onEvent threading + real tool calls ────────────────────────────

/**
 * Helper: configure mockSwarmDispatchAndWait to emit a list of synthetic
 * tool-call events via options.onEvent before returning, then return the
 * standard successful reply.
 *
 * @param {Array<{name: string, arguments: object}>} toolCallDefs
 */
function mockWithToolCallEvents(toolCallDefs) {
	mockSwarmDispatchAndWait.mockImplementationOnce(
		async (_basePath, envelope, opts = {}) => {
			const { onEvent } = opts;
			if (typeof onEvent === "function") {
				for (let i = 0; i < toolCallDefs.length; i++) {
					const tc = toolCallDefs[i];
					onEvent({
						type: "message_update",
						assistantMessageEvent: {
							type: "toolcall_end",
							contentIndex: i,
							toolCall: {
								type: "toolCall",
								id: `tc-${i}`,
								name: tc.name,
								arguments: tc.arguments ?? {},
							},
							partial: {},
						},
					});
				}
			}
			return {
				messageId: "msg-dispatch-001",
				targetAgent: MOCK_TARGET,
				swarmName: "default",
				envelope,
				reply: MOCK_REPLY,
				replyMessageId: MOCK_REPLY_ID,
			};
		},
	);
}

describe("runUnit — Round 6: real tool calls captured from onEvent", () => {
	test("2 tool calls: event.messages[last].content includes both tool_use blocks + text block", async () => {
		// When the swarm emits 2 tool-call events, runUnitViaSwarm should collect both
		// and include them in event.messages[last].content alongside the text reply.
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		mockWithToolCallEvents([
			{ name: "Bash", arguments: { command: "npm test" } },
			{ name: "Read", arguments: { file_path: "/some/file.ts" } },
		]);

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"r6-tool-calls",
			"build it",
			{},
		);

		expect(result.status).toBe("completed");
		const lastMsg = result.event.messages[result.event.messages.length - 1];
		expect(Array.isArray(lastMsg.content)).toBe(true);

		// Should have 2 real tool_use blocks + 1 synthetic checkpoint + 1 text block.
		// The synthetic checkpoint is injected even when real tool calls exist,
		// because the worker did not call checkpoint itself.
		const toolUseBlocks = lastMsg.content.filter((b) => b.type === "tool_use");
		const textBlocks = lastMsg.content.filter((b) => b.type === "text");
		expect(toolUseBlocks).toHaveLength(3);
		expect(textBlocks).toHaveLength(1);

		expect(toolUseBlocks[0]).toMatchObject({ type: "tool_use", name: "Bash" });
		expect(toolUseBlocks[1]).toMatchObject({ type: "tool_use", name: "Read" });
		expect(toolUseBlocks[2]).toMatchObject({
			type: "tool_use",
			name: "checkpoint",
		});
		expect(textBlocks[0].text).toBe(MOCK_REPLY);
	});

	test("checkpoint loop aborts swarm turn at the per-iteration cap", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		mockSwarmDispatchAndWait.mockImplementationOnce(
			async (_basePath, envelope, opts = {}) => {
				for (let i = 0; i < 6; i++) {
					opts.onEvent?.({
						type: "message_update",
						assistantMessageEvent: {
							type: "toolcall_end",
							contentIndex: i,
							toolCall: {
								type: "toolCall",
								id: `tc-checkpoint-${i}`,
								name: "checkpoint",
								arguments: {
									outcome: "continue",
									unitType: "plan-milestone",
									unitId: "M002",
									summary: "still discovering",
									completedItems: ["read files"],
									remainingItems: ["keep planning"],
									verificationEvidence: ["read:file.py"],
								},
							},
							partial: {},
						},
					});
					if (opts.signal?.aborted) break;
				}
				return {
					messageId: "msg-dispatch-001",
					targetAgent: MOCK_TARGET,
					swarmName: "default",
					envelope,
					error: "aborted",
					reply: null,
					replyMessageId: null,
				};
			},
		);

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"plan-slice",
			"M002/S01",
			"plan it",
			{},
		);

		expect(result.status).toBe("completed");
		expect(mockAppendCheckpoint).not.toHaveBeenCalled();
		const lastMsg = result.event.messages[result.event.messages.length - 1];
		const toolUseBlocks = lastMsg.content.filter((b) => b.type === "tool_use");
		expect(toolUseBlocks).toHaveLength(5);
		expect(toolUseBlocks.every((b) => b.name === "checkpoint")).toBe(true);
		expect(lastMsg.content.at(-1).text).toContain("checkpoint limit reached");
		expect(result.swarmToolCallCount).toBe(0);
	});

	test("checkpoint tool call with outcome=complete relies on the real checkpoint write", async () => {
		// The canonical completion detection: when the worker calls checkpoint with
		// outcome='complete', runUnitViaSwarm must not append a duplicate parent
		// checkpoint. The tool execution already updated solver state.
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		mockWithToolCallEvents([
			{
				name: "checkpoint",
				arguments: {
					outcome: "complete",
					summary: "All tasks finished successfully.",
					completedItems: ["feature implemented", "tests passing"],
					remainingItems: [],
					verificationEvidence: ["npm test: all green"],
					pdd: {
						purpose: "Mark unit complete",
						consumer: "orchestrator",
						contract: "outcome=complete signals task done",
						failureBoundary: "none",
						evidence: "tests pass",
						nonGoals: "no rollback",
						invariants: "outcome is accurate",
						assumptions: "tests are definitive",
					},
				},
			},
		]);

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"r6-complete",
			"build it",
			{},
		);

		expect(result.status).toBe("completed");

		expect(mockAppendCheckpoint).not.toHaveBeenCalled();

		// The real checkpoint tool_use block must appear in event.messages[last].content
		const lastMsg = result.event.messages[result.event.messages.length - 1];
		const toolBlocks = lastMsg.content.filter((b) => b.type === "tool_use");
		expect(toolBlocks).toHaveLength(1);
		expect(toolBlocks[0]).toMatchObject({
			type: "tool_use",
			name: "checkpoint",
		});
		expect(toolBlocks[0].input).toMatchObject({ outcome: "complete" });
	});

	test("no tool calls → synthetic swarm_unit_complete placeholder still produced (Round 5 parity)", async () => {
		// When the swarm emits no tool-call events, the fallback synthetic placeholder
		// must still appear so isNoOpExecutorTranscript / classifyExecutorRefusal
		// don't misclassify the swarm turn. This is the Round 5 behaviour.
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		// Default mock emits no events (no onEvent calls) — already set in beforeEach.
		// Confirm the default mock is used for this test.
		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"r6-no-tools",
			"build it",
			{},
		);

		expect(result.status).toBe("completed");
		const lastMsg = result.event.messages[result.event.messages.length - 1];

		// When the worker emits no real tool calls, runUnitViaSwarm now injects
		// a synthetic checkpoint tool_use so the solver pass sees a valid signal
		// rather than looping on a no-op transcript. The original swarm_unit_complete
		// placeholder is superseded by this synthetic checkpoint.
		const toolBlocks = lastMsg.content.filter((b) => b.type === "tool_use");
		expect(toolBlocks.length).toBeGreaterThanOrEqual(1);
		expect(toolBlocks[0]).toMatchObject({
			type: "tool_use",
			name: "checkpoint",
		});
		expect(toolBlocks[0].input.outcome).toBe("continue");

		// The text block must still carry the reply
		const textBlocks = lastMsg.content.filter((b) => b.type === "text");
		expect(textBlocks).toHaveLength(1);
		expect(textBlocks[0].text).toBe(MOCK_REPLY);
	});
});

// ─── swarm-dispatch journal event ────────────────────────────────────────────

describe("runUnit — swarm-dispatch journal events", () => {
	test("emits swarm-dispatch event on happy-path dispatch with correct shape", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		await runUnit(ctx, pi, s, "execute-task", "jrn-001", "implement it", {});

		expect(mockEmitJournalEvent).toHaveBeenCalled();
		const swarmEvent = mockEmitJournalEvent.mock.calls
			.map((c) => c[1])
			.find((e) => e.eventType === "swarm-dispatch");
		expect(swarmEvent).toBeDefined();
		expect(swarmEvent.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
		expect(swarmEvent.data.unitType).toBe("execute-task");
		expect(swarmEvent.data.unitId).toBe("jrn-001");
		expect(swarmEvent.data.targetAgent).toBe(MOCK_TARGET);
		expect(swarmEvent.data.workMode).toBe("build");
		expect(typeof swarmEvent.data.toolCallCount).toBe("number");
		expect(swarmEvent.data.outcome).toBe("continue");
		expect(swarmEvent.data.via).toBe("autonomous-unit");
	});

	test("emits swarm-dispatch event with outcome=complete when worker signals complete", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";

		mockWithToolCallEvents([
			{
				name: "checkpoint",
				arguments: {
					outcome: "complete",
					summary: "All done.",
					completedItems: ["task"],
					remainingItems: [],
					verificationEvidence: ["npm test: pass"],
				},
			},
		]);

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		await runUnit(ctx, pi, s, "execute-task", "jrn-complete", "build it", {});

		const swarmEvent = mockEmitJournalEvent.mock.calls
			.map((c) => c[1])
			.find((e) => e.eventType === "swarm-dispatch");
		expect(swarmEvent).toBeDefined();
		expect(swarmEvent.data.outcome).toBe("complete");
	});

	test("emits swarm-dispatch event with outcome=error when swarmDispatchAndWait throws", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";
		mockSwarmDispatchAndWait.mockRejectedValueOnce(new Error("bus failure"));

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		await runUnit(ctx, pi, s, "execute-task", "jrn-err", "prompt", {});

		const swarmEvent = mockEmitJournalEvent.mock.calls
			.map((c) => c[1])
			.find((e) => e.eventType === "swarm-dispatch");
		expect(swarmEvent).toBeDefined();
		expect(swarmEvent.data.outcome).toBe("error");
		expect(swarmEvent.data.via).toBe("autonomous-unit");
		expect(swarmEvent.data.error).toContain("bus failure");
	});

	test("emits swarm-dispatch event with outcome=no-reply when swarm returns error field", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";
		mockSwarmDispatchAndWait.mockResolvedValueOnce({
			messageId: "m-noreply-jrn",
			targetAgent: "worker-1",
			swarmName: "default",
			reply: null,
			replyMessageId: null,
			error: "runAgentTurn failed",
		});

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		await runUnit(ctx, pi, s, "execute-task", "jrn-noreply", "prompt", {});

		const swarmEvent = mockEmitJournalEvent.mock.calls
			.map((c) => c[1])
			.find((e) => e.eventType === "swarm-dispatch");
		expect(swarmEvent).toBeDefined();
		expect(swarmEvent.data.outcome).toBe("no-reply");
		expect(swarmEvent.data.error).toContain("runAgentTurn failed");
	});

	test("journal write failure does not break dispatch (fail-open)", async () => {
		process.env.SF_AUTONOMOUS_VIA_SWARM = "1";
		mockEmitJournalEvent.mockImplementationOnce(() => {
			throw new Error("disk full");
		});

		const ctx = makeCtx("/proj");
		const pi = makePi();
		const s = makeS("/proj");

		const result = await runUnit(
			ctx,
			pi,
			s,
			"execute-task",
			"jrn-failopen",
			"build it",
			{},
		);

		// Must still complete even though journal threw
		expect(result.status).toBe("completed");
	});
});
