@xandout/libra-harness 0.1.8
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/LICENSE +21 -0
- package/README.md +486 -0
- package/dist/agent.d.ts +174 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +575 -0
- package/dist/agent.js.map +1 -0
- package/dist/ai-sdk-model.d.ts +26 -0
- package/dist/ai-sdk-model.d.ts.map +1 -0
- package/dist/ai-sdk-model.js +260 -0
- package/dist/ai-sdk-model.js.map +1 -0
- package/dist/context.d.ts +75 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +2 -0
- package/dist/context.js.map +1 -0
- package/dist/errors.d.ts +33 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +59 -0
- package/dist/errors.js.map +1 -0
- package/dist/extension.d.ts +63 -0
- package/dist/extension.d.ts.map +1 -0
- package/dist/extension.js +2 -0
- package/dist/extension.js.map +1 -0
- package/dist/extras/extension-loader.d.ts +205 -0
- package/dist/extras/extension-loader.d.ts.map +1 -0
- package/dist/extras/extension-loader.js +315 -0
- package/dist/extras/extension-loader.js.map +1 -0
- package/dist/extras/extensions/auto-steer/extension.d.ts +46 -0
- package/dist/extras/extensions/auto-steer/extension.d.ts.map +1 -0
- package/dist/extras/extensions/auto-steer/extension.js +52 -0
- package/dist/extras/extensions/auto-steer/extension.js.map +1 -0
- package/dist/extras/extensions/auto-steer/extension.json +7 -0
- package/dist/extras/extensions/auto-steer/index.d.ts +3 -0
- package/dist/extras/extensions/auto-steer/index.d.ts.map +1 -0
- package/dist/extras/extensions/auto-steer/index.js +2 -0
- package/dist/extras/extensions/auto-steer/index.js.map +1 -0
- package/dist/extras/extensions/disk-session/extension.d.ts +167 -0
- package/dist/extras/extensions/disk-session/extension.d.ts.map +1 -0
- package/dist/extras/extensions/disk-session/extension.js +492 -0
- package/dist/extras/extensions/disk-session/extension.js.map +1 -0
- package/dist/extras/extensions/disk-session/extension.json +6 -0
- package/dist/extras/extensions/disk-session/extension.test.d.ts +2 -0
- package/dist/extras/extensions/disk-session/extension.test.d.ts.map +1 -0
- package/dist/extras/extensions/disk-session/extension.test.js +672 -0
- package/dist/extras/extensions/disk-session/extension.test.js.map +1 -0
- package/dist/extras/extensions/disk-session/index.d.ts +3 -0
- package/dist/extras/extensions/disk-session/index.d.ts.map +1 -0
- package/dist/extras/extensions/disk-session/index.js +2 -0
- package/dist/extras/extensions/disk-session/index.js.map +1 -0
- package/dist/extras/extensions/emoji/extension.d.ts +20 -0
- package/dist/extras/extensions/emoji/extension.d.ts.map +1 -0
- package/dist/extras/extensions/emoji/extension.js +22 -0
- package/dist/extras/extensions/emoji/extension.js.map +1 -0
- package/dist/extras/extensions/emoji/extension.json +7 -0
- package/dist/extras/extensions/emoji/index.d.ts +3 -0
- package/dist/extras/extensions/emoji/index.d.ts.map +1 -0
- package/dist/extras/extensions/emoji/index.js +2 -0
- package/dist/extras/extensions/emoji/index.js.map +1 -0
- package/dist/extras/extensions/filesystem/extension.d.ts +53 -0
- package/dist/extras/extensions/filesystem/extension.d.ts.map +1 -0
- package/dist/extras/extensions/filesystem/extension.js +350 -0
- package/dist/extras/extensions/filesystem/extension.js.map +1 -0
- package/dist/extras/extensions/filesystem/extension.json +6 -0
- package/dist/extras/extensions/filesystem/extension.test.d.ts +2 -0
- package/dist/extras/extensions/filesystem/extension.test.d.ts.map +1 -0
- package/dist/extras/extensions/filesystem/extension.test.js +387 -0
- package/dist/extras/extensions/filesystem/extension.test.js.map +1 -0
- package/dist/extras/extensions/filesystem/index.d.ts +2 -0
- package/dist/extras/extensions/filesystem/index.d.ts.map +1 -0
- package/dist/extras/extensions/filesystem/index.js +2 -0
- package/dist/extras/extensions/filesystem/index.js.map +1 -0
- package/dist/extras/extensions/keyword-extractor/extension.d.ts +53 -0
- package/dist/extras/extensions/keyword-extractor/extension.d.ts.map +1 -0
- package/dist/extras/extensions/keyword-extractor/extension.js +61 -0
- package/dist/extras/extensions/keyword-extractor/extension.js.map +1 -0
- package/dist/extras/extensions/keyword-extractor/extension.json +6 -0
- package/dist/extras/extensions/keyword-extractor/extension.test.d.ts +2 -0
- package/dist/extras/extensions/keyword-extractor/extension.test.d.ts.map +1 -0
- package/dist/extras/extensions/keyword-extractor/extension.test.js +105 -0
- package/dist/extras/extensions/keyword-extractor/extension.test.js.map +1 -0
- package/dist/extras/extensions/keyword-extractor/index.d.ts +5 -0
- package/dist/extras/extensions/keyword-extractor/index.d.ts.map +1 -0
- package/dist/extras/extensions/keyword-extractor/index.js +3 -0
- package/dist/extras/extensions/keyword-extractor/index.js.map +1 -0
- package/dist/extras/extensions/keyword-extractor/nlp.d.ts +68 -0
- package/dist/extras/extensions/keyword-extractor/nlp.d.ts.map +1 -0
- package/dist/extras/extensions/keyword-extractor/nlp.js +156 -0
- package/dist/extras/extensions/keyword-extractor/nlp.js.map +1 -0
- package/dist/extras/extensions/keyword-extractor/nlp.test.d.ts +2 -0
- package/dist/extras/extensions/keyword-extractor/nlp.test.d.ts.map +1 -0
- package/dist/extras/extensions/keyword-extractor/nlp.test.js +176 -0
- package/dist/extras/extensions/keyword-extractor/nlp.test.js.map +1 -0
- package/dist/extras/extensions/logger/extension.d.ts +52 -0
- package/dist/extras/extensions/logger/extension.d.ts.map +1 -0
- package/dist/extras/extensions/logger/extension.js +68 -0
- package/dist/extras/extensions/logger/extension.js.map +1 -0
- package/dist/extras/extensions/logger/extension.json +7 -0
- package/dist/extras/extensions/logger/index.d.ts +3 -0
- package/dist/extras/extensions/logger/index.d.ts.map +1 -0
- package/dist/extras/extensions/logger/index.js +2 -0
- package/dist/extras/extensions/logger/index.js.map +1 -0
- package/dist/extras/extensions/mcp/extension.d.ts +63 -0
- package/dist/extras/extensions/mcp/extension.d.ts.map +1 -0
- package/dist/extras/extensions/mcp/extension.js +437 -0
- package/dist/extras/extensions/mcp/extension.js.map +1 -0
- package/dist/extras/extensions/mcp/extension.json +7 -0
- package/dist/extras/extensions/mcp/extension.test.d.ts +2 -0
- package/dist/extras/extensions/mcp/extension.test.d.ts.map +1 -0
- package/dist/extras/extensions/mcp/extension.test.js +286 -0
- package/dist/extras/extensions/mcp/extension.test.js.map +1 -0
- package/dist/extras/extensions/mcp/index.d.ts +3 -0
- package/dist/extras/extensions/mcp/index.d.ts.map +1 -0
- package/dist/extras/extensions/mcp/index.js +2 -0
- package/dist/extras/extensions/mcp/index.js.map +1 -0
- package/dist/extras/extensions/mem-session/extension.d.ts +24 -0
- package/dist/extras/extensions/mem-session/extension.d.ts.map +1 -0
- package/dist/extras/extensions/mem-session/extension.js +49 -0
- package/dist/extras/extensions/mem-session/extension.js.map +1 -0
- package/dist/extras/extensions/mem-session/extension.json +6 -0
- package/dist/extras/extensions/mem-session/index.d.ts +2 -0
- package/dist/extras/extensions/mem-session/index.d.ts.map +1 -0
- package/dist/extras/extensions/mem-session/index.js +2 -0
- package/dist/extras/extensions/mem-session/index.js.map +1 -0
- package/dist/extras/extensions/memory/extension.d.ts +54 -0
- package/dist/extras/extensions/memory/extension.d.ts.map +1 -0
- package/dist/extras/extensions/memory/extension.js +143 -0
- package/dist/extras/extensions/memory/extension.js.map +1 -0
- package/dist/extras/extensions/memory/extension.json +7 -0
- package/dist/extras/extensions/memory/index.d.ts +5 -0
- package/dist/extras/extensions/memory/index.d.ts.map +1 -0
- package/dist/extras/extensions/memory/index.js +3 -0
- package/dist/extras/extensions/memory/index.js.map +1 -0
- package/dist/extras/extensions/memory/llm-extractor.d.ts +42 -0
- package/dist/extras/extensions/memory/llm-extractor.d.ts.map +1 -0
- package/dist/extras/extensions/memory/llm-extractor.js +89 -0
- package/dist/extras/extensions/memory/llm-extractor.js.map +1 -0
- package/dist/extras/extensions/memory/types.d.ts +142 -0
- package/dist/extras/extensions/memory/types.d.ts.map +1 -0
- package/dist/extras/extensions/memory/types.js +2 -0
- package/dist/extras/extensions/memory/types.js.map +1 -0
- package/dist/extras/extensions/otel/extension.d.ts +57 -0
- package/dist/extras/extensions/otel/extension.d.ts.map +1 -0
- package/dist/extras/extensions/otel/extension.js +253 -0
- package/dist/extras/extensions/otel/extension.js.map +1 -0
- package/dist/extras/extensions/otel/extension.json +7 -0
- package/dist/extras/extensions/otel/extension.test.d.ts +2 -0
- package/dist/extras/extensions/otel/extension.test.d.ts.map +1 -0
- package/dist/extras/extensions/otel/extension.test.js +399 -0
- package/dist/extras/extensions/otel/extension.test.js.map +1 -0
- package/dist/extras/extensions/otel/index.d.ts +4 -0
- package/dist/extras/extensions/otel/index.d.ts.map +1 -0
- package/dist/extras/extensions/otel/index.js +3 -0
- package/dist/extras/extensions/otel/index.js.map +1 -0
- package/dist/extras/extensions/otel/jsonl-span-exporter.d.ts +60 -0
- package/dist/extras/extensions/otel/jsonl-span-exporter.d.ts.map +1 -0
- package/dist/extras/extensions/otel/jsonl-span-exporter.js +134 -0
- package/dist/extras/extensions/otel/jsonl-span-exporter.js.map +1 -0
- package/dist/extras/extensions/scripts/extension.d.ts +180 -0
- package/dist/extras/extensions/scripts/extension.d.ts.map +1 -0
- package/dist/extras/extensions/scripts/extension.js +618 -0
- package/dist/extras/extensions/scripts/extension.js.map +1 -0
- package/dist/extras/extensions/scripts/extension.json +7 -0
- package/dist/extras/extensions/scripts/extension.test.d.ts +2 -0
- package/dist/extras/extensions/scripts/extension.test.d.ts.map +1 -0
- package/dist/extras/extensions/scripts/extension.test.js +552 -0
- package/dist/extras/extensions/scripts/extension.test.js.map +1 -0
- package/dist/extras/extensions/scripts/index.d.ts +3 -0
- package/dist/extras/extensions/scripts/index.d.ts.map +1 -0
- package/dist/extras/extensions/scripts/index.js +2 -0
- package/dist/extras/extensions/scripts/index.js.map +1 -0
- package/dist/extras/extensions/skills/extension.d.ts +105 -0
- package/dist/extras/extensions/skills/extension.d.ts.map +1 -0
- package/dist/extras/extensions/skills/extension.js +419 -0
- package/dist/extras/extensions/skills/extension.js.map +1 -0
- package/dist/extras/extensions/skills/extension.json +7 -0
- package/dist/extras/extensions/skills/extension.test.d.ts +2 -0
- package/dist/extras/extensions/skills/extension.test.d.ts.map +1 -0
- package/dist/extras/extensions/skills/extension.test.js +184 -0
- package/dist/extras/extensions/skills/extension.test.js.map +1 -0
- package/dist/extras/extensions/skills/index.d.ts +4 -0
- package/dist/extras/extensions/skills/index.d.ts.map +1 -0
- package/dist/extras/extensions/skills/index.js +3 -0
- package/dist/extras/extensions/skills/index.js.map +1 -0
- package/dist/extras/extensions/streaming/extension.d.ts +15 -0
- package/dist/extras/extensions/streaming/extension.d.ts.map +1 -0
- package/dist/extras/extensions/streaming/extension.js +41 -0
- package/dist/extras/extensions/streaming/extension.js.map +1 -0
- package/dist/extras/extensions/streaming/extension.json +6 -0
- package/dist/extras/extensions/streaming/index.d.ts +2 -0
- package/dist/extras/extensions/streaming/index.d.ts.map +1 -0
- package/dist/extras/extensions/streaming/index.js +2 -0
- package/dist/extras/extensions/streaming/index.js.map +1 -0
- package/dist/extras/extensions/structured-output/extension.d.ts +11 -0
- package/dist/extras/extensions/structured-output/extension.d.ts.map +1 -0
- package/dist/extras/extensions/structured-output/extension.js +102 -0
- package/dist/extras/extensions/structured-output/extension.js.map +1 -0
- package/dist/extras/extensions/structured-output/extension.json +7 -0
- package/dist/extras/extensions/structured-output/index.d.ts +3 -0
- package/dist/extras/extensions/structured-output/index.d.ts.map +1 -0
- package/dist/extras/extensions/structured-output/index.js +2 -0
- package/dist/extras/extensions/structured-output/index.js.map +1 -0
- package/dist/extras/extensions/timestamp/extension.d.ts +11 -0
- package/dist/extras/extensions/timestamp/extension.d.ts.map +1 -0
- package/dist/extras/extensions/timestamp/extension.js +21 -0
- package/dist/extras/extensions/timestamp/extension.js.map +1 -0
- package/dist/extras/extensions/timestamp/extension.json +6 -0
- package/dist/extras/extensions/timestamp/index.d.ts +2 -0
- package/dist/extras/extensions/timestamp/index.d.ts.map +1 -0
- package/dist/extras/extensions/timestamp/index.js +2 -0
- package/dist/extras/extensions/timestamp/index.js.map +1 -0
- package/dist/extras/extensions/token-stats/extension.d.ts +50 -0
- package/dist/extras/extensions/token-stats/extension.d.ts.map +1 -0
- package/dist/extras/extensions/token-stats/extension.js +99 -0
- package/dist/extras/extensions/token-stats/extension.js.map +1 -0
- package/dist/extras/extensions/token-stats/extension.json +7 -0
- package/dist/extras/extensions/token-stats/extension.test.d.ts +2 -0
- package/dist/extras/extensions/token-stats/extension.test.d.ts.map +1 -0
- package/dist/extras/extensions/token-stats/extension.test.js +197 -0
- package/dist/extras/extensions/token-stats/extension.test.js.map +1 -0
- package/dist/extras/extensions/token-stats/index.d.ts +3 -0
- package/dist/extras/extensions/token-stats/index.d.ts.map +1 -0
- package/dist/extras/extensions/token-stats/index.js +2 -0
- package/dist/extras/extensions/token-stats/index.js.map +1 -0
- package/dist/extras/extensions/tool-buffer/extension.d.ts +89 -0
- package/dist/extras/extensions/tool-buffer/extension.d.ts.map +1 -0
- package/dist/extras/extensions/tool-buffer/extension.js +182 -0
- package/dist/extras/extensions/tool-buffer/extension.js.map +1 -0
- package/dist/extras/extensions/tool-buffer/extension.json +7 -0
- package/dist/extras/extensions/tool-buffer/extension.test.d.ts +2 -0
- package/dist/extras/extensions/tool-buffer/extension.test.d.ts.map +1 -0
- package/dist/extras/extensions/tool-buffer/extension.test.js +549 -0
- package/dist/extras/extensions/tool-buffer/extension.test.js.map +1 -0
- package/dist/extras/extensions/tool-buffer/index.d.ts +3 -0
- package/dist/extras/extensions/tool-buffer/index.d.ts.map +1 -0
- package/dist/extras/extensions/tool-buffer/index.js +2 -0
- package/dist/extras/extensions/tool-buffer/index.js.map +1 -0
- package/dist/extras/extensions/weather-tool/extension.d.ts +22 -0
- package/dist/extras/extensions/weather-tool/extension.d.ts.map +1 -0
- package/dist/extras/extensions/weather-tool/extension.js +33 -0
- package/dist/extras/extensions/weather-tool/extension.js.map +1 -0
- package/dist/extras/extensions/weather-tool/extension.json +7 -0
- package/dist/extras/extensions/weather-tool/index.d.ts +3 -0
- package/dist/extras/extensions/weather-tool/index.d.ts.map +1 -0
- package/dist/extras/extensions/weather-tool/index.js +2 -0
- package/dist/extras/extensions/weather-tool/index.js.map +1 -0
- package/dist/extras/models/ai-sdk-resolver.d.ts +15 -0
- package/dist/extras/models/ai-sdk-resolver.d.ts.map +1 -0
- package/dist/extras/models/ai-sdk-resolver.js +57 -0
- package/dist/extras/models/ai-sdk-resolver.js.map +1 -0
- package/dist/extras/models/index.d.ts +5 -0
- package/dist/extras/models/index.d.ts.map +1 -0
- package/dist/extras/models/index.js +3 -0
- package/dist/extras/models/index.js.map +1 -0
- package/dist/extras/models/routing-model.d.ts +18 -0
- package/dist/extras/models/routing-model.d.ts.map +1 -0
- package/dist/extras/models/routing-model.js +24 -0
- package/dist/extras/models/routing-model.js.map +1 -0
- package/dist/extras/openai-provider/index.d.ts +3 -0
- package/dist/extras/openai-provider/index.d.ts.map +1 -0
- package/dist/extras/openai-provider/index.js +2 -0
- package/dist/extras/openai-provider/index.js.map +1 -0
- package/dist/extras/openai-provider/server.d.ts +34 -0
- package/dist/extras/openai-provider/server.d.ts.map +1 -0
- package/dist/extras/openai-provider/server.js +335 -0
- package/dist/extras/openai-provider/server.js.map +1 -0
- package/dist/handle.d.ts +56 -0
- package/dist/handle.d.ts.map +1 -0
- package/dist/handle.js +2 -0
- package/dist/handle.js.map +1 -0
- package/dist/hooks.d.ts +98 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +53 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/model.d.ts +75 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +2 -0
- package/dist/model.js.map +1 -0
- package/dist/tool.d.ts +78 -0
- package/dist/tool.d.ts.map +1 -0
- package/dist/tool.js +78 -0
- package/dist/tool.js.map +1 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +21 -0
- package/dist/types.js.map +1 -0
- package/package.json +178 -0
|
@@ -0,0 +1,672 @@
|
|
|
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 } 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('filters out tool-call intermediates from channel context to prevent split pairs', async () => {
|
|
428
|
+
// If a tool call pair (assistant+tool) spans the boundary of the
|
|
429
|
+
// channelContextMessages window, the tool message would be included
|
|
430
|
+
// without its preceding assistant message — causing an LLM API error.
|
|
431
|
+
// The fix: filter out tool messages and tool-only assistant messages
|
|
432
|
+
// from channel context windows (topLevelBefore and recentTopLevel).
|
|
433
|
+
// Thread messages keep tool-call intermediates (they're complete sequences).
|
|
434
|
+
const ext = makeExt({ channelContextMessages: 4, recentChannelMessages: 0 });
|
|
435
|
+
let seenMsgs = [];
|
|
436
|
+
// Create a tool-call exchange early in the channel (NOT the thread parent)
|
|
437
|
+
const toolModel = {
|
|
438
|
+
async generate(req) {
|
|
439
|
+
const hasToolResult = req.messages.some((m) => m.role === 'tool');
|
|
440
|
+
if (!hasToolResult) {
|
|
441
|
+
return {
|
|
442
|
+
message: {
|
|
443
|
+
role: 'assistant',
|
|
444
|
+
content: '',
|
|
445
|
+
toolCalls: [{ id: 'tc1', name: 'test_tool', arguments: '{}' }],
|
|
446
|
+
},
|
|
447
|
+
finishReason: 'tool_calls',
|
|
448
|
+
usage: { promptTokens: 0, completionTokens: 0 },
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
message: { role: 'assistant', content: 'tool result summary' },
|
|
453
|
+
finishReason: 'stop',
|
|
454
|
+
usage: { promptTokens: 0, completionTokens: 0 },
|
|
455
|
+
};
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
const toolAgent = new Agent({ model: toolModel });
|
|
459
|
+
toolAgent.use(ext);
|
|
460
|
+
toolAgent.tool({
|
|
461
|
+
name: 'test_tool',
|
|
462
|
+
description: 'test',
|
|
463
|
+
parameters: { type: 'object', properties: {} },
|
|
464
|
+
async execute() { return { toolCallId: 'tc1', content: 'tool output' }; },
|
|
465
|
+
});
|
|
466
|
+
// Turn 1: tool call exchange (produces assistant+tool+assistant records)
|
|
467
|
+
await toolAgent.run({
|
|
468
|
+
message: 'use the tool',
|
|
469
|
+
metadata: { session: { key: 'C1', messageTs: '1001' } },
|
|
470
|
+
});
|
|
471
|
+
// Turn 2: thread parent (a regular message, no tool calls)
|
|
472
|
+
const regAgent = makeAgent(ext);
|
|
473
|
+
await runTurn(regAgent, 'thread parent', sessionIdentity('C1', '1002'));
|
|
474
|
+
// Turns 3-4: more messages to push the tool exchange out of the window
|
|
475
|
+
await runTurn(regAgent, 'msg3', sessionIdentity('C1', '1003'));
|
|
476
|
+
await runTurn(regAgent, 'msg4', sessionIdentity('C1', '1004'));
|
|
477
|
+
// Now thread reply on ts=1002 — the channel context window (4)
|
|
478
|
+
// would include records from the tool exchange (ts=1001) if not filtered.
|
|
479
|
+
regAgent.hook('beforeContext', 'capture', async (ctx) => {
|
|
480
|
+
seenMsgs = ctx.turn.messages.map((m) => ({ role: m.role, content: m.content }));
|
|
481
|
+
});
|
|
482
|
+
await runTurn(regAgent, 'thread reply', sessionIdentity('C1', '1005', { threadTs: '1002' }));
|
|
483
|
+
// No tool messages should be in the channel context
|
|
484
|
+
const toolMsgs = seenMsgs.filter((m) => m.role === 'tool');
|
|
485
|
+
expect(toolMsgs).toHaveLength(0);
|
|
486
|
+
// No assistant messages with empty content (tool-call only) in channel context
|
|
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
|
+
});
|
|
672
|
+
//# sourceMappingURL=extension.test.js.map
|