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 | 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) => 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( /\s* ({ 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> | 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( /\s* ({ 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((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((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(), ); const complete = vi .spyOn(fixture.ctx.modelRegistry, "complete") .mockImplementation(async (_model, context) => { return new Promise((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(); const pending = Promise.withResolvers(); 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([]); }); });