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.
Files changed (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/PRUNING.md +96 -54
  3. package/README.md +6 -1
  4. package/index.ts +28 -42
  5. package/package.json +1 -1
  6. package/src/batch-capture.test.ts +75 -1
  7. package/src/batch-capture.ts +22 -13
  8. package/src/chain-compressor.test.ts +114 -0
  9. package/src/chain-compressor.ts +29 -4
  10. package/src/chain-detector.test.ts +49 -0
  11. package/src/chain-detector.ts +7 -0
  12. package/src/chain-range-prune.test.ts +342 -7
  13. package/src/chain-range-prune.ts +161 -48
  14. package/src/commands.test.ts +31 -2
  15. package/src/commands.ts +25 -35
  16. package/src/config.test.ts +27 -1
  17. package/src/diagnostics.test.ts +114 -0
  18. package/src/diagnostics.ts +46 -0
  19. package/src/frontier.test.ts +138 -16
  20. package/src/frontier.ts +0 -1
  21. package/src/id-collision.integration.test.ts +251 -0
  22. package/src/indexer.test.ts +336 -0
  23. package/src/indexer.ts +168 -55
  24. package/src/occurrence-key.test.ts +57 -0
  25. package/src/occurrence-key.ts +36 -0
  26. package/src/orphan-sweep.test.ts +67 -0
  27. package/src/orphan-sweep.ts +40 -0
  28. package/src/oversized-spill.integration.test.ts +7 -2
  29. package/src/pruner.test.ts +471 -64
  30. package/src/pruner.ts +84 -54
  31. package/src/query-tool.test.ts +117 -0
  32. package/src/query-tool.ts +47 -31
  33. package/src/range-compression.integration.test.ts +7 -44
  34. package/src/recovery-grace.test.ts +13 -0
  35. package/src/recovery-grace.ts +12 -3
  36. package/src/spill.test.ts +108 -1
  37. package/src/spill.ts +5 -3
  38. package/src/summary-refs.test.ts +51 -1
  39. package/src/summary-refs.ts +15 -4
  40. package/src/test-support.ts +54 -0
  41. package/src/tree-browser.ts +2 -1
  42. package/src/types.ts +56 -49
  43. package/src/thinking-strip.test.ts +0 -257
  44. 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
+ });
@@ -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 resultMap = new Map<string, any>();
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 (const entry of branch) {
97
- if (entry.type !== "message") continue;
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
- if (indexer.isSummarized(id)) return false;
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 resultMap.has(id);
130
+ return true;
122
131
  });
123
132
 
124
133
  if (readyToPrune.length > 0) {
125
- const results = readyToPrune.map((tc: any) => resultMap.get(tc.id));
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
+ });
@@ -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.middleToolCallIds.some((id) => inGraceToolCallIds.has(id)));
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
- if (!deps.indexer.hasPerBatchSummaryCoveringAny(chain.middleToolCallIds)) {
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(chain.middleToolCallIds);
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(chain.middleToolCallIds);
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)];
@@ -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
  }