mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user