@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.
- package/dist/src/blob-manager.d.ts +37 -0
- package/dist/src/blob-manager.d.ts.map +1 -0
- package/dist/src/blob-manager.js +128 -0
- package/dist/src/blob-manager.js.map +1 -0
- package/dist/src/context-log.d.ts +99 -0
- package/dist/src/context-log.d.ts.map +1 -0
- package/dist/src/context-log.js +277 -0
- package/dist/src/context-log.js.map +1 -0
- package/dist/src/context-manager.d.ts +245 -0
- package/dist/src/context-manager.d.ts.map +1 -0
- package/dist/src/context-manager.js +553 -0
- package/dist/src/context-manager.js.map +1 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +12 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/message-store.d.ts +135 -0
- package/dist/src/message-store.d.ts.map +1 -0
- package/dist/src/message-store.js +372 -0
- package/dist/src/message-store.js.map +1 -0
- package/dist/src/strategies/autobiographical.d.ts +199 -0
- package/dist/src/strategies/autobiographical.d.ts.map +1 -0
- package/dist/src/strategies/autobiographical.js +1122 -0
- package/dist/src/strategies/autobiographical.js.map +1 -0
- package/dist/src/strategies/index.d.ts +3 -0
- package/dist/src/strategies/index.d.ts.map +1 -0
- package/dist/src/strategies/index.js +3 -0
- package/dist/src/strategies/index.js.map +1 -0
- package/dist/src/strategies/knowledge.d.ts +46 -0
- package/dist/src/strategies/knowledge.d.ts.map +1 -0
- package/dist/src/strategies/knowledge.js +270 -0
- package/dist/src/strategies/knowledge.js.map +1 -0
- package/dist/src/strategies/passthrough.d.ts +17 -0
- package/dist/src/strategies/passthrough.d.ts.map +1 -0
- package/dist/src/strategies/passthrough.js +69 -0
- package/dist/src/strategies/passthrough.js.map +1 -0
- package/dist/src/types/context.d.ts +108 -0
- package/dist/src/types/context.d.ts.map +1 -0
- package/dist/src/types/context.js +2 -0
- package/dist/src/types/context.js.map +1 -0
- package/dist/src/types/index.d.ts +5 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/index.js +2 -0
- package/dist/src/types/index.js.map +1 -0
- package/dist/src/types/message.d.ts +129 -0
- package/dist/src/types/message.d.ts.map +1 -0
- package/dist/src/types/message.js +2 -0
- package/dist/src/types/message.js.map +1 -0
- package/dist/src/types/strategy.d.ts +233 -0
- package/dist/src/types/strategy.d.ts.map +1 -0
- package/dist/src/types/strategy.js +32 -0
- package/dist/src/types/strategy.js.map +1 -0
- package/dist/test/autobiographical.test.d.ts +2 -0
- package/dist/test/autobiographical.test.d.ts.map +1 -0
- package/dist/test/autobiographical.test.js +46 -0
- package/dist/test/autobiographical.test.js.map +1 -0
- package/dist/test/head-window-reset.test.d.ts +17 -0
- package/dist/test/head-window-reset.test.d.ts.map +1 -0
- package/dist/test/head-window-reset.test.js +342 -0
- package/dist/test/head-window-reset.test.js.map +1 -0
- package/dist/test/integration.test.d.ts +2 -0
- package/dist/test/integration.test.d.ts.map +1 -0
- package/dist/test/integration.test.js +1341 -0
- package/dist/test/integration.test.js.map +1 -0
- package/dist/test/knowledge.test.d.ts +2 -0
- package/dist/test/knowledge.test.d.ts.map +1 -0
- package/dist/test/knowledge.test.js +617 -0
- package/dist/test/knowledge.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +48 -0
- package/src/blob-manager.ts +155 -0
- package/src/context-log.ts +342 -0
- package/src/context-manager.ts +726 -0
- package/src/index.ts +50 -0
- package/src/message-store.ts +479 -0
- package/src/strategies/autobiographical.ts +1355 -0
- package/src/strategies/index.ts +2 -0
- package/src/strategies/knowledge.ts +336 -0
- package/src/strategies/passthrough.ts +98 -0
- package/src/types/context.ts +119 -0
- package/src/types/index.ts +42 -0
- package/src/types/message.ts +140 -0
- 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
|