Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/strata/__tests__/contract.test.ts

Raw
import {
	appendFile,
	mkdtemp,
	readdir,
	readFile,
	rm,
	writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
	type AssistantMessage,
	type Context,
	fauxAssistantMessage,
	fauxProvider,
	InMemoryCredentialStore,
} from "@earendil-works/pi-ai";
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
import { stripTerminalSequences } from "@earendil-works/pi-tui";
import { aroundEach, describe, expect, it, vi } from "vitest";
import {
	createPiSession,
	type PiSession,
} from "../../../test/fixture/create-pi-session.ts";
import {
	cleanupNative,
	fixturePlan,
	git,
	nativeOperation,
	nativeTest,
	pushCancelling,
	testSignal,
	withFakeGh,
} from "./fixture.js";

// Builds a real Pi session through `createPiSession` (public APIs only), then
// drives the extension's `/strata` command with a captured UI context and a
// deterministic scripted provider.
async function createFixture(mode: "tui" | "rpc" | "json" = "tui") {
	const signal = testSignal();
	signal.throwIfAborted();
	const root = await mkdtemp(path.join(tmpdir(), "strata-pi-contract-"));
	let session: PiSession | undefined;
	let closing: Promise<void> | undefined;
	const close = () =>
		(closing ??= (async () => {
			try {
				await session?.dispose();
			} finally {
				await rm(root, {
					recursive: true,
					force: true,
					maxRetries: 3,
					retryDelay: 100,
				});
			}
		})());
	cleanupNative(close);
	try {
		await git(root, "init", "--quiet");
		await appendFile(
			path.join(root, ".git", "config"),
			"[user]\n\tname = Strata Test\n\temail = strata@example.invalid\n",
		);
		await writeFile(
			path.join(root, "contract.ts"),
			"export const attempts = 1;\n",
		);
		await writeFile(
			path.join(root, "consumer.ts"),
			"export const retry = false;\n",
		);
		await git(root, "add", ".");
		await git(root, "commit", "--quiet", "-m", "base");
		await writeFile(
			path.join(root, "contract.ts"),
			"export const attempts = 3;\n",
		);
		await writeFile(
			path.join(root, "consumer.ts"),
			"export const retry = true;\n",
		);
		session = await createPiSession({
			credentials: new InMemoryCredentialStore(),
			cwd: root,
			env: { STRATA_NO_OPEN: "1" },
			extensionPaths: [path.resolve(import.meta.dirname, "../index.ts")],
		});
		const active = session;
		signal.throwIfAborted();
		const runner = active.session.extensionRunner;
		const emit = runner.emit.bind(runner);
		runner.emit = ((...args: Parameters<typeof emit>) =>
			nativeOperation(() => emit(...args))) as typeof runner.emit;
		const faux = fauxProvider({
			provider: "strata-integration",
			models: [{ id: "review", maxTokens: 16_384 }],
		});
		let calls = 0;
		const prompts: string[] = [];
		const respond = (context: Context) => {
			calls++;
			const message = context.messages.findLast((item) => item.role === "user");
			const text =
				typeof message?.content === "string"
					? message.content
					: (message?.content ?? [])
							.filter((part) => part.type === "text")
							.map((part) => part.text)
							.join("");
			prompts.push(text);
			if (text.startsWith("Organize")) {
				const hunks = [
					...text.matchAll(
						/<file path="(contract\.ts|consumer\.ts)">\s*<hunk id="([^"]+)"/g,
					),
				].map(([, filePath, id]) => ({ id: id!, path: filePath! }));
				return fauxAssistantMessage(JSON.stringify(fixturePlan(hunks)));
			}
			return fauxAssistantMessage("Three attempts include the first call.");
		};
		faux.setResponses(Array.from({ length: 20 }, () => respond));
		const base = runner.createContext();
		base.modelRegistry.registerProvider(faux.provider);
		const model = base.modelRegistry.find("strata-integration", "review");
		if (!model) throw new Error("Fixture model missing");
		await active.session.setModel(model);
		const notifications: Array<{ text: string; severity: string }> = [];
		const widgets: Array<{ key: string; content: unknown; options: unknown }> =
			[];
		const uiCalls: Array<{ method: string; args: unknown[] }> = [];
		const statusMessages: Array<{
			role: string;
			customType?: unknown;
			content?: unknown;
			display?: unknown;
			details?: unknown;
		}> = [];
		// pi.sendMessage maps to AgentSession.sendCustomMessage. Capture these
		// without writing to stdout so no-UI modes assert the emitted custom
		// messages.
		vi.spyOn(active.session, "sendCustomMessage").mockImplementation(
			async (message) => {
				statusMessages.push({ role: "custom", ...message });
			},
		);
		const capturingUi = {
			notify: (text: string, severity = "info") => {
				uiCalls.push({ method: "notify", args: [text, severity] });
				notifications.push({ text, severity });
			},
			setWidget: (key: string, content: unknown, options?: unknown) => {
				uiCalls.push({ method: "setWidget", args: [key, content, options] });
				widgets.push({ key, content, options });
			},
			setStatus: (key: string, text?: string) => {
				uiCalls.push({ method: "setStatus", args: [key, text] });
			},
			setFooter: () => {},
			confirm: async () => true,
		};
		if (mode === "json") runner.setUIContext(undefined, "json");
		else
			runner.setUIContext(capturingUi as unknown as ExtensionUIContext, mode);
		const ctx = Object.create(base);
		Object.defineProperty(ctx, "waitForIdle", { value: async () => {} });
		const command = active.extensions[0]!.commands.get("strata");
		if (!command) throw new Error("strata command missing");
		const cancel = () => {
			const stopped = nativeOperation(() => command.handler("close", ctx));
			pushCancelling(stopped);
		};
		signal.throwIfAborted();
		signal.addEventListener("abort", cancel, { once: true });
		cleanupNative(async () => {
			signal.removeEventListener("abort", cancel);
		});
		const run = (args = "") =>
			nativeOperation(async () => {
				signal.throwIfAborted();
				await command.handler(args, ctx);
				signal.throwIfAborted();
			});
		return {
			t: {
				env: process.env,
				session: active.session,
				events: { ui: uiCalls, messages: statusMessages },
			},
			root,
			ctx,
			runner,
			extensions: active.extensions,
			command,
			faux,
			notifications,
			widgets,
			prompts,
			run,
			widgetLines(width = 120): string[] {
				const content = widgets.findLast(
					(item) => item.key === "strata",
				)?.content;
				if (content === undefined) return [];
				if (Array.isArray(content)) return content as string[];
				if (typeof content !== "function") return [];
				const theme = {
					bold: (text: string) => text,
					fg: (_color: string, text: string) => text,
				};
				return content({}, theme).render(width);
			},
			widgetText(width = 120): string {
				return this.widgetLines(width).map(stripTerminalSequences).join("\n");
			},
			get calls() {
				return calls;
			},
			get url(): string {
				const line = this.widgetLines(160)[1] ?? "";
				const match = /\x1b]8;;([^\x1b]+)\x1b\\/.exec(line);
				if (match?.[1]) return match[1];
				const notice = notifications.findLast((item) =>
					item.text.startsWith("Strata review: "),
				);
				if (notice) return notice.text.slice("Strata review: ".length);
				const message = statusMessages.findLast(
					(item) =>
						item.role === "custom" &&
						item.customType === "strata-status" &&
						typeof item.content === "string" &&
						item.content.startsWith("Strata review: "),
				);
				if (message && typeof message.content === "string")
					return message.content.slice("Strata review: ".length);
				throw new Error(`No review URL: ${line}`);
			},
			close,
		};
	} catch (error) {
		await close();
		throw error;
	}
}

let fixture: Awaited<ReturnType<typeof createFixture>> | undefined;
aroundEach(async (run, context) => {
	try {
		await nativeTest(run, context);
	} finally {
		fixture = undefined;
		vi.restoreAllMocks();
	}
});

async function connect(url: string) {
	const response = await fetch(url, {
		redirect: "manual",
		signal: testSignal(),
	});
	const cookie = response.headers.get("set-cookie")?.split(";")[0] ?? "";
	const origin = new URL(url).origin;
	let version = await (
		await fetch(`${origin}/api/state`, {
			headers: { Cookie: cookie },
			signal: testSignal(),
		})
	).json();
	return (route: string, body?: unknown) =>
		nativeOperation(async () => {
			const response = await fetch(`${origin}/api/${route}`, {
				signal: testSignal(),
				method: body === undefined ? "GET" : "POST",
				headers: {
					Cookie: cookie,
					Origin: origin,
					"Content-Type": "application/json",
					"If-Match": String(version.revision),
					"X-Strata-Snapshot": version.snapshotId,
				},
				...(body === undefined ? {} : { body: JSON.stringify(body) }),
			});
			const payload = await response.clone().json();
			if (payload.revision !== undefined) version = payload;
			return response;
		});
}

function planResponse(context: unknown): AssistantMessage {
	const messages = (
		context as {
			messages: Array<{
				role: string;
				content: string | Array<{ type: string; text?: string }>;
			}>;
		}
	).messages;
	const message = messages.findLast((item) => item.role === "user");
	const text =
		typeof message?.content === "string"
			? message.content
			: (message?.content ?? [])
					.filter((part) => part.type === "text")
					.map((part) => part.text ?? "")
					.join("");
	const hunks = [
		...text.matchAll(
			/<file path="(contract\.ts|consumer\.ts)">\s*<hunk id="([^"]+)"/g,
		),
	].map(([, filePath, id]) => ({ id: id!, path: filePath! }));
	return fauxAssistantMessage(JSON.stringify(fixturePlan(hunks)));
}

function expectPhase(value: string): void {
	expect(fixture?.widgetText()).toContain(`● ${value}`);
}

describe("Strata real Pi runtime", () => {
	it("warns about missing Git using the real startup path", async () => {
		fixture = await createFixture();
		fixture.t.env["PATH"] = path.join(fixture.root, "missing-bin");
		await fixture.runner.emit({ type: "session_start", reason: "reload" });
		await vi.waitFor(() =>
			expect(
				fixture?.t.events.ui.some(
					(call) =>
						call.method === "notify" &&
						call.args[0] === "Strata requires Git on PATH." &&
						call.args[1] === "warning",
				),
			).toBe(true),
		);
	});

	it("writes its opt-in lifecycle trace inside the fixture sandbox", async () => {
		const root = await mkdtemp(path.join(tmpdir(), "pi-ext-strata-debug-"));
		const session = await createPiSession({
			credentials: new InMemoryCredentialStore(),
			cwd: root,
			extensionPaths: [path.resolve(import.meta.dirname, "../index.ts")],
			env: { PI_STRATA_DEBUG: "1" },
		});
		try {
			const runner = session.session.extensionRunner;
			await runner.emit({ type: "session_start", reason: "startup" });
			await session.extensions[0]!.commands.get("strata")?.handler(
				"--invalid",
				runner.createCommandContext(),
			);
			await runner.emit({ type: "session_shutdown", reason: "quit" });
			const stateHome = session.env["XDG_STATE_HOME"];
			if (!stateHome) throw new Error("missing sandbox state home");
			const directory = path.join(stateHome, "pi-ext", "debug", "strata");
			const [file] = await readdir(directory);
			const records = (await readFile(path.join(directory, file!), "utf8"))
				.trim()
				.split("\n")
				.map((line) => JSON.parse(line));
			expect(records.map((record) => record.event)).toEqual(
				expect.arrayContaining(["session.start", "session.shutdown"]),
			);
			expect(records).toContainEqual(
				expect.objectContaining({
					event: "review.open.finish",
					outcome: "failed",
				}),
			);
		} finally {
			await session.dispose();
			await rm(root, {
				recursive: true,
				force: true,
				maxRetries: 3,
				retryDelay: 100,
			});
		}
	});

	it("loads optional gh only for --pr and reports a missing CLI", async () => {
		fixture = await createFixture();
		const originalPath = fixture.t.env["PATH"];
		fixture.t.env["PATH"] = path.join(fixture.root, "missing-bin");
		await fixture.run("--pr 7");
		expectPhase("Error");
		expect(fixture.widgetText()).toContain("optional gh CLI on PATH");
		await fixture.run("close");
		fixture.t.env["PATH"] = originalPath;
	});

	it("reports gh authentication failure without raw CLI output", async () => {
		fixture = await createFixture();
		await withFakeGh(
			`process.stderr.write("authentication failed: secret-token-value"); process.exitCode = 1;`,
			async () => {
				await fixture?.run("--pr 7");
			},
		);
		expectPhase("Error");
		expect(fixture.widgetText()).toContain("GitHub authentication failed");
		expect(fixture.widgetText()).not.toContain("secret-token-value");
		expect(fixture.calls).toBe(0);
	});

	it("refuses a dirty PR checkout through the real extension path", async () => {
		fixture = await createFixture();
		const commit = await git(fixture.root, "rev-parse", "HEAD");
		await git(
			fixture.root,
			"remote",
			"add",
			"origin",
			"https://github.com/acme/project.git",
		);
		const metadata = {
			number: 7,
			url: "https://github.com/acme/project/pull/7",
			title: "Fixture PR",
			body: "",
			author: { login: "author" },
			baseRefName: "main",
			baseRefOid: commit,
			headRefName: "feature",
			headRefOid: commit,
			headRepository: {
				name: "project",
				nameWithOwner: "acme/project",
			},
			headRepositoryOwner: { login: "acme" },
		};
		await withFakeGh(
			`if (args[0] === "pr") process.stdout.write(${JSON.stringify(JSON.stringify(metadata))}); else process.stdout.write("[]");`,
			async () => {
				await fixture?.run("--pr 7");
			},
		);
		expectPhase("Error");
		expect(fixture.widgetText()).toContain("requires a clean worktree");
		expect(fixture.calls).toBe(0);
	});

	it("places the link widget above the editor without footer or notification noise", async () => {
		fixture = await createFixture();
		expect(fixture.extensions[0]!.path).toBe(
			path.resolve(import.meta.dirname, "../index.ts"),
		);
		expect(fixture.command.getArgumentCompletions?.("--")).toHaveLength(3);
		expect(fixture.calls).toBe(0);
		await fixture.run();
		expect(fixture.calls).toBe(1);
		expectPhase("Ready");
		expect(fixture.widgetText()).toContain("0/2 reviewed");
		expect(fixture.widgetText()).toContain("2 layers");
		expect(fixture.widgets.at(-1)?.options).toEqual({
			placement: "aboveEditor",
		});
		expect(
			fixture.t.events.ui.filter((call) => call.method === "setStatus"),
		).toEqual([]);
		expect(
			fixture.notifications.some((item) =>
				item.text.startsWith("Strata review:"),
			),
		).toBe(false);
		const url = fixture.url;
		const widgetCalls = fixture.widgets.length;
		await fixture.run();
		expect(fixture.url).toBe(url);
		expect(fixture.calls).toBe(1);
		expect(fixture.widgets.length).toBe(widgetCalls + 1);
		expect(fixture.notifications).toEqual([]);
	});

	it("persists draft progress, Ask history, close state, and snapshot-bound reopen", async () => {
		fixture = await createFixture();
		await fixture.run();
		const firstUrl = fixture.url;
		const api = await connect(firstUrl);
		const { review } = await (await api("state")).json();
		const draft = {
			reviewed: [review.snapshot.hunks[0].id],
			findings: [
				{
					id: "finding-1",
					hunkId: review.snapshot.hunks[0].id,
					side: "new",
					line: 1,
					severity: "minor",
					text: "Check the bound",
				},
			],
			notes: "Check retry bounds",
		};
		expect((await api("draft", draft)).status).toBe(200);
		expect(fixture.widgetText()).toContain("1/2 reviewed");
		expect(fixture.widgetText()).toContain("1 comment");
		expectPhase("Ready");
		const answer = await api("ask", {
			layerId: "layer-1",
			hunkId: review.snapshot.hunks[0].id,
			question: "How many attempts?",
		});
		expect((await answer.json()).answer).toBe(
			"Three attempts include the first call.",
		);
		expect(fixture.calls).toBe(2);
		await fixture.run("close");
		expect(fixture.widgetLines()).toEqual([]);
		await fixture.run();
		expect(fixture.calls).toBe(2);
		expect(fixture.url).not.toBe(firstUrl);
		const resumed = await connect(fixture.url);
		const resumedState = await (await resumed("state")).json();
		expect(resumedState.review.draft).toEqual(draft);
		expect(resumedState.threads).toEqual([]);
		expect(fixture.widgetText()).toContain("1/2 reviewed");
	});

	it("reflects pending Ask work and returns to Ready", async () => {
		fixture = await createFixture();
		await fixture.run();
		const url = fixture.url;
		const api = await connect(url);
		const { review } = await (await api("state")).json();
		let releaseAsk!: (message: AssistantMessage) => void;
		let askStarted!: () => void;
		const didAskStart = new Promise<void>((resolve) => {
			askStarted = resolve;
		});
		vi.spyOn(fixture.ctx.modelRegistry, "complete").mockImplementationOnce(
			async (_model, _context, options) => {
				const signal = (options as { signal?: AbortSignal } | undefined)
					?.signal;
				return new Promise<AssistantMessage>((resolve, reject) => {
					signal?.addEventListener("abort", () => reject(signal?.reason), {
						once: true,
					});
					releaseAsk = resolve;
					askStarted();
				});
			},
		);
		const asking = api("ask", {
			layerId: "layer-1",
			hunkId: review.snapshot.hunks[0].id,
			question: "Wait for this answer",
		});
		await didAskStart;
		expectPhase("Answering");
		releaseAsk(fauxAssistantMessage("Done"));
		expect((await asking).status).toBe(200);
		expectPhase("Ready");
	});

	it("preserves the active link and counts after an Ask error", async () => {
		fixture = await createFixture();
		await fixture.run();
		const url = fixture.url;
		const api = await connect(url);
		vi.spyOn(fixture.ctx.modelRegistry, "complete").mockRejectedValueOnce(
			new Error("model\x1b]8;;https://evil.example\x07 unavailable"),
		);
		expect(
			(
				await api("ask", {
					layerId: "layer-1",
					hunkId: null,
					question: "Fail",
				})
			).status,
		).toBe(500);
		expectPhase("Error");
		expect(fixture.widgetText()).toContain(
			"model ]8;;https://evil.example unavailable",
		);
		expect(fixture.url).toBe(url);
		expect(fixture.widgetText()).toContain("0/2 reviewed");
	});

	it("avoids replanning when the snapshot and forge context are unchanged", async () => {
		fixture = await createFixture();
		await fixture.run();
		const api = await connect(fixture.url);
		const complete = vi.spyOn(fixture.ctx.modelRegistry, "complete");
		expect((await api("refresh", { confirm: true })).status).toBe(200);
		expect(complete).not.toHaveBeenCalled();
		expectPhase("Ready");
	});

	it("keeps stale until refresh carries unchanged draft progress", async () => {
		fixture = await createFixture();
		await fixture.run();
		const api = await connect(fixture.url);
		const { review } = await (await api("state")).json();
		const draft = {
			reviewed: [review.snapshot.hunks[0].id],
			findings: [],
			notes: "Verify the retry contract.",
		};
		await writeFile(
			path.join(fixture.root, "contract.ts"),
			"export const attempts = 4;\n",
		);
		expect((await api("submit", { draft })).status).toBe(409);
		expectPhase("Stale");
		expect(fixture.widgetText()).toContain("1/2 reviewed");
		expect((await api("draft", draft)).status).toBe(200);
		expectPhase("Stale");
		expect((await api("refresh", { confirm: true })).status).toBe(200);
		expectPhase("Ready");
		expect(fixture.widgetText()).toContain("1/2 reviewed");
	});

	it("keeps Feedback sent while preserving reviewed progress", async () => {
		fixture = await createFixture();
		await writeFile(
			path.join(fixture.root, "contract.ts"),
			"export const attempts = 4;\n",
		);
		await fixture.run();
		const api = await connect(fixture.url);
		const { review: refreshedReview } = await (await api("state")).json();
		const send = vi
			.spyOn(fixture.t.session, "prompt")
			.mockResolvedValue(undefined);
		const feedback = {
			reviewed: [refreshedReview.snapshot.hunks[0].id],
			findings: [],
			notes: "Four attempts are intentional.",
		};
		expect((await api("submit", { draft: feedback })).status).toBe(200);
		await vi.waitFor(() => expect(send).toHaveBeenCalled());
		expect(send.mock.calls[0]![0]).toContain(refreshedReview.snapshot.id);
		expectPhase("Feedback sent");
		expect(fixture.widgetText()).toContain("1/2 reviewed");
		expect((await (await api("state")).json()).review.draft).toMatchObject({
			reviewed: [refreshedReview.snapshot.hunks[0].id],
			findings: [],
			notes: "",
		});
	});

	it("persists local recovery across browser Close and a new workspace port", async () => {
		fixture = await createFixture();
		await fixture.run();
		const originalUrl = fixture.url;
		const api = await connect(originalUrl);
		const original = await (await api("state")).json();
		const text = JSON.stringify({
			reviewed: [],
			findings: [],
			notes: "Unacknowledged browser work",
		});
		expect(
			(
				await api("close", {
					recoveries: [{ snapshotId: original.snapshotId, text }],
				})
			).status,
		).toBe(200);
		await vi.waitFor(() => expect(fixture?.widgetText()).toBe(""));
		await fixture.run();
		expect(fixture.url).not.toBe(originalUrl);
		const reopened = await connect(fixture.url);
		const latest = await (await reopened("state")).json();
		expect(latest.review.recoveries).toEqual([
			expect.objectContaining({ snapshotId: original.snapshotId, text }),
		]);
		expect(latest.review.draft.notes).toBe("");
		expect(fixture.calls).toBe(1);
	});

	it("retains URL and error discovery in RPC mode without component factories", async () => {
		fixture = await createFixture("rpc");
		await fixture.run();
		const url = fixture.url;
		expect(fixture.widgets).toEqual([]);
		expect(fixture.notifications).toEqual([
			{ text: `Strata review: ${url}`, severity: "info" },
		]);
		await fixture.run();
		expect(fixture.notifications).toHaveLength(1);
		await fixture.run("close");
		await writeFile(
			path.join(fixture.root, "contract.ts"),
			"export const attempts = 5;\n",
		);
		fixture.faux.setResponses([fauxAssistantMessage("not json")]);
		await fixture.run();
		expect(fixture.notifications.at(-1)).toMatchObject({ severity: "error" });
	});

	it("emits URL and error custom messages without terminal writes or LLM turns when no UI exists", async () => {
		const stdout = vi.spyOn(process.stdout, "write").mockReturnValue(true);
		const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
		fixture = await createFixture("json");
		await fixture.run();
		const statusMessages = () =>
			fixture?.t.events.messages.filter(
				(message) =>
					message.role === "custom" && message.customType === "strata-status",
			) ?? [];
		expect(statusMessages()).toHaveLength(1);
		expect(statusMessages()[0]).toMatchObject({
			content: expect.stringMatching(
				/Strata review: http:\/\/127\.0\.0\.1:\d+\/\?token=\S+/,
			),
			display: true,
			details: { severity: "info" },
		});
		expect(fixture.calls).toBe(1);
		await fixture.run();
		expect(statusMessages()).toHaveLength(1);
		expect(fixture.calls).toBe(1);
		await fixture.run("close");
		await writeFile(
			path.join(fixture.root, "contract.ts"),
			"export const attempts = 5;\n",
		);
		fixture.faux.setResponses([fauxAssistantMessage("not json")]);
		await fixture.run();
		expect(statusMessages().at(-1)).toMatchObject({
			content: expect.stringContaining("Strata failed:"),
			display: true,
			details: { severity: "error" },
		});
		expect(fixture.calls).toBe(1);
		expect(stdout).not.toHaveBeenCalled();
		expect(stderr).not.toHaveBeenCalled();
	});

	it("keeps initial failures visible and resets them on reopen", async () => {
		fixture = await createFixture();
		fixture.faux.setResponses([fauxAssistantMessage("not json")]);
		await fixture.run();
		expectPhase("Error");
		expect(fixture.widgetText()).toContain("Strata failed:");
		expect(fixture.widgetText(240)).toContain("Run /strata to retry");
		expect(fixture.notifications).toEqual([]);
		await fixture.run("--unknown");
		expectPhase("Error");
		expect(fixture.widgetText()).toContain("usage:");
		expect(fixture.widgetText()).not.toContain("model response is not JSON");
	});

	it("guards cancelled and replaced generations against late callbacks", async () => {
		fixture = await createFixture();
		const pendingCalls: Array<{
			context: unknown;
			resolve(message: AssistantMessage): void;
		}> = [];
		const started = Array.from({ length: 2 }, () =>
			Promise.withResolvers<void>(),
		);
		const complete = vi
			.spyOn(fixture.ctx.modelRegistry, "complete")
			.mockImplementation(async (_model, context) => {
				return new Promise<AssistantMessage>((resolve) => {
					pendingCalls.push({ context, resolve });
					started[pendingCalls.length - 1]!.resolve();
				});
			});
		testSignal().addEventListener(
			"abort",
			() => {
				for (const call of pendingCalls)
					call.resolve(planResponse(call.context));
			},
			{ once: true },
		);
		const oldOpen = fixture.run();
		await started[0]!.promise;
		expect(pendingCalls).toHaveLength(1);
		await fixture.run("close");
		expect(fixture.widgetLines()).toEqual([]);
		const replacement = fixture.run();
		await started[1]!.promise;
		expect(pendingCalls).toHaveLength(2);
		pendingCalls[1]!.resolve(planResponse(pendingCalls[1]!.context));
		await replacement;
		expectPhase("Ready");
		const replacementUrl = fixture.url;
		pendingCalls[0]!.resolve(planResponse(pendingCalls[0]!.context));
		await oldOpen;
		expectPhase("Ready");
		expect(fixture.url).toBe(replacementUrl);
		expect(complete).toHaveBeenCalledTimes(2);
	});

	it("drains pending generation on shutdown and ignores its late callback", async () => {
		fixture = await createFixture();
		await writeFile(
			path.join(fixture.root, "contract.ts"),
			"export const attempts = 6;\n",
		);
		const started = Promise.withResolvers<void>();
		const pending = Promise.withResolvers<AssistantMessage>();
		let context: unknown;
		const complete = vi
			.spyOn(fixture.ctx.modelRegistry, "complete")
			.mockImplementation(async (_model, value) => {
				context = value;
				started.resolve();
				return pending.promise;
			});
		testSignal().addEventListener(
			"abort",
			() =>
				pending.resolve(
					context ? planResponse(context) : fauxAssistantMessage("cancelled"),
				),
			{ once: true },
		);
		const shutdownOpen = fixture.run();
		await started.promise;
		expect(complete).toHaveBeenCalledOnce();
		let settled = false;
		const shutdown = fixture.runner
			.emit({ type: "session_shutdown", reason: "reload" })
			.then(() => {
				settled = true;
			});
		expect(fixture.widgetLines()).toEqual([]);
		await Promise.resolve();
		expect(settled).toBe(false);
		pending.resolve(planResponse(context));
		await shutdown;
		await shutdownOpen;
		expect(settled).toBe(true);
		expect(fixture.widgetLines()).toEqual([]);
		expect(fixture.notifications).toEqual([]);
	});
});