import { describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { CURRENT_SESSION_VERSION, type SessionHeader, type SessionMessageEntry, } from "@oh-my-pi/pi-coding-agent/session/session-entries"; import { loadEntriesFromFile } from "@oh-my-pi/pi-coding-agent/session/session-loader"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { getTerminalId } from "@oh-my-pi/pi-tui"; import { getAgentDir, getTerminalSessionsDir, removeWithRetries, setAgentDir, TempDir } from "@oh-my-pi/pi-utils"; interface JsonlMessageEntry { type: "message"; id: string; parentId: string | null; timestamp: string; message: { role: "user"; content: string; timestamp: number; }; } async function createSessionWithArtifacts(root: string): Promise<{ cwd: string; sessionDir: string; sourceFile: string; sourceArtifactsDir: string; }> { const cwd = path.join(root, "project"); const sessionDir = path.join(root, "sessions"); const sourceFile = path.join(sessionDir, "source.jsonl"); const sourceArtifactsDir = sourceFile.slice(0, -".jsonl".length); const sourceHeader: SessionHeader = { type: "session", version: CURRENT_SESSION_VERSION, id: "source-with-artifacts", timestamp: new Date().toISOString(), cwd, }; await fs.mkdir(path.join(sourceArtifactsDir, "nested"), { recursive: true }); await Bun.write(sourceFile, `${JSON.stringify(sourceHeader)}\n`); await Bun.write(path.join(sourceArtifactsDir, "1.read.log"), "tool output"); await Bun.write(path.join(sourceArtifactsDir, "nested", "result.txt"), "nested output"); return { cwd, sessionDir, sourceFile, sourceArtifactsDir }; } describe("SessionManager.forkFrom", () => { it("suppresses terminal breadcrumbs while preserving source history under a new parented session", async () => { using tempDir = TempDir.createSync("@omp-session-fork-"); const previousAgentDir = getAgentDir(); const previousTermSessionId = process.env.TERM_SESSION_ID; setAgentDir(path.join(tempDir.path(), "agent")); process.env.TERM_SESSION_ID = "omp-fork-test"; try { const cwd = path.join(tempDir.path(), "project"); const sessionDir = path.join(tempDir.path(), "sessions"); await fs.mkdir(sessionDir, { recursive: true }); const sourceFile = path.join(sessionDir, "source.jsonl"); const timestamp = new Date().toISOString(); const sourceHeader: SessionHeader = { type: "session", version: CURRENT_SESSION_VERSION, id: "source-session", timestamp, cwd, }; const sourceMessage: JsonlMessageEntry = { type: "message", id: "message-1", parentId: null, timestamp, message: { role: "user", content: "hello", timestamp: Date.now() }, }; const sourceText = `${JSON.stringify(sourceHeader)}\n${JSON.stringify(sourceMessage)}\n`; await Bun.write(sourceFile, sourceText); const terminalId = getTerminalId(); expect(terminalId).toBeString(); const breadcrumbFile = path.join(getTerminalSessionsDir(), terminalId ?? "missing"); await removeWithRetries(breadcrumbFile); const forked = await SessionManager.forkFrom(sourceFile, cwd, sessionDir, undefined, { suppressBreadcrumb: true, }); await Bun.sleep(10); const cloneFile = forked.getSessionFile(); expect(cloneFile).toBeString(); if (!cloneFile) throw new Error("expected forked session file"); expect(await Bun.file(sourceFile).text()).toBe(sourceText); expect(await Bun.file(breadcrumbFile).exists()).toBe(false); expect(cloneFile).not.toBe(sourceFile); const cloneEntries = await loadEntriesFromFile(cloneFile); const cloneHeader = cloneEntries.find((entry): entry is SessionHeader => entry.type === "session"); const cloneMessage = cloneEntries.find((entry): entry is SessionMessageEntry => entry.type === "message"); expect(cloneHeader?.id).not.toBe(sourceHeader.id); expect(cloneHeader?.parentSession).toBe(sourceHeader.id); expect(cloneHeader?.cwd).toBe(cwd); if (cloneMessage?.message.role !== "user") throw new Error("expected forked user message"); expect(cloneMessage.message.content).toBe("hello"); } finally { if (previousTermSessionId === undefined) { delete process.env.TERM_SESSION_ID; } else { process.env.TERM_SESSION_ID = previousTermSessionId; } setAgentDir(previousAgentDir); } }); it("copies source artifacts recursively into the fork by default", async () => { using tempDir = TempDir.createSync("@omp-session-fork-artifacts-"); const { cwd, sessionDir, sourceFile, sourceArtifactsDir } = await createSessionWithArtifacts(tempDir.path()); const forked = await SessionManager.forkFrom(sourceFile, cwd, sessionDir, undefined, { suppressBreadcrumb: true, }); const forkFile = forked.getSessionFile(); if (!forkFile) throw new Error("expected forked session file"); const forkArtifactsDir = forkFile.slice(0, -".jsonl".length); expect(await Bun.file(path.join(forkArtifactsDir, "1.read.log")).text()).toBe("tool output"); expect(await Bun.file(path.join(forkArtifactsDir, "nested", "result.txt")).text()).toBe("nested output"); expect(await Bun.file(path.join(sourceArtifactsDir, "1.read.log")).text()).toBe("tool output"); }); it("does not copy artifacts when the caller opts out", async () => { using tempDir = TempDir.createSync("@omp-session-fork-no-artifacts-"); const { cwd, sessionDir, sourceFile } = await createSessionWithArtifacts(tempDir.path()); const forked = await SessionManager.forkFrom(sourceFile, cwd, sessionDir, undefined, { copyArtifacts: false, suppressBreadcrumb: true, }); const forkFile = forked.getSessionFile(); if (!forkFile) throw new Error("expected forked session file"); const forkArtifactsDir = forkFile.slice(0, -".jsonl".length); expect(await Bun.file(path.join(forkArtifactsDir, "1.read.log")).exists()).toBe(false); }); it("does not treat an extensionless source's parent directory as artifacts", async () => { using tempDir = TempDir.createSync("@omp-session-fork-extensionless-"); const cwd = path.join(tempDir.path(), "project"); const sessionDir = path.join(tempDir.path(), "sessions"); const forkDir = path.join(tempDir.path(), "forks"); const sourceFile = path.join(sessionDir, "source"); const unrelatedFile = path.join(sessionDir, "unrelated.txt"); const sourceHeader: SessionHeader = { type: "session", version: CURRENT_SESSION_VERSION, id: "extensionless-source", timestamp: new Date().toISOString(), cwd, }; await fs.mkdir(sessionDir, { recursive: true }); await Bun.write(sourceFile, `${JSON.stringify(sourceHeader)}\n`); await Bun.write(unrelatedFile, "must not be copied"); const forked = await SessionManager.forkFrom(sourceFile, cwd, forkDir, undefined, { suppressBreadcrumb: true, }); const forkFile = forked.getSessionFile(); if (!forkFile) throw new Error("expected forked session file"); const forkArtifactsDir = forkFile.slice(0, -".jsonl".length); expect(await Bun.file(path.join(forkArtifactsDir, "unrelated.txt")).exists()).toBe(false); expect(await Bun.file(unrelatedFile).text()).toBe("must not be copied"); }); });