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
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ToolCallIndexer } from "./indexer.js";
|
|
3
|
+
import { pruneMessages } from "./pruner.js";
|
|
4
|
+
import { detectChains } from "./chain-detector.js";
|
|
5
|
+
import { compressEligible } from "./chain-compressor.js";
|
|
6
|
+
import { captureUnindexedBatchesFromSession } from "./batch-capture.js";
|
|
7
|
+
import { expectNoOrphanToolResults } from "./test-support.js";
|
|
8
|
+
import { CUSTOM_TYPE_CHAIN, CUSTOM_TYPE_INDEX, CUSTOM_TYPE_SUMMARY } from "./types.js";
|
|
9
|
+
import type { CapturedBatch } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const user = (ts: number, text: string) => ({ role: "user", content: [{ type: "text", text }], timestamp: ts });
|
|
12
|
+
const callTurn = (ts: number, ids: string[]) => ({
|
|
13
|
+
role: "assistant",
|
|
14
|
+
content: ids.map((id) => ({ type: "toolCall", id, name: "bash", input: { cmd: id } })),
|
|
15
|
+
timestamp: ts,
|
|
16
|
+
});
|
|
17
|
+
const result = (ts: number, id: string, text: string) => ({
|
|
18
|
+
role: "toolResult",
|
|
19
|
+
toolCallId: id,
|
|
20
|
+
toolName: "bash",
|
|
21
|
+
content: [{ type: "text", text }],
|
|
22
|
+
isError: false,
|
|
23
|
+
timestamp: ts,
|
|
24
|
+
});
|
|
25
|
+
const finalTurn = (ts: number, text: string) => ({ role: "assistant", content: [{ type: "text", text }], timestamp: ts });
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The incident shape: two closeable chains, then a live turn reusing bash_23.
|
|
29
|
+
* Pre-fix, the session-wide id-set drop deleted the live `bash_23` assistant
|
|
30
|
+
* turn and left `LIVE 24` (`gauntlet_setting_24`) orphaned, which the
|
|
31
|
+
* provider rejects with a 400.
|
|
32
|
+
*/
|
|
33
|
+
const buildSession = () => [
|
|
34
|
+
user(1000, "1"),
|
|
35
|
+
callTurn(1100, ["bash_18"]),
|
|
36
|
+
result(1150, "bash_18", "OUT 18"),
|
|
37
|
+
finalTurn(1200, "done 1"),
|
|
38
|
+
user(2000, "2"),
|
|
39
|
+
callTurn(2100, ["bash_23"]),
|
|
40
|
+
result(2150, "bash_23", "OUT 23 first"),
|
|
41
|
+
finalTurn(2200, "done 2"),
|
|
42
|
+
user(3000, "3"),
|
|
43
|
+
callTurn(3100, ["bash_23", "gauntlet_setting_24"]),
|
|
44
|
+
result(3150, "bash_23", "LIVE 23"),
|
|
45
|
+
result(3160, "gauntlet_setting_24", "LIVE 24"),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const batchFor = (turnIndex: number, ts: number, id: string, resultTs: number, text: string): CapturedBatch => ({
|
|
49
|
+
turnIndex,
|
|
50
|
+
timestamp: ts,
|
|
51
|
+
assistantText: "",
|
|
52
|
+
toolCalls: [{ toolCallId: id, toolName: "bash", args: { cmd: id }, resultText: text, isError: false, resultTimestamp: resultTs }],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Indexes + summarizes the two older batches, then compresses their chains.
|
|
57
|
+
* Mirrors index.ts's live-flush ordering and keying exactly: allocate refs ->
|
|
58
|
+
* send/append the summary -> registerSummaryRefs -> addBatch ->
|
|
59
|
+
* registerSummaryBody(<keys>). `keyer` controls the shape of the ids passed
|
|
60
|
+
* to registerSummaryBody so both the production (bare-id) bug and the fixed
|
|
61
|
+
* (occurrence-key) contract can be exercised with the same helper.
|
|
62
|
+
*/
|
|
63
|
+
const primeIndexer = async (
|
|
64
|
+
messages: any[],
|
|
65
|
+
keyer: (r: { toolCallId: string; resultTimestamp?: number }) => string = (r) =>
|
|
66
|
+
`${r.toolCallId}@${r.resultTimestamp}`,
|
|
67
|
+
) => {
|
|
68
|
+
const appended: Array<{ type: string; data: any }> = [];
|
|
69
|
+
const indexer = new ToolCallIndexer();
|
|
70
|
+
const append = (type: string, data?: unknown) => appended.push({ type, data });
|
|
71
|
+
const refsByToolCallId = new Map<string, import("./types.js").SummaryToolCallRef>();
|
|
72
|
+
|
|
73
|
+
for (const [turnIndex, spec] of [
|
|
74
|
+
[0, { id: "bash_18", ts: 1000, resultTs: 1150, text: "OUT 18" }],
|
|
75
|
+
[1, { id: "bash_23", ts: 2000, resultTs: 2150, text: "OUT 23 first" }],
|
|
76
|
+
] as const) {
|
|
77
|
+
const batch = batchFor(turnIndex, spec.ts, spec.id, spec.resultTs, spec.text);
|
|
78
|
+
const refs = indexer.allocateSummaryRefs(batch);
|
|
79
|
+
// (send/append the summary message here in the real flush; no-op for this harness)
|
|
80
|
+
indexer.registerSummaryRefs(refs);
|
|
81
|
+
indexer.addBatch(batch, append);
|
|
82
|
+
indexer.registerSummaryBody(refs.map(keyer), `summary of ${spec.id}`);
|
|
83
|
+
for (const ref of refs) refsByToolCallId.set(ref.toolCallId, ref);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let nextBlock = 1;
|
|
87
|
+
await compressEligible(detectChains(messages), 0, {
|
|
88
|
+
indexer,
|
|
89
|
+
blockRefs: { issue: () => `b${nextBlock++}` } as any,
|
|
90
|
+
appendEntry: append,
|
|
91
|
+
now: () => 9000,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return { indexer, appended, refsByToolCallId };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const chainConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: false, fuseRangeSummary: false } as any;
|
|
98
|
+
const syntheticsOf = (messages: any[]) =>
|
|
99
|
+
messages.filter((m: any) => m.role === "user" && m.content?.[0]?.text?.startsWith("<compressed-chain"));
|
|
100
|
+
|
|
101
|
+
describe("id collision, end to end", () => {
|
|
102
|
+
test("render keeps the live turn, drops both chain interiors, leaves no orphan", async () => {
|
|
103
|
+
const messages = buildSession();
|
|
104
|
+
const { indexer } = await primeIndexer(messages);
|
|
105
|
+
const out = pruneMessages(messages, indexer, chainConfig);
|
|
106
|
+
|
|
107
|
+
expect(out.pruned).toBe(true);
|
|
108
|
+
// live turn intact, both results verbatim. Synthetics also carry
|
|
109
|
+
// timestamp=compressedAt(9000) here, so they must be excluded from this
|
|
110
|
+
// filter or they'd inflate the count - see chain-range-prune.test.ts's
|
|
111
|
+
// "compressedAt kept below 3000" comment for the same caveat.
|
|
112
|
+
const liveReal = out.messages.filter(
|
|
113
|
+
(m: any) => m.timestamp >= 3000 && !(m.content?.[0]?.text ?? "").startsWith("<compressed-chain"),
|
|
114
|
+
);
|
|
115
|
+
expect(liveReal).toHaveLength(4);
|
|
116
|
+
expect(out.messages.find((m: any) => m.timestamp === 3150).content[0].text).toBe("LIVE 23");
|
|
117
|
+
expect(out.messages.find((m: any) => m.timestamp === 3160).content[0].text).toBe("LIVE 24");
|
|
118
|
+
// both chain interiors gone, one synthetic each, bodies non-empty
|
|
119
|
+
expect(out.messages.some((m: any) => m.timestamp === 1150 || m.timestamp === 2150)).toBe(false);
|
|
120
|
+
const synthetics = syntheticsOf(out.messages);
|
|
121
|
+
expect(synthetics).toHaveLength(2);
|
|
122
|
+
const bash18Synthetic = synthetics.find((s: any) => s.content[0].text.includes("summary of bash_18"));
|
|
123
|
+
const bash23Synthetic = synthetics.find((s: any) => s.content[0].text.includes("summary of bash_23"));
|
|
124
|
+
expect(bash18Synthetic).toBeDefined();
|
|
125
|
+
expect(bash23Synthetic).toBeDefined();
|
|
126
|
+
expect(bash18Synthetic).not.toBe(bash23Synthetic);
|
|
127
|
+
expect(bash18Synthetic.content[0].text).not.toContain("summary of bash_23");
|
|
128
|
+
expect(bash23Synthetic.content[0].text).not.toContain("summary of bash_18");
|
|
129
|
+
expectNoOrphanToolResults(out.messages);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Regression for the live-flush bug (ref #8, index.ts registerSummaryBody
|
|
133
|
+
// call): production must key registerSummaryBody with the occurrence key
|
|
134
|
+
// (`id@resultTimestamp`), because hasPerBatchSummaryCoveringAny /
|
|
135
|
+
// getPerBatchSummariesForToolCallIds are always queried with occurrence
|
|
136
|
+
// keys (src/chain-compressor.ts's `lookupKeys`). Bare ids (`tc.toolCallId`)
|
|
137
|
+
// silently mismatch and every chain is skipped as "no-summary" - it only
|
|
138
|
+
// appears to work after a restart because reconstructFromSession rebuilds
|
|
139
|
+
// bodies from summary refs, which DO carry resultTimestamp. The default
|
|
140
|
+
// `keyer` on primeIndexer above pins the correct (occurrence-key) shape;
|
|
141
|
+
// this test pins the failure mode of the bare-id shape as a contrast.
|
|
142
|
+
test("live-flush occurrence-key contract: chains compress with non-empty, per-chain-distinct bodies", async () => {
|
|
143
|
+
const messages = buildSession();
|
|
144
|
+
const { indexer } = await primeIndexer(messages); // default keyer = occurrence key, i.e. the fixed index.ts contract
|
|
145
|
+
const out = pruneMessages(messages, indexer, chainConfig);
|
|
146
|
+
|
|
147
|
+
const synthetics = syntheticsOf(out.messages);
|
|
148
|
+
expect(synthetics).toHaveLength(2);
|
|
149
|
+
expect(synthetics.some((s: any) => s.content[0].text.includes("summary of bash_18"))).toBe(true);
|
|
150
|
+
expect(synthetics.some((s: any) => s.content[0].text.includes("summary of bash_23"))).toBe(true);
|
|
151
|
+
expectNoOrphanToolResults(out.messages);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("live-flush bare-id keying bug: chains are skipped as no-summary, no synthetics emitted", async () => {
|
|
155
|
+
const messages = buildSession();
|
|
156
|
+
// Mirrors the production BUG exactly: `tc.toolCallId` with no resultTimestamp,
|
|
157
|
+
// matching index.ts's pre-fix `batch.toolCalls.map((tc) => tc.toolCallId)`.
|
|
158
|
+
const { indexer } = await primeIndexer(messages, (r) => r.toolCallId);
|
|
159
|
+
const out = pruneMessages(messages, indexer, chainConfig);
|
|
160
|
+
|
|
161
|
+
const synthetics = syntheticsOf(out.messages);
|
|
162
|
+
expect(synthetics).toHaveLength(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("re-rendering the same session is deep-equal", async () => {
|
|
166
|
+
const messages = buildSession();
|
|
167
|
+
const { indexer } = await primeIndexer(messages);
|
|
168
|
+
const first = pruneMessages(messages, indexer, chainConfig);
|
|
169
|
+
const second = pruneMessages(first.messages, indexer, chainConfig);
|
|
170
|
+
expect(second.messages).toEqual(first.messages);
|
|
171
|
+
expectNoOrphanToolResults(second.messages);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("G4/C2: a live collision batch is captured (not filtered as summarized) and separately addressable after summarization", () => {
|
|
175
|
+
// Prime the indexer with an already-summarized bash_23 occurrence.
|
|
176
|
+
const indexer = new ToolCallIndexer();
|
|
177
|
+
indexer.addBatch(
|
|
178
|
+
{
|
|
179
|
+
turnIndex: 0,
|
|
180
|
+
timestamp: 2000,
|
|
181
|
+
assistantText: "",
|
|
182
|
+
toolCalls: [{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "OUT 23 first", isError: false, resultTimestamp: 2150 }],
|
|
183
|
+
},
|
|
184
|
+
() => {},
|
|
185
|
+
);
|
|
186
|
+
expect(indexer.isSummarized("bash_23@2150")).toBe(true);
|
|
187
|
+
|
|
188
|
+
// A NEW live occurrence of the same bare id, at a later resultTimestamp,
|
|
189
|
+
// not yet in the index.
|
|
190
|
+
const branch = [
|
|
191
|
+
{ type: "message", message: user(3000, "3") },
|
|
192
|
+
{ type: "message", message: callTurn(3100, ["bash_23"]) },
|
|
193
|
+
{ type: "message", message: result(3150, "bash_23", "LIVE 23") },
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
const batches = captureUnindexedBatchesFromSession(branch, indexer);
|
|
197
|
+
// The live occurrence must be captured, NOT skipped as already-summarized -
|
|
198
|
+
// isSummarized is asked with the occurrence key (bash_23@3150), which is
|
|
199
|
+
// distinct from the primed bash_23@2150.
|
|
200
|
+
expect(batches).toHaveLength(1);
|
|
201
|
+
expect(batches[0].toolCalls).toHaveLength(1);
|
|
202
|
+
expect(batches[0].toolCalls[0].toolCallId).toBe("bash_23");
|
|
203
|
+
expect(batches[0].toolCalls[0].resultTimestamp).toBe(3150);
|
|
204
|
+
expect(batches[0].toolCalls[0].resultText).toBe("LIVE 23");
|
|
205
|
+
|
|
206
|
+
// Capture it into the index (mirrors a successful summarization flush) and
|
|
207
|
+
// confirm both occurrences remain separately addressable.
|
|
208
|
+
indexer.addBatch(batches[0], () => {});
|
|
209
|
+
expect(indexer.getRecord("bash_23@2150")?.resultText).toBe("OUT 23 first");
|
|
210
|
+
expect(indexer.getRecord("bash_23@3150")?.resultText).toBe("LIVE 23");
|
|
211
|
+
expect(indexer.isSummarized("bash_23@3150")).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a restart-shaped rebuild reproduces both tN refs and non-empty synthetics", async () => {
|
|
215
|
+
const messages = buildSession();
|
|
216
|
+
const { appended, refsByToolCallId } = await primeIndexer(messages);
|
|
217
|
+
|
|
218
|
+
// Replay only what the session would hold: index, summary and chain entries.
|
|
219
|
+
const branch: any[] = [];
|
|
220
|
+
for (const { type, data } of appended) {
|
|
221
|
+
if (type === CUSTOM_TYPE_INDEX) branch.push({ type: "custom", customType: CUSTOM_TYPE_INDEX, data });
|
|
222
|
+
if (type === CUSTOM_TYPE_CHAIN) branch.push({ type: "custom", customType: CUSTOM_TYPE_CHAIN, data });
|
|
223
|
+
}
|
|
224
|
+
// Use the refs the flush actually allocated (via allocateSummaryRefs), not
|
|
225
|
+
// hand-picked shortIds - this proves the restart replay honors whatever
|
|
226
|
+
// numbering the flush produced instead of assuming t1/t2.
|
|
227
|
+
for (const id of ["bash_18", "bash_23"]) {
|
|
228
|
+
const ref = refsByToolCallId.get(id);
|
|
229
|
+
if (!ref) throw new Error(`primeIndexer did not allocate a ref for ${id}`);
|
|
230
|
+
branch.push({
|
|
231
|
+
type: "custom_message",
|
|
232
|
+
customType: CUSTOM_TYPE_SUMMARY,
|
|
233
|
+
content: `summary of ${id}`,
|
|
234
|
+
details: { toolCallRefs: [ref] },
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const [ref18, ref23] = [refsByToolCallId.get("bash_18")!, refsByToolCallId.get("bash_23")!];
|
|
239
|
+
const rebuilt = new ToolCallIndexer();
|
|
240
|
+
rebuilt.reconstructFromSession({ sessionManager: { getBranch: () => branch } } as any);
|
|
241
|
+
expect(rebuilt.getRecord(ref18.shortId)?.resultText).toBe("OUT 18");
|
|
242
|
+
expect(rebuilt.getRecord(ref23.shortId)?.resultText).toBe("OUT 23 first");
|
|
243
|
+
|
|
244
|
+
const out = pruneMessages(buildSession(), rebuilt, chainConfig);
|
|
245
|
+
const synthetics = syntheticsOf(out.messages);
|
|
246
|
+
expect(synthetics).toHaveLength(2);
|
|
247
|
+
expect(synthetics.some((s: any) => s.content[0].text.includes("summary of bash_18"))).toBe(true);
|
|
248
|
+
expect(synthetics.some((s: any) => s.content[0].text.includes("summary of bash_23"))).toBe(true);
|
|
249
|
+
expectNoOrphanToolResults(out.messages);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ToolCallIndexer } from "./indexer.js";
|
|
3
|
+
import { CUSTOM_TYPE_INDEX, CUSTOM_TYPE_SUMMARY, CUSTOM_TYPE_DEDUP_ALIAS } from "./types.js";
|
|
4
|
+
import type { CapturedBatch } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const batch = (
|
|
7
|
+
turnIndex: number,
|
|
8
|
+
timestamp: number,
|
|
9
|
+
calls: { id: string; ts?: number; text: string }[],
|
|
10
|
+
): CapturedBatch => ({
|
|
11
|
+
turnIndex,
|
|
12
|
+
timestamp,
|
|
13
|
+
assistantText: "",
|
|
14
|
+
toolCalls: calls.map((c) => ({
|
|
15
|
+
toolCallId: c.id,
|
|
16
|
+
toolName: "bash",
|
|
17
|
+
args: { cmd: "ls" },
|
|
18
|
+
resultText: c.text,
|
|
19
|
+
isError: false,
|
|
20
|
+
...(c.ts !== undefined ? { resultTimestamp: c.ts } : {}),
|
|
21
|
+
})),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("occurrence keying", () => {
|
|
25
|
+
test("two batches reusing one provider id keep both records", () => {
|
|
26
|
+
const indexer = new ToolCallIndexer();
|
|
27
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
28
|
+
indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {});
|
|
29
|
+
|
|
30
|
+
expect(indexer.getRecord("bash_23@1150")?.resultText).toBe("FIRST");
|
|
31
|
+
expect(indexer.getRecord("bash_23@2150")?.resultText).toBe("SECOND");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("isSummarized is keyed by occurrence, so a live reused id is not summarized", () => {
|
|
35
|
+
const indexer = new ToolCallIndexer();
|
|
36
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
37
|
+
|
|
38
|
+
expect(indexer.isSummarized("bash_23@1150")).toBe(true);
|
|
39
|
+
expect(indexer.isSummarized("bash_23@3150")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("getRecordsForId returns every occurrence of a bare id, chronologically", () => {
|
|
43
|
+
const indexer = new ToolCallIndexer();
|
|
44
|
+
indexer.addBatch(batch(0, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {});
|
|
45
|
+
indexer.addBatch(batch(1, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
46
|
+
|
|
47
|
+
const records = indexer.getRecordsForId("bash_23");
|
|
48
|
+
expect(records.map((r) => r.resultText)).toEqual(["FIRST", "SECOND"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("getRecordsForId on an occurrence key or short ref returns exactly one", () => {
|
|
52
|
+
const indexer = new ToolCallIndexer();
|
|
53
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
54
|
+
indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }]);
|
|
55
|
+
|
|
56
|
+
expect(indexer.getRecordsForId("bash_23@1150").map((r) => r.resultText)).toEqual(["FIRST"]);
|
|
57
|
+
expect(indexer.getRecordsForId("t1").map((r) => r.resultText)).toEqual(["FIRST"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("short refs resolve per occurrence", () => {
|
|
61
|
+
const indexer = new ToolCallIndexer();
|
|
62
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
63
|
+
indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {});
|
|
64
|
+
indexer.registerSummaryRefs([
|
|
65
|
+
{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 },
|
|
66
|
+
{ shortId: "t2", toolCallId: "bash_23", resultTimestamp: 2150 },
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
expect(indexer.getRecord("t1")?.resultText).toBe("FIRST");
|
|
70
|
+
expect(indexer.getRecord("t2")?.resultText).toBe("SECOND");
|
|
71
|
+
expect(indexer.getShortRefForToolCallId("bash_23@2150")).toBe("t2");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("allocateSummaryRefs mints refs carrying the occurrence timestamp", () => {
|
|
75
|
+
const indexer = new ToolCallIndexer();
|
|
76
|
+
const b = batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]);
|
|
77
|
+
const refs = indexer.allocateSummaryRefs(b);
|
|
78
|
+
|
|
79
|
+
expect(refs).toEqual([{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("content dedup does not alias across batches that merely share an id", () => {
|
|
83
|
+
const indexer = new ToolCallIndexer();
|
|
84
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
85
|
+
indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {});
|
|
86
|
+
|
|
87
|
+
// Each occurrence's content resolves to its OWN occurrence key, never
|
|
88
|
+
// to the other occurrence merely because they share a bare id.
|
|
89
|
+
expect(indexer.lookupByContent("bash", "FIRST")).toBe("bash_23@1150");
|
|
90
|
+
expect(indexer.lookupByContent("bash", "SECOND")).toBe("bash_23@2150");
|
|
91
|
+
expect(indexer.lookupByContent("bash", "UNSEEN")).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("registerDuplicate persists both occurrence sides and reuses the short ref", () => {
|
|
95
|
+
const indexer = new ToolCallIndexer();
|
|
96
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "SAME" }]), () => {});
|
|
97
|
+
indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }]);
|
|
98
|
+
|
|
99
|
+
let persisted: any;
|
|
100
|
+
indexer.registerDuplicate("bash_99@4150", "bash_23@1150", (customType, data) => {
|
|
101
|
+
persisted = { customType, data };
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(persisted).toEqual({
|
|
105
|
+
customType: CUSTOM_TYPE_DEDUP_ALIAS,
|
|
106
|
+
data: {
|
|
107
|
+
newToolCallId: "bash_99",
|
|
108
|
+
newResultTimestamp: 4150,
|
|
109
|
+
originalToolCallId: "bash_23",
|
|
110
|
+
originalResultTimestamp: 1150,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
expect(indexer.isSummarized("bash_99@4150")).toBe(true);
|
|
114
|
+
expect(indexer.getRecord("bash_99@4150")?.resultText).toBe("SAME");
|
|
115
|
+
expect(indexer.getShortRefForToolCallId("bash_99@4150")).toBe("t1");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("getRecordsForId includes a dedup-alias occurrence, labelled with its own timestamp", () => {
|
|
119
|
+
const indexer = new ToolCallIndexer();
|
|
120
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "SAME" }]), () => {});
|
|
121
|
+
// Alias shares the SAME bare id as the original but occurred later - the
|
|
122
|
+
// G5 conformance shape: a content-deduplicated collision on a reused id.
|
|
123
|
+
indexer.registerDuplicate("bash_23@9150", "bash_23@1150", () => {});
|
|
124
|
+
|
|
125
|
+
const records = indexer.getRecordsForId("bash_23");
|
|
126
|
+
expect(records).toHaveLength(2);
|
|
127
|
+
expect(records.map((r) => r.resultText)).toEqual(["SAME", "SAME"]);
|
|
128
|
+
expect(records.map((r) => r.resultTimestamp)).toEqual([1150, 9150]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("summary body lookups are occurrence-keyed", () => {
|
|
132
|
+
const indexer = new ToolCallIndexer();
|
|
133
|
+
indexer.registerSummaryBody(["bash_23@1150"], "summary of FIRST");
|
|
134
|
+
|
|
135
|
+
expect(indexer.hasPerBatchSummaryCoveringAny(["bash_23@1150"])).toBe(true);
|
|
136
|
+
expect(indexer.hasPerBatchSummaryCoveringAny(["bash_23@3150"])).toBe(false);
|
|
137
|
+
expect(indexer.getPerBatchSummaryTextForToolCallIds(["bash_23@1150"])).toBe("summary of FIRST");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("fail-closed asymmetry: isSummarized vs bare-id resolution", () => {
|
|
142
|
+
test("single occurrence: isSummarized on the bare id is strict-false, resolveToolCallId/getRecord fall back", () => {
|
|
143
|
+
const indexer = new ToolCallIndexer();
|
|
144
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
145
|
+
|
|
146
|
+
expect(indexer.isSummarized("bash_23")).toBe(false);
|
|
147
|
+
expect(indexer.resolveToolCallId("bash_23")).toBe("bash_23@1150");
|
|
148
|
+
expect(indexer.getRecord("bash_23")?.resultText).toBe("FIRST");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("two occurrences: resolveToolCallId on the bare id is ambiguous, getRecordsForId expands both", () => {
|
|
152
|
+
const indexer = new ToolCallIndexer();
|
|
153
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {});
|
|
154
|
+
indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {});
|
|
155
|
+
|
|
156
|
+
expect(indexer.resolveToolCallId("bash_23")).toBeUndefined();
|
|
157
|
+
expect(indexer.getRecordsForId("bash_23").map((r) => r.resultText)).toEqual(["FIRST", "SECOND"]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("mixed legacy + new occurrence under the same bare id: hasLegacyBareRecord is false (fail-closed), getRecordsForId returns both", () => {
|
|
161
|
+
const indexer = new ToolCallIndexer();
|
|
162
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", text: "OLD" }]), () => {});
|
|
163
|
+
indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "NEW" }]), () => {});
|
|
164
|
+
|
|
165
|
+
// A mixed bare+occurrence id must fail closed: the pruner's legacy-bare
|
|
166
|
+
// path would otherwise stub an unrelated live occurrence with the stale
|
|
167
|
+
// legacy record (F1 regression - see pruner.test.ts).
|
|
168
|
+
expect(indexer.hasLegacyBareRecord("bash_23")).toBe(false);
|
|
169
|
+
expect(indexer.getRecordsForId("bash_23").map((r) => r.resultText)).toEqual(["OLD", "NEW"]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("hasLegacyBareRecord: true for pure-legacy, false for pure-occurrence, false for mixed", () => {
|
|
173
|
+
const legacyOnly = new ToolCallIndexer();
|
|
174
|
+
legacyOnly.addBatch(batch(0, 1000, [{ id: "bash_1", text: "OLD" }]), () => {});
|
|
175
|
+
expect(legacyOnly.hasLegacyBareRecord("bash_1")).toBe(true);
|
|
176
|
+
|
|
177
|
+
const occOnly = new ToolCallIndexer();
|
|
178
|
+
occOnly.addBatch(batch(0, 1000, [{ id: "bash_2", ts: 1150, text: "NEW" }]), () => {});
|
|
179
|
+
expect(occOnly.hasLegacyBareRecord("bash_2")).toBe(false);
|
|
180
|
+
|
|
181
|
+
const mixed = new ToolCallIndexer();
|
|
182
|
+
mixed.addBatch(batch(0, 1000, [{ id: "bash_3", text: "OLD" }]), () => {});
|
|
183
|
+
mixed.addBatch(batch(1, 2000, [{ id: "bash_3", ts: 2150, text: "NEW" }]), () => {});
|
|
184
|
+
expect(mixed.hasLegacyBareRecord("bash_3")).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("rebuild is order-independent: a dedup alias entry appearing BEFORE its summary entry still inherits the short ref", () => {
|
|
188
|
+
const indexEntry = {
|
|
189
|
+
type: "custom",
|
|
190
|
+
customType: CUSTOM_TYPE_INDEX,
|
|
191
|
+
data: {
|
|
192
|
+
toolCalls: [
|
|
193
|
+
{
|
|
194
|
+
toolCallId: "bash_23",
|
|
195
|
+
toolName: "bash",
|
|
196
|
+
args: {},
|
|
197
|
+
resultText: "FIRST",
|
|
198
|
+
isError: false,
|
|
199
|
+
turnIndex: 0,
|
|
200
|
+
timestamp: 1000,
|
|
201
|
+
resultTimestamp: 1150,
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
const dedupEntry = {
|
|
207
|
+
type: "custom",
|
|
208
|
+
customType: CUSTOM_TYPE_DEDUP_ALIAS,
|
|
209
|
+
data: {
|
|
210
|
+
newToolCallId: "bash_99",
|
|
211
|
+
newResultTimestamp: 4150,
|
|
212
|
+
originalToolCallId: "bash_23",
|
|
213
|
+
originalResultTimestamp: 1150,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
const summaryEntry = {
|
|
217
|
+
type: "custom_message",
|
|
218
|
+
customType: CUSTOM_TYPE_SUMMARY,
|
|
219
|
+
content: "summary text",
|
|
220
|
+
details: {
|
|
221
|
+
toolCallRefs: [{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }],
|
|
222
|
+
toolNames: ["bash"],
|
|
223
|
+
turnIndex: 0,
|
|
224
|
+
timestamp: 1000,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const indexer = new ToolCallIndexer();
|
|
229
|
+
// dedup entry appears BEFORE the summary entry in the branch, unlike every other test here.
|
|
230
|
+
const ctx = { sessionManager: { getBranch: () => [indexEntry, dedupEntry, summaryEntry] } } as any;
|
|
231
|
+
indexer.reconstructFromSession(ctx);
|
|
232
|
+
|
|
233
|
+
expect(indexer.getShortRefForToolCallId("bash_99@4150")).toBe("t1");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("session rebuild", () => {
|
|
238
|
+
test("rebuilds occurrence records, both short refs and the dedup alias", () => {
|
|
239
|
+
const indexEntry = {
|
|
240
|
+
type: "custom",
|
|
241
|
+
customType: CUSTOM_TYPE_INDEX,
|
|
242
|
+
data: {
|
|
243
|
+
toolCalls: [
|
|
244
|
+
{
|
|
245
|
+
toolCallId: "bash_23",
|
|
246
|
+
toolName: "bash",
|
|
247
|
+
args: {},
|
|
248
|
+
resultText: "FIRST",
|
|
249
|
+
isError: false,
|
|
250
|
+
turnIndex: 0,
|
|
251
|
+
timestamp: 1000,
|
|
252
|
+
resultTimestamp: 1150,
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
toolCallId: "bash_23",
|
|
256
|
+
toolName: "bash",
|
|
257
|
+
args: {},
|
|
258
|
+
resultText: "SECOND",
|
|
259
|
+
isError: false,
|
|
260
|
+
turnIndex: 1,
|
|
261
|
+
timestamp: 2000,
|
|
262
|
+
resultTimestamp: 2150,
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
const summaryEntry = {
|
|
268
|
+
type: "custom_message",
|
|
269
|
+
customType: CUSTOM_TYPE_SUMMARY,
|
|
270
|
+
content: "summary text",
|
|
271
|
+
details: {
|
|
272
|
+
toolCallRefs: [
|
|
273
|
+
{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 },
|
|
274
|
+
{ shortId: "t2", toolCallId: "bash_23", resultTimestamp: 2150 },
|
|
275
|
+
],
|
|
276
|
+
toolNames: ["bash", "bash"],
|
|
277
|
+
turnIndex: 1,
|
|
278
|
+
timestamp: 2000,
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
const dedupEntry = {
|
|
282
|
+
type: "custom",
|
|
283
|
+
customType: CUSTOM_TYPE_DEDUP_ALIAS,
|
|
284
|
+
data: {
|
|
285
|
+
newToolCallId: "bash_99",
|
|
286
|
+
newResultTimestamp: 4150,
|
|
287
|
+
originalToolCallId: "bash_23",
|
|
288
|
+
originalResultTimestamp: 1150,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const indexer = new ToolCallIndexer();
|
|
293
|
+
const ctx = { sessionManager: { getBranch: () => [indexEntry, summaryEntry, dedupEntry] } } as any;
|
|
294
|
+
indexer.reconstructFromSession(ctx);
|
|
295
|
+
|
|
296
|
+
expect(indexer.getRecord("t1")?.resultText).toBe("FIRST");
|
|
297
|
+
expect(indexer.getRecord("t2")?.resultText).toBe("SECOND");
|
|
298
|
+
expect(indexer.getRecord("bash_99@4150")?.resultText).toBe("FIRST");
|
|
299
|
+
expect(indexer.getShortRefForToolCallId("bash_99@4150")).toBe("t1");
|
|
300
|
+
expect(indexer.getPerBatchSummaryTextForToolCallIds(["bash_23@2150"])).toBe("summary text");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("legacy entries without resultTimestamp keep bare-id keys", () => {
|
|
304
|
+
const indexEntry = {
|
|
305
|
+
type: "custom",
|
|
306
|
+
customType: CUSTOM_TYPE_INDEX,
|
|
307
|
+
data: {
|
|
308
|
+
toolCalls: [
|
|
309
|
+
{
|
|
310
|
+
toolCallId: "bash_7",
|
|
311
|
+
toolName: "bash",
|
|
312
|
+
args: {},
|
|
313
|
+
resultText: "OLD",
|
|
314
|
+
isError: false,
|
|
315
|
+
turnIndex: 0,
|
|
316
|
+
timestamp: 1000,
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
const legacyDedupEntry = {
|
|
322
|
+
type: "custom",
|
|
323
|
+
customType: CUSTOM_TYPE_DEDUP_ALIAS,
|
|
324
|
+
data: { newToolCallId: "bash_8", originalToolCallId: "bash_7" },
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const indexer = new ToolCallIndexer();
|
|
328
|
+
const ctx = { sessionManager: { getBranch: () => [indexEntry, legacyDedupEntry] } } as any;
|
|
329
|
+
indexer.reconstructFromSession(ctx);
|
|
330
|
+
|
|
331
|
+
expect(indexer.getRecord("bash_7")?.resultText).toBe("OLD");
|
|
332
|
+
expect(indexer.hasLegacyBareRecord("bash_7")).toBe(true);
|
|
333
|
+
expect(indexer.hasLegacyBareRecord("bash_23")).toBe(false);
|
|
334
|
+
expect(indexer.isSummarized("bash_8")).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
});
|