@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.
Files changed (289) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +486 -0
  3. package/dist/agent.d.ts +174 -0
  4. package/dist/agent.d.ts.map +1 -0
  5. package/dist/agent.js +575 -0
  6. package/dist/agent.js.map +1 -0
  7. package/dist/ai-sdk-model.d.ts +26 -0
  8. package/dist/ai-sdk-model.d.ts.map +1 -0
  9. package/dist/ai-sdk-model.js +260 -0
  10. package/dist/ai-sdk-model.js.map +1 -0
  11. package/dist/context.d.ts +75 -0
  12. package/dist/context.d.ts.map +1 -0
  13. package/dist/context.js +2 -0
  14. package/dist/context.js.map +1 -0
  15. package/dist/errors.d.ts +33 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +59 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/extension.d.ts +63 -0
  20. package/dist/extension.d.ts.map +1 -0
  21. package/dist/extension.js +2 -0
  22. package/dist/extension.js.map +1 -0
  23. package/dist/extras/extension-loader.d.ts +205 -0
  24. package/dist/extras/extension-loader.d.ts.map +1 -0
  25. package/dist/extras/extension-loader.js +315 -0
  26. package/dist/extras/extension-loader.js.map +1 -0
  27. package/dist/extras/extensions/auto-steer/extension.d.ts +46 -0
  28. package/dist/extras/extensions/auto-steer/extension.d.ts.map +1 -0
  29. package/dist/extras/extensions/auto-steer/extension.js +52 -0
  30. package/dist/extras/extensions/auto-steer/extension.js.map +1 -0
  31. package/dist/extras/extensions/auto-steer/extension.json +7 -0
  32. package/dist/extras/extensions/auto-steer/index.d.ts +3 -0
  33. package/dist/extras/extensions/auto-steer/index.d.ts.map +1 -0
  34. package/dist/extras/extensions/auto-steer/index.js +2 -0
  35. package/dist/extras/extensions/auto-steer/index.js.map +1 -0
  36. package/dist/extras/extensions/disk-session/extension.d.ts +167 -0
  37. package/dist/extras/extensions/disk-session/extension.d.ts.map +1 -0
  38. package/dist/extras/extensions/disk-session/extension.js +492 -0
  39. package/dist/extras/extensions/disk-session/extension.js.map +1 -0
  40. package/dist/extras/extensions/disk-session/extension.json +6 -0
  41. package/dist/extras/extensions/disk-session/extension.test.d.ts +2 -0
  42. package/dist/extras/extensions/disk-session/extension.test.d.ts.map +1 -0
  43. package/dist/extras/extensions/disk-session/extension.test.js +672 -0
  44. package/dist/extras/extensions/disk-session/extension.test.js.map +1 -0
  45. package/dist/extras/extensions/disk-session/index.d.ts +3 -0
  46. package/dist/extras/extensions/disk-session/index.d.ts.map +1 -0
  47. package/dist/extras/extensions/disk-session/index.js +2 -0
  48. package/dist/extras/extensions/disk-session/index.js.map +1 -0
  49. package/dist/extras/extensions/emoji/extension.d.ts +20 -0
  50. package/dist/extras/extensions/emoji/extension.d.ts.map +1 -0
  51. package/dist/extras/extensions/emoji/extension.js +22 -0
  52. package/dist/extras/extensions/emoji/extension.js.map +1 -0
  53. package/dist/extras/extensions/emoji/extension.json +7 -0
  54. package/dist/extras/extensions/emoji/index.d.ts +3 -0
  55. package/dist/extras/extensions/emoji/index.d.ts.map +1 -0
  56. package/dist/extras/extensions/emoji/index.js +2 -0
  57. package/dist/extras/extensions/emoji/index.js.map +1 -0
  58. package/dist/extras/extensions/filesystem/extension.d.ts +53 -0
  59. package/dist/extras/extensions/filesystem/extension.d.ts.map +1 -0
  60. package/dist/extras/extensions/filesystem/extension.js +350 -0
  61. package/dist/extras/extensions/filesystem/extension.js.map +1 -0
  62. package/dist/extras/extensions/filesystem/extension.json +6 -0
  63. package/dist/extras/extensions/filesystem/extension.test.d.ts +2 -0
  64. package/dist/extras/extensions/filesystem/extension.test.d.ts.map +1 -0
  65. package/dist/extras/extensions/filesystem/extension.test.js +387 -0
  66. package/dist/extras/extensions/filesystem/extension.test.js.map +1 -0
  67. package/dist/extras/extensions/filesystem/index.d.ts +2 -0
  68. package/dist/extras/extensions/filesystem/index.d.ts.map +1 -0
  69. package/dist/extras/extensions/filesystem/index.js +2 -0
  70. package/dist/extras/extensions/filesystem/index.js.map +1 -0
  71. package/dist/extras/extensions/keyword-extractor/extension.d.ts +53 -0
  72. package/dist/extras/extensions/keyword-extractor/extension.d.ts.map +1 -0
  73. package/dist/extras/extensions/keyword-extractor/extension.js +61 -0
  74. package/dist/extras/extensions/keyword-extractor/extension.js.map +1 -0
  75. package/dist/extras/extensions/keyword-extractor/extension.json +6 -0
  76. package/dist/extras/extensions/keyword-extractor/extension.test.d.ts +2 -0
  77. package/dist/extras/extensions/keyword-extractor/extension.test.d.ts.map +1 -0
  78. package/dist/extras/extensions/keyword-extractor/extension.test.js +105 -0
  79. package/dist/extras/extensions/keyword-extractor/extension.test.js.map +1 -0
  80. package/dist/extras/extensions/keyword-extractor/index.d.ts +5 -0
  81. package/dist/extras/extensions/keyword-extractor/index.d.ts.map +1 -0
  82. package/dist/extras/extensions/keyword-extractor/index.js +3 -0
  83. package/dist/extras/extensions/keyword-extractor/index.js.map +1 -0
  84. package/dist/extras/extensions/keyword-extractor/nlp.d.ts +68 -0
  85. package/dist/extras/extensions/keyword-extractor/nlp.d.ts.map +1 -0
  86. package/dist/extras/extensions/keyword-extractor/nlp.js +156 -0
  87. package/dist/extras/extensions/keyword-extractor/nlp.js.map +1 -0
  88. package/dist/extras/extensions/keyword-extractor/nlp.test.d.ts +2 -0
  89. package/dist/extras/extensions/keyword-extractor/nlp.test.d.ts.map +1 -0
  90. package/dist/extras/extensions/keyword-extractor/nlp.test.js +176 -0
  91. package/dist/extras/extensions/keyword-extractor/nlp.test.js.map +1 -0
  92. package/dist/extras/extensions/logger/extension.d.ts +52 -0
  93. package/dist/extras/extensions/logger/extension.d.ts.map +1 -0
  94. package/dist/extras/extensions/logger/extension.js +68 -0
  95. package/dist/extras/extensions/logger/extension.js.map +1 -0
  96. package/dist/extras/extensions/logger/extension.json +7 -0
  97. package/dist/extras/extensions/logger/index.d.ts +3 -0
  98. package/dist/extras/extensions/logger/index.d.ts.map +1 -0
  99. package/dist/extras/extensions/logger/index.js +2 -0
  100. package/dist/extras/extensions/logger/index.js.map +1 -0
  101. package/dist/extras/extensions/mcp/extension.d.ts +63 -0
  102. package/dist/extras/extensions/mcp/extension.d.ts.map +1 -0
  103. package/dist/extras/extensions/mcp/extension.js +437 -0
  104. package/dist/extras/extensions/mcp/extension.js.map +1 -0
  105. package/dist/extras/extensions/mcp/extension.json +7 -0
  106. package/dist/extras/extensions/mcp/extension.test.d.ts +2 -0
  107. package/dist/extras/extensions/mcp/extension.test.d.ts.map +1 -0
  108. package/dist/extras/extensions/mcp/extension.test.js +286 -0
  109. package/dist/extras/extensions/mcp/extension.test.js.map +1 -0
  110. package/dist/extras/extensions/mcp/index.d.ts +3 -0
  111. package/dist/extras/extensions/mcp/index.d.ts.map +1 -0
  112. package/dist/extras/extensions/mcp/index.js +2 -0
  113. package/dist/extras/extensions/mcp/index.js.map +1 -0
  114. package/dist/extras/extensions/mem-session/extension.d.ts +24 -0
  115. package/dist/extras/extensions/mem-session/extension.d.ts.map +1 -0
  116. package/dist/extras/extensions/mem-session/extension.js +49 -0
  117. package/dist/extras/extensions/mem-session/extension.js.map +1 -0
  118. package/dist/extras/extensions/mem-session/extension.json +6 -0
  119. package/dist/extras/extensions/mem-session/index.d.ts +2 -0
  120. package/dist/extras/extensions/mem-session/index.d.ts.map +1 -0
  121. package/dist/extras/extensions/mem-session/index.js +2 -0
  122. package/dist/extras/extensions/mem-session/index.js.map +1 -0
  123. package/dist/extras/extensions/memory/extension.d.ts +54 -0
  124. package/dist/extras/extensions/memory/extension.d.ts.map +1 -0
  125. package/dist/extras/extensions/memory/extension.js +143 -0
  126. package/dist/extras/extensions/memory/extension.js.map +1 -0
  127. package/dist/extras/extensions/memory/extension.json +7 -0
  128. package/dist/extras/extensions/memory/index.d.ts +5 -0
  129. package/dist/extras/extensions/memory/index.d.ts.map +1 -0
  130. package/dist/extras/extensions/memory/index.js +3 -0
  131. package/dist/extras/extensions/memory/index.js.map +1 -0
  132. package/dist/extras/extensions/memory/llm-extractor.d.ts +42 -0
  133. package/dist/extras/extensions/memory/llm-extractor.d.ts.map +1 -0
  134. package/dist/extras/extensions/memory/llm-extractor.js +89 -0
  135. package/dist/extras/extensions/memory/llm-extractor.js.map +1 -0
  136. package/dist/extras/extensions/memory/types.d.ts +142 -0
  137. package/dist/extras/extensions/memory/types.d.ts.map +1 -0
  138. package/dist/extras/extensions/memory/types.js +2 -0
  139. package/dist/extras/extensions/memory/types.js.map +1 -0
  140. package/dist/extras/extensions/otel/extension.d.ts +57 -0
  141. package/dist/extras/extensions/otel/extension.d.ts.map +1 -0
  142. package/dist/extras/extensions/otel/extension.js +253 -0
  143. package/dist/extras/extensions/otel/extension.js.map +1 -0
  144. package/dist/extras/extensions/otel/extension.json +7 -0
  145. package/dist/extras/extensions/otel/extension.test.d.ts +2 -0
  146. package/dist/extras/extensions/otel/extension.test.d.ts.map +1 -0
  147. package/dist/extras/extensions/otel/extension.test.js +399 -0
  148. package/dist/extras/extensions/otel/extension.test.js.map +1 -0
  149. package/dist/extras/extensions/otel/index.d.ts +4 -0
  150. package/dist/extras/extensions/otel/index.d.ts.map +1 -0
  151. package/dist/extras/extensions/otel/index.js +3 -0
  152. package/dist/extras/extensions/otel/index.js.map +1 -0
  153. package/dist/extras/extensions/otel/jsonl-span-exporter.d.ts +60 -0
  154. package/dist/extras/extensions/otel/jsonl-span-exporter.d.ts.map +1 -0
  155. package/dist/extras/extensions/otel/jsonl-span-exporter.js +134 -0
  156. package/dist/extras/extensions/otel/jsonl-span-exporter.js.map +1 -0
  157. package/dist/extras/extensions/scripts/extension.d.ts +180 -0
  158. package/dist/extras/extensions/scripts/extension.d.ts.map +1 -0
  159. package/dist/extras/extensions/scripts/extension.js +618 -0
  160. package/dist/extras/extensions/scripts/extension.js.map +1 -0
  161. package/dist/extras/extensions/scripts/extension.json +7 -0
  162. package/dist/extras/extensions/scripts/extension.test.d.ts +2 -0
  163. package/dist/extras/extensions/scripts/extension.test.d.ts.map +1 -0
  164. package/dist/extras/extensions/scripts/extension.test.js +552 -0
  165. package/dist/extras/extensions/scripts/extension.test.js.map +1 -0
  166. package/dist/extras/extensions/scripts/index.d.ts +3 -0
  167. package/dist/extras/extensions/scripts/index.d.ts.map +1 -0
  168. package/dist/extras/extensions/scripts/index.js +2 -0
  169. package/dist/extras/extensions/scripts/index.js.map +1 -0
  170. package/dist/extras/extensions/skills/extension.d.ts +105 -0
  171. package/dist/extras/extensions/skills/extension.d.ts.map +1 -0
  172. package/dist/extras/extensions/skills/extension.js +419 -0
  173. package/dist/extras/extensions/skills/extension.js.map +1 -0
  174. package/dist/extras/extensions/skills/extension.json +7 -0
  175. package/dist/extras/extensions/skills/extension.test.d.ts +2 -0
  176. package/dist/extras/extensions/skills/extension.test.d.ts.map +1 -0
  177. package/dist/extras/extensions/skills/extension.test.js +184 -0
  178. package/dist/extras/extensions/skills/extension.test.js.map +1 -0
  179. package/dist/extras/extensions/skills/index.d.ts +4 -0
  180. package/dist/extras/extensions/skills/index.d.ts.map +1 -0
  181. package/dist/extras/extensions/skills/index.js +3 -0
  182. package/dist/extras/extensions/skills/index.js.map +1 -0
  183. package/dist/extras/extensions/streaming/extension.d.ts +15 -0
  184. package/dist/extras/extensions/streaming/extension.d.ts.map +1 -0
  185. package/dist/extras/extensions/streaming/extension.js +41 -0
  186. package/dist/extras/extensions/streaming/extension.js.map +1 -0
  187. package/dist/extras/extensions/streaming/extension.json +6 -0
  188. package/dist/extras/extensions/streaming/index.d.ts +2 -0
  189. package/dist/extras/extensions/streaming/index.d.ts.map +1 -0
  190. package/dist/extras/extensions/streaming/index.js +2 -0
  191. package/dist/extras/extensions/streaming/index.js.map +1 -0
  192. package/dist/extras/extensions/structured-output/extension.d.ts +11 -0
  193. package/dist/extras/extensions/structured-output/extension.d.ts.map +1 -0
  194. package/dist/extras/extensions/structured-output/extension.js +102 -0
  195. package/dist/extras/extensions/structured-output/extension.js.map +1 -0
  196. package/dist/extras/extensions/structured-output/extension.json +7 -0
  197. package/dist/extras/extensions/structured-output/index.d.ts +3 -0
  198. package/dist/extras/extensions/structured-output/index.d.ts.map +1 -0
  199. package/dist/extras/extensions/structured-output/index.js +2 -0
  200. package/dist/extras/extensions/structured-output/index.js.map +1 -0
  201. package/dist/extras/extensions/timestamp/extension.d.ts +11 -0
  202. package/dist/extras/extensions/timestamp/extension.d.ts.map +1 -0
  203. package/dist/extras/extensions/timestamp/extension.js +21 -0
  204. package/dist/extras/extensions/timestamp/extension.js.map +1 -0
  205. package/dist/extras/extensions/timestamp/extension.json +6 -0
  206. package/dist/extras/extensions/timestamp/index.d.ts +2 -0
  207. package/dist/extras/extensions/timestamp/index.d.ts.map +1 -0
  208. package/dist/extras/extensions/timestamp/index.js +2 -0
  209. package/dist/extras/extensions/timestamp/index.js.map +1 -0
  210. package/dist/extras/extensions/token-stats/extension.d.ts +50 -0
  211. package/dist/extras/extensions/token-stats/extension.d.ts.map +1 -0
  212. package/dist/extras/extensions/token-stats/extension.js +99 -0
  213. package/dist/extras/extensions/token-stats/extension.js.map +1 -0
  214. package/dist/extras/extensions/token-stats/extension.json +7 -0
  215. package/dist/extras/extensions/token-stats/extension.test.d.ts +2 -0
  216. package/dist/extras/extensions/token-stats/extension.test.d.ts.map +1 -0
  217. package/dist/extras/extensions/token-stats/extension.test.js +197 -0
  218. package/dist/extras/extensions/token-stats/extension.test.js.map +1 -0
  219. package/dist/extras/extensions/token-stats/index.d.ts +3 -0
  220. package/dist/extras/extensions/token-stats/index.d.ts.map +1 -0
  221. package/dist/extras/extensions/token-stats/index.js +2 -0
  222. package/dist/extras/extensions/token-stats/index.js.map +1 -0
  223. package/dist/extras/extensions/tool-buffer/extension.d.ts +89 -0
  224. package/dist/extras/extensions/tool-buffer/extension.d.ts.map +1 -0
  225. package/dist/extras/extensions/tool-buffer/extension.js +182 -0
  226. package/dist/extras/extensions/tool-buffer/extension.js.map +1 -0
  227. package/dist/extras/extensions/tool-buffer/extension.json +7 -0
  228. package/dist/extras/extensions/tool-buffer/extension.test.d.ts +2 -0
  229. package/dist/extras/extensions/tool-buffer/extension.test.d.ts.map +1 -0
  230. package/dist/extras/extensions/tool-buffer/extension.test.js +549 -0
  231. package/dist/extras/extensions/tool-buffer/extension.test.js.map +1 -0
  232. package/dist/extras/extensions/tool-buffer/index.d.ts +3 -0
  233. package/dist/extras/extensions/tool-buffer/index.d.ts.map +1 -0
  234. package/dist/extras/extensions/tool-buffer/index.js +2 -0
  235. package/dist/extras/extensions/tool-buffer/index.js.map +1 -0
  236. package/dist/extras/extensions/weather-tool/extension.d.ts +22 -0
  237. package/dist/extras/extensions/weather-tool/extension.d.ts.map +1 -0
  238. package/dist/extras/extensions/weather-tool/extension.js +33 -0
  239. package/dist/extras/extensions/weather-tool/extension.js.map +1 -0
  240. package/dist/extras/extensions/weather-tool/extension.json +7 -0
  241. package/dist/extras/extensions/weather-tool/index.d.ts +3 -0
  242. package/dist/extras/extensions/weather-tool/index.d.ts.map +1 -0
  243. package/dist/extras/extensions/weather-tool/index.js +2 -0
  244. package/dist/extras/extensions/weather-tool/index.js.map +1 -0
  245. package/dist/extras/models/ai-sdk-resolver.d.ts +15 -0
  246. package/dist/extras/models/ai-sdk-resolver.d.ts.map +1 -0
  247. package/dist/extras/models/ai-sdk-resolver.js +57 -0
  248. package/dist/extras/models/ai-sdk-resolver.js.map +1 -0
  249. package/dist/extras/models/index.d.ts +5 -0
  250. package/dist/extras/models/index.d.ts.map +1 -0
  251. package/dist/extras/models/index.js +3 -0
  252. package/dist/extras/models/index.js.map +1 -0
  253. package/dist/extras/models/routing-model.d.ts +18 -0
  254. package/dist/extras/models/routing-model.d.ts.map +1 -0
  255. package/dist/extras/models/routing-model.js +24 -0
  256. package/dist/extras/models/routing-model.js.map +1 -0
  257. package/dist/extras/openai-provider/index.d.ts +3 -0
  258. package/dist/extras/openai-provider/index.d.ts.map +1 -0
  259. package/dist/extras/openai-provider/index.js +2 -0
  260. package/dist/extras/openai-provider/index.js.map +1 -0
  261. package/dist/extras/openai-provider/server.d.ts +34 -0
  262. package/dist/extras/openai-provider/server.d.ts.map +1 -0
  263. package/dist/extras/openai-provider/server.js +335 -0
  264. package/dist/extras/openai-provider/server.js.map +1 -0
  265. package/dist/handle.d.ts +56 -0
  266. package/dist/handle.d.ts.map +1 -0
  267. package/dist/handle.js +2 -0
  268. package/dist/handle.js.map +1 -0
  269. package/dist/hooks.d.ts +98 -0
  270. package/dist/hooks.d.ts.map +1 -0
  271. package/dist/hooks.js +53 -0
  272. package/dist/hooks.js.map +1 -0
  273. package/dist/index.d.ts +16 -0
  274. package/dist/index.d.ts.map +1 -0
  275. package/dist/index.js +9 -0
  276. package/dist/index.js.map +1 -0
  277. package/dist/model.d.ts +75 -0
  278. package/dist/model.d.ts.map +1 -0
  279. package/dist/model.js +2 -0
  280. package/dist/model.js.map +1 -0
  281. package/dist/tool.d.ts +78 -0
  282. package/dist/tool.d.ts.map +1 -0
  283. package/dist/tool.js +78 -0
  284. package/dist/tool.js.map +1 -0
  285. package/dist/types.d.ts +83 -0
  286. package/dist/types.d.ts.map +1 -0
  287. package/dist/types.js +21 -0
  288. package/dist/types.js.map +1 -0
  289. 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