mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
147 lines
6.0 KiB
TypeScript
147 lines
6.0 KiB
TypeScript
/**
|
|
* 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<string, unknown>);
|
|
|
|
// 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);
|
|
});
|