mirror of
https://github.com/wassname/pi-dynamic-context-pruning.git
synced 2026-06-27 16:30:59 +08:00
285 lines
9.0 KiB
TypeScript
285 lines
9.0 KiB
TypeScript
/**
|
|
* Minimal self-contained tests for the applyCompressionBlocks logic inside
|
|
* applyPruning. No test framework — just assert + console.log.
|
|
*
|
|
* Run with: bun run pruner.test.ts
|
|
*/
|
|
|
|
import assert from "assert";
|
|
import { applyPruning } from "./pruner.js";
|
|
import type { DcpState } from "./state.js";
|
|
import type { DcpConfig } from "./config.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Minimal factories
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeConfig(): DcpConfig {
|
|
return {
|
|
enabled: true,
|
|
debug: false,
|
|
manualMode: { enabled: false, automaticStrategies: false },
|
|
compress: {
|
|
maxContextPercent: 0.8,
|
|
minContextPercent: 0.4,
|
|
nudgeFrequency: 5,
|
|
iterationNudgeThreshold: 15,
|
|
nudgeForce: "soft",
|
|
protectedTools: [],
|
|
protectUserMessages: false,
|
|
},
|
|
strategies: {
|
|
deduplication: { enabled: false, protectedTools: [] },
|
|
purgeErrors: { enabled: false, turns: 4, protectedTools: [] },
|
|
},
|
|
protectedFilePatterns: [],
|
|
pruneNotification: "off",
|
|
};
|
|
}
|
|
|
|
function makeState(compressionBlocks: DcpState["compressionBlocks"] = []): DcpState {
|
|
return {
|
|
toolCalls: new Map(),
|
|
prunedToolIds: new Set(),
|
|
compressionBlocks,
|
|
nextBlockId: 1,
|
|
messageIdSnapshot: new Map(),
|
|
currentTurn: 0,
|
|
tokensSaved: 0,
|
|
totalPruneCount: 0,
|
|
manualMode: false,
|
|
nudgeCounter: 0,
|
|
lastNudgeTurn: -1,
|
|
};
|
|
}
|
|
|
|
// Four-message sequence that exercises the bug:
|
|
// user(1000) → assistant+toolCall(2000) → toolResult(3000) → user(4000)
|
|
function makeMessages(): any[] {
|
|
return [
|
|
{
|
|
role: "user",
|
|
content: [{ type: "text", text: "please read the file" }],
|
|
timestamp: 1000,
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "toolCall", id: "toolu_abc", name: "read", arguments: {} }],
|
|
timestamp: 2000,
|
|
},
|
|
{
|
|
role: "toolResult",
|
|
toolCallId: "toolu_abc",
|
|
toolName: "read",
|
|
content: [{ type: "text", text: "file content" }],
|
|
isError: false,
|
|
timestamp: 3000,
|
|
},
|
|
{
|
|
role: "user",
|
|
content: [{ type: "text", text: "thanks" }],
|
|
timestamp: 4000,
|
|
},
|
|
];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: find the first orphaned tool_use in a result array
|
|
//
|
|
// An assistant message is "orphaned" if it contains a toolCall block whose
|
|
// id does NOT have a matching toolResult as the very next message.
|
|
// ---------------------------------------------------------------------------
|
|
function findOrphanedToolUse(result: any[]): string | null {
|
|
for (let i = 0; i < result.length; i++) {
|
|
const msg = result[i];
|
|
if (msg.role !== "assistant") continue;
|
|
|
|
const content: any[] = Array.isArray(msg.content) ? msg.content : [];
|
|
const toolCallBlocks = content.filter((b: any) => b.type === "toolCall");
|
|
if (toolCallBlocks.length === 0) continue;
|
|
|
|
for (const tc of toolCallBlocks) {
|
|
const next = result[i + 1];
|
|
const nextIsMatchingResult =
|
|
next &&
|
|
next.role === "toolResult" &&
|
|
next.toolCallId === tc.id;
|
|
|
|
if (!nextIsMatchingResult) {
|
|
return (
|
|
`assistant at index ${i} (ts=${msg.timestamp}) has toolCall id="${tc.id}" ` +
|
|
`but next message is: ${next ? `role="${next.role}" toolCallId="${next.toolCallId}"` : "<nothing>"}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return null; // no orphan found
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 1 — BUG SCENARIO
|
|
//
|
|
// Compression block covers ONLY the toolResult (startTimestamp=3000,
|
|
// endTimestamp=3000). Without the backward-expansion fix, the assistant
|
|
// message with the toolCall block survives but its toolResult is gone →
|
|
// orphaned tool_use. With the fix the assistant is pulled into the range
|
|
// and both messages are removed together.
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
console.log("TEST 1: compression block covers only the toolResult (bug scenario)");
|
|
|
|
const messages = makeMessages();
|
|
const state = makeState([
|
|
{
|
|
id: 1,
|
|
topic: "file read",
|
|
summary: "The file was read and contained some data.",
|
|
startTimestamp: 3000,
|
|
endTimestamp: 3000,
|
|
anchorTimestamp: 4000,
|
|
active: true,
|
|
summaryTokenEstimate: 15,
|
|
createdAt: Date.now(),
|
|
},
|
|
]);
|
|
const config = makeConfig();
|
|
|
|
const result = applyPruning(messages, state, config);
|
|
|
|
console.log(" Result messages (role, timestamp):");
|
|
for (const m of result) {
|
|
const ts = m.timestamp;
|
|
const preview =
|
|
typeof m.content === "string"
|
|
? m.content.slice(0, 60)
|
|
: Array.isArray(m.content)
|
|
? m.content.map((b: any) => b.text ?? b.type ?? "?").join(" | ").slice(0, 60)
|
|
: "?";
|
|
console.log(` role="${m.role}" ts=${ts} content="${preview}"`);
|
|
}
|
|
|
|
// 1a. No orphaned tool_use
|
|
const orphan = findOrphanedToolUse(result);
|
|
assert.strictEqual(
|
|
orphan,
|
|
null,
|
|
`FAIL — orphaned tool_use detected: ${orphan}`
|
|
);
|
|
console.log(" PASS: no orphaned tool_use in result");
|
|
|
|
// 1b. The assistant message at ts=2000 must NOT survive without its partner
|
|
const assistantInResult = result.find(
|
|
(m) => m.role === "assistant" && m.timestamp === 2000
|
|
);
|
|
if (assistantInResult) {
|
|
// If it survived, its immediate successor must be the matching toolResult
|
|
const idx = result.indexOf(assistantInResult);
|
|
const successor = result[idx + 1];
|
|
assert.ok(
|
|
successor && successor.role === "toolResult" && successor.toolCallId === "toolu_abc",
|
|
`FAIL — assistant(ts=2000) survived but successor is not the matching toolResult ` +
|
|
`(got role="${successor?.role}" toolCallId="${successor?.toolCallId}")`
|
|
);
|
|
console.log(" PASS: assistant survived with its toolResult partner intact");
|
|
} else {
|
|
// The preferred outcome: both removed together
|
|
const toolResultInResult = result.find(
|
|
(m) => m.role === "toolResult" && m.toolCallId === "toolu_abc"
|
|
);
|
|
assert.strictEqual(
|
|
toolResultInResult,
|
|
undefined,
|
|
"FAIL — assistant removed but orphaned toolResult still present"
|
|
);
|
|
console.log(" PASS: both assistant and toolResult removed together");
|
|
}
|
|
|
|
console.log("TEST 1 PASSED\n");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 2 — PASSING SCENARIO
|
|
//
|
|
// Compression block covers BOTH the assistant and the toolResult
|
|
// (startTimestamp=2000, endTimestamp=3000). Both messages must be removed
|
|
// and no orphaned tool_use must remain.
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
console.log("TEST 2: compression block covers both assistant and toolResult (passing scenario)");
|
|
|
|
const messages = makeMessages();
|
|
const state = makeState([
|
|
{
|
|
id: 1,
|
|
topic: "file read",
|
|
summary: "The file was read and contained some data.",
|
|
startTimestamp: 2000,
|
|
endTimestamp: 3000,
|
|
anchorTimestamp: 4000,
|
|
active: true,
|
|
summaryTokenEstimate: 15,
|
|
createdAt: Date.now(),
|
|
},
|
|
]);
|
|
const config = makeConfig();
|
|
|
|
const result = applyPruning(messages, state, config);
|
|
|
|
console.log(" Result messages (role, timestamp):");
|
|
for (const m of result) {
|
|
const ts = m.timestamp;
|
|
const preview =
|
|
typeof m.content === "string"
|
|
? m.content.slice(0, 60)
|
|
: Array.isArray(m.content)
|
|
? m.content.map((b: any) => b.text ?? b.type ?? "?").join(" | ").slice(0, 60)
|
|
: "?";
|
|
console.log(` role="${m.role}" ts=${ts} content="${preview}"`);
|
|
}
|
|
|
|
// 2a. No orphaned tool_use
|
|
const orphan = findOrphanedToolUse(result);
|
|
assert.strictEqual(
|
|
orphan,
|
|
null,
|
|
`FAIL — orphaned tool_use detected: ${orphan}`
|
|
);
|
|
console.log(" PASS: no orphaned tool_use in result");
|
|
|
|
// 2b. The assistant at ts=2000 must be absent from the result
|
|
const assistantInResult = result.find(
|
|
(m) => m.role === "assistant" && m.timestamp === 2000
|
|
);
|
|
assert.strictEqual(
|
|
assistantInResult,
|
|
undefined,
|
|
`FAIL — assistant(ts=2000) should have been removed but is still present`
|
|
);
|
|
console.log(" PASS: assistant(ts=2000) removed");
|
|
|
|
// 2c. The toolResult must also be absent
|
|
const toolResultInResult = result.find(
|
|
(m) => m.role === "toolResult" && m.toolCallId === "toolu_abc"
|
|
);
|
|
assert.strictEqual(
|
|
toolResultInResult,
|
|
undefined,
|
|
`FAIL — toolResult(toolCallId="toolu_abc") should have been removed but is still present`
|
|
);
|
|
console.log(" PASS: toolResult(toolu_abc) removed");
|
|
|
|
// 2d. A synthetic summary message should be present
|
|
const synthetic = result.find(
|
|
(m) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.includes("Compressed section")
|
|
);
|
|
assert.ok(
|
|
synthetic,
|
|
"FAIL — expected a synthetic [Compressed section] user message in result"
|
|
);
|
|
console.log(" PASS: synthetic summary message present");
|
|
|
|
console.log("TEST 2 PASSED\n");
|
|
}
|
|
|
|
console.log("All tests passed.");
|