pi-condense 2.4.3 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/PRUNING.md +96 -54
- package/README.md +6 -1
- package/index.ts +28 -42
- package/package.json +1 -1
- package/src/batch-capture.test.ts +75 -1
- package/src/batch-capture.ts +22 -13
- package/src/chain-compressor.test.ts +114 -0
- package/src/chain-compressor.ts +29 -4
- package/src/chain-detector.test.ts +49 -0
- package/src/chain-detector.ts +7 -0
- package/src/chain-range-prune.test.ts +342 -7
- package/src/chain-range-prune.ts +161 -48
- package/src/commands.test.ts +31 -2
- package/src/commands.ts +25 -35
- package/src/config.test.ts +27 -1
- package/src/diagnostics.test.ts +114 -0
- package/src/diagnostics.ts +46 -0
- package/src/frontier.test.ts +138 -16
- package/src/frontier.ts +0 -1
- package/src/id-collision.integration.test.ts +251 -0
- package/src/indexer.test.ts +336 -0
- package/src/indexer.ts +168 -55
- package/src/occurrence-key.test.ts +57 -0
- package/src/occurrence-key.ts +36 -0
- package/src/orphan-sweep.test.ts +67 -0
- package/src/orphan-sweep.ts +40 -0
- package/src/oversized-spill.integration.test.ts +7 -2
- package/src/pruner.test.ts +471 -64
- package/src/pruner.ts +84 -54
- package/src/query-tool.test.ts +117 -0
- package/src/query-tool.ts +47 -31
- package/src/range-compression.integration.test.ts +7 -44
- package/src/recovery-grace.test.ts +13 -0
- package/src/recovery-grace.ts +12 -3
- package/src/spill.test.ts +108 -1
- package/src/spill.ts +5 -3
- package/src/summary-refs.test.ts +51 -1
- package/src/summary-refs.ts +15 -4
- package/src/test-support.ts +54 -0
- package/src/tree-browser.ts +2 -1
- package/src/types.ts +56 -49
- package/src/thinking-strip.test.ts +0 -257
- package/src/thinking-strip.ts +0 -83
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { serializeBatchForSummarizer } from "./batch-capture.js";
|
|
2
|
+
import { captureBatch, captureUnindexedBatchesFromSession, serializeBatchForSummarizer } from "./batch-capture.js";
|
|
3
3
|
import type { CapturedBatch, CapturedToolCall } from "./types.js";
|
|
4
4
|
|
|
5
5
|
function toolCall(overrides: Partial<CapturedToolCall> = {}): CapturedToolCall {
|
|
@@ -49,3 +49,77 @@ describe("serializeBatchForSummarizer", () => {
|
|
|
49
49
|
expect(result).toContain("[[3:write]] Tool:");
|
|
50
50
|
});
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
describe("occurrence capture", () => {
|
|
54
|
+
test("captureBatch records the matched result's timestamp", () => {
|
|
55
|
+
const message = {
|
|
56
|
+
role: "assistant",
|
|
57
|
+
content: [{ type: "toolCall", id: "bash_23", name: "bash", input: { cmd: "ls" } }],
|
|
58
|
+
timestamp: 2100,
|
|
59
|
+
};
|
|
60
|
+
const results = [
|
|
61
|
+
{ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "ok" }], isError: false, timestamp: 2150 },
|
|
62
|
+
];
|
|
63
|
+
const batch = captureBatch(message, results, 0, 9999);
|
|
64
|
+
expect(batch.toolCalls[0].resultTimestamp).toBe(2150);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("captureBatch omits resultTimestamp when no result matched", () => {
|
|
68
|
+
const message = { role: "assistant", content: [{ type: "toolCall", id: "x", name: "bash", input: {} }], timestamp: 1 };
|
|
69
|
+
const batch = captureBatch(message, [], 0, 9999);
|
|
70
|
+
expect(batch.toolCalls[0].resultTimestamp).toBeUndefined();
|
|
71
|
+
expect("resultTimestamp" in batch.toolCalls[0]).toBe(false);
|
|
72
|
+
expect(batch.toolCalls[0].resultText).toBe("(no result)");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("rescan pairs each assistant with the results of its OWN turn when ids repeat", () => {
|
|
76
|
+
const entry = (message: any) => ({ type: "message", message, timestamp: undefined });
|
|
77
|
+
const branch = [
|
|
78
|
+
entry({ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 }),
|
|
79
|
+
entry({ role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 1100 }),
|
|
80
|
+
entry({ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "FIRST" }], isError: false, timestamp: 1150 }),
|
|
81
|
+
entry({ role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 2100 }),
|
|
82
|
+
entry({ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "SECOND" }], isError: false, timestamp: 2150 }),
|
|
83
|
+
];
|
|
84
|
+
const batches = captureUnindexedBatchesFromSession(branch, { isSummarized: () => false });
|
|
85
|
+
expect(batches).toHaveLength(2);
|
|
86
|
+
expect(batches[0].toolCalls[0].resultText).toBe("FIRST");
|
|
87
|
+
expect(batches[0].toolCalls[0].resultTimestamp).toBe(1150);
|
|
88
|
+
expect(batches[1].toolCalls[0].resultText).toBe("SECOND");
|
|
89
|
+
expect(batches[1].toolCalls[0].resultTimestamp).toBe(2150);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("rescan asks isSummarized with the occurrence key, not the bare id", () => {
|
|
93
|
+
const asked: string[] = [];
|
|
94
|
+
const entry = (message: any) => ({ type: "message", message });
|
|
95
|
+
const branch = [
|
|
96
|
+
entry({ role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 1100 }),
|
|
97
|
+
entry({ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 1150 }),
|
|
98
|
+
];
|
|
99
|
+
captureUnindexedBatchesFromSession(branch, { isSummarized: (id: string) => (asked.push(id), false) });
|
|
100
|
+
expect(asked).toContain("bash_23@1150");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("rescan still skips a call whose result has not arrived", () => {
|
|
104
|
+
const entry = (message: any) => ({ type: "message", message });
|
|
105
|
+
const branch = [
|
|
106
|
+
entry({ role: "assistant", content: [{ type: "toolCall", id: "pending", name: "bash", input: {} }], timestamp: 1 }),
|
|
107
|
+
];
|
|
108
|
+
expect(captureUnindexedBatchesFromSession(branch, { isSummarized: () => false })).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("rescan does not pair a result that falls outside its own assistant's turn window", () => {
|
|
112
|
+
// bash_23's result lands AFTER the next assistant message, i.e. in the
|
|
113
|
+
// second assistant's window, not the first's. Per-turn scanning does not
|
|
114
|
+
// fabricate a pair for the first assistant (no in-window result), and the
|
|
115
|
+
// second assistant has no bash_23 call to attach the result to either, so
|
|
116
|
+
// no batch is emitted at all.
|
|
117
|
+
const entry = (message: any) => ({ type: "message", message });
|
|
118
|
+
const branch = [
|
|
119
|
+
entry({ role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 1000 }),
|
|
120
|
+
entry({ role: "assistant", content: [{ type: "toolCall", id: "other", name: "bash", input: {} }], timestamp: 1100 }),
|
|
121
|
+
entry({ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "late" }], isError: false, timestamp: 1150 }),
|
|
122
|
+
];
|
|
123
|
+
expect(captureUnindexedBatchesFromSession(branch, { isSummarized: () => false })).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
});
|
package/src/batch-capture.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CapturedBatch, CapturedToolCall, BatchingMode } from "./types.js";
|
|
2
|
+
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
2
3
|
|
|
3
4
|
/** Joins the text blocks of a ToolResultMessage into a single string. */
|
|
4
5
|
export function extractToolResultText(msg: any): string {
|
|
@@ -37,6 +38,7 @@ export function captureBatch(
|
|
|
37
38
|
|
|
38
39
|
let resultText = "(no result)";
|
|
39
40
|
let isError = false;
|
|
41
|
+
const resultTimestamp = match ? resultTimestampOf(match.timestamp) : undefined;
|
|
40
42
|
|
|
41
43
|
if (match) {
|
|
42
44
|
resultText = extractToolResultText(match);
|
|
@@ -49,6 +51,7 @@ export function captureBatch(
|
|
|
49
51
|
args: block.input ?? block.args ?? block.arguments ?? {},
|
|
50
52
|
resultText,
|
|
51
53
|
isError,
|
|
54
|
+
...(resultTimestamp !== undefined ? { resultTimestamp } : {}),
|
|
52
55
|
} satisfies CapturedToolCall;
|
|
53
56
|
});
|
|
54
57
|
|
|
@@ -70,14 +73,7 @@ export function captureUnindexedBatchesFromSession(
|
|
|
70
73
|
): CapturedBatch[] {
|
|
71
74
|
// branch is SessionEntry[]. Each message entry has { type: "message", message: AgentMessage }.
|
|
72
75
|
// We must unwrap the SessionEntry wrapper before accessing role/toolCallId.
|
|
73
|
-
const
|
|
74
|
-
for (const entry of branch) {
|
|
75
|
-
if (entry.type !== "message") continue;
|
|
76
|
-
const m = entry.message;
|
|
77
|
-
if (m.role === "toolResult" && m.toolCallId) {
|
|
78
|
-
resultMap.set(m.toolCallId, m);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
76
|
+
const entries = branch.filter((entry: any) => entry.type === "message");
|
|
81
77
|
|
|
82
78
|
const batches: CapturedBatch[] = [];
|
|
83
79
|
// turnCounter increments for EVERY assistant message (not just prunable ones).
|
|
@@ -93,8 +89,8 @@ export function captureUnindexedBatchesFromSession(
|
|
|
93
89
|
// a single user → final-agent-message span when batchingMode === "agent-message".
|
|
94
90
|
let userTurnGroup = 0;
|
|
95
91
|
|
|
96
|
-
for (
|
|
97
|
-
|
|
92
|
+
for (let i = 0; i < entries.length; i++) {
|
|
93
|
+
const entry = entries[i];
|
|
98
94
|
const msg = entry.message;
|
|
99
95
|
|
|
100
96
|
// Advance userTurnGroup on every user message so all subsequent assistant
|
|
@@ -109,6 +105,17 @@ export function captureUnindexedBatchesFromSession(
|
|
|
109
105
|
// Stable turn index: count every assistant message regardless of pruning state
|
|
110
106
|
const currentTurnIndex = turnCounter++;
|
|
111
107
|
|
|
108
|
+
// Per-turn result map: only the results between this assistant message and
|
|
109
|
+
// the next one. A branch-wide map is last-wins and mis-pairs repeated ids.
|
|
110
|
+
const turnResults = new Map<string, any>();
|
|
111
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
112
|
+
const m = entries[j].message;
|
|
113
|
+
if (m.role === "assistant") break;
|
|
114
|
+
if (m.role === "toolResult" && m.toolCallId && !turnResults.has(m.toolCallId)) {
|
|
115
|
+
turnResults.set(m.toolCallId, m);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
112
119
|
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
113
120
|
const toolCallBlocks = content.filter((c: any) => c.type === "toolCall");
|
|
114
121
|
|
|
@@ -116,13 +123,15 @@ export function captureUnindexedBatchesFromSession(
|
|
|
116
123
|
const readyToPrune = toolCallBlocks.filter((tc: any) => {
|
|
117
124
|
const id = tc.id;
|
|
118
125
|
if (!id) return false;
|
|
119
|
-
|
|
126
|
+
const result = turnResults.get(id);
|
|
127
|
+
if (!result) return false;
|
|
128
|
+
if (indexer.isSummarized(occKey(id, resultTimestampOf(result.timestamp)))) return false;
|
|
120
129
|
if (exclude(tc.name, tc.input ?? tc.arguments)) return false;
|
|
121
|
-
return
|
|
130
|
+
return true;
|
|
122
131
|
});
|
|
123
132
|
|
|
124
133
|
if (readyToPrune.length > 0) {
|
|
125
|
-
const results = readyToPrune.map((tc: any) =>
|
|
134
|
+
const results = readyToPrune.map((tc: any) => turnResults.get(tc.id));
|
|
126
135
|
const readyIds = new Set(readyToPrune.map((tc: any) => tc.id));
|
|
127
136
|
// We pass the full message but then trim back down to only the tool calls
|
|
128
137
|
// whose results already exist in the session. This lets a flush prune
|
|
@@ -3,6 +3,8 @@ import { selectEligible, compressEligible } from "./chain-compressor.js";
|
|
|
3
3
|
import type { ChainCompressorIndexerDeps } from "./chain-compressor.js";
|
|
4
4
|
import type { ChainRange, ChainCompressionEntry } from "./types.js";
|
|
5
5
|
import { CUSTOM_TYPE_CHAIN } from "./types.js";
|
|
6
|
+
import { detectChains } from "./chain-detector.js";
|
|
7
|
+
import { inGraceRecoveryToolCallIds } from "./recovery-grace.js";
|
|
6
8
|
|
|
7
9
|
function closed(startUserTimestamp: number, toolCallIds: string[] = [`tc-${startUserTimestamp}`]): ChainRange {
|
|
8
10
|
return { startUserTimestamp, middleToolCallIds: toolCallIds, finalAssistantTimestamp: startUserTimestamp + 100 };
|
|
@@ -313,3 +315,115 @@ describe("selectEligible - recovery grace deferral", () => {
|
|
|
313
315
|
expect(eligible.map((c) => c.startUserTimestamp)).toEqual([1, 2, 3]);
|
|
314
316
|
});
|
|
315
317
|
});
|
|
318
|
+
|
|
319
|
+
describe("occurrence keys in compression", () => {
|
|
320
|
+
const chain = {
|
|
321
|
+
startUserTimestamp: 1000,
|
|
322
|
+
middleToolCallIds: ["bash_23"],
|
|
323
|
+
middleOccurrenceKeys: ["bash_23@1150"],
|
|
324
|
+
protectedToolCallIds: [],
|
|
325
|
+
finalAssistantTimestamp: 1200,
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
test("summary/toolRef lookups receive occurrence keys, not bare ids", async () => {
|
|
329
|
+
const asked: string[][] = [];
|
|
330
|
+
const deps = {
|
|
331
|
+
indexer: {
|
|
332
|
+
getChainEntries: () => [],
|
|
333
|
+
hasPerBatchSummaryCoveringAny: (ids: string[]) => (asked.push(ids), true),
|
|
334
|
+
getPerBatchSummariesForToolCallIds: (ids: string[]) => (asked.push(ids), ["s1"]),
|
|
335
|
+
getToolRefsForToolCallIds: (ids: string[]) => (asked.push(ids), ["t1"]),
|
|
336
|
+
registerChain: () => {},
|
|
337
|
+
},
|
|
338
|
+
blockRefs: { issue: () => "b1" },
|
|
339
|
+
appendEntry: () => {},
|
|
340
|
+
now: () => 5000,
|
|
341
|
+
};
|
|
342
|
+
await compressEligible([chain as any], 0, deps as any);
|
|
343
|
+
expect(asked).toEqual([["bash_23@1150"], ["bash_23@1150"]]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("persists droppedOccurrenceKeys alongside droppedToolCallIds", async () => {
|
|
347
|
+
const appended: any[] = [];
|
|
348
|
+
const deps = {
|
|
349
|
+
indexer: {
|
|
350
|
+
getChainEntries: () => [],
|
|
351
|
+
hasPerBatchSummaryCoveringAny: () => true,
|
|
352
|
+
getPerBatchSummariesForToolCallIds: () => ["s1"],
|
|
353
|
+
getToolRefsForToolCallIds: () => ["t1"],
|
|
354
|
+
registerChain: () => {},
|
|
355
|
+
},
|
|
356
|
+
blockRefs: { issue: () => "b1" },
|
|
357
|
+
appendEntry: (_t: string, data: unknown) => appended.push(data),
|
|
358
|
+
now: () => 5000,
|
|
359
|
+
};
|
|
360
|
+
const { compressedEntries } = await compressEligible([chain as any], 0, deps as any);
|
|
361
|
+
expect(compressedEntries[0].droppedToolCallIds).toEqual(["bash_23"]);
|
|
362
|
+
expect(compressedEntries[0].droppedOccurrenceKeys).toEqual(["bash_23@1150"]);
|
|
363
|
+
expect((appended[0] as any).droppedOccurrenceKeys).toEqual(["bash_23@1150"]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("a chain without middleOccurrenceKeys falls back to bare ids", async () => {
|
|
367
|
+
const deps = {
|
|
368
|
+
indexer: {
|
|
369
|
+
getChainEntries: () => [],
|
|
370
|
+
hasPerBatchSummaryCoveringAny: () => true,
|
|
371
|
+
getPerBatchSummariesForToolCallIds: () => ["s1"],
|
|
372
|
+
getToolRefsForToolCallIds: () => ["t1"],
|
|
373
|
+
registerChain: () => {},
|
|
374
|
+
},
|
|
375
|
+
blockRefs: { issue: () => "b1" },
|
|
376
|
+
appendEntry: () => {},
|
|
377
|
+
now: () => 5000,
|
|
378
|
+
};
|
|
379
|
+
const { compressedEntries } = await compressEligible(
|
|
380
|
+
[{ ...chain, middleOccurrenceKeys: undefined } as any],
|
|
381
|
+
0,
|
|
382
|
+
deps as any,
|
|
383
|
+
);
|
|
384
|
+
expect(compressedEntries[0].droppedOccurrenceKeys).toBeUndefined();
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("selectEligible - recovery grace wiring (production boundary)", () => {
|
|
389
|
+
// Crosses the real boundary: real detectChains() + real inGraceRecoveryToolCallIds()
|
|
390
|
+
// feed real selectEligible(). Hand-injecting bare ids (as the suite above does) hides
|
|
391
|
+
// the format mismatch this test pins down.
|
|
392
|
+
it("does not compress a chain whose recovery-query result is still in grace", () => {
|
|
393
|
+
const messages = [
|
|
394
|
+
{ role: "user", timestamp: 1, content: [{ type: "text", text: "u1" }] },
|
|
395
|
+
{
|
|
396
|
+
role: "assistant",
|
|
397
|
+
timestamp: 1,
|
|
398
|
+
content: [{ type: "toolCall", id: "t1", name: "context_tree_query", input: {} }],
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
role: "toolResult",
|
|
402
|
+
toolCallId: "t1",
|
|
403
|
+
toolName: "context_tree_query",
|
|
404
|
+
timestamp: 100,
|
|
405
|
+
content: [{ type: "text", text: "recovered" }],
|
|
406
|
+
},
|
|
407
|
+
{ role: "assistant", timestamp: 2, content: [{ type: "text", text: "done" }] },
|
|
408
|
+
{ role: "user", timestamp: 3, content: [{ type: "text", text: "u2" }] },
|
|
409
|
+
{ role: "assistant", timestamp: 3, content: [{ type: "toolCall", id: "b", name: "bash", input: {} }] },
|
|
410
|
+
{
|
|
411
|
+
role: "toolResult",
|
|
412
|
+
toolCallId: "b",
|
|
413
|
+
toolName: "bash",
|
|
414
|
+
timestamp: 200,
|
|
415
|
+
content: [{ type: "text", text: "ok" }],
|
|
416
|
+
},
|
|
417
|
+
{ role: "assistant", timestamp: 4, content: [{ type: "text", text: "done2" }] },
|
|
418
|
+
];
|
|
419
|
+
|
|
420
|
+
const chains = detectChains(messages);
|
|
421
|
+
expect(chains.map((c) => c.startUserTimestamp)).toEqual([1, 3]);
|
|
422
|
+
|
|
423
|
+
const inGrace = inGraceRecoveryToolCallIds(messages, 3);
|
|
424
|
+
expect([...inGrace]).toEqual(["t1@100"]);
|
|
425
|
+
|
|
426
|
+
const eligible = selectEligible(chains, 0, new Set(), inGrace);
|
|
427
|
+
expect(eligible.map((c) => c.startUserTimestamp)).toEqual([3]);
|
|
428
|
+
});
|
|
429
|
+
});
|
package/src/chain-compressor.ts
CHANGED
|
@@ -2,6 +2,26 @@ import { CUSTOM_TYPE_CHAIN } from "./types.js";
|
|
|
2
2
|
import type { ChainRange, ChainCompressionEntry } from "./types.js";
|
|
3
3
|
import type { ToolCallIndexer } from "./indexer.js";
|
|
4
4
|
import type { BlockRefIssuer } from "./block-refs.js";
|
|
5
|
+
import { bareToolCallId, parseOccKey } from "./occurrence-key.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Grace ids are keyed the same way `recovery-grace.ts` keys them: occurrence
|
|
9
|
+
* (`id@timestamp`) when the recovery message carried a timestamp, bare id
|
|
10
|
+
* otherwise. A chain's middles are compared occurrence-first (exact match on
|
|
11
|
+
* `middleOccurrenceKeys`, falling back to `middleToolCallIds` for chains built
|
|
12
|
+
* before the field existed), so a graced occurrence never defers a chain
|
|
13
|
+
* holding a DIFFERENT occurrence of the same reused provider id. The only
|
|
14
|
+
* bare-to-bare fallback is for grace entries that themselves have no
|
|
15
|
+
* timestamp discriminant — there is no exact key to compare in that case.
|
|
16
|
+
*/
|
|
17
|
+
function chainMatchesGrace(chain: ChainRange, inGraceToolCallIds: Set<string>): boolean {
|
|
18
|
+
const keys = chain.middleOccurrenceKeys?.length ? chain.middleOccurrenceKeys : chain.middleToolCallIds;
|
|
19
|
+
if (keys.some((k) => inGraceToolCallIds.has(k))) return true;
|
|
20
|
+
for (const g of inGraceToolCallIds) {
|
|
21
|
+
if (parseOccKey(g).resultTimestamp === undefined && keys.some((k) => bareToolCallId(k) === g)) return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
5
25
|
|
|
6
26
|
/**
|
|
7
27
|
* Pure eligibility filter: given all detected chains, return the subset
|
|
@@ -31,7 +51,7 @@ export function selectEligible(
|
|
|
31
51
|
c.middleToolCallIds.length > 0,
|
|
32
52
|
);
|
|
33
53
|
const toCompress = candidates.slice(0, Math.max(0, candidates.length - rollingWindow));
|
|
34
|
-
return toCompress.filter((c) => !c
|
|
54
|
+
return toCompress.filter((c) => !chainMatchesGrace(c, inGraceToolCallIds));
|
|
35
55
|
}
|
|
36
56
|
|
|
37
57
|
/**
|
|
@@ -96,19 +116,23 @@ export async function compressEligible(
|
|
|
96
116
|
|
|
97
117
|
const compressedEntries: ChainCompressionEntry[] = [];
|
|
98
118
|
for (const chain of eligible) {
|
|
99
|
-
|
|
119
|
+
// summaryBodies / toolRefs live in occurrence-key space (src/indexer.ts).
|
|
120
|
+
// Bare ids would match nothing and silently skip every chain.
|
|
121
|
+
const lookupKeys = chain.middleOccurrenceKeys?.length ? chain.middleOccurrenceKeys : chain.middleToolCallIds;
|
|
122
|
+
|
|
123
|
+
if (!deps.indexer.hasPerBatchSummaryCoveringAny(lookupKeys)) {
|
|
100
124
|
skipped.push({ startUserTimestamp: chain.startUserTimestamp, reason: "no-summary" });
|
|
101
125
|
continue;
|
|
102
126
|
}
|
|
103
127
|
|
|
104
128
|
const blockId = deps.blockRefs.issue();
|
|
105
|
-
const toolRefs = deps.indexer.getToolRefsForToolCallIds(
|
|
129
|
+
const toolRefs = deps.indexer.getToolRefsForToolCallIds(lookupKeys);
|
|
106
130
|
|
|
107
131
|
// B: fuse this span's per-batch summaries into one cohesive summary.
|
|
108
132
|
// Gated on >= 2 summaries (nothing to fuse otherwise). Non-fatal.
|
|
109
133
|
let rangeSummaryText: string | undefined;
|
|
110
134
|
if (deps.fuseRange) {
|
|
111
|
-
const summaries = deps.indexer.getPerBatchSummariesForToolCallIds(
|
|
135
|
+
const summaries = deps.indexer.getPerBatchSummariesForToolCallIds(lookupKeys);
|
|
112
136
|
if (summaries.length >= 2) {
|
|
113
137
|
try {
|
|
114
138
|
const fused = await deps.fuseRange(summaries.join("\n\n"));
|
|
@@ -128,6 +152,7 @@ export async function compressEligible(
|
|
|
128
152
|
compressedAt: deps.now(),
|
|
129
153
|
...(rangeSummaryText ? { rangeSummaryText } : {}),
|
|
130
154
|
...(chain.protectedToolCallIds?.length ? { protectedToolCallIds: chain.protectedToolCallIds } : {}),
|
|
155
|
+
...(chain.middleOccurrenceKeys?.length ? { droppedOccurrenceKeys: chain.middleOccurrenceKeys } : {}),
|
|
131
156
|
};
|
|
132
157
|
|
|
133
158
|
deps.appendEntry(CUSTOM_TYPE_CHAIN, entry);
|
|
@@ -259,6 +259,55 @@ describe("detectChains protectedToolCallIds", () => {
|
|
|
259
259
|
});
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
+
describe("middleOccurrenceKeys", () => {
|
|
263
|
+
test("emits one occurrence key per middle tool result", () => {
|
|
264
|
+
const messages = [
|
|
265
|
+
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 },
|
|
266
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "bash_1", name: "bash", input: {} }], timestamp: 1100 },
|
|
267
|
+
{ role: "toolResult", toolCallId: "bash_1", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 1150 },
|
|
268
|
+
{ role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1200 },
|
|
269
|
+
];
|
|
270
|
+
const [chain] = detectChains(messages);
|
|
271
|
+
expect(chain.middleToolCallIds).toEqual(["bash_1"]);
|
|
272
|
+
expect(chain.middleOccurrenceKeys).toEqual(["bash_1@1150"]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("a tool call with no result contributes no occurrence key", () => {
|
|
276
|
+
const messages = [
|
|
277
|
+
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 },
|
|
278
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "bash_1", name: "bash", input: {} }], timestamp: 1100 },
|
|
279
|
+
{ role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1200 },
|
|
280
|
+
];
|
|
281
|
+
const [chain] = detectChains(messages);
|
|
282
|
+
expect(chain.middleToolCallIds).toEqual(["bash_1"]);
|
|
283
|
+
expect(chain.middleOccurrenceKeys).toEqual([]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("two chains reusing one id get distinct occurrence keys", () => {
|
|
287
|
+
const turn = (base: number) => [
|
|
288
|
+
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: base },
|
|
289
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: base + 100 },
|
|
290
|
+
{ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: base + 150 },
|
|
291
|
+
{ role: "assistant", content: [{ type: "text", text: "done" }], timestamp: base + 200 },
|
|
292
|
+
];
|
|
293
|
+
const chains = detectChains([...turn(1000), ...turn(2000)]);
|
|
294
|
+
expect(chains.map((c) => c.middleOccurrenceKeys)).toEqual([["bash_23@1150"], ["bash_23@2150"]]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("an interrupted chain still reports the keys it collected", () => {
|
|
298
|
+
const messages = [
|
|
299
|
+
{ role: "user", content: [{ type: "text", text: "a" }], timestamp: 1000 },
|
|
300
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "bash_1", name: "bash", input: {} }], timestamp: 1100 },
|
|
301
|
+
{ role: "toolResult", toolCallId: "bash_1", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 1150 },
|
|
302
|
+
{ role: "user", content: [{ type: "text", text: "b" }], timestamp: 2000 },
|
|
303
|
+
{ role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 2200 },
|
|
304
|
+
];
|
|
305
|
+
const [interrupted] = detectChains(messages);
|
|
306
|
+
expect(interrupted.finalAssistantTimestamp).toBeNull();
|
|
307
|
+
expect(interrupted.middleOccurrenceKeys).toEqual(["bash_1@1150"]);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
262
311
|
describe("withClosingMessage", () => {
|
|
263
312
|
test("undefined closing returns the same array reference", () => {
|
|
264
313
|
const msgs = [userMsg(100)];
|
package/src/chain-detector.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
1
2
|
import type { ChainRange } from "./types.js";
|
|
2
3
|
|
|
3
4
|
/** Prefix that identifies a synthetic chain-compression user message. */
|
|
@@ -45,6 +46,7 @@ export function detectChains(
|
|
|
45
46
|
let state: State = "idle";
|
|
46
47
|
let chainStart: { timestamp: number } | null = null;
|
|
47
48
|
let middleIds = new Set<string>();
|
|
49
|
+
let middleKeys = new Set<string>();
|
|
48
50
|
let protectedIds = new Set<string>();
|
|
49
51
|
|
|
50
52
|
const emitInterrupted = () => {
|
|
@@ -52,6 +54,7 @@ export function detectChains(
|
|
|
52
54
|
ranges.push({
|
|
53
55
|
startUserTimestamp: chainStart.timestamp,
|
|
54
56
|
middleToolCallIds: [...middleIds],
|
|
57
|
+
middleOccurrenceKeys: [...middleKeys],
|
|
55
58
|
protectedToolCallIds: [...protectedIds],
|
|
56
59
|
finalAssistantTimestamp: null,
|
|
57
60
|
});
|
|
@@ -64,6 +67,7 @@ export function detectChains(
|
|
|
64
67
|
emitInterrupted();
|
|
65
68
|
chainStart = { timestamp: msg.timestamp };
|
|
66
69
|
middleIds = new Set();
|
|
70
|
+
middleKeys = new Set();
|
|
67
71
|
protectedIds = new Set();
|
|
68
72
|
state = "inChain";
|
|
69
73
|
continue;
|
|
@@ -82,6 +86,7 @@ export function detectChains(
|
|
|
82
86
|
if (msg.role === "toolResult") {
|
|
83
87
|
if (msg.toolCallId) {
|
|
84
88
|
middleIds.add(msg.toolCallId);
|
|
89
|
+
middleKeys.add(occKey(msg.toolCallId, resultTimestampOf(msg.timestamp)));
|
|
85
90
|
// toolResult fallback — results carry no args; name-only by design,
|
|
86
91
|
// the assistant block always precedes its result so no protection is lost
|
|
87
92
|
if (isProtected(msg.toolName, undefined)) protectedIds.add(msg.toolCallId);
|
|
@@ -93,11 +98,13 @@ export function detectChains(
|
|
|
93
98
|
ranges.push({
|
|
94
99
|
startUserTimestamp: chainStart!.timestamp,
|
|
95
100
|
middleToolCallIds: [...middleIds],
|
|
101
|
+
middleOccurrenceKeys: [...middleKeys],
|
|
96
102
|
protectedToolCallIds: [...protectedIds],
|
|
97
103
|
finalAssistantTimestamp: msg.timestamp,
|
|
98
104
|
});
|
|
99
105
|
chainStart = null;
|
|
100
106
|
middleIds = new Set();
|
|
107
|
+
middleKeys = new Set();
|
|
101
108
|
protectedIds = new Set();
|
|
102
109
|
state = "idle";
|
|
103
110
|
}
|