import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai"; import { type ExtensionCommandContext, initTheme, } from "@earendil-works/pi-coding-agent"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createPiSession, type PiSession, } from "../../../test/fixture/create-pi-session.ts"; function extensionPath(): string { return fileURLToPath(new URL("../index.ts", import.meta.url)); } describe("session-rename contract", () => { let session: PiSession | undefined; afterEach(async () => { await session?.dispose(); session = undefined; vi.restoreAllMocks(); }); it("loads in real Pi extension runtime", async () => { const extPath = extensionPath(); session = await createPiSession({ extensionPaths: [extPath] }); expect(session.extensions).toHaveLength(1); expect(session.extensions[0]!.path).toBe(extPath); }); it("writes its opt-in lifecycle trace inside the fixture sandbox", async () => { const root = mkdtempSync( path.join(tmpdir(), "pi-ext-session-rename-debug-"), ); try { session = await createPiSession({ cwd: root, extensionPaths: [extensionPath()], env: { PI_SESSION_RENAME_DEBUG: "1" }, }); const runner = session.session.extensionRunner; await runner.emit({ type: "session_start", reason: "startup" }); 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", "session-rename", ); const [file] = readdirSync(directory); const events = readFileSync(path.join(directory, file!), "utf8") .trim() .split("\n") .map((line) => JSON.parse(line).event); expect(events).toEqual( expect.arrayContaining(["session.start", "session.shutdown"]), ); } finally { await session?.dispose(); session = undefined; rmSync(root, { recursive: true, force: true }); } }); it("routes naming through the active custom provider", async () => { session = await createPiSession({ extensionPaths: [extensionPath()] }); const runner = session.session.extensionRunner; const base = runner.createContext(); const faux = fauxProvider({ provider: "session-rename-test", models: [{ id: "worker", maxTokens: 4_096 }], }); faux.setResponses([fauxAssistantMessage("Klaus routing fix")]); const stream = vi.spyOn(faux.provider, "stream"); base.modelRegistry.registerProvider(faux.provider); const model = base.modelRegistry.find("session-rename-test", "worker"); if (!model) throw new Error("missing custom model"); const manager = base.sessionManager as any; const sessionFile = path.join(session.cwd, "session.jsonl"); const sessionManager = Object.create(manager) as typeof manager; sessionManager.getSessionFile = () => sessionFile; const ctx = Object.create(base) as typeof base; Object.defineProperties(ctx, { model: { value: model }, sessionManager: { value: sessionManager }, }); manager.appendMessage({ role: "user", content: "Fix Klaus routing", timestamp: Date.now(), }); manager.appendMessage(fauxAssistantMessage("Inspecting custom providers.")); const command = session.extensions[0]!.commands.get("session-rename"); if (!command) throw new Error("missing session-rename command"); await command.handler("", ctx as unknown as ExtensionCommandContext); expect(stream).toHaveBeenCalledOnce(); expect(manager.getSessionName()).toBe("klaus routing fix"); }); it("reports naming failures through Pi UI", async () => { session = await createPiSession({ extensionPaths: [extensionPath()], env: { PI_SESSION_RENAME_DEBUG: "1" }, }); const runner = session.session.extensionRunner; const base = runner.createContext(); const faux = fauxProvider({ provider: "session-rename-error", models: [{ id: "worker", maxTokens: 4_096 }], }); base.modelRegistry.registerProvider(faux.provider); const model = base.modelRegistry.find("session-rename-error", "worker"); if (!model) throw new Error("missing custom model"); vi.spyOn(base.modelRegistry, "complete").mockRejectedValue( new Error("provider exploded"), ); const manager = base.sessionManager as any; const sessionFile = path.join(session.cwd, "session.jsonl"); const sessionManager = Object.create(manager) as typeof manager; sessionManager.getSessionFile = () => sessionFile; const ctx = Object.create(base) as typeof base; Object.defineProperties(ctx, { hasUI: { value: true }, model: { value: model }, sessionManager: { value: sessionManager }, }); manager.appendMessage({ role: "user", content: "Trigger rename failure", timestamp: Date.now(), }); manager.appendMessage(fauxAssistantMessage("Ready.")); initTheme(undefined, false); const notify = vi.spyOn(ctx.ui, "notify"); const command = session.extensions[0]!.commands.get("session-rename"); if (!command) throw new Error("missing session-rename command"); await command.handler("", ctx as unknown as ExtensionCommandContext); expect(notify).toHaveBeenCalledWith( "session-rename failed: provider exploded", "error", ); 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", "session-rename"); const [file] = readdirSync(directory); const records = readFileSync(path.join(directory, file!), "utf8") .trim() .split("\n") .map((line) => JSON.parse(line)); expect(records).toContainEqual( expect.objectContaining({ event: "name.outcome", outcome: "failed" }), ); }); });