import { execFile } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, test, vi } from "vitest"; import type { ExtensionAPI, ExtensionContext } from "@selesai/code"; import rewindExtension from "./index.ts"; /** Loads the vendored rewind extension against the built host to prove module-level compatibility. */ test("pi-rewind-hook extension module loads against the host", async () => { const mod = await import("./index.ts"); expect(mod.default).toBeTypeOf("function"); }); type Handler = (event: unknown, ctx: ExtensionContext) => unknown; interface FakeExecResult { stdout: string; stderr: string; code: number; killed: boolean; } function runGit(cwd: string, args: string[]): Promise { return new Promise((resolve) => { execFile("git", args, { cwd }, (error, stdout, stderr) => { const code = error ? (typeof error.code === "number" ? error.code : 1) : 0; resolve({ stdout, stderr, code, killed: false }); }); }); } const tempRepos: string[] = []; afterEach(async () => { await Promise.all(tempRepos.splice(0).map((repoDir) => rm(repoDir, { recursive: true, force: true }))); }); async function createTempRepo(): Promise { const repoDir = await mkdtemp(join(tmpdir(), "pi-rewind-test-")); tempRepos.push(repoDir); await runGit(repoDir, ["init", "-q"]); // Snapshot commits need a committer identity; keep it local to the temp repo. await runGit(repoDir, ["config", "user.email", "rewind-test@example.com"]); await runGit(repoDir, ["config", "user.name", "Rewind Test"]); await writeFile(join(repoDir, "file.txt"), "content\n"); return repoDir; } function userMessageEntry(id: string, text: string) { return { type: "message", id, parentId: null, timestamp: "2025-01-01T00:00:00.000Z", message: { role: "user", content: text, timestamp: 1 }, }; } function beforeAgentStartUserEvent(prompt: string) { return { type: "before_agent_start", messageType: "user", prompt, systemPrompt: "system prompt" }; } function beforeAgentStartCustomEvent() { return { type: "before_agent_start", messageType: "custom", message: { customType: "intercom", content: "wake", display: false }, systemPrompt: "system prompt", }; } /** Runs the extension against a temp git repo and records appended entries. */ function createHarness(repoDir: string, branch: unknown[]) { const handlers = new Map(); const appendedEntries: Array<{ customType: string; data: unknown }> = []; const pi = { on: (event: string, handler: Handler) => { handlers.set(event, handler); }, events: { on: () => undefined }, exec: (_command: string, args: string[]) => runGit(repoDir, args), appendEntry: (customType: string, data: unknown) => { appendedEntries.push({ customType, data }); }, } as unknown as ExtensionAPI; const ctx = { hasUI: false, ui: { notify: () => undefined, setStatus: () => undefined, theme: { fg: () => "" } }, sessionManager: { getSessionId: () => "rewind-test-session", getSessionFile: () => join(repoDir, "session.jsonl"), getHeader: () => ({ id: "rewind-test-session", cwd: repoDir }), getCwd: () => repoDir, getBranch: () => branch, getEntries: () => [], getEntry: () => undefined, }, } as unknown as ExtensionContext; rewindExtension(pi); return { handlers, appendedEntries, ctx }; } async function startSession(handlers: Map, ctx: ExtensionContext) { await handlers.get("session_start")!({ type: "session_start", reason: "startup" }, ctx); } async function runTurn(handlers: Map, ctx: ExtensionContext) { await handlers.get("turn_start")!({ type: "turn_start", turnIndex: 0 }, ctx); await handlers.get("agent_end")!({ type: "agent_end" }, ctx); } /** Entry ids bound by the rewind-turn records the extension appended. */ function boundEntryIds(appendedEntries: Array<{ customType: string; data: unknown }>): string[] { const ids: string[] = []; for (const entry of appendedEntries) { if (entry.customType !== "rewind-turn") continue; const bindings = (entry.data as { bindings?: Array<[string, number]> }).bindings ?? []; for (const [entryId] of bindings) { ids.push(entryId); } } return ids; } // The branch's newest user message differs from the prompt under test, so the // bound entry reveals which prompt text the extension captured for the turn. const branchMessages = [userMessageEntry("user-1", "first prompt"), userMessageEntry("user-2", "second prompt")]; test("keeps the previous user prompt when a custom-message run starts", async () => { const repoDir = await createTempRepo(); vi.stubEnv("SELESAI_CODING_AGENT_DIR", join(repoDir, "agent")); const { handlers, appendedEntries, ctx } = createHarness(repoDir, branchMessages); await startSession(handlers, ctx); await handlers.get("before_agent_start")!(beforeAgentStartUserEvent("first prompt"), ctx); await handlers.get("before_agent_start")!(beforeAgentStartCustomEvent(), ctx); await runTurn(handlers, ctx); expect(boundEntryIds(appendedEntries)).toEqual(["user-1"]); }); test("updates the active prompt from a user-message run after a custom-message run", async () => { const repoDir = await createTempRepo(); vi.stubEnv("SELESAI_CODING_AGENT_DIR", join(repoDir, "agent")); const { handlers, appendedEntries, ctx } = createHarness(repoDir, branchMessages); await startSession(handlers, ctx); await handlers.get("before_agent_start")!(beforeAgentStartCustomEvent(), ctx); await handlers.get("before_agent_start")!(beforeAgentStartUserEvent("first prompt"), ctx); await runTurn(handlers, ctx); expect(boundEntryIds(appendedEntries)).toEqual(["user-1"]); });