@animalabs/context-manager 0.1.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 (83) hide show
  1. package/dist/src/blob-manager.d.ts +37 -0
  2. package/dist/src/blob-manager.d.ts.map +1 -0
  3. package/dist/src/blob-manager.js +128 -0
  4. package/dist/src/blob-manager.js.map +1 -0
  5. package/dist/src/context-log.d.ts +99 -0
  6. package/dist/src/context-log.d.ts.map +1 -0
  7. package/dist/src/context-log.js +277 -0
  8. package/dist/src/context-log.js.map +1 -0
  9. package/dist/src/context-manager.d.ts +245 -0
  10. package/dist/src/context-manager.d.ts.map +1 -0
  11. package/dist/src/context-manager.js +553 -0
  12. package/dist/src/context-manager.js.map +1 -0
  13. package/dist/src/index.d.ts +12 -0
  14. package/dist/src/index.d.ts.map +1 -0
  15. package/dist/src/index.js +12 -0
  16. package/dist/src/index.js.map +1 -0
  17. package/dist/src/message-store.d.ts +135 -0
  18. package/dist/src/message-store.d.ts.map +1 -0
  19. package/dist/src/message-store.js +372 -0
  20. package/dist/src/message-store.js.map +1 -0
  21. package/dist/src/strategies/autobiographical.d.ts +199 -0
  22. package/dist/src/strategies/autobiographical.d.ts.map +1 -0
  23. package/dist/src/strategies/autobiographical.js +1122 -0
  24. package/dist/src/strategies/autobiographical.js.map +1 -0
  25. package/dist/src/strategies/index.d.ts +3 -0
  26. package/dist/src/strategies/index.d.ts.map +1 -0
  27. package/dist/src/strategies/index.js +3 -0
  28. package/dist/src/strategies/index.js.map +1 -0
  29. package/dist/src/strategies/knowledge.d.ts +46 -0
  30. package/dist/src/strategies/knowledge.d.ts.map +1 -0
  31. package/dist/src/strategies/knowledge.js +270 -0
  32. package/dist/src/strategies/knowledge.js.map +1 -0
  33. package/dist/src/strategies/passthrough.d.ts +17 -0
  34. package/dist/src/strategies/passthrough.d.ts.map +1 -0
  35. package/dist/src/strategies/passthrough.js +69 -0
  36. package/dist/src/strategies/passthrough.js.map +1 -0
  37. package/dist/src/types/context.d.ts +108 -0
  38. package/dist/src/types/context.d.ts.map +1 -0
  39. package/dist/src/types/context.js +2 -0
  40. package/dist/src/types/context.js.map +1 -0
  41. package/dist/src/types/index.d.ts +5 -0
  42. package/dist/src/types/index.d.ts.map +1 -0
  43. package/dist/src/types/index.js +2 -0
  44. package/dist/src/types/index.js.map +1 -0
  45. package/dist/src/types/message.d.ts +129 -0
  46. package/dist/src/types/message.d.ts.map +1 -0
  47. package/dist/src/types/message.js +2 -0
  48. package/dist/src/types/message.js.map +1 -0
  49. package/dist/src/types/strategy.d.ts +233 -0
  50. package/dist/src/types/strategy.d.ts.map +1 -0
  51. package/dist/src/types/strategy.js +32 -0
  52. package/dist/src/types/strategy.js.map +1 -0
  53. package/dist/test/autobiographical.test.d.ts +2 -0
  54. package/dist/test/autobiographical.test.d.ts.map +1 -0
  55. package/dist/test/autobiographical.test.js +46 -0
  56. package/dist/test/autobiographical.test.js.map +1 -0
  57. package/dist/test/head-window-reset.test.d.ts +17 -0
  58. package/dist/test/head-window-reset.test.d.ts.map +1 -0
  59. package/dist/test/head-window-reset.test.js +342 -0
  60. package/dist/test/head-window-reset.test.js.map +1 -0
  61. package/dist/test/integration.test.d.ts +2 -0
  62. package/dist/test/integration.test.d.ts.map +1 -0
  63. package/dist/test/integration.test.js +1341 -0
  64. package/dist/test/integration.test.js.map +1 -0
  65. package/dist/test/knowledge.test.d.ts +2 -0
  66. package/dist/test/knowledge.test.d.ts.map +1 -0
  67. package/dist/test/knowledge.test.js +617 -0
  68. package/dist/test/knowledge.test.js.map +1 -0
  69. package/dist/tsconfig.tsbuildinfo +1 -0
  70. package/package.json +48 -0
  71. package/src/blob-manager.ts +155 -0
  72. package/src/context-log.ts +342 -0
  73. package/src/context-manager.ts +726 -0
  74. package/src/index.ts +50 -0
  75. package/src/message-store.ts +479 -0
  76. package/src/strategies/autobiographical.ts +1355 -0
  77. package/src/strategies/index.ts +2 -0
  78. package/src/strategies/knowledge.ts +336 -0
  79. package/src/strategies/passthrough.ts +98 -0
  80. package/src/types/context.ts +119 -0
  81. package/src/types/index.ts +42 -0
  82. package/src/types/message.ts +140 -0
  83. package/src/types/strategy.ts +282 -0
@@ -0,0 +1,1341 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { rmSync, existsSync } from 'node:fs';
4
+ import { ContextManager, PassthroughStrategy, AutobiographicalStrategy, ContextLog, MessageStore } from '../src/index.js';
5
+ import { JsStore } from '@animalabs/chronicle';
6
+ const TEST_STORE_PATH = './test-context-store';
7
+ function cleanup() {
8
+ if (existsSync(TEST_STORE_PATH)) {
9
+ rmSync(TEST_STORE_PATH, { recursive: true, force: true });
10
+ }
11
+ }
12
+ describe('ContextManager', () => {
13
+ before(() => cleanup());
14
+ after(() => cleanup());
15
+ describe('Basic Operations', () => {
16
+ it('should create a context manager', async () => {
17
+ cleanup();
18
+ const manager = await ContextManager.open({
19
+ path: TEST_STORE_PATH,
20
+ });
21
+ assert.ok(manager);
22
+ const stats = manager.stats();
23
+ assert.strictEqual(stats.messageCount, 0);
24
+ assert.strictEqual(stats.contextEntryCount, 0);
25
+ });
26
+ it('should add messages', async () => {
27
+ cleanup();
28
+ const manager = await ContextManager.open({
29
+ path: TEST_STORE_PATH,
30
+ });
31
+ const content = [{ type: 'text', text: 'Hello, world!' }];
32
+ const id = manager.addMessage('User', content);
33
+ assert.ok(id);
34
+ const stats = manager.stats();
35
+ assert.strictEqual(stats.messageCount, 1);
36
+ const message = manager.getMessage(id);
37
+ assert.ok(message);
38
+ assert.strictEqual(message.participant, 'User');
39
+ assert.strictEqual(message.content.length, 1);
40
+ assert.strictEqual(message.content[0].type, 'text');
41
+ if (message.content[0].type === 'text') {
42
+ assert.strictEqual(message.content[0].text, 'Hello, world!');
43
+ }
44
+ });
45
+ it('should add multiple messages', async () => {
46
+ cleanup();
47
+ const manager = await ContextManager.open({
48
+ path: TEST_STORE_PATH,
49
+ });
50
+ manager.addMessage('User', [{ type: 'text', text: 'Hello' }]);
51
+ manager.addMessage('Claude', [{ type: 'text', text: 'Hi there!' }]);
52
+ manager.addMessage('User', [{ type: 'text', text: 'How are you?' }]);
53
+ const stats = manager.stats();
54
+ assert.strictEqual(stats.messageCount, 3);
55
+ const messages = manager.getAllMessages();
56
+ assert.strictEqual(messages.length, 3);
57
+ assert.strictEqual(messages[0].participant, 'User');
58
+ assert.strictEqual(messages[1].participant, 'Claude');
59
+ assert.strictEqual(messages[2].participant, 'User');
60
+ });
61
+ it('should edit messages', async () => {
62
+ cleanup();
63
+ const manager = await ContextManager.open({
64
+ path: TEST_STORE_PATH,
65
+ });
66
+ const id = manager.addMessage('User', [{ type: 'text', text: 'Original text' }]);
67
+ manager.editMessage(id, [{ type: 'text', text: 'Edited text' }]);
68
+ const message = manager.getMessage(id);
69
+ assert.ok(message);
70
+ if (message.content[0].type === 'text') {
71
+ assert.strictEqual(message.content[0].text, 'Edited text');
72
+ }
73
+ });
74
+ it('should remove messages', async () => {
75
+ cleanup();
76
+ const manager = await ContextManager.open({
77
+ path: TEST_STORE_PATH,
78
+ });
79
+ const id1 = manager.addMessage('User', [{ type: 'text', text: 'First' }]);
80
+ manager.addMessage('User', [{ type: 'text', text: 'Second' }]);
81
+ assert.strictEqual(manager.stats().messageCount, 2);
82
+ manager.removeMessage(id1);
83
+ assert.strictEqual(manager.stats().messageCount, 1);
84
+ // After removal, the remaining message should still be accessible
85
+ const remaining = manager.getAllMessages();
86
+ assert.strictEqual(remaining.length, 1);
87
+ if (remaining[0].content[0].type === 'text') {
88
+ assert.strictEqual(remaining[0].content[0].text, 'Second');
89
+ }
90
+ });
91
+ });
92
+ describe('Context Compilation', () => {
93
+ it('should compile context with PassthroughStrategy', async () => {
94
+ cleanup();
95
+ const manager = await ContextManager.open({
96
+ path: TEST_STORE_PATH,
97
+ strategy: new PassthroughStrategy(),
98
+ });
99
+ manager.addMessage('User', [{ type: 'text', text: 'Hello' }]);
100
+ manager.addMessage('Claude', [{ type: 'text', text: 'Hi!' }]);
101
+ const { messages } = await manager.compile();
102
+ assert.strictEqual(messages.length, 2);
103
+ assert.strictEqual(messages[0].participant, 'User');
104
+ assert.strictEqual(messages[1].participant, 'Claude');
105
+ });
106
+ it('should respect token budget in compilation', async () => {
107
+ cleanup();
108
+ const manager = await ContextManager.open({
109
+ path: TEST_STORE_PATH,
110
+ strategy: new PassthroughStrategy(),
111
+ });
112
+ // Add many messages
113
+ for (let i = 0; i < 100; i++) {
114
+ manager.addMessage('User', [{ type: 'text', text: 'Message '.repeat(100) }]);
115
+ }
116
+ // Compile with a small budget
117
+ const { messages } = await manager.compile({
118
+ maxTokens: 1000,
119
+ reserveForResponse: 200,
120
+ });
121
+ // Should have fewer messages than we added
122
+ assert.ok(messages.length < 100);
123
+ assert.ok(messages.length > 0);
124
+ });
125
+ it('should report readiness', async () => {
126
+ cleanup();
127
+ const manager = await ContextManager.open({
128
+ path: TEST_STORE_PATH,
129
+ strategy: new PassthroughStrategy(),
130
+ });
131
+ // Passthrough is always ready
132
+ assert.strictEqual(manager.isReady(), true);
133
+ assert.strictEqual(manager.getPendingWork(), null);
134
+ });
135
+ });
136
+ describe('Branching', () => {
137
+ it('should list branches', async () => {
138
+ cleanup();
139
+ const manager = await ContextManager.open({
140
+ path: TEST_STORE_PATH,
141
+ });
142
+ const branches = manager.listBranches();
143
+ assert.ok(branches.length >= 1); // Should have at least 'main'
144
+ });
145
+ it('should get current branch', async () => {
146
+ cleanup();
147
+ const manager = await ContextManager.open({
148
+ path: TEST_STORE_PATH,
149
+ });
150
+ const branch = manager.currentBranch();
151
+ assert.ok(branch.name);
152
+ assert.ok(branch.id);
153
+ });
154
+ it('should branch at specific message sequence (time-travel)', async () => {
155
+ cleanup();
156
+ const manager = await ContextManager.open({
157
+ path: TEST_STORE_PATH,
158
+ });
159
+ // Add several messages to create history
160
+ const id1 = manager.addMessage('Alice', [{ type: 'text', text: 'Message 1' }]);
161
+ const id2 = manager.addMessage('Alice', [{ type: 'text', text: 'Message 2' }]);
162
+ const id3 = manager.addMessage('Bot', [{ type: 'text', text: 'Message 3' }]);
163
+ const id4 = manager.addMessage('Alice', [{ type: 'text', text: 'Message 4' }]);
164
+ const id5 = manager.addMessage('Bot', [{ type: 'text', text: 'Message 5' }]);
165
+ // Get the sequence of message 2 (we'll branch at this point)
166
+ const msg2 = manager.getMessage(id2);
167
+ assert.ok(msg2, 'Message 2 should exist');
168
+ const msg2Sequence = msg2.sequence;
169
+ // Branch at message 2
170
+ const branchId = manager.branchAt(id2, 'time-travel-test');
171
+ // Verify the branch was created with correct branch point
172
+ const branches = manager.listBranches();
173
+ const newBranch = branches.find(b => b.name === 'time-travel-test');
174
+ assert.ok(newBranch, 'Time-travel branch should exist');
175
+ assert.strictEqual(newBranch.branchPoint, msg2Sequence, 'Branch point should equal message 2 sequence');
176
+ });
177
+ });
178
+ describe('AutobiographicalStrategy', () => {
179
+ it('should initialize without errors', async () => {
180
+ cleanup();
181
+ const strategy = new AutobiographicalStrategy({
182
+ targetChunkTokens: 1000,
183
+ recentWindowTokens: 5000,
184
+ });
185
+ const manager = await ContextManager.open({
186
+ path: TEST_STORE_PATH,
187
+ strategy,
188
+ });
189
+ assert.ok(manager);
190
+ assert.strictEqual(manager.getStrategy().name, 'autobiographical');
191
+ });
192
+ it('should compile with recent messages when no compression needed', async () => {
193
+ cleanup();
194
+ const strategy = new AutobiographicalStrategy({
195
+ targetChunkTokens: 1000,
196
+ recentWindowTokens: 50000, // Large window, no compression
197
+ });
198
+ const manager = await ContextManager.open({
199
+ path: TEST_STORE_PATH,
200
+ strategy,
201
+ });
202
+ manager.addMessage('User', [{ type: 'text', text: 'Hello' }]);
203
+ manager.addMessage('Claude', [{ type: 'text', text: 'Hi!' }]);
204
+ const { messages } = await manager.compile();
205
+ // Should include our messages
206
+ assert.ok(messages.length >= 2);
207
+ });
208
+ });
209
+ describe('Persistence', () => {
210
+ it('should persist messages across sessions', async () => {
211
+ // Use a unique path for this test to avoid conflicts
212
+ const persistPath = './test-persist-store';
213
+ if (existsSync(persistPath)) {
214
+ rmSync(persistPath, { recursive: true, force: true });
215
+ }
216
+ // Create and add messages
217
+ const manager = await ContextManager.open({
218
+ path: persistPath,
219
+ });
220
+ manager.addMessage('User', [{ type: 'text', text: 'Persisted message' }]);
221
+ manager.sync();
222
+ manager.close();
223
+ // Reopen and check
224
+ const manager2 = await ContextManager.open({
225
+ path: persistPath,
226
+ });
227
+ const messages = manager2.getAllMessages();
228
+ assert.strictEqual(messages.length, 1);
229
+ if (messages[0].content[0].type === 'text') {
230
+ assert.strictEqual(messages[0].content[0].text, 'Persisted message');
231
+ }
232
+ manager2.close();
233
+ // Cleanup
234
+ rmSync(persistPath, { recursive: true, force: true });
235
+ });
236
+ });
237
+ describe('Edit Propagation', () => {
238
+ /**
239
+ * Strategy that populates context log with mixed source relations for testing.
240
+ */
241
+ class TestPropagationStrategy {
242
+ name = 'test-propagation';
243
+ contextLog = null;
244
+ setContextLog(log) {
245
+ this.contextLog = log;
246
+ }
247
+ checkReadiness() {
248
+ return { ready: true };
249
+ }
250
+ async onNewMessage(message, ctx) {
251
+ // Don't add automatically - tests will add entries manually
252
+ }
253
+ select(store, log, budget) {
254
+ return log.getAll();
255
+ }
256
+ // Public method for tests to add entries with specific relations
257
+ addEntry(participant, content, sourceMessageId, sourceRelation) {
258
+ if (!this.contextLog)
259
+ throw new Error('Context log not set');
260
+ this.contextLog.append(participant, content, sourceMessageId, sourceRelation);
261
+ }
262
+ }
263
+ it('should propagate edits to copy entries', async () => {
264
+ cleanup();
265
+ const storePath = './test-propagation-store-1';
266
+ if (existsSync(storePath)) {
267
+ rmSync(storePath, { recursive: true, force: true });
268
+ }
269
+ // Create store and set up context log manually
270
+ const store = JsStore.openOrCreate({ path: storePath });
271
+ try {
272
+ MessageStore.register(store);
273
+ }
274
+ catch { }
275
+ try {
276
+ ContextLog.register(store);
277
+ }
278
+ catch { }
279
+ const messageStore = new MessageStore(store);
280
+ const contextLog = new ContextLog(store);
281
+ // Add a message
282
+ const msg = messageStore.append('User', [{ type: 'text', text: 'Original text' }]);
283
+ // Add context entry with 'copy' relation
284
+ contextLog.append('User', [{ type: 'text', text: 'Original text' }], msg.id, 'copy');
285
+ // Edit the message in message store
286
+ messageStore.edit(msg.id, [{ type: 'text', text: 'Edited text' }]);
287
+ // Manually trigger propagation (simulating what ContextManager does)
288
+ const entries = contextLog.findBySource(msg.id);
289
+ for (const entry of entries) {
290
+ if (entry.sourceRelation === 'copy') {
291
+ contextLog.edit(entry.index, [{ type: 'text', text: 'Edited text' }]);
292
+ }
293
+ }
294
+ // Verify context log was updated
295
+ const updatedEntry = contextLog.get(0);
296
+ assert.ok(updatedEntry);
297
+ assert.strictEqual(updatedEntry.content[0].type, 'text');
298
+ if (updatedEntry.content[0].type === 'text') {
299
+ assert.strictEqual(updatedEntry.content[0].text, 'Edited text');
300
+ }
301
+ store.close();
302
+ rmSync(storePath, { recursive: true, force: true });
303
+ });
304
+ it('should NOT propagate edits to derived entries', async () => {
305
+ cleanup();
306
+ const storePath = './test-propagation-store-2';
307
+ if (existsSync(storePath)) {
308
+ rmSync(storePath, { recursive: true, force: true });
309
+ }
310
+ const store = JsStore.openOrCreate({ path: storePath });
311
+ try {
312
+ MessageStore.register(store);
313
+ }
314
+ catch { }
315
+ try {
316
+ ContextLog.register(store);
317
+ }
318
+ catch { }
319
+ const messageStore = new MessageStore(store);
320
+ const contextLog = new ContextLog(store);
321
+ // Add a message
322
+ const msg = messageStore.append('User', [{ type: 'text', text: 'Original text' }]);
323
+ // Add context entry with 'derived' relation (like a compression summary)
324
+ contextLog.append('User', [{ type: 'text', text: 'Summary of original' }], msg.id, 'derived');
325
+ // Edit the message in message store
326
+ messageStore.edit(msg.id, [{ type: 'text', text: 'Edited text' }]);
327
+ // Propagation logic - should NOT update derived entries
328
+ const entries = contextLog.findBySource(msg.id);
329
+ for (const entry of entries) {
330
+ if (entry.sourceRelation === 'copy') {
331
+ contextLog.edit(entry.index, [{ type: 'text', text: 'Edited text' }]);
332
+ }
333
+ // 'derived' entries are intentionally skipped
334
+ }
335
+ // Verify context log was NOT updated (derived entries stay stale)
336
+ const unchangedEntry = contextLog.get(0);
337
+ assert.ok(unchangedEntry);
338
+ assert.strictEqual(unchangedEntry.content[0].type, 'text');
339
+ if (unchangedEntry.content[0].type === 'text') {
340
+ assert.strictEqual(unchangedEntry.content[0].text, 'Summary of original');
341
+ }
342
+ store.close();
343
+ rmSync(storePath, { recursive: true, force: true });
344
+ });
345
+ it('should NOT propagate edits to referenced entries', async () => {
346
+ cleanup();
347
+ const storePath = './test-propagation-store-3';
348
+ if (existsSync(storePath)) {
349
+ rmSync(storePath, { recursive: true, force: true });
350
+ }
351
+ const store = JsStore.openOrCreate({ path: storePath });
352
+ try {
353
+ MessageStore.register(store);
354
+ }
355
+ catch { }
356
+ try {
357
+ ContextLog.register(store);
358
+ }
359
+ catch { }
360
+ const messageStore = new MessageStore(store);
361
+ const contextLog = new ContextLog(store);
362
+ // Add a message
363
+ const msg = messageStore.append('User', [{ type: 'text', text: 'Original text' }]);
364
+ // Add context entry with 'referenced' relation
365
+ contextLog.append('User', [{ type: 'text', text: 'Mentions the original' }], msg.id, 'referenced');
366
+ // Edit the message in message store
367
+ messageStore.edit(msg.id, [{ type: 'text', text: 'Edited text' }]);
368
+ // Propagation logic - should NOT update referenced entries
369
+ const entries = contextLog.findBySource(msg.id);
370
+ for (const entry of entries) {
371
+ if (entry.sourceRelation === 'copy') {
372
+ contextLog.edit(entry.index, [{ type: 'text', text: 'Edited text' }]);
373
+ }
374
+ // 'referenced' entries are intentionally skipped
375
+ }
376
+ // Verify context log was NOT updated
377
+ const unchangedEntry = contextLog.get(0);
378
+ assert.ok(unchangedEntry);
379
+ assert.strictEqual(unchangedEntry.content[0].type, 'text');
380
+ if (unchangedEntry.content[0].type === 'text') {
381
+ assert.strictEqual(unchangedEntry.content[0].text, 'Mentions the original');
382
+ }
383
+ store.close();
384
+ rmSync(storePath, { recursive: true, force: true });
385
+ });
386
+ it('should handle mixed source relations correctly', async () => {
387
+ cleanup();
388
+ const storePath = './test-propagation-store-4';
389
+ if (existsSync(storePath)) {
390
+ rmSync(storePath, { recursive: true, force: true });
391
+ }
392
+ const store = JsStore.openOrCreate({ path: storePath });
393
+ try {
394
+ MessageStore.register(store);
395
+ }
396
+ catch { }
397
+ try {
398
+ ContextLog.register(store);
399
+ }
400
+ catch { }
401
+ const messageStore = new MessageStore(store);
402
+ const contextLog = new ContextLog(store);
403
+ // Add a message
404
+ const msg = messageStore.append('User', [{ type: 'text', text: 'Original text' }]);
405
+ // Add multiple context entries with different relations
406
+ contextLog.append('User', [{ type: 'text', text: 'Copy of original' }], msg.id, 'copy');
407
+ contextLog.append('Claude', [{ type: 'text', text: 'Summary of original' }], msg.id, 'derived');
408
+ contextLog.append('System', [{ type: 'text', text: 'References original' }], msg.id, 'referenced');
409
+ // Edit the message
410
+ messageStore.edit(msg.id, [{ type: 'text', text: 'Edited text' }]);
411
+ // Propagate (simulating ContextManager logic)
412
+ const entries = contextLog.findBySource(msg.id);
413
+ for (const entry of entries) {
414
+ if (entry.sourceRelation === 'copy') {
415
+ contextLog.edit(entry.index, [{ type: 'text', text: 'Edited text' }]);
416
+ }
417
+ }
418
+ // Verify: copy was updated, others were not
419
+ const copyEntry = contextLog.get(0);
420
+ const derivedEntry = contextLog.get(1);
421
+ const referencedEntry = contextLog.get(2);
422
+ assert.ok(copyEntry);
423
+ assert.ok(derivedEntry);
424
+ assert.ok(referencedEntry);
425
+ if (copyEntry.content[0].type === 'text') {
426
+ assert.strictEqual(copyEntry.content[0].text, 'Edited text', 'copy should be updated');
427
+ }
428
+ if (derivedEntry.content[0].type === 'text') {
429
+ assert.strictEqual(derivedEntry.content[0].text, 'Summary of original', 'derived should NOT be updated');
430
+ }
431
+ if (referencedEntry.content[0].type === 'text') {
432
+ assert.strictEqual(referencedEntry.content[0].text, 'References original', 'referenced should NOT be updated');
433
+ }
434
+ store.close();
435
+ rmSync(storePath, { recursive: true, force: true });
436
+ });
437
+ });
438
+ describe('Index Consistency', () => {
439
+ it('should maintain correct idToIndex mapping after removals', async () => {
440
+ cleanup();
441
+ const manager = await ContextManager.open({
442
+ path: TEST_STORE_PATH,
443
+ });
444
+ // Add multiple messages
445
+ const id1 = manager.addMessage('User', [{ type: 'text', text: 'First' }]);
446
+ const id2 = manager.addMessage('User', [{ type: 'text', text: 'Second' }]);
447
+ const id3 = manager.addMessage('User', [{ type: 'text', text: 'Third' }]);
448
+ assert.strictEqual(manager.stats().messageCount, 3);
449
+ // Remove the middle message
450
+ manager.removeMessage(id2);
451
+ assert.strictEqual(manager.stats().messageCount, 2);
452
+ // Verify we can still access the remaining messages by ID
453
+ const msg1 = manager.getMessage(id1);
454
+ const msg3 = manager.getMessage(id3);
455
+ assert.ok(msg1);
456
+ assert.ok(msg3);
457
+ if (msg1.content[0].type === 'text') {
458
+ assert.strictEqual(msg1.content[0].text, 'First');
459
+ }
460
+ if (msg3.content[0].type === 'text') {
461
+ assert.strictEqual(msg3.content[0].text, 'Third');
462
+ }
463
+ // Removed message should not be found
464
+ const msg2 = manager.getMessage(id2);
465
+ assert.strictEqual(msg2, null);
466
+ manager.close();
467
+ });
468
+ it('should rebuild source index after context log removals', async () => {
469
+ cleanup();
470
+ const storePath = './test-index-store';
471
+ if (existsSync(storePath)) {
472
+ rmSync(storePath, { recursive: true, force: true });
473
+ }
474
+ const store = JsStore.openOrCreate({ path: storePath });
475
+ try {
476
+ MessageStore.register(store);
477
+ }
478
+ catch { }
479
+ try {
480
+ ContextLog.register(store);
481
+ }
482
+ catch { }
483
+ const messageStore = new MessageStore(store);
484
+ const contextLog = new ContextLog(store);
485
+ // Add messages
486
+ const msg1 = messageStore.append('User', [{ type: 'text', text: 'First' }]);
487
+ const msg2 = messageStore.append('User', [{ type: 'text', text: 'Second' }]);
488
+ // Add context entries
489
+ contextLog.append('User', [{ type: 'text', text: 'First' }], msg1.id, 'copy');
490
+ contextLog.append('User', [{ type: 'text', text: 'Second' }], msg2.id, 'copy');
491
+ // Verify initial state
492
+ let entries1 = contextLog.findBySource(msg1.id);
493
+ let entries2 = contextLog.findBySource(msg2.id);
494
+ assert.strictEqual(entries1.length, 1);
495
+ assert.strictEqual(entries2.length, 1);
496
+ // Remove first entry
497
+ contextLog.remove(0);
498
+ // Source index should be rebuilt correctly
499
+ entries1 = contextLog.findBySource(msg1.id);
500
+ entries2 = contextLog.findBySource(msg2.id);
501
+ // After removal, msg1's entry is gone
502
+ assert.strictEqual(entries1.length, 0);
503
+ // msg2's entry still exists (though index may have changed)
504
+ // Note: After redaction, Chronicle shifts indices
505
+ assert.strictEqual(contextLog.length(), 1);
506
+ store.close();
507
+ rmSync(storePath, { recursive: true, force: true });
508
+ });
509
+ });
510
+ describe('Blob Storage', () => {
511
+ it('should store and retrieve blob content', async () => {
512
+ cleanup();
513
+ const storePath = './test-blob-store';
514
+ if (existsSync(storePath)) {
515
+ rmSync(storePath, { recursive: true, force: true });
516
+ }
517
+ const manager = await ContextManager.open({
518
+ path: storePath,
519
+ });
520
+ // Add a message with base64 image content
521
+ const imageData = Buffer.from('fake-image-data').toString('base64');
522
+ const id = manager.addMessage('User', [{
523
+ type: 'image',
524
+ source: {
525
+ type: 'base64',
526
+ data: imageData,
527
+ mediaType: 'image/png',
528
+ },
529
+ }]);
530
+ // Retrieve and verify
531
+ const message = manager.getMessage(id);
532
+ assert.ok(message);
533
+ assert.strictEqual(message.content.length, 1);
534
+ assert.strictEqual(message.content[0].type, 'image');
535
+ if (message.content[0].type === 'image' && message.content[0].source.type === 'base64') {
536
+ assert.strictEqual(message.content[0].source.data, imageData);
537
+ assert.strictEqual(message.content[0].source.mediaType, 'image/png');
538
+ }
539
+ manager.close();
540
+ rmSync(storePath, { recursive: true, force: true });
541
+ });
542
+ it('should deduplicate identical blobs', async () => {
543
+ cleanup();
544
+ const storePath = './test-blob-dedup';
545
+ if (existsSync(storePath)) {
546
+ rmSync(storePath, { recursive: true, force: true });
547
+ }
548
+ const store = JsStore.openOrCreate({ path: storePath });
549
+ try {
550
+ MessageStore.register(store);
551
+ }
552
+ catch { }
553
+ const messageStore = new MessageStore(store);
554
+ // Same image data
555
+ const imageData = Buffer.from('identical-image-data').toString('base64');
556
+ // Add two messages with identical images
557
+ messageStore.append('User', [{
558
+ type: 'image',
559
+ source: {
560
+ type: 'base64',
561
+ data: imageData,
562
+ mediaType: 'image/png',
563
+ },
564
+ }]);
565
+ messageStore.append('User', [{
566
+ type: 'image',
567
+ source: {
568
+ type: 'base64',
569
+ data: imageData,
570
+ mediaType: 'image/png',
571
+ },
572
+ }]);
573
+ // Check blob count - should be 1 due to deduplication (by hash)
574
+ const stats = store.stats();
575
+ assert.strictEqual(stats.blobCount, 1, 'Identical blobs should be deduplicated');
576
+ store.close();
577
+ rmSync(storePath, { recursive: true, force: true });
578
+ });
579
+ });
580
+ describe('Autobiographical Chunk Stability', () => {
581
+ it('should maintain chunk boundaries after adding more messages', async () => {
582
+ cleanup();
583
+ const storePath = './test-chunk-stability';
584
+ if (existsSync(storePath)) {
585
+ rmSync(storePath, { recursive: true, force: true });
586
+ }
587
+ // Use very small chunk size to trigger chunking with few messages
588
+ const strategy = new AutobiographicalStrategy({
589
+ targetChunkTokens: 100, // Very small - will trigger chunking quickly
590
+ recentWindowTokens: 200, // Small recent window
591
+ });
592
+ const manager = await ContextManager.open({
593
+ path: storePath,
594
+ strategy,
595
+ });
596
+ // Add enough messages to create at least one chunk outside recent window
597
+ // Each message ~25 tokens (100 chars / 4), need >100 tokens to chunk, >200 for outside recent
598
+ for (let i = 0; i < 20; i++) {
599
+ manager.addMessage('User', [{ type: 'text', text: `Message ${i}: ${'x'.repeat(100)}` }]);
600
+ manager.addMessage('Claude', [{ type: 'text', text: `Response ${i}: ${'y'.repeat(100)}` }]);
601
+ }
602
+ // Get initial message IDs
603
+ const initialMessages = manager.getAllMessages();
604
+ const initialIds = initialMessages.map(m => m.id);
605
+ // Compile to see initial state (this triggers rebuildChunks)
606
+ const initialCompiled = await manager.compile();
607
+ const initialCompiledCount = initialCompiled.messages.length;
608
+ // Add more messages
609
+ for (let i = 20; i < 30; i++) {
610
+ manager.addMessage('User', [{ type: 'text', text: `Message ${i}: ${'x'.repeat(100)}` }]);
611
+ manager.addMessage('Claude', [{ type: 'text', text: `Response ${i}: ${'y'.repeat(100)}` }]);
612
+ }
613
+ // Compile again
614
+ const secondCompiled = await manager.compile();
615
+ // The original messages should still have the same IDs
616
+ const afterMessages = manager.getAllMessages();
617
+ for (let i = 0; i < initialIds.length; i++) {
618
+ const found = afterMessages.find(m => m.id === initialIds[i]);
619
+ assert.ok(found, `Original message ${i} should still exist with same ID`);
620
+ }
621
+ // Should have more messages now
622
+ assert.ok(afterMessages.length > initialMessages.length);
623
+ manager.close();
624
+ rmSync(storePath, { recursive: true, force: true });
625
+ });
626
+ it('should preserve compressed chunks when adding new messages', async () => {
627
+ // This test verifies that compressed chunk summaries remain stable
628
+ // when new messages are added (the chunk key is based on message IDs)
629
+ cleanup();
630
+ const storePath = './test-chunk-preserve';
631
+ if (existsSync(storePath)) {
632
+ rmSync(storePath, { recursive: true, force: true });
633
+ }
634
+ // Without a real membrane, we can't actually compress, but we can verify
635
+ // the chunk boundary logic by checking that rebuildChunks preserves state
636
+ const strategy = new AutobiographicalStrategy({
637
+ targetChunkTokens: 100,
638
+ recentWindowTokens: 50,
639
+ });
640
+ const manager = await ContextManager.open({
641
+ path: storePath,
642
+ strategy,
643
+ });
644
+ // Add initial messages
645
+ const ids = [];
646
+ for (let i = 0; i < 10; i++) {
647
+ ids.push(manager.addMessage('User', [{ type: 'text', text: `Message ${i}: test content here` }]));
648
+ }
649
+ // First compile establishes chunk boundaries
650
+ await manager.compile();
651
+ // Add more messages
652
+ for (let i = 10; i < 15; i++) {
653
+ ids.push(manager.addMessage('User', [{ type: 'text', text: `Message ${i}: more content` }]));
654
+ }
655
+ // Second compile should maintain old chunk boundaries
656
+ await manager.compile();
657
+ // All original messages should still be accessible
658
+ for (let i = 0; i < 10; i++) {
659
+ const msg = manager.getMessage(ids[i]);
660
+ assert.ok(msg, `Message ${i} should still be accessible`);
661
+ }
662
+ manager.close();
663
+ rmSync(storePath, { recursive: true, force: true });
664
+ });
665
+ });
666
+ describe('App-Owned Store', () => {
667
+ it('should work with an app-provided store', async () => {
668
+ cleanup();
669
+ const storePath = './test-app-owned-store';
670
+ if (existsSync(storePath)) {
671
+ rmSync(storePath, { recursive: true, force: true });
672
+ }
673
+ // App creates and owns the store
674
+ const store = JsStore.openOrCreate({ path: storePath });
675
+ // App registers its own state (snapshot strategy for simple key-value)
676
+ store.registerState({ id: 'app-state', strategy: 'snapshot' });
677
+ // Pass to context manager
678
+ const manager = await ContextManager.open({
679
+ store,
680
+ strategy: new PassthroughStrategy(),
681
+ });
682
+ // Context manager works
683
+ manager.addMessage('User', [{ type: 'text', text: 'Hello' }]);
684
+ const { messages: compiled } = await manager.compile();
685
+ assert.strictEqual(compiled.length, 1);
686
+ // App can use its own state via the store
687
+ store.setStateJson('app-state', { lastTool: 'web_search' });
688
+ const appState = store.getStateJson('app-state');
689
+ assert.ok(appState);
690
+ assert.strictEqual(appState.lastTool, 'web_search');
691
+ // manager.close() should NOT close the store (app owns it)
692
+ manager.close();
693
+ assert.strictEqual(store.isClosed(), false);
694
+ // App closes the store when done
695
+ store.close();
696
+ assert.strictEqual(store.isClosed(), true);
697
+ rmSync(storePath, { recursive: true, force: true });
698
+ });
699
+ it('should allow access to store via getStore()', async () => {
700
+ cleanup();
701
+ const manager = await ContextManager.open({
702
+ path: TEST_STORE_PATH,
703
+ });
704
+ const store = manager.getStore();
705
+ assert.ok(store);
706
+ // Can register additional states
707
+ try {
708
+ store.registerState({ id: 'additional-state', strategy: 'snapshot' });
709
+ }
710
+ catch {
711
+ // May already exist
712
+ }
713
+ // manager.close() SHOULD close the store (manager owns it)
714
+ manager.close();
715
+ assert.strictEqual(store.isClosed(), true);
716
+ });
717
+ });
718
+ describe('Multi-Agent Namespacing', () => {
719
+ it('should support multiple agents sharing messages with separate context logs', async () => {
720
+ cleanup();
721
+ const storePath = './test-multi-agent';
722
+ if (existsSync(storePath)) {
723
+ rmSync(storePath, { recursive: true, force: true });
724
+ }
725
+ // Create shared store
726
+ const store = JsStore.openOrCreate({ path: storePath });
727
+ // Create two agents with different namespaces
728
+ const agentAlpha = await ContextManager.open({
729
+ store,
730
+ namespace: 'alpha',
731
+ strategy: new PassthroughStrategy(),
732
+ });
733
+ const agentBeta = await ContextManager.open({
734
+ store,
735
+ namespace: 'beta',
736
+ strategy: new PassthroughStrategy(),
737
+ });
738
+ // Add message via agent alpha
739
+ const msgId = agentAlpha.addMessage('User', [{ type: 'text', text: 'Hello agents!' }]);
740
+ // Both agents should see the same message (shared message store)
741
+ const alphaMessages = agentAlpha.getAllMessages();
742
+ const betaMessages = agentBeta.getAllMessages();
743
+ assert.strictEqual(alphaMessages.length, 1);
744
+ assert.strictEqual(betaMessages.length, 1);
745
+ assert.strictEqual(alphaMessages[0].id, betaMessages[0].id);
746
+ // Add message via agent beta
747
+ agentBeta.addMessage('Claude', [{ type: 'text', text: 'Hello from beta!' }]);
748
+ // Both should see both messages
749
+ assert.strictEqual(agentAlpha.getAllMessages().length, 2);
750
+ assert.strictEqual(agentBeta.getAllMessages().length, 2);
751
+ // Compile - each agent has its own context
752
+ const alphaCompiled = await agentAlpha.compile();
753
+ const betaCompiled = await agentBeta.compile();
754
+ // Both see the same messages in their compiled context
755
+ assert.strictEqual(alphaCompiled.messages.length, 2);
756
+ assert.strictEqual(betaCompiled.messages.length, 2);
757
+ // Context logs are separate - verified by the fact that both work independently
758
+ agentAlpha.close();
759
+ agentBeta.close();
760
+ store.close();
761
+ rmSync(storePath, { recursive: true, force: true });
762
+ });
763
+ it('should isolate context log entries between namespaced agents', async () => {
764
+ cleanup();
765
+ const storePath = './test-multi-agent-isolation';
766
+ if (existsSync(storePath)) {
767
+ rmSync(storePath, { recursive: true, force: true });
768
+ }
769
+ const store = JsStore.openOrCreate({ path: storePath });
770
+ // Use AutobiographicalStrategy with different settings per agent
771
+ const agentAlpha = await ContextManager.open({
772
+ store,
773
+ namespace: 'alpha',
774
+ strategy: new AutobiographicalStrategy({
775
+ targetChunkTokens: 100,
776
+ recentWindowTokens: 500,
777
+ }),
778
+ });
779
+ const agentBeta = await ContextManager.open({
780
+ store,
781
+ namespace: 'beta',
782
+ strategy: new AutobiographicalStrategy({
783
+ targetChunkTokens: 200,
784
+ recentWindowTokens: 1000,
785
+ }),
786
+ });
787
+ // Add shared messages
788
+ for (let i = 0; i < 5; i++) {
789
+ agentAlpha.addMessage('User', [{ type: 'text', text: `Message ${i}` }]);
790
+ }
791
+ // Both see all messages
792
+ assert.strictEqual(agentAlpha.getAllMessages().length, 5);
793
+ assert.strictEqual(agentBeta.getAllMessages().length, 5);
794
+ // Each compiles with its own strategy settings
795
+ const alphaCompiled = await agentAlpha.compile();
796
+ const betaCompiled = await agentBeta.compile();
797
+ // Both should compile successfully
798
+ assert.ok(alphaCompiled.messages.length > 0);
799
+ assert.ok(betaCompiled.messages.length > 0);
800
+ agentAlpha.close();
801
+ agentBeta.close();
802
+ store.close();
803
+ rmSync(storePath, { recursive: true, force: true });
804
+ });
805
+ });
806
+ describe('Compression Failure Recovery', () => {
807
+ it('should handle LLM failures gracefully and allow retry', async () => {
808
+ cleanup();
809
+ const storePath = './test-compression-fail';
810
+ if (existsSync(storePath)) {
811
+ rmSync(storePath, { recursive: true, force: true });
812
+ }
813
+ // Mock membrane that tracks calls and can be configured to fail
814
+ let callCount = 0;
815
+ let shouldFail = true;
816
+ const mockMembrane = {
817
+ complete: async () => {
818
+ callCount++;
819
+ if (shouldFail) {
820
+ throw new Error('Simulated LLM failure');
821
+ }
822
+ return {
823
+ content: [{ type: 'text', text: 'Summary of the conversation chunk' }],
824
+ };
825
+ },
826
+ };
827
+ const strategy = new AutobiographicalStrategy({
828
+ targetChunkTokens: 100,
829
+ recentWindowTokens: 50,
830
+ });
831
+ const manager = await ContextManager.open({
832
+ path: storePath,
833
+ strategy,
834
+ membrane: mockMembrane,
835
+ });
836
+ // Add enough messages to create a chunk that needs compression
837
+ for (let i = 0; i < 15; i++) {
838
+ manager.addMessage('User', [{ type: 'text', text: `Message ${i}: ${'x'.repeat(50)}` }]);
839
+ }
840
+ // Strategy should not be ready (has pending compression)
841
+ // Note: It might be ready if no chunks were formed yet, so we compile first
842
+ await manager.compile();
843
+ // Try tick which attempts compression - should fail
844
+ if (!manager.isReady()) {
845
+ try {
846
+ await manager.tick();
847
+ }
848
+ catch (e) {
849
+ // Expected to fail
850
+ assert.ok(e instanceof Error);
851
+ assert.strictEqual(e.message, 'Simulated LLM failure');
852
+ }
853
+ }
854
+ // System should still be functional
855
+ const messages = manager.getAllMessages();
856
+ assert.strictEqual(messages.length, 15);
857
+ // Can still compile (uses uncompressed chunks + recent window)
858
+ const compiled = await manager.compile();
859
+ assert.ok(compiled.messages.length > 0);
860
+ // Now allow success
861
+ shouldFail = false;
862
+ // Retry compression
863
+ if (!manager.isReady()) {
864
+ await manager.tick();
865
+ }
866
+ // Should still work
867
+ const recompiled = await manager.compile();
868
+ assert.ok(recompiled.messages.length > 0);
869
+ manager.close();
870
+ rmSync(storePath, { recursive: true, force: true });
871
+ });
872
+ it('should not mark chunk as compressed after failure', async () => {
873
+ cleanup();
874
+ const storePath = './test-compression-state';
875
+ if (existsSync(storePath)) {
876
+ rmSync(storePath, { recursive: true, force: true });
877
+ }
878
+ // Mock membrane that always fails
879
+ const mockMembrane = {
880
+ complete: async () => {
881
+ throw new Error('Always fails');
882
+ },
883
+ };
884
+ const strategy = new AutobiographicalStrategy({
885
+ targetChunkTokens: 50,
886
+ recentWindowTokens: 25,
887
+ });
888
+ const manager = await ContextManager.open({
889
+ path: storePath,
890
+ strategy,
891
+ membrane: mockMembrane,
892
+ });
893
+ // Add messages
894
+ for (let i = 0; i < 10; i++) {
895
+ manager.addMessage('User', [{ type: 'text', text: `Message ${i}` }]);
896
+ }
897
+ // Compile to trigger chunk analysis
898
+ await manager.compile();
899
+ // Try tick multiple times - should keep failing but not corrupt state
900
+ for (let i = 0; i < 3; i++) {
901
+ if (!manager.isReady()) {
902
+ try {
903
+ await manager.tick();
904
+ }
905
+ catch {
906
+ // Expected
907
+ }
908
+ }
909
+ }
910
+ // Messages should still be intact
911
+ const messages = manager.getAllMessages();
912
+ assert.strictEqual(messages.length, 10);
913
+ // Compile should still work (falls back to uncompressed)
914
+ const compiled = await manager.compile();
915
+ assert.ok(compiled.messages.length > 0);
916
+ manager.close();
917
+ rmSync(storePath, { recursive: true, force: true });
918
+ });
919
+ });
920
+ describe('Context Injection Merging', () => {
921
+ it('should return empty systemInjections when no injections provided', async () => {
922
+ cleanup();
923
+ const manager = await ContextManager.open({
924
+ path: TEST_STORE_PATH,
925
+ strategy: new PassthroughStrategy(),
926
+ });
927
+ manager.addMessage('User', [{ type: 'text', text: 'Hello' }]);
928
+ manager.addMessage('Claude', [{ type: 'text', text: 'Hi!' }]);
929
+ const result = await manager.compile();
930
+ assert.strictEqual(result.messages.length, 2);
931
+ assert.strictEqual(result.systemInjections.length, 0);
932
+ });
933
+ it('should return empty systemInjections when injections array is empty', async () => {
934
+ cleanup();
935
+ const manager = await ContextManager.open({
936
+ path: TEST_STORE_PATH,
937
+ strategy: new PassthroughStrategy(),
938
+ });
939
+ manager.addMessage('User', [{ type: 'text', text: 'Hello' }]);
940
+ const result = await manager.compile(undefined, []);
941
+ assert.strictEqual(result.messages.length, 1);
942
+ assert.strictEqual(result.systemInjections.length, 0);
943
+ });
944
+ it('should separate system-position injections into systemInjections', async () => {
945
+ cleanup();
946
+ const manager = await ContextManager.open({
947
+ path: TEST_STORE_PATH,
948
+ strategy: new PassthroughStrategy(),
949
+ });
950
+ manager.addMessage('User', [{ type: 'text', text: 'Hello' }]);
951
+ manager.addMessage('Claude', [{ type: 'text', text: 'Hi!' }]);
952
+ const injections = [
953
+ {
954
+ namespace: 'memory',
955
+ position: 'system',
956
+ content: [{ type: 'text', text: '<memories>User likes cats</memories>' }],
957
+ metadata: { memoryIds: ['mem_1'] },
958
+ },
959
+ ];
960
+ const result = await manager.compile(undefined, injections);
961
+ // System injections should be separated out
962
+ assert.strictEqual(result.systemInjections.length, 1);
963
+ assert.strictEqual(result.systemInjections[0].type, 'text');
964
+ if (result.systemInjections[0].type === 'text') {
965
+ assert.ok(result.systemInjections[0].text.includes('User likes cats'));
966
+ }
967
+ // Messages should be unchanged (no system injection in messages array)
968
+ assert.strictEqual(result.messages.length, 2);
969
+ assert.strictEqual(result.messages[0].participant, 'User');
970
+ assert.strictEqual(result.messages[1].participant, 'Claude');
971
+ });
972
+ it('should insert beforeUser injections before last user message', async () => {
973
+ cleanup();
974
+ const manager = await ContextManager.open({
975
+ path: TEST_STORE_PATH,
976
+ strategy: new PassthroughStrategy(),
977
+ });
978
+ manager.addMessage('User', [{ type: 'text', text: 'First question' }]);
979
+ manager.addMessage('Claude', [{ type: 'text', text: 'First answer' }]);
980
+ manager.addMessage('User', [{ type: 'text', text: 'Second question' }]);
981
+ const injections = [
982
+ {
983
+ namespace: 'rag',
984
+ position: 'beforeUser',
985
+ content: [{ type: 'text', text: 'Relevant document excerpt...' }],
986
+ },
987
+ ];
988
+ const result = await manager.compile(undefined, injections);
989
+ // Should be: User, Claude, injection:rag, User
990
+ assert.strictEqual(result.messages.length, 4);
991
+ assert.strictEqual(result.messages[0].participant, 'User');
992
+ assert.strictEqual(result.messages[1].participant, 'Claude');
993
+ assert.strictEqual(result.messages[2].participant, 'injection:rag');
994
+ assert.strictEqual(result.messages[3].participant, 'User');
995
+ // Verify injection content
996
+ if (result.messages[2].content[0].type === 'text') {
997
+ assert.strictEqual(result.messages[2].content[0].text, 'Relevant document excerpt...');
998
+ }
999
+ });
1000
+ it('should insert afterUser injections after last user message', async () => {
1001
+ cleanup();
1002
+ const manager = await ContextManager.open({
1003
+ path: TEST_STORE_PATH,
1004
+ strategy: new PassthroughStrategy(),
1005
+ });
1006
+ manager.addMessage('User', [{ type: 'text', text: 'First question' }]);
1007
+ manager.addMessage('Claude', [{ type: 'text', text: 'First answer' }]);
1008
+ manager.addMessage('User', [{ type: 'text', text: 'Second question' }]);
1009
+ const injections = [
1010
+ {
1011
+ namespace: 'context',
1012
+ position: 'afterUser',
1013
+ content: [{ type: 'text', text: 'Additional context after user input' }],
1014
+ },
1015
+ ];
1016
+ const result = await manager.compile(undefined, injections);
1017
+ // Should be: User, Claude, User, injection:context
1018
+ assert.strictEqual(result.messages.length, 4);
1019
+ assert.strictEqual(result.messages[0].participant, 'User');
1020
+ assert.strictEqual(result.messages[1].participant, 'Claude');
1021
+ assert.strictEqual(result.messages[2].participant, 'User');
1022
+ assert.strictEqual(result.messages[3].participant, 'injection:context');
1023
+ });
1024
+ it('should handle all three injection positions together', async () => {
1025
+ cleanup();
1026
+ const manager = await ContextManager.open({
1027
+ path: TEST_STORE_PATH,
1028
+ strategy: new PassthroughStrategy(),
1029
+ });
1030
+ manager.addMessage('User', [{ type: 'text', text: 'Hello' }]);
1031
+ manager.addMessage('Claude', [{ type: 'text', text: 'Hi!' }]);
1032
+ manager.addMessage('User', [{ type: 'text', text: 'How are you?' }]);
1033
+ const injections = [
1034
+ {
1035
+ namespace: 'memory',
1036
+ position: 'system',
1037
+ content: [{ type: 'text', text: 'System memory' }],
1038
+ },
1039
+ {
1040
+ namespace: 'rag',
1041
+ position: 'beforeUser',
1042
+ content: [{ type: 'text', text: 'Before user context' }],
1043
+ },
1044
+ {
1045
+ namespace: 'tools',
1046
+ position: 'afterUser',
1047
+ content: [{ type: 'text', text: 'After user context' }],
1048
+ },
1049
+ ];
1050
+ const result = await manager.compile(undefined, injections);
1051
+ // System injections separated out
1052
+ assert.strictEqual(result.systemInjections.length, 1);
1053
+ if (result.systemInjections[0].type === 'text') {
1054
+ assert.strictEqual(result.systemInjections[0].text, 'System memory');
1055
+ }
1056
+ // Messages: User, Claude, injection:rag, User, injection:tools
1057
+ assert.strictEqual(result.messages.length, 5);
1058
+ assert.strictEqual(result.messages[0].participant, 'User');
1059
+ assert.strictEqual(result.messages[1].participant, 'Claude');
1060
+ assert.strictEqual(result.messages[2].participant, 'injection:rag');
1061
+ assert.strictEqual(result.messages[3].participant, 'User');
1062
+ assert.strictEqual(result.messages[4].participant, 'injection:tools');
1063
+ });
1064
+ it('should handle multiple injections at the same position', async () => {
1065
+ cleanup();
1066
+ const manager = await ContextManager.open({
1067
+ path: TEST_STORE_PATH,
1068
+ strategy: new PassthroughStrategy(),
1069
+ });
1070
+ manager.addMessage('User', [{ type: 'text', text: 'Question' }]);
1071
+ const injections = [
1072
+ {
1073
+ namespace: 'memory',
1074
+ position: 'beforeUser',
1075
+ content: [{ type: 'text', text: 'Memory injection' }],
1076
+ },
1077
+ {
1078
+ namespace: 'rag',
1079
+ position: 'beforeUser',
1080
+ content: [{ type: 'text', text: 'RAG injection' }],
1081
+ },
1082
+ {
1083
+ namespace: 'persona',
1084
+ position: 'system',
1085
+ content: [{ type: 'text', text: 'Persona context' }],
1086
+ },
1087
+ {
1088
+ namespace: 'compliance',
1089
+ position: 'system',
1090
+ content: [{ type: 'text', text: 'Compliance rules' }],
1091
+ },
1092
+ ];
1093
+ const result = await manager.compile(undefined, injections);
1094
+ // Two system injections (flattened into content blocks)
1095
+ assert.strictEqual(result.systemInjections.length, 2);
1096
+ // Messages: injection:memory, injection:rag, User (both beforeUser injections before user)
1097
+ assert.strictEqual(result.messages.length, 3);
1098
+ assert.strictEqual(result.messages[0].participant, 'injection:memory');
1099
+ assert.strictEqual(result.messages[1].participant, 'injection:rag');
1100
+ assert.strictEqual(result.messages[2].participant, 'User');
1101
+ });
1102
+ it('should handle injections when there is no user message', async () => {
1103
+ cleanup();
1104
+ const manager = await ContextManager.open({
1105
+ path: TEST_STORE_PATH,
1106
+ strategy: new PassthroughStrategy(),
1107
+ });
1108
+ // Only assistant messages, no user message
1109
+ manager.addMessage('Claude', [{ type: 'text', text: 'Thinking aloud...' }]);
1110
+ const injections = [
1111
+ {
1112
+ namespace: 'memory',
1113
+ position: 'system',
1114
+ content: [{ type: 'text', text: 'System context' }],
1115
+ },
1116
+ {
1117
+ namespace: 'rag',
1118
+ position: 'beforeUser',
1119
+ content: [{ type: 'text', text: 'Before user (no user to anchor)' }],
1120
+ },
1121
+ {
1122
+ namespace: 'tools',
1123
+ position: 'afterUser',
1124
+ content: [{ type: 'text', text: 'After user (no user to anchor)' }],
1125
+ },
1126
+ ];
1127
+ const result = await manager.compile(undefined, injections);
1128
+ // System injection works regardless
1129
+ assert.strictEqual(result.systemInjections.length, 1);
1130
+ // beforeUser has no user message to anchor to — should be skipped
1131
+ // afterUser has no user message — should append at end
1132
+ assert.strictEqual(result.messages.length, 2);
1133
+ assert.strictEqual(result.messages[0].participant, 'Claude');
1134
+ assert.strictEqual(result.messages[1].participant, 'injection:tools');
1135
+ });
1136
+ it('should handle injections with multimodal content', async () => {
1137
+ cleanup();
1138
+ const manager = await ContextManager.open({
1139
+ path: TEST_STORE_PATH,
1140
+ strategy: new PassthroughStrategy(),
1141
+ });
1142
+ manager.addMessage('User', [{ type: 'text', text: 'Look at this' }]);
1143
+ const injections = [
1144
+ {
1145
+ namespace: 'vision',
1146
+ position: 'beforeUser',
1147
+ content: [
1148
+ { type: 'text', text: 'Here is relevant visual context:' },
1149
+ {
1150
+ type: 'image',
1151
+ source: {
1152
+ type: 'base64',
1153
+ data: 'iVBORw0KGgo=',
1154
+ mediaType: 'image/png',
1155
+ },
1156
+ },
1157
+ ],
1158
+ },
1159
+ ];
1160
+ const result = await manager.compile(undefined, injections);
1161
+ assert.strictEqual(result.messages.length, 2);
1162
+ assert.strictEqual(result.messages[0].participant, 'injection:vision');
1163
+ assert.strictEqual(result.messages[0].content.length, 2);
1164
+ assert.strictEqual(result.messages[0].content[0].type, 'text');
1165
+ assert.strictEqual(result.messages[0].content[1].type, 'image');
1166
+ });
1167
+ it('should preserve injection order within same position', async () => {
1168
+ cleanup();
1169
+ const manager = await ContextManager.open({
1170
+ path: TEST_STORE_PATH,
1171
+ strategy: new PassthroughStrategy(),
1172
+ });
1173
+ manager.addMessage('User', [{ type: 'text', text: 'Question' }]);
1174
+ const injections = [
1175
+ {
1176
+ namespace: 'first',
1177
+ position: 'afterUser',
1178
+ content: [{ type: 'text', text: 'First after' }],
1179
+ },
1180
+ {
1181
+ namespace: 'second',
1182
+ position: 'afterUser',
1183
+ content: [{ type: 'text', text: 'Second after' }],
1184
+ },
1185
+ {
1186
+ namespace: 'third',
1187
+ position: 'afterUser',
1188
+ content: [{ type: 'text', text: 'Third after' }],
1189
+ },
1190
+ ];
1191
+ const result = await manager.compile(undefined, injections);
1192
+ // Order should be preserved: User, first, second, third
1193
+ assert.strictEqual(result.messages.length, 4);
1194
+ assert.strictEqual(result.messages[1].participant, 'injection:first');
1195
+ assert.strictEqual(result.messages[2].participant, 'injection:second');
1196
+ assert.strictEqual(result.messages[3].participant, 'injection:third');
1197
+ });
1198
+ it('should handle case-insensitive user participant matching', async () => {
1199
+ cleanup();
1200
+ const manager = await ContextManager.open({
1201
+ path: TEST_STORE_PATH,
1202
+ strategy: new PassthroughStrategy(),
1203
+ });
1204
+ // Different casing of "user"
1205
+ manager.addMessage('user', [{ type: 'text', text: 'lowercase user' }]);
1206
+ const injections = [
1207
+ {
1208
+ namespace: 'test',
1209
+ position: 'beforeUser',
1210
+ content: [{ type: 'text', text: 'Injected before' }],
1211
+ },
1212
+ ];
1213
+ const result = await manager.compile(undefined, injections);
1214
+ // Should still find the user message despite lowercase
1215
+ assert.strictEqual(result.messages.length, 2);
1216
+ assert.strictEqual(result.messages[0].participant, 'injection:test');
1217
+ assert.strictEqual(result.messages[1].participant, 'user');
1218
+ });
1219
+ });
1220
+ describe('Tool Pair Integrity', () => {
1221
+ it('should not split tool_use/tool_result across head window boundary', async () => {
1222
+ cleanup();
1223
+ const manager = await ContextManager.open({
1224
+ path: TEST_STORE_PATH,
1225
+ strategy: new AutobiographicalStrategy({
1226
+ headWindowTokens: 200,
1227
+ recentWindowTokens: 50,
1228
+ }),
1229
+ });
1230
+ // Small messages that fit in the head window
1231
+ manager.addMessage('user', [{ type: 'text', text: 'Hello' }]);
1232
+ manager.addMessage('assistant', [{ type: 'text', text: 'Hi there' }]);
1233
+ manager.addMessage('user', [{ type: 'text', text: 'Do a search' }]);
1234
+ // Assistant with tool_use — fits in head window
1235
+ manager.addMessage('assistant', [
1236
+ { type: 'text', text: 'Sure' },
1237
+ { type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'test' } },
1238
+ ]);
1239
+ // Huge tool_result — does NOT fit in head window, pushes over budget
1240
+ manager.addMessage('user', [
1241
+ { type: 'tool_result', toolUseId: 'call_1', content: 'x'.repeat(5000) },
1242
+ ]);
1243
+ const { messages } = await manager.compile();
1244
+ // The head window must NOT include the tool_use without the tool_result.
1245
+ // Check that no message in the output has a tool_use block without a
1246
+ // subsequent tool_result message.
1247
+ for (let i = 0; i < messages.length; i++) {
1248
+ const hasToolUse = messages[i].content.some(b => b.type === 'tool_use');
1249
+ if (hasToolUse) {
1250
+ // Next message must have tool_result
1251
+ assert.ok(i + 1 < messages.length, 'tool_use message is not the last message');
1252
+ const nextHasToolResult = messages[i + 1].content.some(b => b.type === 'tool_result');
1253
+ assert.ok(nextHasToolResult, 'tool_use must be followed by tool_result');
1254
+ }
1255
+ }
1256
+ });
1257
+ it('should not split tool_use/tool_result across recent window boundary', async () => {
1258
+ cleanup();
1259
+ const manager = await ContextManager.open({
1260
+ path: TEST_STORE_PATH,
1261
+ strategy: new AutobiographicalStrategy({
1262
+ headWindowTokens: 50,
1263
+ recentWindowTokens: 300,
1264
+ }),
1265
+ });
1266
+ // Enough messages to push the tool pair out of the head window
1267
+ for (let i = 0; i < 6; i++) {
1268
+ manager.addMessage(i % 2 === 0 ? 'user' : 'assistant', [
1269
+ { type: 'text', text: `Msg ${i}: ${'y'.repeat(100)}` },
1270
+ ]);
1271
+ }
1272
+ // Assistant with tool_use
1273
+ manager.addMessage('assistant', [
1274
+ { type: 'text', text: 'Let me check' },
1275
+ { type: 'tool_use', id: 'call_2', name: 'lookup', input: {} },
1276
+ ]);
1277
+ // tool_result
1278
+ manager.addMessage('user', [
1279
+ { type: 'tool_result', toolUseId: 'call_2', content: 'Result data here' },
1280
+ ]);
1281
+ // A follow-up
1282
+ manager.addMessage('assistant', [{ type: 'text', text: 'Based on that...' }]);
1283
+ manager.addMessage('user', [{ type: 'text', text: 'Thanks' }]);
1284
+ const { messages } = await manager.compile();
1285
+ // Validate no orphaned tool_use or tool_result in the output
1286
+ for (let i = 0; i < messages.length; i++) {
1287
+ const hasToolUse = messages[i].content.some(b => b.type === 'tool_use');
1288
+ if (hasToolUse) {
1289
+ assert.ok(i + 1 < messages.length, 'tool_use message is not the last message');
1290
+ const nextHasToolResult = messages[i + 1].content.some(b => b.type === 'tool_result');
1291
+ assert.ok(nextHasToolResult, 'tool_use must be followed by tool_result');
1292
+ }
1293
+ const hasToolResult = messages[i].content.some(b => b.type === 'tool_result');
1294
+ if (hasToolResult) {
1295
+ assert.ok(i > 0, 'tool_result is not the first message');
1296
+ const prevHasToolUse = messages[i - 1].content.some(b => b.type === 'tool_use');
1297
+ assert.ok(prevHasToolUse, 'tool_result must be preceded by tool_use');
1298
+ }
1299
+ }
1300
+ });
1301
+ it('should drop oversized tool_result along with its tool_use', async () => {
1302
+ // This reproduces the exact zulip-app bug: a massive tool_result
1303
+ // that exceeds both head and recent window budgets.
1304
+ cleanup();
1305
+ const manager = await ContextManager.open({
1306
+ path: TEST_STORE_PATH,
1307
+ strategy: new AutobiographicalStrategy({
1308
+ headWindowTokens: 200,
1309
+ recentWindowTokens: 200,
1310
+ }),
1311
+ });
1312
+ manager.addMessage('user', [{ type: 'text', text: 'Hi' }]);
1313
+ manager.addMessage('assistant', [{ type: 'text', text: 'Hello' }]);
1314
+ manager.addMessage('user', [{ type: 'text', text: 'List streams' }]);
1315
+ manager.addMessage('assistant', [
1316
+ { type: 'text', text: 'Sure' },
1317
+ { type: 'tool_use', id: 'call_big', name: 'list_streams', input: {} },
1318
+ ]);
1319
+ // Oversized tool_result: exceeds both windows
1320
+ manager.addMessage('user', [
1321
+ { type: 'tool_result', toolUseId: 'call_big', content: 'x'.repeat(10000) },
1322
+ ]);
1323
+ const { messages } = await manager.compile();
1324
+ // The compiled output must be valid: no orphaned tool_use or tool_result
1325
+ for (let i = 0; i < messages.length; i++) {
1326
+ const hasToolUse = messages[i].content.some(b => b.type === 'tool_use');
1327
+ if (hasToolUse) {
1328
+ assert.ok(i + 1 < messages.length &&
1329
+ messages[i + 1].content.some(b => b.type === 'tool_result'), `Orphaned tool_use at index ${i}`);
1330
+ }
1331
+ const hasToolResult = messages[i].content.some(b => b.type === 'tool_result');
1332
+ if (hasToolResult) {
1333
+ assert.ok(i > 0 &&
1334
+ messages[i - 1].content.some(b => b.type === 'tool_use'), `Orphaned tool_result at index ${i}`);
1335
+ }
1336
+ }
1337
+ });
1338
+ });
1339
+ });
1340
+ // Run with: node --test dist/test/integration.test.js
1341
+ //# sourceMappingURL=integration.test.js.map