repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/decay/__tests__/retention.test.ts
Rawimport {
fauxProvider,
InMemoryCredentialStore,
InMemoryModelsStore,
} from "@earendil-works/pi-ai";
import {
createAgentSession,
DefaultResourceLoader,
estimateTokens,
ModelRuntime,
type SessionBeforeCompactEvent,
SessionManager,
SettingsManager,
sessionEntryToContextMessages,
} from "@earendil-works/pi-coding-agent";
import { describe, expect, it } from "vitest";
import {
type DecayDetails,
estimateKeptTokens,
parseDecayDetails,
} from "../core";
const settings = {
enabled: true,
reserveTokens: 16_384,
keepRecentTokens: 20_000,
};
function user(tokens: number, marker: string) {
return {
role: "user" as const,
content: [
{
type: "text" as const,
text: marker + "x".repeat(tokens * 4 - marker.length),
},
],
timestamp: 1,
};
}
function assistant(tokens: number, marker: string) {
return {
role: "assistant" as const,
content: [
{
type: "text" as const,
text: marker + "x".repeat(tokens * 4 - marker.length),
},
],
api: "test",
provider: "test",
model: "test",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop" as const,
timestamp: 1,
};
}
async function prepare(manager: SessionManager) {
const settingsManager = SettingsManager.inMemory({ compaction: settings });
const faux = fauxProvider({
models: [{ id: "retention", contextWindow: 128_000, maxTokens: 16_384 }],
});
const modelRuntime = await ModelRuntime.create({
credentials: new InMemoryCredentialStore(),
modelsPath: null,
modelsStore: new InMemoryModelsStore(),
refreshOnCreate: false,
});
modelRuntime.registerNativeProvider(faux.provider);
let captured: SessionBeforeCompactEvent | undefined;
const resourceLoader = new DefaultResourceLoader({
cwd: process.cwd(),
agentDir: process.cwd(),
settingsManager,
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
extensionFactories: [
(pi) => {
pi.on("session_before_compact", (event) => {
captured = event;
return { cancel: true };
});
},
],
});
await resourceLoader.reload();
const { session } = await createAgentSession({
model: faux.getModel("retention"),
modelRuntime,
noTools: "all",
resourceLoader,
sessionManager: manager,
settingsManager,
});
try {
await expect(session.compact()).rejects.toThrow("Compaction cancelled");
} finally {
session.dispose();
}
expect(faux.state.callCount).toBe(0);
if (!captured) throw new Error("session_before_compact was not captured");
return captured;
}
function projectedSuffix(event: SessionBeforeCompactEvent) {
const boundary = event.branchEntries.findIndex(
(entry) => entry.id === event.preparation.firstKeptEntryId,
);
return event.branchEntries
.slice(boundary)
.flatMap((entry) =>
entry.type === "compaction" ? [] : sessionEntryToContextMessages(entry),
);
}
function settleAndCompare(
manager: SessionManager,
event: SessionBeforeCompactEvent,
summary = "new summary",
) {
const suffix = projectedSuffix(event);
const estimated = estimateKeptTokens(event);
expect(estimated).toBe(
suffix.reduce((total, message) => total + estimateTokens(message), 0),
);
manager.appendCompaction(
summary,
event.preparation.firstKeptEntryId,
event.preparation.tokensBefore,
);
const rebuilt = manager.buildSessionContext().messages;
expect(rebuilt[0]).toMatchObject({ role: "compactionSummary", summary });
expect(rebuilt.slice(1)).toEqual(suffix);
return { estimated, suffix, rebuilt };
}
describe("settled Decay retention accounting", () => {
it("keeps normal-boundary message objects verbatim and may exceed the 20k target", async () => {
const manager = SessionManager.inMemory();
manager.appendMessage(user(12_000, "old"));
const firstKept = user(12_000, "kept-a");
const newest = user(12_000, "kept-b");
manager.appendMessage(firstKept);
manager.appendMessage(newest);
const event = await prepare(manager);
expect(event.preparation.isSplitTurn).toBe(false);
const result = settleAndCompare(manager, event);
expect(result.estimated).toBe(24_000);
expect(result.estimated).toBeGreaterThan(settings.keepRecentTokens);
expect(result.suffix).toHaveLength(2);
expect(result.suffix[0]).toBe(firstKept);
expect(result.suffix[1]).toBe(newest);
});
it("keeps split-turn suffix message objects verbatim", async () => {
const manager = SessionManager.inMemory();
manager.appendMessage(user(100, "request"));
const firstKept = assistant(12_000, "kept-a");
const newest = assistant(12_000, "kept-b");
manager.appendMessage(firstKept);
manager.appendMessage(newest);
const event = await prepare(manager);
expect(event.preparation.isSplitTurn).toBe(true);
const result = settleAndCompare(manager, event);
expect(result.estimated).toBe(24_000);
expect(result.suffix[0]).toBe(firstKept);
expect(result.suffix[1]).toBe(newest);
});
it("can stay below 20k at a tool-result cut and excludes metadata", async () => {
const manager = SessionManager.inMemory();
manager.appendMessage(user(1_000, "old"));
manager.appendMessage({
...assistant(1, "a"),
content: [
{ type: "toolCall", id: "call-1", name: "read", arguments: {} },
],
} as never);
manager.appendMessage({
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "x".repeat(21_000 * 4) }],
isError: false,
timestamp: 1,
});
const metadataId = manager.appendCustomEntry("retention-test", {
notContext: "x".repeat(40_000),
});
const newest = user(100, "new");
manager.appendMessage(newest);
const event = await prepare(manager);
expect(event.preparation.firstKeptEntryId).toBe(metadataId);
const result = settleAndCompare(manager, event);
expect(result.estimated).toBe(100);
expect(result.estimated).toBeLessThan(settings.keepRecentTokens);
expect(result.suffix).toEqual([newest]);
});
it("drops an older compaction summary when the later boundary precedes it", async () => {
const manager = SessionManager.inMemory();
for (let index = 1; index <= 4; index++)
manager.appendMessage(user(8_000, `turn-${index}`));
const first = (await prepare(manager)).preparation;
manager.appendCompaction(
"s".repeat(4_000),
first.firstKeptEntryId,
first.tokensBefore,
);
manager.appendMessage(user(4_000, "later"));
const secondEvent = await prepare(manager);
const boundaryIndex = secondEvent.branchEntries.findIndex(
(entry) => entry.id === secondEvent.preparation.firstKeptEntryId,
);
const olderCompactionIndex = secondEvent.branchEntries.findIndex(
(entry) => entry.type === "compaction",
);
expect(boundaryIndex).toBeLessThan(olderCompactionIndex);
const result = settleAndCompare(manager, secondEvent);
expect(
result.suffix.some((message) => message.role === "compactionSummary"),
).toBe(false);
});
it("cuts before a repeated-compaction checkpoint and drops the older summary", async () => {
const manager = SessionManager.inMemory();
for (let index = 1; index <= 4; index++)
manager.appendMessage(user(8_000, `turn-${index}`));
const first = (await prepare(manager)).preparation;
manager.appendCompaction(
"older checkpoint",
first.firstKeptEntryId,
first.tokensBefore,
);
manager.appendMessage(user(12_000, "later-a"));
manager.appendMessage(user(12_000, "later-b"));
const secondEvent = await prepare(manager);
const boundaryIndex = secondEvent.branchEntries.findIndex(
(entry) => entry.id === secondEvent.preparation.firstKeptEntryId,
);
const olderCompactionIndex = secondEvent.branchEntries.findIndex(
(entry) => entry.type === "compaction",
);
expect(boundaryIndex).toBeLessThan(olderCompactionIndex);
const result = settleAndCompare(manager, secondEvent);
expect(result.estimated).toBe(24_000);
expect(
result.suffix.some((message) => message.role === "compactionSummary"),
).toBe(false);
});
it("returns undefined when the prepared boundary is absent", async () => {
const manager = SessionManager.inMemory();
manager.appendMessage(user(12_000, "old"));
manager.appendMessage(user(12_000, "kept-a"));
manager.appendMessage(user(12_000, "kept-b"));
const preparedEvent = await prepare(manager);
const event = {
...preparedEvent,
preparation: {
...preparedEvent.preparation,
firstKeptEntryId: "missing",
},
};
expect(estimateKeptTokens(event)).toBeUndefined();
});
it("parses an optional valid estimate and rejects invalid present values", () => {
const legacy: DecayDetails = {
decay: {
version: 1,
model: "p/m",
elapsedMs: 1,
chunkCount: 1,
keepRecentTokens: 20_000,
atoms: [],
readFiles: [],
modifiedFiles: [],
},
};
expect(parseDecayDetails(legacy)).toEqual(legacy);
const withEstimate: DecayDetails = {
decay: { ...legacy.decay, estimatedKeptTokens: 24_000 },
};
expect(parseDecayDetails(withEstimate)).toEqual(withEstimate);
for (const invalid of [-1, 1.5, Number.NaN, "20000", null]) {
expect(
parseDecayDetails({
decay: { ...legacy.decay, estimatedKeptTokens: invalid },
}),
).toBeUndefined();
}
});
});
// These are pure accounting tests. They exercise Pi's real preparation and session
// rebuilding APIs, but intentionally do not invoke Decay's extension hook or any model.