fix: prevent orphaned tool_use/tool_result after compression with passthrough roles

Backward and forward expansion now skip PI-internal passthrough roles
(compaction, branch_summary, custom_message) when scanning for paired
assistant↔toolResult messages, ensuring atomic removal. Added a
post-compression repair safety net and deep-cloning to prevent content
mutation across context events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Greg Harvell
2026-04-09 20:03:27 -04:00
parent 24cf4e5b80
commit 8681bd833b
5 changed files with 409 additions and 4 deletions
+285
View File
@@ -423,4 +423,289 @@ function findOrphanedToolUse(result: any[]): string | null {
console.log("TEST 4 PASSED\n");
}
// ---------------------------------------------------------------------------
// Test 5 — PASSTHROUGH ROLE BETWEEN ASSISTANT AND TOOLRESULT (BACKWARD)
//
// A `compaction` message sits between the assistant and the toolResult.
// The compression range covers only the toolResult. Backward expansion
// must skip the compaction to find the assistant and include it atomically.
//
// Sequence:
// user(1000) → assistant(2000, toolCall_X) → compaction(2500)
// → toolResult_X(3000) → user(4000)
// Compression block: [3000..3000]
// Expected: assistant + toolResult removed together (no orphans)
// ---------------------------------------------------------------------------
{
console.log("TEST 5: passthrough role between assistant and toolResult (backward expansion)");
const messages: any[] = [
{ role: "user", content: [{ type: "text", text: "read file" }], timestamp: 1000 },
{ role: "assistant", content: [{ type: "toolCall", id: "toolu_X", name: "read", arguments: {} }], timestamp: 2000 },
{ role: "compaction", content: [{ type: "text", text: "compaction summary" }], timestamp: 2500 },
{ role: "toolResult", toolCallId: "toolu_X", toolName: "read", isError: false, content: [{ type: "text", text: "file data" }], timestamp: 3000 },
{ role: "user", content: [{ type: "text", text: "thanks" }], timestamp: 4000 },
];
const state = makeState([
{
id: 1,
topic: "file read",
summary: "File was read successfully.",
startTimestamp: 3000,
endTimestamp: 3000,
anchorTimestamp: 4000,
active: true,
summaryTokenEstimate: 10,
createdAt: Date.now(),
},
]);
const result = applyPruning(messages, state, makeConfig());
console.log(" Result messages:");
for (const m of result) {
const preview = Array.isArray(m.content)
? m.content.map((b: any) => b.text ?? b.type ?? "?").join(" | ").slice(0, 60)
: String(m.content).slice(0, 60);
console.log(` role="${m.role}" ts=${m.timestamp} content="${preview}"`);
}
const orphan = findOrphanedToolUse(result);
assert.strictEqual(orphan, null, `FAIL — orphaned tool_use detected: ${orphan}`);
console.log(" PASS: no orphaned tool_use in result");
const assistantPresent = result.some((m: any) => m.role === "assistant" && m.timestamp === 2000);
const toolResultPresent = result.some((m: any) => m.role === "toolResult" && m.toolCallId === "toolu_X");
assert.ok(!assistantPresent, "FAIL — assistant should have been removed");
assert.ok(!toolResultPresent, "FAIL — toolResult should have been removed");
console.log(" PASS: assistant + toolResult removed atomically despite compaction in between");
console.log("TEST 5 PASSED\n");
}
// ---------------------------------------------------------------------------
// Test 6 — PASSTHROUGH ROLE BETWEEN TOOLRESULTS (FORWARD EXPANSION)
//
// An assistant has two tool calls. A `branch_summary` message sits between
// the two toolResults. The compression range covers the assistant.
// Forward expansion must skip the branch_summary to find both toolResults.
//
// Sequence:
// user(1000) → assistant(2000, toolCall_A + toolCall_B)
// → toolResult_A(3000) → branch_summary(3500)
// → toolResult_B(4000) → user(5000)
// Compression block: [2000..2000]
// Expected: assistant + both toolResults removed together (no orphans)
// ---------------------------------------------------------------------------
{
console.log("TEST 6: passthrough role between toolResults (forward expansion)");
const messages: any[] = [
{ role: "user", content: [{ type: "text", text: "do things" }], timestamp: 1000 },
{ role: "assistant", content: [
{ type: "toolCall", id: "toolu_A", name: "read", arguments: {} },
{ type: "toolCall", id: "toolu_B", name: "write", arguments: {} },
], timestamp: 2000 },
{ role: "toolResult", toolCallId: "toolu_A", toolName: "read", isError: false, content: [{ type: "text", text: "A result" }], timestamp: 3000 },
{ role: "branch_summary", content: [{ type: "text", text: "branch summary" }], timestamp: 3500 },
{ role: "toolResult", toolCallId: "toolu_B", toolName: "write", isError: false, content: [{ type: "text", text: "B result" }], timestamp: 4000 },
{ role: "user", content: [{ type: "text", text: "thanks" }], timestamp: 5000 },
];
const state = makeState([
{
id: 1,
topic: "two tools",
summary: "Both tools were called.",
startTimestamp: 2000,
endTimestamp: 2000,
anchorTimestamp: 5000,
active: true,
summaryTokenEstimate: 10,
createdAt: Date.now(),
},
]);
const result = applyPruning(messages, state, makeConfig());
console.log(" Result messages:");
for (const m of result) {
const preview = Array.isArray(m.content)
? m.content.map((b: any) => b.text ?? b.type ?? "?").join(" | ").slice(0, 60)
: String(m.content).slice(0, 60);
console.log(` role="${m.role}" ts=${m.timestamp} content="${preview}"`);
}
const orphan = findOrphanedToolUse(result);
assert.strictEqual(orphan, null, `FAIL — orphaned tool_use detected: ${orphan}`);
console.log(" PASS: no orphaned tool_use in result");
const assistantPresent = result.some((m: any) => m.role === "assistant" && m.timestamp === 2000);
const toolResultAPresent = result.some((m: any) => m.role === "toolResult" && m.toolCallId === "toolu_A");
const toolResultBPresent = result.some((m: any) => m.role === "toolResult" && m.toolCallId === "toolu_B");
assert.ok(!assistantPresent, "FAIL — assistant should have been removed");
assert.ok(!toolResultAPresent, "FAIL — toolResult_A should have been removed");
assert.ok(!toolResultBPresent, "FAIL — toolResult_B should have been removed");
console.log(" PASS: assistant + both toolResults removed despite branch_summary in between");
console.log("TEST 6 PASSED\n");
}
// ---------------------------------------------------------------------------
// Test 7 — CONTENT MUTATION ISOLATION
//
// Verifies that applyPruning does not mutate the original message objects.
// After calling applyPruning, the original messages' content arrays should
// remain unchanged (no injected dcp-id blocks).
// ---------------------------------------------------------------------------
{
console.log("TEST 7: content mutation isolation");
const messages = makeMessages();
// Deep-snapshot the original content for comparison
const originalContents = messages.map((m: any) =>
JSON.stringify(m.content)
);
const state = makeState(); // no compression blocks
const config = makeConfig();
// Run applyPruning — this should NOT mutate the originals
applyPruning(messages, state, config);
let mutated = false;
for (let i = 0; i < messages.length; i++) {
const current = JSON.stringify(messages[i].content);
if (current !== originalContents[i]) {
console.log(` FAIL — message[${i}] content was mutated`);
console.log(` before: ${originalContents[i]}`);
console.log(` after: ${current}`);
mutated = true;
}
}
assert.ok(!mutated, "FAIL — original message content was mutated by applyPruning");
console.log(" PASS: original message content unchanged after applyPruning");
console.log("TEST 7 PASSED\n");
}
// ---------------------------------------------------------------------------
// Test 8 — ORPHANED TOOLRESULT REPAIR
//
// Two compression blocks where the second removes an assistant but forward
// expansion cannot reach its toolResult due to processing order. The repair
// function should clean up the orphan.
//
// Sequence:
// user(1000) → assistant_1(2000, toolCall_X) → toolResult_X(3000) →
// user(4000) → assistant_2(5000, toolCall_Y) → toolResult_Y(6000) → user(7000)
//
// Block 1: [1000..3000] — removes user, assistant_1, toolResult_X
// Block 2: [4000..5000] — removes user, assistant_2 (toolResult_Y is outside)
// Forward expansion from assistant_2 should catch toolResult_Y, but if it
// doesn't (edge case), repair must clean it up.
// ---------------------------------------------------------------------------
{
console.log("TEST 8: orphaned toolResult repair (post-compression safety net)");
const messages: any[] = [
{ role: "user", content: [{ type: "text", text: "first" }], timestamp: 1000 },
{ role: "assistant", content: [{ type: "toolCall", id: "toolu_X", name: "read", arguments: {} }], timestamp: 2000 },
{ role: "toolResult", toolCallId: "toolu_X", toolName: "read", isError: false, content: [{ type: "text", text: "X data" }], timestamp: 3000 },
{ role: "user", content: [{ type: "text", text: "second" }], timestamp: 4000 },
{ role: "assistant", content: [{ type: "toolCall", id: "toolu_Y", name: "write", arguments: {} }], timestamp: 5000 },
{ role: "toolResult", toolCallId: "toolu_Y", toolName: "write", isError: false, content: [{ type: "text", text: "Y data" }], timestamp: 6000 },
{ role: "user", content: [{ type: "text", text: "done" }], timestamp: 7000 },
];
const state = makeState([
{
id: 1,
topic: "block one",
summary: "First block compressed.",
startTimestamp: 1000,
endTimestamp: 3000,
anchorTimestamp: 4000,
active: true,
summaryTokenEstimate: 10,
createdAt: Date.now(),
},
{
id: 2,
topic: "block two",
summary: "Second block compressed.",
startTimestamp: 4000,
endTimestamp: 5000,
anchorTimestamp: 7000,
active: true,
summaryTokenEstimate: 10,
createdAt: Date.now(),
},
]);
const result = applyPruning(messages, state, makeConfig());
console.log(" Result messages:");
for (const m of result) {
const preview = Array.isArray(m.content)
? m.content.map((b: any) => b.text ?? b.type ?? "?").join(" | ").slice(0, 60)
: String(m.content).slice(0, 60);
console.log(` role="${m.role}" ts=${m.timestamp} content="${preview}"`);
}
// No orphaned tool_use or tool_result should remain
const orphan = findOrphanedToolUse(result);
assert.strictEqual(orphan, null, `FAIL — orphaned tool_use detected: ${orphan}`);
const orphanedResults = result.filter(
(m: any) => (m.role === "toolResult" || m.role === "bashExecution") &&
!result.some((a: any) =>
a.role === "assistant" &&
Array.isArray(a.content) &&
a.content.some((b: any) => b.type === "toolCall" && b.id === m.toolCallId)
)
);
assert.strictEqual(orphanedResults.length, 0, `FAIL — ${orphanedResults.length} orphaned toolResult(s) found`);
console.log(" PASS: no orphaned tool_use or toolResult in result");
console.log("TEST 8 PASSED\n");
}
// ---------------------------------------------------------------------------
// Test 9 — DIRECT ORPHAN REPAIR (pre-broken state)
//
// Directly construct a message array with an orphaned toolResult (no matching
// assistant toolCall exists). The repair function should remove it.
// ---------------------------------------------------------------------------
{
console.log("TEST 9: direct orphan repair (pre-broken toolResult)");
const messages: any[] = [
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: 1000 },
{ role: "toolResult", toolCallId: "orphan_id", toolName: "read", isError: false, content: [{ type: "text", text: "orphan data" }], timestamp: 2000 },
{ role: "user", content: [{ type: "text", text: "bye" }], timestamp: 3000 },
];
const state = makeState(); // no compression blocks — repair runs as safety net
const config = makeConfig();
const result = applyPruning(messages, state, config);
console.log(" Result messages:");
for (const m of result) {
const preview = Array.isArray(m.content)
? m.content.map((b: any) => b.text ?? b.type ?? "?").join(" | ").slice(0, 60)
: String(m.content).slice(0, 60);
console.log(` role="${m.role}" ts=${m.timestamp} content="${preview}"`);
}
const orphanPresent = result.some((m: any) => m.role === "toolResult" && m.toolCallId === "orphan_id");
assert.ok(!orphanPresent, "FAIL — orphaned toolResult should have been removed by repair");
console.log(" PASS: orphaned toolResult removed by repair function");
console.log("TEST 9 PASSED\n");
}
console.log("All tests passed.");