/** * output-file-compaction-e2e.test.ts — regression for issue #145: output-file * streaming must survive session compaction. * * Uses a REAL pi AgentSession (faux model backend, in-memory session manager) * and drives a REAL session.compact() — no mocked compaction semantics, so * this breaks if pi changes how compaction rebuilds session.messages. */ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fauxAssistantMessage, fauxText } from "@earendil-works/pi-ai"; import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { streamToOutputFile, writeInitialEntry } from "../src/output-file.js"; import { fauxModelBackend } from "./helpers/faux-model-backend.js"; import { registerFauxProvider } from "./helpers/pi-ai.js"; const TURNS_BEFORE_COMPACT = 6; describe("output-file streaming across a real compaction (#145)", () => { let tmp: string; let prevAgentDir: string | undefined; beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), "issue-145-")); prevAgentDir = process.env.PI_CODING_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = tmp; // hermetic: no dev-env extensions/themes }); afterEach(() => { if (prevAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = prevAgentDir; rmSync(tmp, { recursive: true, force: true }); }); it("keeps writing post-compaction messages to the output file", async () => { const cwd = tmp; const faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }], }); const model = faux.getModel(); const backend = fauxModelBackend(model); // Context-branching responder: compaction issues a variable number of // model calls (summary, plus a turn-prefix summary when the cut point // splits a turn), so a fixed FIFO would desync. Decide from the request. const respond = (context: { messages: Array<{ role: string; content: unknown }> }) => { const last = context.messages[context.messages.length - 1]; const text = typeof last?.content === "string" ? last.content : JSON.stringify(last?.content ?? ""); // Compaction requests first — their instruction embeds the transcript, // so the "question N" branch below would otherwise match inside it. // Markers are pi's own prompt texts (SUMMARIZATION_PROMPT and // TURN_PREFIX_SUMMARIZATION_PROMPT in core/compaction). if (text.includes("conversation to summarize") || text.includes("PREFIX of a turn")) { return fauxAssistantMessage([fauxText("summary of everything so far")]); } if (text.includes("final question")) { return fauxAssistantMessage([fauxText("POST-COMPACTION-ANSWER")]); } const q = text.match(/question (\d+)/); if (last?.role === "user" && q) { return fauxAssistantMessage([fauxText(`answer-${q[1]} ${"filler ".repeat(200)}`)]); } throw new Error(`unexpected faux request: ${text.slice(0, 120)}`); }; faux.setResponses(Array.from({ length: 16 }, () => respond)); const agentDir = getAgentDir(); const loader = new DefaultResourceLoader({ cwd, agentDir, systemPromptOverride: () => "You are a test agent.", appendSystemPromptOverride: () => [], noPromptTemplates: true, noThemes: true, noContextFiles: true, }); await loader.reload(); const { session } = await createAgentSession({ cwd, agentDir, model, // Registry for pre-0.80.8 Pi, runtime for post — each ignores the other. modelRegistry: backend.modelRegistry as never, modelRuntime: backend.modelRuntime as never, resourceLoader: loader, sessionManager: SessionManager.inMemory(cwd), settingsManager: SettingsManager.inMemory({ // Auto-compaction off (deterministic manual compact() below); a small // keep-window so the bulky early turns actually get summarized away. compaction: { enabled: false, keepRecentTokens: 500 }, retry: { enabled: false }, }), }); const outPath = join(tmp, "agent.output"); writeInitialEntry(outPath, "agent-145", "repro", cwd); const cleanup = streamToOutputFile(session as never, outPath, "agent-145", cwd); const readEntries = () => readFileSync(outPath, "utf-8") .trim() .split("\n") .map((line) => JSON.parse(line) as Record); // Build up history, streaming as we go (turn_end fires per prompt). for (let i = 0; i < TURNS_BEFORE_COMPACT; i++) { await session.prompt(`question ${i}`); } const writtenBeforeCompact = readEntries().length; expect(writtenBeforeCompact).toBeGreaterThan(TURNS_BEFORE_COMPACT); // sanity: streaming worked pre-compaction const lenBefore = session.messages.length; const result = await session.compact(); expect(result?.summary).toContain("summary"); // sanity: real compaction ran // Sanity: compaction actually shrank the array — the exact condition that // used to strand writtenCount past the end and halt streaming (#145). expect(session.messages.length).toBeLessThan(lenBefore); // The run continues after compaction... await session.prompt("final question"); cleanup(); // The post-compaction turn reaches the output file... const entries = readEntries(); const all = JSON.stringify(entries); expect(all).toContain("final question"); expect(all).toContain("POST-COMPACTION-ANSWER"); // ...and re-anchoring didn't double-write the compaction-kept tail: every // pre-compaction answer still appears exactly once. for (let i = 0; i < TURNS_BEFORE_COMPACT; i++) { expect(all.split(`answer-${i} `).length - 1).toBe(1); } expect(entries.length).toBe(writtenBeforeCompact + 2); // + final question + its answer }, 30_000); });