@xandout/libra-harness 0.1.127 → 0.1.129

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 (34) hide show
  1. package/README.md +13 -114
  2. package/dist/extensions/code-tools/tools/shell.d.ts.map +1 -1
  3. package/dist/extensions/code-tools/tools/shell.js +8 -0
  4. package/dist/extensions/code-tools/tools/shell.js.map +1 -1
  5. package/dist/extensions/code-tools/tools/task-wrapper.sh +18 -0
  6. package/package.json +12 -68
  7. package/dist/extensions/code-tools/extension.test.d.ts +0 -2
  8. package/dist/extensions/code-tools/extension.test.d.ts.map +0 -1
  9. package/dist/extensions/code-tools/extension.test.js +0 -535
  10. package/dist/extensions/code-tools/extension.test.js.map +0 -1
  11. package/dist/extensions/disk-session/extension.test.d.ts +0 -2
  12. package/dist/extensions/disk-session/extension.test.d.ts.map +0 -1
  13. package/dist/extensions/disk-session/extension.test.js +0 -1101
  14. package/dist/extensions/disk-session/extension.test.js.map +0 -1
  15. package/dist/extensions/index.d.ts +0 -3
  16. package/dist/extensions/index.d.ts.map +0 -1
  17. package/dist/extensions/index.js +0 -2
  18. package/dist/extensions/index.js.map +0 -1
  19. package/dist/extensions/loader.d.ts +0 -205
  20. package/dist/extensions/loader.d.ts.map +0 -1
  21. package/dist/extensions/loader.js +0 -315
  22. package/dist/extensions/loader.js.map +0 -1
  23. package/dist/extensions/skills/extension.test.d.ts +0 -2
  24. package/dist/extensions/skills/extension.test.d.ts.map +0 -1
  25. package/dist/extensions/skills/extension.test.js +0 -184
  26. package/dist/extensions/skills/extension.test.js.map +0 -1
  27. package/dist/openai-provider/index.d.ts +0 -3
  28. package/dist/openai-provider/index.d.ts.map +0 -1
  29. package/dist/openai-provider/index.js +0 -2
  30. package/dist/openai-provider/index.js.map +0 -1
  31. package/dist/openai-provider/server.d.ts +0 -34
  32. package/dist/openai-provider/server.d.ts.map +0 -1
  33. package/dist/openai-provider/server.js +0 -335
  34. package/dist/openai-provider/server.js.map +0 -1
@@ -1,1101 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { tmpdir } from 'node:os';
5
- import { Agent, messageContentToText } from '@xandout/libra-harness';
6
- import { createDiskSessionExtension, sanitizeConversationMessages } from './index.js';
7
- // ── Mock model ─────────────────────────────────────────────────────
8
- // Returns a fixed assistant message. Optionally logs what the agent sees.
9
- function mockModel(seen) {
10
- return {
11
- async generate(req) {
12
- if (seen)
13
- seen(req.messages);
14
- return {
15
- message: { role: 'assistant', content: 'reply' },
16
- finishReason: 'stop',
17
- usage: { promptTokens: 0, completionTokens: 0 },
18
- };
19
- },
20
- };
21
- }
22
- // ── Helpers ────────────────────────────────────────────────────────
23
- function sessionIdentity(key, messageTs, opts = {}) {
24
- return {
25
- key,
26
- messageTs,
27
- threadTs: opts.threadTs,
28
- isDirect: opts.isDirect,
29
- };
30
- }
31
- function runTurn(agent, message, identity) {
32
- return agent.run({ message, metadata: { session: identity } });
33
- }
34
- // ── Test setup ─────────────────────────────────────────────────────
35
- let tmpDir;
36
- beforeEach(() => {
37
- tmpDir = mkdtempSync(join(tmpdir(), 'disk-session-test-'));
38
- });
39
- afterEach(() => {
40
- rmSync(tmpDir, { recursive: true, force: true });
41
- });
42
- function makeExt(opts) {
43
- return createDiskSessionExtension({ sessionDir: tmpDir, ...opts });
44
- }
45
- function makeAgent(ext, seen) {
46
- const agent = new Agent({ model: mockModel(seen) });
47
- agent.use(ext);
48
- return agent;
49
- }
50
- function readJsonl(sessionKey) {
51
- const path = join(tmpDir, `${sessionKey}.jsonl`);
52
- if (!existsSync(path))
53
- return [];
54
- return readFileSync(path, 'utf-8')
55
- .split('\n')
56
- .filter((l) => l.trim())
57
- .map((l) => JSON.parse(l));
58
- }
59
- // ═══════════════════════════════════════════════════════════════════
60
- // Tests
61
- // ═══════════════════════════════════════════════════════════════════
62
- describe('disk-session', () => {
63
- // ── Basic persistence ───────────────────────────────────────────
64
- it('persists user and assistant messages to JSONL after a turn', async () => {
65
- const ext = makeExt();
66
- const agent = makeAgent(ext);
67
- await runTurn(agent, 'hello', sessionIdentity('C1', '1001'));
68
- const records = ext.getRecords('C1');
69
- expect(records).toHaveLength(2);
70
- expect(records[0].role).toBe('user');
71
- expect(records[0].content).toBe('hello');
72
- expect(records[1].role).toBe('assistant');
73
- expect(records[1].content).toBe('reply');
74
- const fileRecords = readJsonl('C1');
75
- expect(fileRecords).toHaveLength(2);
76
- expect(fileRecords[0].content).toBe('hello');
77
- });
78
- it('persists user message to disk immediately (before agent runs)', async () => {
79
- const ext = makeExt();
80
- // Model that never resolves — simulates a crash mid-turn
81
- const hangingAgent = new Agent({
82
- model: {
83
- async generate() {
84
- return new Promise(() => { }); // never resolves
85
- },
86
- },
87
- });
88
- hangingAgent.use(ext);
89
- // Start the turn but don't await it
90
- const turnPromise = runTurn(hangingAgent, 'important message', sessionIdentity('C1', '1001'));
91
- // Give beforeTurn a tick to run
92
- await new Promise((r) => setTimeout(r, 50));
93
- // The user message should already be on disk, even though
94
- // the agent hasn't finished (and never will)
95
- const fileRecords = readJsonl('C1');
96
- expect(fileRecords.length).toBeGreaterThanOrEqual(1);
97
- expect(fileRecords[0].role).toBe('user');
98
- expect(fileRecords[0].content).toBe('important message');
99
- // Clean up the hanging promise
100
- turnPromise.halt?.();
101
- });
102
- it('does not persist system messages', async () => {
103
- const ext = makeExt();
104
- const agent = makeAgent(ext);
105
- agent.hook('beforeContext', 'test', async (ctx) => {
106
- ctx.turn.messages.push({ role: 'system', content: 'ephemeral' });
107
- });
108
- await runTurn(agent, 'hello', sessionIdentity('C1', '1001'));
109
- const records = ext.getRecords('C1');
110
- expect(records.every((r) => r.role !== 'system')).toBe(true);
111
- });
112
- // ── History loading ─────────────────────────────────────────────
113
- it('loads session history on subsequent turns', async () => {
114
- const ext = makeExt();
115
- const agent = makeAgent(ext);
116
- let seenMsgs = [];
117
- await runTurn(agent, 'first', sessionIdentity('C1', '1001'));
118
- agent.hook('beforeContext', 'capture', async (ctx) => {
119
- seenMsgs = ctx.turn.messages.map((m) => ({ role: m.role, content: m.content }));
120
- });
121
- await runTurn(agent, 'second', sessionIdentity('C1', '1002'));
122
- // Should see: [first, reply, second]
123
- expect(seenMsgs.map((m) => m.content)).toContain('first');
124
- expect(seenMsgs.map((m) => m.content)).toContain('reply');
125
- expect(seenMsgs.map((m) => m.content)).toContain('second');
126
- });
127
- it('starts with empty context for a new session', async () => {
128
- const ext = makeExt();
129
- let seenMsgs = [];
130
- const agent = makeAgent(ext, (msgs) => { seenMsgs = msgs; });
131
- await runTurn(agent, 'hello', sessionIdentity('NEW', '1001'));
132
- // Only the user message (no history)
133
- expect(seenMsgs.filter((m) => m.role === 'user')).toHaveLength(1);
134
- });
135
- // ── Thread forking ──────────────────────────────────────────────
136
- it('forks thread context: channel context before parent + thread history', async () => {
137
- const ext = makeExt({ channelContextMessages: 5, recentChannelMessages: 0 });
138
- let seenMsgs = [];
139
- const agent = makeAgent(ext);
140
- // Top-level message A (will be thread parent)
141
- await runTurn(agent, 'top A', sessionIdentity('C1', '1001'));
142
- // Top-level message B (comes after A)
143
- await runTurn(agent, 'top B', sessionIdentity('C1', '1002'));
144
- // Thread reply on A (threadTs=1001)
145
- agent.hook('beforeContext', 'capture', async (ctx) => {
146
- seenMsgs = ctx.turn.messages.map((m) => ({ role: m.role, content: m.content }));
147
- });
148
- await runTurn(agent, 'thread reply', sessionIdentity('C1', '1003', { threadTs: '1001' }));
149
- const contents = seenMsgs.map((m) => m.content);
150
- // Should see: [top A, reply, thread reply] — NOT top B
151
- expect(contents).toContain('top A');
152
- expect(contents).toContain('thread reply');
153
- expect(contents).not.toContain('top B');
154
- });
155
- it('accumulates thread history across multiple thread replies', async () => {
156
- const ext = makeExt({ channelContextMessages: 5 });
157
- let seenMsgs = [];
158
- const agent = makeAgent(ext);
159
- await runTurn(agent, 'parent', sessionIdentity('C1', '1001'));
160
- await runTurn(agent, 'reply 1', sessionIdentity('C1', '1002', { threadTs: '1001' }));
161
- agent.hook('beforeContext', 'capture', async (ctx) => {
162
- seenMsgs = ctx.turn.messages.map((m) => ({ role: m.role, content: m.content }));
163
- });
164
- await runTurn(agent, 'reply 2', sessionIdentity('C1', '1003', { threadTs: '1001' }));
165
- const contents = seenMsgs.map((m) => m.content);
166
- expect(contents).toContain('parent');
167
- expect(contents).toContain('reply 1');
168
- expect(contents).toContain('reply 2');
169
- });
170
- it('isolates threads from each other', async () => {
171
- const ext = makeExt({ channelContextMessages: 5, recentChannelMessages: 0 });
172
- let seenMsgs = [];
173
- const agent = makeAgent(ext);
174
- // Thread 1
175
- await runTurn(agent, 'thread1 parent', sessionIdentity('C1', '1001'));
176
- await runTurn(agent, 'thread1 reply', sessionIdentity('C1', '1002', { threadTs: '1001' }));
177
- // Thread 2
178
- await runTurn(agent, 'thread2 parent', sessionIdentity('C1', '1003'));
179
- await runTurn(agent, 'thread2 reply', sessionIdentity('C1', '1004', { threadTs: '1003' }));
180
- // New reply in thread 1 — should NOT see thread 2 messages
181
- agent.hook('beforeContext', 'capture', async (ctx) => {
182
- seenMsgs = ctx.turn.messages.map((m) => ({ role: m.role, content: m.content }));
183
- });
184
- await runTurn(agent, 'thread1 reply 2', sessionIdentity('C1', '1005', { threadTs: '1001' }));
185
- const contents = seenMsgs.map((m) => m.content);
186
- expect(contents).toContain('thread1 parent');
187
- expect(contents).toContain('thread1 reply');
188
- expect(contents).not.toContain('thread2 parent');
189
- expect(contents).not.toContain('thread2 reply');
190
- });
191
- // ── Concurrent turns (snapshot isolation) ───────────────────────
192
- it('concurrent turns do not see each other\'s assistant responses', async () => {
193
- const ext = makeExt();
194
- const seenByTurn = [];
195
- const agent = makeAgent(ext, (msgs) => {
196
- seenByTurn.push(msgs.map((m) => ({ role: m.role, content: m.content })));
197
- });
198
- // Seed one exchange
199
- await runTurn(agent, 'seed', sessionIdentity('C1', '1001'));
200
- // Run two turns concurrently
201
- await Promise.all([
202
- runTurn(agent, 'from A', sessionIdentity('C1', '1002')),
203
- runTurn(agent, 'from B', sessionIdentity('C1', '1003')),
204
- ]);
205
- // seenByTurn[0] is the seed turn, [1] and [2] are the concurrent turns
206
- const turnA = seenByTurn[1];
207
- const turnB = seenByTurn[2];
208
- const aContents = turnA.map((m) => m.content);
209
- const bContents = turnB.map((m) => m.content);
210
- // Both see the seed
211
- expect(aContents).toContain('seed');
212
- expect(bContents).toContain('seed');
213
- // Neither sees the other's assistant response.
214
- // (User messages may be visible since they're persisted immediately
215
- // in beforeTurn — that's correct, they're real channel messages.
216
- // But the assistant response from a concurrent turn should never
217
- // leak into another turn's context.)
218
- const aAssistantMsgs = turnA.filter((m) => m.role === 'assistant').map((m) => m.content);
219
- const bAssistantMsgs = turnB.filter((m) => m.role === 'assistant').map((m) => m.content);
220
- // Each turn should have exactly one assistant message (its own reply)
221
- expect(aAssistantMsgs).toHaveLength(1);
222
- expect(bAssistantMsgs).toHaveLength(1);
223
- // Both replies are 'reply' (from the mock), but they're from
224
- // this turn, not the other turn. The key invariant: neither
225
- // turn has MORE than one assistant message (which would indicate
226
- // it saw the other turn's response).
227
- });
228
- it('concurrent turns both append to the log without clobbering', async () => {
229
- const ext = makeExt();
230
- const agent = makeAgent(ext);
231
- await runTurn(agent, 'seed', sessionIdentity('C1', '1001'));
232
- await Promise.all([
233
- runTurn(agent, 'from A', sessionIdentity('C1', '1002')),
234
- runTurn(agent, 'from B', sessionIdentity('C1', '1003')),
235
- ]);
236
- const records = ext.getRecords('C1');
237
- // seed (2) + A (2) + B (2) = 6
238
- expect(records).toHaveLength(6);
239
- const contents = records.map((r) => r.content);
240
- expect(contents).toContain('seed');
241
- expect(contents).toContain('from A');
242
- expect(contents).toContain('from B');
243
- });
244
- // ── Disk persistence ────────────────────────────────────────────
245
- it('appends to JSONL file (never rewrites)', async () => {
246
- const ext = makeExt();
247
- const agent = makeAgent(ext);
248
- await runTurn(agent, 'first', sessionIdentity('C1', '1001'));
249
- // Read the file after turn 1
250
- const fileAfter1 = readJsonl('C1');
251
- expect(fileAfter1).toHaveLength(2);
252
- await runTurn(agent, 'second', sessionIdentity('C1', '1002'));
253
- // Read the file after turn 2 — should have 4 records (appended)
254
- const fileAfter2 = readJsonl('C1');
255
- expect(fileAfter2).toHaveLength(4);
256
- // First 2 records unchanged (append-only)
257
- expect(fileAfter2[0]).toEqual(fileAfter1[0]);
258
- expect(fileAfter2[1]).toEqual(fileAfter1[1]);
259
- });
260
- it('reloads sessions from disk on startup', async () => {
261
- const ext1 = makeExt();
262
- const agent1 = makeAgent(ext1);
263
- await runTurn(agent1, 'persisted', sessionIdentity('C1', '1001'));
264
- // Create a new extension pointing at the same dir — should load
265
- const ext2 = makeExt();
266
- const records = ext2.getRecords('C1');
267
- expect(records).toHaveLength(2);
268
- expect(records[0].content).toBe('persisted');
269
- });
270
- it('separates sessions by key', async () => {
271
- const ext = makeExt();
272
- const agent = makeAgent(ext);
273
- await runTurn(agent, 'in C1', sessionIdentity('C1', '1001'));
274
- await runTurn(agent, 'in C2', sessionIdentity('C2', '1001'));
275
- expect(ext.getRecords('C1')).toHaveLength(2);
276
- expect(ext.getRecords('C2')).toHaveLength(2);
277
- expect(ext.getRecords('C1')[0].content).toBe('in C1');
278
- expect(ext.getRecords('C2')[0].content).toBe('in C2');
279
- expect(ext.getSessions()).toContain('C1');
280
- expect(ext.getSessions()).toContain('C2');
281
- });
282
- // ── DM / direct sessions ────────────────────────────────────────
283
- it('treats direct sessions as simple last-N history (no forking)', async () => {
284
- const ext = makeExt({ maxContextMessages: 10 });
285
- let seenMsgs = [];
286
- const agent = makeAgent(ext);
287
- await runTurn(agent, 'dm 1', sessionIdentity('D1', '1001', { isDirect: true }));
288
- await runTurn(agent, 'dm 2', sessionIdentity('D1', '1002', { isDirect: true }));
289
- agent.hook('beforeContext', 'capture', async (ctx) => {
290
- seenMsgs = ctx.turn.messages.map((m) => ({ role: m.role, content: m.content }));
291
- });
292
- await runTurn(agent, 'dm 3', sessionIdentity('D1', '1003', { isDirect: true }));
293
- const contents = seenMsgs.map((m) => m.content);
294
- // Should see all prior DM messages (simple last-N)
295
- expect(contents).toContain('dm 1');
296
- expect(contents).toContain('dm 2');
297
- expect(contents).toContain('dm 3');
298
- });
299
- // ── Record metadata ─────────────────────────────────────────────
300
- it('tags records with ts and threadTs', async () => {
301
- const ext = makeExt();
302
- const agent = makeAgent(ext);
303
- await runTurn(agent, 'top', sessionIdentity('C1', '1001'));
304
- await runTurn(agent, 'thread', sessionIdentity('C1', '1002', { threadTs: '1001' }));
305
- const records = ext.getRecords('C1');
306
- // Top-level: no threadTs
307
- expect(records[0].ts).toBe('1001');
308
- expect(records[0].threadTs).toBeUndefined();
309
- // Thread: has threadTs
310
- expect(records[2].ts).toBe('1002');
311
- expect(records[2].threadTs).toBe('1001');
312
- });
313
- // ── Clearing ────────────────────────────────────────────────────
314
- it('clears a single session', async () => {
315
- const ext = makeExt();
316
- const agent = makeAgent(ext);
317
- await runTurn(agent, 'in C1', sessionIdentity('C1', '1001'));
318
- await runTurn(agent, 'in C2', sessionIdentity('C2', '1001'));
319
- ext.clear('C1');
320
- expect(ext.getRecords('C1')).toHaveLength(0);
321
- expect(ext.getRecords('C2')).toHaveLength(2);
322
- });
323
- it('clears all sessions', async () => {
324
- const ext = makeExt();
325
- const agent = makeAgent(ext);
326
- await runTurn(agent, 'in C1', sessionIdentity('C1', '1001'));
327
- await runTurn(agent, 'in C2', sessionIdentity('C2', '1001'));
328
- ext.clearAll();
329
- expect(ext.getRecords('C1')).toHaveLength(0);
330
- expect(ext.getRecords('C2')).toHaveLength(0);
331
- expect(ext.getSessions()).toHaveLength(0);
332
- });
333
- // ── Max context trimming ────────────────────────────────────────
334
- it('trims context to maxContextMessages for top-level', async () => {
335
- const ext = makeExt({ maxContextMessages: 4 });
336
- let seenMsgs = [];
337
- const agent = makeAgent(ext);
338
- // 5 top-level turns = 10 records (user + assistant each)
339
- for (let i = 1; i <= 5; i++) {
340
- agent.hook('beforeContext', 'capture', async (ctx) => {
341
- seenMsgs = ctx.turn.messages.map((m) => ({ role: m.role, content: m.content }));
342
- });
343
- await runTurn(agent, `msg${i}`, sessionIdentity('C1', `100${i}`));
344
- }
345
- // The 5th turn should see at most 4 history messages + 1 new = 5
346
- // (maxContextMessages limits the history, not the total)
347
- const userMsgs = seenMsgs.filter((m) => m.role === 'user');
348
- // Should not see msg1 (trimmed), should see msg3, msg4, msg5
349
- expect(userMsgs.map((m) => m.content)).not.toContain('msg1');
350
- });
351
- // ── Fallback when parent not in cache ───────────────────────────
352
- it('falls back to last-N when thread parent is not in cache', async () => {
353
- const ext = makeExt({ maxRecords: 4, channelContextMessages: 5 });
354
- let seenMsgs = [];
355
- const agent = makeAgent(ext);
356
- // Fill cache so the parent gets evicted
357
- await runTurn(agent, 'parent', sessionIdentity('C1', '1001'));
358
- await runTurn(agent, 'msg2', sessionIdentity('C1', '1002'));
359
- await runTurn(agent, 'msg3', sessionIdentity('C1', '1003'));
360
- // parent (ts=1001) should be evicted from in-memory cache (maxRecords=4,
361
- // but we have 6 records: 3 user + 3 assistant). Actually with maxRecords=4,
362
- // the first 2 records (parent user + parent assistant) get evicted.
363
- agent.hook('beforeContext', 'capture', async (ctx) => {
364
- seenMsgs = ctx.turn.messages.map((m) => ({ role: m.role, content: m.content }));
365
- });
366
- // Thread reply on the evicted parent
367
- await runTurn(agent, 'thread reply', sessionIdentity('C1', '1004', { threadTs: '1001' }));
368
- // Should not crash, should fall back to last-N
369
- const contents = seenMsgs.map((m) => m.content);
370
- expect(contents).toContain('thread reply');
371
- });
372
- // ── Recent channel context for thread revivals ─────────────────
373
- it('includes recent top-level messages after the last thread reply', async () => {
374
- // 3 top-level turns after thread = 6 records. Use 10 to see all.
375
- const ext = makeExt({ channelContextMessages: 5, recentChannelMessages: 10 });
376
- let seenMsgs = [];
377
- const agent = makeAgent(ext);
378
- // Thread parent + one reply
379
- await runTurn(agent, 'thread parent', sessionIdentity('C1', '1001'));
380
- await runTurn(agent, 'thread reply 1', sessionIdentity('C1', '1002', { threadTs: '1001' }));
381
- // Top-level messages after the thread (channel moved on)
382
- await runTurn(agent, 'top after 1', sessionIdentity('C1', '1003'));
383
- await runTurn(agent, 'top after 2', sessionIdentity('C1', '1004'));
384
- await runTurn(agent, 'top after 3', sessionIdentity('C1', '1005'));
385
- // Come back to the thread a "week later"
386
- agent.hook('beforeContext', 'capture', async (ctx) => {
387
- seenMsgs = ctx.turn.messages.map((m) => ({ role: m.role, content: m.content }));
388
- });
389
- await runTurn(agent, 'we solved this', sessionIdentity('C1', '1006', { threadTs: '1001' }));
390
- const contents = seenMsgs.map((m) => m.content);
391
- // Sees the thread history
392
- expect(contents).toContain('thread parent');
393
- expect(contents).toContain('thread reply 1');
394
- expect(contents).toContain('we solved this');
395
- // Also sees recent top-level messages that happened after the thread
396
- expect(contents).toContain('top after 1');
397
- expect(contents).toContain('top after 2');
398
- expect(contents).toContain('top after 3');
399
- });
400
- it('limits recent channel messages to recentChannelMessages count', async () => {
401
- // 5 top-level turns = 10 records. recentChannelMessages=4 gives
402
- // last 4 records = 2 full turns (after 4 + after 5).
403
- const ext = makeExt({ channelContextMessages: 5, recentChannelMessages: 4 });
404
- let seenMsgs = [];
405
- const agent = makeAgent(ext);
406
- await runTurn(agent, 'parent', sessionIdentity('C1', '1001'));
407
- await runTurn(agent, 'thread reply', sessionIdentity('C1', '1002', { threadTs: '1001' }));
408
- // 5 top-level messages after the thread
409
- await runTurn(agent, 'after 1', sessionIdentity('C1', '1003'));
410
- await runTurn(agent, 'after 2', sessionIdentity('C1', '1004'));
411
- await runTurn(agent, 'after 3', sessionIdentity('C1', '1005'));
412
- await runTurn(agent, 'after 4', sessionIdentity('C1', '1006'));
413
- await runTurn(agent, 'after 5', sessionIdentity('C1', '1007'));
414
- agent.hook('beforeContext', 'capture', async (ctx) => {
415
- seenMsgs = ctx.turn.messages.map((m) => ({ role: m.role, content: m.content }));
416
- });
417
- await runTurn(agent, 'revival', sessionIdentity('C1', '1008', { threadTs: '1001' }));
418
- const contents = seenMsgs.map((m) => m.content);
419
- // Should see only the last 4 records (2 turns: after 4 + after 5)
420
- expect(contents).not.toContain('after 1');
421
- expect(contents).not.toContain('after 2');
422
- expect(contents).not.toContain('after 3');
423
- expect(contents).toContain('after 4');
424
- expect(contents).toContain('after 5');
425
- });
426
- // ── Tool-call intermediate filtering ────────────────────────────
427
- it('preserves tool-call intermediates but slices at turn boundaries to prevent split sequences', async () => {
428
- // If a tool call pair (assistant+tool) spans the boundary of the
429
- // channelContextMessages window, the tool message would be sliced mid-sequence
430
- // if we just did a naive .slice(). With sliceAtTurnBoundary, it skips forward
431
- // to the next user message, dropping the partial turn entirely rather than splitting it.
432
- const ext = makeExt({ channelContextMessages: 3, recentChannelMessages: 0 }); // 3 ensures it cuts into the 4-message turn
433
- let seenMsgs = [];
434
- // Create a tool-call exchange early in the channel (NOT the thread parent)
435
- const toolModel = {
436
- async generate(req) {
437
- const hasToolResult = req.messages.some((m) => m.role === 'tool');
438
- if (!hasToolResult) {
439
- return {
440
- message: {
441
- role: 'assistant',
442
- content: '',
443
- toolCalls: [{ id: 'tc1', name: 'test_tool', arguments: '{}' }],
444
- },
445
- finishReason: 'tool_calls',
446
- usage: { promptTokens: 0, completionTokens: 0 },
447
- };
448
- }
449
- return {
450
- message: { role: 'assistant', content: 'tool result summary' },
451
- finishReason: 'stop',
452
- usage: { promptTokens: 0, completionTokens: 0 },
453
- };
454
- },
455
- };
456
- const toolAgent = new Agent({ model: toolModel });
457
- toolAgent.use(ext);
458
- toolAgent.tool({
459
- name: 'test_tool',
460
- description: 'test',
461
- parameters: { type: 'object', properties: {} },
462
- async execute() { return { toolCallId: 'tc1', content: 'tool output' }; },
463
- });
464
- // Turn 1: tool call exchange (produces user+assistant[tool]+tool+assistant records = 4 records)
465
- await toolAgent.run({
466
- message: 'use the tool',
467
- metadata: { session: { key: 'C1', messageTs: '1001' } },
468
- });
469
- // Turn 2: thread parent (a regular message, no tool calls)
470
- const regAgent = makeAgent(ext);
471
- await runTurn(regAgent, 'thread parent', sessionIdentity('C1', '1002'));
472
- // Now thread reply on ts=1002
473
- // The topLevelBefore messages before the parent are the 4 messages from Turn 1.
474
- // We configured channelContextMessages: 3.
475
- // If we took the last 3 messages of Turn 1, we would get [assistant, tool, assistant].
476
- // But sliceAtTurnBoundary will see it starts with 'assistant', skip forward looking for
477
- // a 'user' or 'system', find none, and return an empty array.
478
- regAgent.hook('beforeContext', 'capture', async (ctx) => {
479
- seenMsgs = ctx.turn.messages.map((m) => ({ role: m.role, content: m.content }));
480
- });
481
- await runTurn(regAgent, 'thread reply', sessionIdentity('C1', '1005', { threadTs: '1002' }));
482
- // Because the context window (3) starts in the middle of Turn 1,
483
- // sliceAtTurnBoundary will drop it entirely.
484
- const toolMsgs = seenMsgs.filter((m) => m.role === 'tool');
485
- expect(toolMsgs).toHaveLength(0);
486
- // No assistant messages from Turn 1 should be present.
487
- const emptyAssistantMsgs = seenMsgs.filter((m) => m.role === 'assistant' && !messageContentToText(m.content).trim());
488
- expect(emptyAssistantMsgs).toHaveLength(0);
489
- // But the thread messages should still be there
490
- const contents = seenMsgs.map((m) => m.content);
491
- expect(contents).toContain('thread parent');
492
- expect(contents).toContain('thread reply');
493
- });
494
- // ── Enrichment bag (sessionMeta → record.meta) ──────────────────
495
- // disk-session persists `ctx.turn.metadata.sessionMeta` opaquely as
496
- // `record.meta`. It does not inspect or interpret the contents — any
497
- // extension that writes into the bag before disk-session's
498
- // beforeTurn hook runs will have its data persisted.
499
- it('persists sessionMeta as record.meta on the user record', async () => {
500
- const ext = makeExt();
501
- // Register the enricher BEFORE disk-session so its beforeTurn
502
- // hook runs first (hooks run in registration order within a stage).
503
- const agent = new Agent({ model: mockModel() });
504
- agent.hook('beforeTurn', 'enricher', async (ctx) => {
505
- ctx.turn.metadata.sessionMeta = { keywords: { terms: ['MCP', 'auth'] } };
506
- });
507
- agent.use(ext);
508
- await runTurn(agent, 'find MCP auth docs', sessionIdentity('C1', '1001'));
509
- const records = ext.getRecords('C1');
510
- expect(records[0].role).toBe('user');
511
- expect(records[0].meta).toEqual({ keywords: { terms: ['MCP', 'auth'] } });
512
- // Also persisted to disk.
513
- const fileRecords = readJsonl('C1');
514
- expect(fileRecords[0].meta).toEqual({ keywords: { terms: ['MCP', 'auth'] } });
515
- });
516
- it('persists sessionMeta on assistant records too', async () => {
517
- const ext = makeExt();
518
- const agent = new Agent({ model: mockModel() });
519
- agent.hook('beforeTurn', 'enricher', async (ctx) => {
520
- ctx.turn.metadata.sessionMeta = { topic: 'testing' };
521
- });
522
- agent.use(ext);
523
- await runTurn(agent, 'hello', sessionIdentity('C1', '1001'));
524
- const records = ext.getRecords('C1');
525
- expect(records).toHaveLength(2);
526
- expect(records[0].meta).toEqual({ topic: 'testing' });
527
- expect(records[1].meta).toEqual({ topic: 'testing' });
528
- });
529
- it('omits meta when sessionMeta is absent', async () => {
530
- const ext = makeExt();
531
- const agent = makeAgent(ext);
532
- await runTurn(agent, 'hello', sessionIdentity('C1', '1001'));
533
- const records = ext.getRecords('C1');
534
- expect(records[0].meta).toBeUndefined();
535
- });
536
- // ── Custom resolver ──────────────────────────────────────────────
537
- it('supports a custom resolver for host-specific identity extraction', async () => {
538
- // Simulate a host that stores identity under a host-specific key.
539
- const ext = createDiskSessionExtension({
540
- sessionDir: tmpDir,
541
- resolver: {
542
- resolve(metadata) {
543
- const host = metadata.myPlatform;
544
- if (!host)
545
- return undefined;
546
- return { key: `mp_${host.channelId}`, messageTs: host.ts, threadTs: host.thread };
547
- },
548
- },
549
- });
550
- const agent = makeAgent(ext);
551
- await agent.run({
552
- message: 'hello',
553
- metadata: { myPlatform: { channelId: 'X1', ts: '2001' } },
554
- });
555
- const records = ext.getRecords('mp_X1');
556
- expect(records).toHaveLength(2);
557
- expect(records[0].content).toBe('hello');
558
- });
559
- it('skips session handling when resolver returns undefined', async () => {
560
- const ext = createDiskSessionExtension({
561
- sessionDir: tmpDir,
562
- resolver: {
563
- resolve() { return undefined; },
564
- },
565
- });
566
- const agent = makeAgent(ext);
567
- await agent.run({ message: 'hello', metadata: {} });
568
- // No records persisted
569
- expect(ext.getSessions()).toHaveLength(0);
570
- });
571
- it('falls back to metadata.sessionId when no session identity is present', async () => {
572
- const ext = makeExt();
573
- const agent = makeAgent(ext);
574
- await agent.run({ message: 'hello', metadata: { sessionId: 'fallback-key' } });
575
- const records = ext.getRecords('fallback-key');
576
- expect(records).toHaveLength(2);
577
- expect(records[0].content).toBe('hello');
578
- });
579
- it('uses "default" key when no session metadata is present', async () => {
580
- const ext = makeExt();
581
- const agent = makeAgent(ext);
582
- await agent.run({ message: 'hello', metadata: {} });
583
- const records = ext.getRecords('default');
584
- expect(records).toHaveLength(2);
585
- expect(records[0].content).toBe('hello');
586
- });
587
- it('persists the system prompt on the user record', async () => {
588
- const ext = makeExt();
589
- const agent = new Agent({
590
- model: mockModel(),
591
- systemPrompt: 'You are a test agent. Be concise.',
592
- });
593
- agent.use(ext);
594
- await runTurn(agent, 'hello', sessionIdentity('s1', 't1'));
595
- const records = ext.getRecords('s1');
596
- expect(records).toHaveLength(2);
597
- expect(records[0].role).toBe('user');
598
- expect(records[0].systemPrompt).toBe('You are a test agent. Be concise.');
599
- expect(records[1].role).toBe('assistant');
600
- expect(records[1].systemPrompt).toBeUndefined();
601
- });
602
- it('captures system prompt after appendSystemPrompt modifies it', async () => {
603
- const ext = makeExt();
604
- const agent = new Agent({
605
- model: mockModel(),
606
- systemPrompt: 'Base prompt.',
607
- });
608
- agent.appendSystemPrompt('Appended skill content.');
609
- agent.use(ext);
610
- await runTurn(agent, 'hello', sessionIdentity('s1', 't1'));
611
- const records = ext.getRecords('s1');
612
- expect(records[0].systemPrompt).toBe('Base prompt.\n\nAppended skill content.');
613
- });
614
- // ── Background messages (appendMessage) ────────────────────────
615
- it('appendMessage persists a system record to memory and disk', () => {
616
- const ext = makeExt();
617
- ext.appendMessage('s1', '[U1]: anyone seen the invoice?', {
618
- ts: '1001',
619
- meta: { sender: 'U1', channelId: 'C1' },
620
- });
621
- const records = ext.getRecords('s1');
622
- expect(records).toHaveLength(1);
623
- expect(records[0].role).toBe('system');
624
- expect(records[0].content).toBe('[U1]: anyone seen the invoice?');
625
- expect(records[0].ts).toBe('1001');
626
- expect(records[0].meta).toEqual({ sender: 'U1', channelId: 'C1' });
627
- // Persisted to disk.
628
- const onDisk = readJsonl('s1');
629
- expect(onDisk).toHaveLength(1);
630
- expect(onDisk[0].role).toBe('system');
631
- expect(onDisk[0].content).toBe('[U1]: anyone seen the invoice?');
632
- });
633
- it('background system messages appear in LLM context when agent is triggered', async () => {
634
- const seen = (msgs) => {
635
- capturedMsgs = [...msgs];
636
- };
637
- let capturedMsgs = [];
638
- const ext = makeExt();
639
- const agent = makeAgent(ext, seen);
640
- // Simulate background chatter — messages the bot observed but
641
- // weren't directed at it.
642
- ext.appendMessage('s1', '[U1]: anyone seen the Johnson invoice?');
643
- ext.appendMessage('s1', '[U2]: I think it\'s in the shared drive');
644
- // Now the agent is triggered.
645
- await runTurn(agent, '[U1]: @bot what\'s the status of the Johnson job?', sessionIdentity('s1', 't3'));
646
- // The LLM should see: [system, system, user]
647
- // The two background messages as system context, then the trigger.
648
- expect(capturedMsgs).toHaveLength(3);
649
- expect(capturedMsgs[0].role).toBe('system');
650
- expect(capturedMsgs[0].content).toBe('[U1]: anyone seen the Johnson invoice?');
651
- expect(capturedMsgs[1].role).toBe('system');
652
- expect(capturedMsgs[1].content).toBe('[U2]: I think it\'s in the shared drive');
653
- expect(capturedMsgs[2].role).toBe('user');
654
- expect(capturedMsgs[2].content).toBe('[U1]: @bot what\'s the status of the Johnson job?');
655
- });
656
- it('background system messages are not double-persisted by afterTurn', async () => {
657
- const ext = makeExt();
658
- const agent = makeAgent(ext);
659
- ext.appendMessage('s1', '[U1]: background chatter', { ts: 't1' });
660
- await runTurn(agent, '[U2]: @bot hello', sessionIdentity('s1', 't2'));
661
- // Should be: [system (background), user (trigger), assistant (reply)]
662
- // NOT: [system, user, system, user, assistant] — the background
663
- // system message is NOT in turn.messages so afterTurn can't see it.
664
- const records = ext.getRecords('s1');
665
- expect(records).toHaveLength(3);
666
- expect(records[0].role).toBe('system');
667
- expect(records[0].content).toBe('[U1]: background chatter');
668
- expect(records[1].role).toBe('user');
669
- expect(records[2].role).toBe('assistant');
670
- });
671
- // ── Schema sanitization & Steering integrity ───────────────────
672
- describe('sanitizeConversationMessages', () => {
673
- it('drops orphan tool messages that have no preceding assistant message', () => {
674
- const input = [
675
- { role: 'user', content: 'hello' },
676
- { role: 'tool', toolCallId: 'call_orphan', content: 'orphan output' },
677
- { role: 'assistant', content: 'hi' },
678
- ];
679
- const output = sanitizeConversationMessages(input);
680
- expect(output).toEqual([
681
- { role: 'user', content: 'hello' },
682
- { role: 'assistant', content: 'hi' },
683
- ]);
684
- });
685
- it('drops orphan tool messages following an assistant message that has no tool_calls', () => {
686
- const input = [
687
- { role: 'assistant', content: 'just text, no tool calls' },
688
- { role: 'tool', toolCallId: 'call_orphan', content: 'unexpected result' },
689
- ];
690
- const output = sanitizeConversationMessages(input);
691
- expect(output).toEqual([
692
- { role: 'assistant', content: 'just text, no tool calls' },
693
- ]);
694
- });
695
- it('drops duplicate tool results for the same toolCallId', () => {
696
- const input = [
697
- {
698
- role: 'assistant',
699
- content: '',
700
- toolCalls: [{ id: 'call_1', name: 'search', arguments: '{}' }],
701
- },
702
- { role: 'tool', toolCallId: 'call_1', content: 'first result' },
703
- { role: 'tool', toolCallId: 'call_1', content: 'duplicate result' },
704
- ];
705
- const output = sanitizeConversationMessages(input);
706
- expect(output).toHaveLength(2);
707
- expect(output[0].role).toBe('assistant');
708
- expect(output[1].role).toBe('tool');
709
- expect(output[1].content).toBe('first result');
710
- });
711
- it('prunes unfulfilled tool calls from an assistant message when only partial calls completed', () => {
712
- const input = [
713
- {
714
- role: 'assistant',
715
- content: '',
716
- toolCalls: [
717
- { id: 'call_1', name: 'tool1', arguments: '{}' },
718
- { id: 'call_2', name: 'tool2', arguments: '{}' },
719
- ],
720
- },
721
- { role: 'tool', toolCallId: 'call_1', content: 'res1' },
722
- { role: 'user', content: 'next turn' },
723
- ];
724
- const output = sanitizeConversationMessages(input);
725
- expect(output).toHaveLength(3);
726
- expect(output[0].role).toBe('assistant');
727
- expect(output[0].toolCalls).toEqual([
728
- { id: 'call_1', name: 'tool1', arguments: '{}' },
729
- ]);
730
- expect(output[1].role).toBe('tool');
731
- expect(output[2].role).toBe('user');
732
- });
733
- it('strips toolCalls from assistant when none completed but assistant has text content', () => {
734
- const input = [
735
- {
736
- role: 'assistant',
737
- content: 'Let me look that up',
738
- toolCalls: [{ id: 'call_1', name: 'tool1', arguments: '{}' }],
739
- },
740
- { role: 'user', content: 'cancelled' },
741
- ];
742
- const output = sanitizeConversationMessages(input);
743
- expect(output).toEqual([
744
- { role: 'assistant', content: 'Let me look that up' },
745
- { role: 'user', content: 'cancelled' },
746
- ]);
747
- expect(output[0].toolCalls).toBeUndefined();
748
- });
749
- it('drops empty assistant message when no tool calls completed and content is empty', () => {
750
- const input = [
751
- { role: 'user', content: 'search something' },
752
- {
753
- role: 'assistant',
754
- content: '',
755
- toolCalls: [{ id: 'call_1', name: 'tool1', arguments: '{}' }],
756
- },
757
- { role: 'user', content: 'new prompt' },
758
- ];
759
- const output = sanitizeConversationMessages(input);
760
- expect(output).toEqual([
761
- { role: 'user', content: 'search something' },
762
- { role: 'user', content: 'new prompt' },
763
- ]);
764
- });
765
- });
766
- describe('steering and tool-call persistence integrity', () => {
767
- it('persists steering messages in correct chronological order without duplicating records', async () => {
768
- const ext = makeExt();
769
- let iteration = 0;
770
- const model = {
771
- async generate(_req) {
772
- iteration++;
773
- if (iteration === 1) {
774
- // First iteration: model issues a tool call
775
- return {
776
- message: {
777
- role: 'assistant',
778
- content: 'calling tool',
779
- toolCalls: [{ id: 'tc1', name: 'my_tool', arguments: '{}' }],
780
- },
781
- finishReason: 'tool_calls',
782
- };
783
- }
784
- // Second iteration (after tool + steering): model returns final answer
785
- return {
786
- message: { role: 'assistant', content: 'done with steering response' },
787
- finishReason: 'stop',
788
- };
789
- },
790
- };
791
- const agent = new Agent({ model: model });
792
- agent.use(ext);
793
- agent.tool({
794
- name: 'my_tool',
795
- parameters: { type: 'object' },
796
- async execute() {
797
- // While the tool is executing, inject steering into this turn!
798
- agent.steer('keep me posted');
799
- return { toolCallId: 'tc1', content: 'tool output' };
800
- },
801
- });
802
- await agent.run({
803
- message: 'initial prompt',
804
- metadata: { session: sessionIdentity('steer-test', '100') },
805
- });
806
- const records = ext.getRecords('steer-test');
807
- // Expected records in strict chronological order:
808
- // 1. user: 'initial prompt'
809
- // 2. assistant: 'calling tool' (toolCalls: [tc1])
810
- // 3. tool: 'tool output' (toolCallId: tc1)
811
- // 4. user: '[steering] keep me posted'
812
- // 5. assistant: 'done with steering response'
813
- expect(records).toHaveLength(5);
814
- expect(records[0].role).toBe('user');
815
- expect(records[0].content).toBe('initial prompt');
816
- expect(records[1].role).toBe('assistant');
817
- expect(records[1].content).toBe('calling tool');
818
- expect(records[1].toolCalls).toBeDefined();
819
- expect(records[2].role).toBe('tool');
820
- expect(records[2].toolCallId).toBe('tc1');
821
- expect(records[3].role).toBe('user');
822
- expect(records[3].content).toBe('[steering] keep me posted');
823
- expect(records[4].role).toBe('assistant');
824
- expect(records[4].content).toBe('done with steering response');
825
- // Verify file on disk matches memory records exactly (no duplicates!)
826
- const diskRecords = readJsonl('steer-test');
827
- expect(diskRecords).toHaveLength(5);
828
- expect(diskRecords.map((r) => r.role)).toEqual([
829
- 'user',
830
- 'assistant',
831
- 'tool',
832
- 'user',
833
- 'assistant',
834
- ]);
835
- });
836
- it('buildContext filters out corrupted orphan tool records from existing session files', async () => {
837
- // Manually simulate a corrupted file with Ronny\'s duplicate tool bug:
838
- // Record 1: user
839
- // Record 2: assistant (with tool call)
840
- // Record 3: tool result
841
- // Record 4: user (steering)
842
- // Record 5: assistant (text only, no tool calls)
843
- // Record 6: DUPLICATE tool result (orphan!)
844
- const corruptedRecords = [
845
- { role: 'user', content: 'scrape site', ts: '1', recordedAt: new Date().toISOString() },
846
- {
847
- role: 'assistant',
848
- content: 'running scraper',
849
- toolCalls: [{ id: 'call_99', name: 'scraper', arguments: '{}' }],
850
- ts: '2',
851
- recordedAt: new Date().toISOString(),
852
- },
853
- {
854
- role: 'tool',
855
- content: 'scrape result',
856
- toolCallId: 'call_99',
857
- name: 'scraper',
858
- ts: '3',
859
- recordedAt: new Date().toISOString(),
860
- },
861
- { role: 'user', content: '[steering] update please', ts: '4', recordedAt: new Date().toISOString() },
862
- { role: 'assistant', content: 'will do', ts: '5', recordedAt: new Date().toISOString() },
863
- {
864
- role: 'tool',
865
- content: 'scrape result',
866
- toolCallId: 'call_99',
867
- name: 'scraper',
868
- ts: '6',
869
- recordedAt: new Date().toISOString(),
870
- },
871
- ];
872
- const fs = await import('node:fs');
873
- const filePath = join(tmpDir, 'corrupt_session.jsonl');
874
- fs.writeFileSync(filePath, corruptedRecords.map((r) => JSON.stringify(r)).join('\n') + '\n');
875
- // Create new extension instance that loads the corrupted file
876
- const loadedExt = makeExt({ loadOnStartup: true });
877
- let seenByModel = [];
878
- const agent = makeAgent(loadedExt, (msgs) => {
879
- seenByModel = msgs;
880
- });
881
- // Run next turn
882
- await runTurn(agent, 'what is next?', sessionIdentity('corrupt_session', '7'));
883
- // The model should NOT see the orphan tool record #6!
884
- // In the context passed to the model, every tool message must follow an assistant with toolCalls.
885
- for (let i = 0; i < seenByModel.length; i++) {
886
- if (seenByModel[i].role === 'tool') {
887
- const prev = seenByModel[i - 1];
888
- expect(prev).toBeDefined();
889
- const isValidPreceding = (prev.role === 'assistant' && prev.toolCalls?.some((tc) => tc.id === seenByModel[i].toolCallId)) ||
890
- (prev.role === 'tool');
891
- expect(isValidPreceding).toBe(true);
892
- }
893
- }
894
- // Specifically, record #6 must have been omitted
895
- const toolMsgs = seenByModel.filter((m) => m.role === 'tool');
896
- expect(toolMsgs).toHaveLength(1);
897
- });
898
- });
899
- // ── Auto-summarization & Cache-Preserving Compaction ────────────
900
- describe('auto-summarization and cache preservation', () => {
901
- it('auto-summarizes earlier messages using the session model when reaching maxContextMessages', async () => {
902
- let summarizationCallCount = 0;
903
- let summarizedPromptText = '';
904
- const testModel = {
905
- async generate(req) {
906
- const lastMsg = req.messages[req.messages.length - 1];
907
- const text = messageContentToText(lastMsg.content);
908
- if (req.systemPrompt?.includes('summarizer') || text.includes('summarize')) {
909
- summarizationCallCount++;
910
- summarizedPromptText = text;
911
- return {
912
- message: { role: 'assistant', content: 'Summarized decisions: files modified and tasks done.' },
913
- finishReason: 'stop',
914
- usage: { promptTokens: 10, completionTokens: 5 },
915
- };
916
- }
917
- return {
918
- message: { role: 'assistant', content: `Reply to: ${text}` },
919
- finishReason: 'stop',
920
- usage: { promptTokens: 5, completionTokens: 5 },
921
- };
922
- },
923
- };
924
- // Set threshold to 6 messages, eviction step to 3 messages
925
- const ext = createDiskSessionExtension({
926
- sessionDir: tmpDir,
927
- maxContextMessages: 6,
928
- contextEvictionStep: 3,
929
- model: testModel,
930
- });
931
- const agent = new Agent({ model: testModel });
932
- agent.use(ext);
933
- // Turn 1: 2 records (user1, assistant1)
934
- await runTurn(agent, 'message 1', sessionIdentity('compact-test', '1'));
935
- // Turn 2: 4 records (user2, assistant2)
936
- await runTurn(agent, 'message 2', sessionIdentity('compact-test', '2'));
937
- // Turn 3: 6 records (user3, assistant3)
938
- await runTurn(agent, 'message 3', sessionIdentity('compact-test', '3'));
939
- expect(summarizationCallCount).toBe(0);
940
- expect(ext.getRecords('compact-test')).toHaveLength(6);
941
- // Turn 4: history is at 6 records (>= maxContextMessages).
942
- // Auto-summarization triggers in beforeTurn!
943
- let seenInTurn4 = [];
944
- const trackingAgent = new Agent({
945
- model: {
946
- async generate(req) {
947
- const lastMsg = req.messages[req.messages.length - 1];
948
- const text = messageContentToText(lastMsg.content);
949
- if (req.systemPrompt?.includes('summarizer') || text.includes('summarize')) {
950
- return testModel.generate(req);
951
- }
952
- seenInTurn4 = req.messages;
953
- return {
954
- message: { role: 'assistant', content: 'Turn 4 reply' },
955
- finishReason: 'stop',
956
- usage: { promptTokens: 5, completionTokens: 5 },
957
- };
958
- },
959
- },
960
- });
961
- trackingAgent.use(ext);
962
- const res4 = await runTurn(trackingAgent, 'message 4', sessionIdentity('compact-test', '4'));
963
- // 1. Summarization was invoked
964
- expect(summarizationCallCount).toBe(1);
965
- expect(summarizedPromptText).toContain('message 1');
966
- // 2. The human's response does NOT leak any compaction text
967
- expect(res4.message).toBe('Turn 4 reply');
968
- // 3. Immediately before the model acts, the model receives the notification
969
- const noticeMsg = seenInTurn4.find((m) => m.role === 'system' && messageContentToText(m.content).includes('automatically compacted into the summary above'));
970
- expect(noticeMsg).toBeDefined();
971
- // 4. The context begins with the summary record
972
- const summaryMsg = seenInTurn4.find((m) => messageContentToText(m.content).includes('Summarized decisions'));
973
- expect(summaryMsg).toBeDefined();
974
- });
975
- it('preserves prompt cache prefix stability across consecutive turns between compactions', async () => {
976
- const prefixesSeen = [];
977
- const model = {
978
- async generate(req) {
979
- if (req.systemPrompt?.includes('summarizer')) {
980
- return {
981
- message: { role: 'assistant', content: 'Consolidated summary' },
982
- finishReason: 'stop',
983
- usage: { promptTokens: 10, completionTokens: 5 },
984
- };
985
- }
986
- // Record the first message in the history prefix
987
- prefixesSeen.push(messageContentToText(req.messages[0].content));
988
- return {
989
- message: { role: 'assistant', content: 'ok' },
990
- finishReason: 'stop',
991
- usage: { promptTokens: 5, completionTokens: 5 },
992
- };
993
- },
994
- };
995
- const ext = createDiskSessionExtension({
996
- sessionDir: tmpDir,
997
- maxContextMessages: 10,
998
- contextEvictionStep: 5,
999
- model,
1000
- });
1001
- const agent = new Agent({ model });
1002
- agent.use(ext);
1003
- // Run 5 turns (records grow from 2 to 10)
1004
- for (let i = 1; i <= 5; i++) {
1005
- await runTurn(agent, `turn ${i}`, sessionIdentity('cache-test', `${i}`));
1006
- }
1007
- // During turns 1-5, all turns share the exact same first message ("turn 1")
1008
- for (let i = 0; i < 5; i++) {
1009
- expect(prefixesSeen[i]).toBe('turn 1');
1010
- }
1011
- // Turn 6 triggers auto-summarization because records count is 10 (>= maxContextMessages)
1012
- await runTurn(agent, 'turn 6', sessionIdentity('cache-test', '6'));
1013
- // Turn 7 and Turn 8: history has been compacted to a summary record at index 0.
1014
- // Both turn 7 and turn 8 must start with the exact same summary record!
1015
- await runTurn(agent, 'turn 7', sessionIdentity('cache-test', '7'));
1016
- await runTurn(agent, 'turn 8', sessionIdentity('cache-test', '8'));
1017
- const prefix7 = prefixesSeen[6];
1018
- const prefix8 = prefixesSeen[7];
1019
- expect(prefix7).toContain('Consolidated summary');
1020
- expect(prefix8).toBe(prefix7); // 100% prefix cache hit!
1021
- });
1022
- it('backs off context limit (e.g. 100 -> 50 -> 25) when a context length error occurs', async () => {
1023
- let callCount = 0;
1024
- const model = {
1025
- async generate(req) {
1026
- callCount++;
1027
- if (req.systemPrompt?.includes('summarizer')) {
1028
- return {
1029
- message: { role: 'assistant', content: 'Compact backoff summary' },
1030
- finishReason: 'stop',
1031
- usage: { promptTokens: 5, completionTokens: 5 },
1032
- };
1033
- }
1034
- if (callCount === 3) {
1035
- // Throw a context length error on turn 3
1036
- throw new Error('400 Maximum context length exceeded: prompt too long');
1037
- }
1038
- return {
1039
- message: { role: 'assistant', content: 'ok' },
1040
- finishReason: 'stop',
1041
- usage: { promptTokens: 5, completionTokens: 5 },
1042
- };
1043
- },
1044
- };
1045
- const ext = createDiskSessionExtension({
1046
- sessionDir: tmpDir,
1047
- maxContextMessages: 100,
1048
- fallbackThresholds: [100, 50, 25],
1049
- model,
1050
- });
1051
- const agent = new Agent({
1052
- model,
1053
- errorPolicy: 'fallback',
1054
- });
1055
- agent.use(ext);
1056
- expect(ext.getEffectiveLimit('backoff-session')).toBe(100);
1057
- // Turn 1 & 2 succeed
1058
- await runTurn(agent, 'msg 1', sessionIdentity('backoff-session', '1'));
1059
- await runTurn(agent, 'msg 2', sessionIdentity('backoff-session', '2'));
1060
- // Turn 3 throws context length error
1061
- const res3 = await runTurn(agent, 'msg 3', sessionIdentity('backoff-session', '3'));
1062
- expect(res3.finishReason).toBe('error');
1063
- // The extension detected the context error and backed off to 50
1064
- expect(ext.getEffectiveLimit('backoff-session')).toBe(50);
1065
- });
1066
- it('uses the agent configured model automatically if not explicitly provided in config', async () => {
1067
- let summarizerCalled = false;
1068
- const model = {
1069
- async generate(req) {
1070
- if (req.systemPrompt?.includes('summarizer')) {
1071
- summarizerCalled = true;
1072
- return {
1073
- message: { role: 'assistant', content: 'Auto summary using agent model' },
1074
- finishReason: 'stop',
1075
- usage: { promptTokens: 5, completionTokens: 5 },
1076
- };
1077
- }
1078
- return {
1079
- message: { role: 'assistant', content: 'agent reply' },
1080
- finishReason: 'stop',
1081
- usage: { promptTokens: 5, completionTokens: 5 },
1082
- };
1083
- },
1084
- };
1085
- // Notice: NO model passed into createDiskSessionExtension
1086
- const ext = createDiskSessionExtension({
1087
- sessionDir: tmpDir,
1088
- maxContextMessages: 4,
1089
- contextEvictionStep: 2,
1090
- });
1091
- const agent = new Agent({ model });
1092
- agent.use(ext);
1093
- await runTurn(agent, 'msg 1', sessionIdentity('agent-model-test', '1'));
1094
- await runTurn(agent, 'msg 2', sessionIdentity('agent-model-test', '2'));
1095
- // Reached 4 messages, turn 3 triggers summarizer
1096
- await runTurn(agent, 'msg 3', sessionIdentity('agent-model-test', '3'));
1097
- expect(summarizerCalled).toBe(true);
1098
- });
1099
- });
1100
- });
1101
- //# sourceMappingURL=extension.test.js.map