@onmars/lunar-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/package.json +32 -0
  4. package/src/__tests__/clear-command.test.ts +214 -0
  5. package/src/__tests__/command-handler.test.ts +169 -0
  6. package/src/__tests__/compact-command.test.ts +80 -0
  7. package/src/__tests__/config-command.test.ts +240 -0
  8. package/src/__tests__/config-loader.test.ts +1512 -0
  9. package/src/__tests__/config.test.ts +429 -0
  10. package/src/__tests__/cron-command.test.ts +418 -0
  11. package/src/__tests__/cron-parser.test.ts +259 -0
  12. package/src/__tests__/daemon.test.ts +346 -0
  13. package/src/__tests__/dedup.test.ts +404 -0
  14. package/src/__tests__/e2e-sanitization.ts +168 -0
  15. package/src/__tests__/e2e-skill-loader.test.ts +176 -0
  16. package/src/__tests__/fixtures/AGENTS.md +4 -0
  17. package/src/__tests__/fixtures/IDENTITY.md +2 -0
  18. package/src/__tests__/fixtures/SOUL.md +3 -0
  19. package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
  20. package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
  21. package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
  22. package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
  23. package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
  24. package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
  25. package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
  26. package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
  27. package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
  28. package/src/__tests__/hook-runner.test.ts +1689 -0
  29. package/src/__tests__/input-sanitization.test.ts +367 -0
  30. package/src/__tests__/logger.test.ts +163 -0
  31. package/src/__tests__/memory-orchestrator.test.ts +552 -0
  32. package/src/__tests__/model-catalog.test.ts +215 -0
  33. package/src/__tests__/model-command.test.ts +185 -0
  34. package/src/__tests__/moon-loader.test.ts +398 -0
  35. package/src/__tests__/ping-command.test.ts +85 -0
  36. package/src/__tests__/plugin.test.ts +258 -0
  37. package/src/__tests__/remind-command.test.ts +368 -0
  38. package/src/__tests__/reset-command.test.ts +92 -0
  39. package/src/__tests__/router.test.ts +1246 -0
  40. package/src/__tests__/scheduler.test.ts +469 -0
  41. package/src/__tests__/security.test.ts +214 -0
  42. package/src/__tests__/session-meta.test.ts +101 -0
  43. package/src/__tests__/session-tracker.test.ts +389 -0
  44. package/src/__tests__/session.test.ts +241 -0
  45. package/src/__tests__/skill-loader.test.ts +153 -0
  46. package/src/__tests__/status-command.test.ts +153 -0
  47. package/src/__tests__/stop-command.test.ts +60 -0
  48. package/src/__tests__/think-command.test.ts +146 -0
  49. package/src/__tests__/usage-api.test.ts +222 -0
  50. package/src/__tests__/usage-command-api-fail.test.ts +48 -0
  51. package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
  52. package/src/__tests__/usage-command.test.ts +173 -0
  53. package/src/__tests__/whoami-command.test.ts +124 -0
  54. package/src/index.ts +122 -0
  55. package/src/lib/command-handler.ts +135 -0
  56. package/src/lib/commands/clear.ts +69 -0
  57. package/src/lib/commands/compact.ts +14 -0
  58. package/src/lib/commands/config-show.ts +49 -0
  59. package/src/lib/commands/cron.ts +118 -0
  60. package/src/lib/commands/help.ts +26 -0
  61. package/src/lib/commands/model.ts +71 -0
  62. package/src/lib/commands/ping.ts +24 -0
  63. package/src/lib/commands/remind.ts +75 -0
  64. package/src/lib/commands/status.ts +118 -0
  65. package/src/lib/commands/stop.ts +18 -0
  66. package/src/lib/commands/think.ts +42 -0
  67. package/src/lib/commands/usage.ts +56 -0
  68. package/src/lib/commands/whoami.ts +23 -0
  69. package/src/lib/config-loader.ts +1449 -0
  70. package/src/lib/config.ts +202 -0
  71. package/src/lib/cron-parser.ts +388 -0
  72. package/src/lib/daemon.ts +216 -0
  73. package/src/lib/dedup.ts +414 -0
  74. package/src/lib/hook-runner.ts +1270 -0
  75. package/src/lib/logger.ts +55 -0
  76. package/src/lib/memory-orchestrator.ts +415 -0
  77. package/src/lib/model-catalog.ts +240 -0
  78. package/src/lib/moon-loader.ts +291 -0
  79. package/src/lib/plugin.ts +148 -0
  80. package/src/lib/router.ts +1135 -0
  81. package/src/lib/scheduler.ts +422 -0
  82. package/src/lib/security.ts +259 -0
  83. package/src/lib/session-tracker.ts +222 -0
  84. package/src/lib/session.ts +158 -0
  85. package/src/lib/skill-loader.ts +166 -0
  86. package/src/lib/usage-api.ts +145 -0
  87. package/src/types/agent.ts +86 -0
  88. package/src/types/channel.ts +93 -0
  89. package/src/types/index.ts +32 -0
  90. package/src/types/memory.ts +92 -0
  91. package/src/types/moon.ts +56 -0
  92. package/src/types/voice.ts +74 -0
@@ -0,0 +1,1246 @@
1
+ /**
2
+ * # Router — Functional Specification
3
+ *
4
+ * The Router is the central orchestrator of the message flow:
5
+ *
6
+ * Incoming Message → Auth → Session → [Memory Recall] → Agent Query → [Redaction] → Response
7
+ *
8
+ * ## Responsibilities:
9
+ * 1. **Auth gating**: only allowed sender IDs can trigger agent queries
10
+ * 2. **Session management**: maps channel:chatId → agentSessionId for conversation resumption
11
+ * 3. **Moon resolution**: selects the right persona (SOUL.md) per channel
12
+ * 4. **Auto-recall** (optional): calls MemoryProvider.recall() before each query and
13
+ * injects relevant memories into the system prompt as `## Memory Context`
14
+ * 5. **Memory instructions** (optional): injects brain/memory API usage instructions
15
+ * into system prompt with 3-level priority cascade:
16
+ * 1. `config.yaml memory.instructionsFile` (explicit path)
17
+ * 2. `prompts/{provider}.md` in workspace (convention-based)
18
+ * 3. Provider's built-in `agentInstructions()` (fallback)
19
+ * 6. **Output redaction**: scrubs known secret patterns before sending to channel
20
+ * 7. **Typing indicator**: sends Discord-style typing signals during processing
21
+ * 8. **Error handling**: agent failures are caught and surfaced as user-friendly messages
22
+ */
23
+ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
24
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
25
+ import { resolve } from 'node:path'
26
+ import type { LunarConfig } from '../lib/config'
27
+ import type { MemoryConfig, RecallConfig, SecurityConfig } from '../lib/config-loader'
28
+ import { MoonLoader } from '../lib/moon-loader'
29
+ import { PluginRegistry } from '../lib/plugin'
30
+ import { Router } from '../lib/router'
31
+ import { SessionStore } from '../lib/session'
32
+ import type { Agent, AgentEvent } from '../types/agent'
33
+ import type { Channel, IncomingMessage } from '../types/channel'
34
+ import type { MemoryProvider, MemoryResult } from '../types/memory'
35
+
36
+ // ─── Test infrastructure ───────────────────────────────────────────
37
+
38
+ const TMP = resolve(import.meta.dir, '.tmp-router-test')
39
+ const DB_PATH = resolve(TMP, 'test.db')
40
+
41
+ function setupWorkspace(extraFiles?: Record<string, string>): string {
42
+ rmSync(TMP, { recursive: true, force: true })
43
+ mkdirSync(resolve(TMP, 'prompts'), { recursive: true })
44
+ writeFileSync(resolve(TMP, 'SOUL.md'), '# Test Moon\nI am a test assistant.')
45
+ writeFileSync(resolve(TMP, 'AGENTS.md'), 'Be helpful.')
46
+ writeFileSync(resolve(TMP, 'IDENTITY.md'), '- **Name:** TestMoon\n- **Emoji:** 🧪')
47
+
48
+ if (extraFiles) {
49
+ for (const [path, content] of Object.entries(extraFiles)) {
50
+ const fullPath = resolve(TMP, path)
51
+ mkdirSync(resolve(fullPath, '..'), { recursive: true })
52
+ writeFileSync(fullPath, content)
53
+ }
54
+ }
55
+ return TMP
56
+ }
57
+
58
+ function cleanup(): void {
59
+ rmSync(TMP, { recursive: true, force: true })
60
+ }
61
+
62
+ const mockConfig: LunarConfig = {
63
+ discordToken: 'test-token',
64
+ discordGuildId: 'test-guild',
65
+ discordAllowedUsers: ['user-123'],
66
+ rootDir: TMP,
67
+ moonWorkspace: TMP,
68
+ dataDir: resolve(TMP, 'store'),
69
+ agentBackend: 'claude',
70
+ logLevel: 'info',
71
+ dev: false,
72
+ }
73
+
74
+ function createChannel(): Channel & {
75
+ lastSent: { target: string; msg: any } | null
76
+ typingCount: number
77
+ } {
78
+ const ch = {
79
+ id: 'discord',
80
+ name: 'Mock Discord',
81
+ lastSent: null as { target: string; msg: any } | null,
82
+ typingCount: 0,
83
+ connect: async () => {},
84
+ disconnect: async () => {},
85
+ isConnected: () => true,
86
+ send: async (target: string, msg: any) => {
87
+ ch.lastSent = { target, msg }
88
+ return undefined
89
+ },
90
+ sendTyping: async () => {
91
+ ch.typingCount++
92
+ },
93
+ onMessage: () => {},
94
+ }
95
+ return ch as typeof ch & Channel
96
+ }
97
+
98
+ function createAgent(response = 'Hello!'): Agent {
99
+ return {
100
+ id: 'claude',
101
+ name: 'Mock Claude',
102
+ init: async () => {},
103
+ destroy: async () => {},
104
+ query: async function* (input) {
105
+ yield { type: 'text' as const, content: response }
106
+ yield { type: 'done' as const, sessionId: 'session-1' }
107
+ },
108
+ health: async () => ({ ok: true }),
109
+ }
110
+ }
111
+
112
+ /** Spy agent: captures the AgentInput so tests can inspect systemPrompt */
113
+ function createSpyAgent(): Agent & { capturedInput: any } {
114
+ const spy: Agent & { capturedInput: any } = {
115
+ id: 'claude',
116
+ name: 'Spy Claude',
117
+ capturedInput: null,
118
+ init: async () => {},
119
+ destroy: async () => {},
120
+ query: async function* (input) {
121
+ spy.capturedInput = input
122
+ yield { type: 'text' as const, content: 'ok' }
123
+ yield { type: 'done' as const, sessionId: 's1' }
124
+ },
125
+ health: async () => ({ ok: true }),
126
+ }
127
+ return spy
128
+ }
129
+
130
+ function createMemory(
131
+ recallResults: MemoryResult[] = [],
132
+ ): MemoryProvider & { lastRecallQuery: string | null } {
133
+ const mem: MemoryProvider & { lastRecallQuery: string | null } = {
134
+ id: 'brain',
135
+ name: 'Test Brain',
136
+ lastRecallQuery: null,
137
+ init: async () => {},
138
+ destroy: async () => {},
139
+ recall: async (query: string) => {
140
+ mem.lastRecallQuery = query
141
+ return recallResults
142
+ },
143
+ save: async () => {},
144
+ health: async () => ({ ok: true }),
145
+ agentInstructions: () => '## Test Brain Instructions\nUse curl to query the brain.',
146
+ }
147
+ return mem
148
+ }
149
+
150
+ function msg(text: string, senderId = 'user-123'): IncomingMessage {
151
+ return {
152
+ id: 'msg-1',
153
+ channelId: 'chan-1',
154
+ text,
155
+ sender: { id: senderId, name: 'Test User' },
156
+ timestamp: new Date(),
157
+ }
158
+ }
159
+
160
+ // ═══════════════════════════════════════════════════════════════════
161
+
162
+ describe('Router', () => {
163
+ let registry: PluginRegistry
164
+ let sessions: SessionStore
165
+ let moonLoader: MoonLoader
166
+ let channel: ReturnType<typeof createChannel>
167
+
168
+ beforeEach(async () => {
169
+ setupWorkspace()
170
+ registry = new PluginRegistry()
171
+ sessions = new SessionStore(DB_PATH)
172
+ moonLoader = new MoonLoader()
173
+ await moonLoader.loadAll(TMP)
174
+ channel = createChannel()
175
+ registry.registerChannel(channel)
176
+ })
177
+
178
+ afterEach(() => {
179
+ sessions.close()
180
+ cleanup()
181
+ })
182
+
183
+ // ─── Core message flow ─────────────────────────────────────────
184
+
185
+ describe('basic message flow', () => {
186
+ it('routes message → agent → channel response', async () => {
187
+ registry.registerAgent(createAgent('Hello from agent!'), true)
188
+ const router = new Router(registry, sessions, mockConfig, moonLoader, [])
189
+
190
+ await router.route('discord', msg('Hi'))
191
+
192
+ expect(channel.lastSent).not.toBeNull()
193
+ expect(channel.lastSent!.msg.text).toBe('Hello from agent!')
194
+ })
195
+
196
+ it('sends typing indicator while processing', async () => {
197
+ registry.registerAgent(createAgent(), true)
198
+ const router = new Router(registry, sessions, mockConfig, moonLoader, [])
199
+
200
+ await router.route('discord', msg('Hello'))
201
+
202
+ expect(channel.typingCount).toBeGreaterThan(0)
203
+ })
204
+
205
+ it('creates persistent session on first message', async () => {
206
+ registry.registerAgent(createAgent(), true)
207
+ const router = new Router(registry, sessions, mockConfig, moonLoader, [])
208
+
209
+ await router.route('discord', msg('Hello'))
210
+
211
+ const session = sessions.get('claude:discord:chan-1')
212
+ expect(session).not.toBeNull()
213
+ expect(session!.agentSessionId).toBe('session-1')
214
+ })
215
+ })
216
+
217
+ // ─── Authorization ─────────────────────────────────────────────
218
+
219
+ describe('auth — sender allowlist', () => {
220
+ it('rejects messages from unauthorized senders (silent drop)', async () => {
221
+ registry.registerAgent(createAgent(), true)
222
+ const router = new Router(registry, sessions, mockConfig, moonLoader, [])
223
+
224
+ await router.route('discord', msg('Hello', 'unauthorized-user'))
225
+
226
+ expect(channel.lastSent).toBeNull()
227
+ })
228
+
229
+ it('allows all senders when allowlist is empty (open mode)', async () => {
230
+ const openConfig = { ...mockConfig, discordAllowedUsers: [] }
231
+ registry.registerAgent(createAgent(), true)
232
+ const router = new Router(registry, sessions, openConfig, moonLoader, [])
233
+
234
+ await router.route('discord', msg('Hello', 'anyone'))
235
+
236
+ expect(channel.lastSent).not.toBeNull()
237
+ })
238
+ })
239
+
240
+ // ─── Auto-recall ───────────────────────────────────────────────
241
+
242
+ describe('auto-recall — inject relevant memories before query', () => {
243
+ const autoRecall: RecallConfig = { enabled: true, limit: 5, minScore: 0.3, budget: 800 }
244
+
245
+ it('calls memory.recall() with user message as query', async () => {
246
+ const memory = createMemory([
247
+ { content: 'Alice likes TypeScript', score: 0.8, metadata: { type: 'fact' } },
248
+ ])
249
+ const spy = createSpyAgent()
250
+ registry.registerAgent(spy, true)
251
+
252
+ const router = new Router(
253
+ registry,
254
+ sessions,
255
+ mockConfig,
256
+ moonLoader,
257
+ [],
258
+ undefined,
259
+ memory,
260
+ autoRecall,
261
+ )
262
+
263
+ await router.route('discord', msg('What do I like?'))
264
+
265
+ expect(memory.lastRecallQuery).toBe('What do I like?')
266
+ })
267
+
268
+ it('injects recalled memories into system prompt', async () => {
269
+ const spy = createSpyAgent()
270
+ registry.registerAgent(spy, true)
271
+
272
+ const memory = createMemory([
273
+ { content: 'Important fact', score: 0.85, metadata: { type: 'fact' } },
274
+ ])
275
+
276
+ const router = new Router(
277
+ registry,
278
+ sessions,
279
+ mockConfig,
280
+ moonLoader,
281
+ [],
282
+ undefined,
283
+ memory,
284
+ autoRecall,
285
+ )
286
+
287
+ await router.route('discord', msg('test'))
288
+
289
+ // System prompt should contain the memory context section
290
+ expect(spy.capturedInput.systemPrompt).toContain('Memory Context')
291
+ expect(spy.capturedInput.systemPrompt).toContain('Important fact')
292
+ expect(spy.capturedInput.systemPrompt).toContain('85%')
293
+ })
294
+
295
+ it('skips recall entirely when autoRecall.enabled=false', async () => {
296
+ const memory = createMemory()
297
+ registry.registerAgent(createAgent(), true)
298
+
299
+ const router = new Router(registry, sessions, mockConfig, moonLoader, [], undefined, memory, {
300
+ ...autoRecall,
301
+ enabled: false,
302
+ })
303
+
304
+ await router.route('discord', msg('test'))
305
+
306
+ expect(memory.lastRecallQuery).toBeNull()
307
+ })
308
+
309
+ it('does not inject memory section when recall returns empty', async () => {
310
+ const spy = createSpyAgent()
311
+ registry.registerAgent(spy, true)
312
+
313
+ const memory = createMemory([]) // empty results
314
+ const router = new Router(
315
+ registry,
316
+ sessions,
317
+ mockConfig,
318
+ moonLoader,
319
+ [],
320
+ undefined,
321
+ memory,
322
+ autoRecall,
323
+ )
324
+
325
+ await router.route('discord', msg('test'))
326
+
327
+ expect(spy.capturedInput.systemPrompt).not.toContain('Memory Context')
328
+ })
329
+
330
+ it('continues gracefully when recall throws (no crash)', async () => {
331
+ const failingMemory = createMemory()
332
+ failingMemory.recall = async () => {
333
+ throw new Error('Brain is down')
334
+ }
335
+ registry.registerAgent(createAgent(), true)
336
+
337
+ const router = new Router(
338
+ registry,
339
+ sessions,
340
+ mockConfig,
341
+ moonLoader,
342
+ [],
343
+ undefined,
344
+ failingMemory,
345
+ autoRecall,
346
+ )
347
+
348
+ await router.route('discord', msg('test'))
349
+
350
+ // Should still get a response — recall failure is non-fatal
351
+ expect(channel.lastSent).not.toBeNull()
352
+ expect(channel.lastSent!.msg.text).toBe('Hello!')
353
+ })
354
+ })
355
+
356
+ // ─── Memory instructions cascade ──────────────────────────────
357
+
358
+ describe('memory instructions — 3-level priority cascade', () => {
359
+ const autoRecall: RecallConfig = { enabled: true, limit: 5, minScore: 0.3, budget: 800 }
360
+ const baseMemConfig: MemoryConfig = {
361
+ recall: { enabled: true, limit: 5, minScore: 0.3, budget: 800 },
362
+ dedup: { strategy: 'adaptive', threshold: 0.85 },
363
+ providers: { brain: { url: 'http://localhost:8082' } },
364
+ }
365
+
366
+ it('Level 3 (lowest): uses provider built-in agentInstructions()', async () => {
367
+ const spy = createSpyAgent()
368
+ registry.registerAgent(spy, true)
369
+
370
+ const router = new Router(
371
+ registry,
372
+ sessions,
373
+ mockConfig,
374
+ moonLoader,
375
+ [],
376
+ undefined,
377
+ createMemory(),
378
+ autoRecall,
379
+ TMP,
380
+ baseMemConfig,
381
+ )
382
+
383
+ await router.route('discord', msg('test'))
384
+
385
+ expect(spy.capturedInput.systemPrompt).toContain('Test Brain Instructions')
386
+ })
387
+
388
+ it('Level 2: prompts/{provider}.md overrides built-in', async () => {
389
+ setupWorkspace({
390
+ 'prompts/brain.md': '## Custom Brain Prompt\nDo custom things with the brain.',
391
+ })
392
+ await moonLoader.loadAll(TMP)
393
+
394
+ const spy = createSpyAgent()
395
+ registry = new PluginRegistry()
396
+ registry.registerChannel(channel)
397
+ registry.registerAgent(spy, true)
398
+
399
+ const router = new Router(
400
+ registry,
401
+ sessions,
402
+ mockConfig,
403
+ moonLoader,
404
+ [],
405
+ undefined,
406
+ createMemory(),
407
+ autoRecall,
408
+ TMP,
409
+ baseMemConfig,
410
+ )
411
+
412
+ await router.route('discord', msg('test'))
413
+
414
+ expect(spy.capturedInput.systemPrompt).toContain('Custom Brain Prompt')
415
+ expect(spy.capturedInput.systemPrompt).not.toContain('Test Brain Instructions')
416
+ })
417
+
418
+ it('Level 1 (highest): config.instructionsFile overrides everything', async () => {
419
+ setupWorkspace({
420
+ 'prompts/brain.md': 'Convention prompt',
421
+ 'custom/my-brain-prompt.md': '## Override Prompt\nExplicit override.',
422
+ })
423
+ await moonLoader.loadAll(TMP)
424
+
425
+ const spy = createSpyAgent()
426
+ registry = new PluginRegistry()
427
+ registry.registerChannel(channel)
428
+ registry.registerAgent(spy, true)
429
+
430
+ const memConfig = {
431
+ ...baseMemConfig,
432
+ providers: {
433
+ brain: { url: 'http://localhost:8082', instructions: 'custom/my-brain-prompt.md' },
434
+ },
435
+ }
436
+ const router = new Router(
437
+ registry,
438
+ sessions,
439
+ mockConfig,
440
+ moonLoader,
441
+ [],
442
+ undefined,
443
+ createMemory(),
444
+ autoRecall,
445
+ TMP,
446
+ memConfig,
447
+ )
448
+
449
+ await router.route('discord', msg('test'))
450
+
451
+ expect(spy.capturedInput.systemPrompt).toContain('Override Prompt')
452
+ expect(spy.capturedInput.systemPrompt).not.toContain('Convention prompt')
453
+ expect(spy.capturedInput.systemPrompt).not.toContain('Test Brain Instructions')
454
+ })
455
+
456
+ it('no instructions when provider lacks agentInstructions()', async () => {
457
+ const spy = createSpyAgent()
458
+ registry.registerAgent(spy, true)
459
+
460
+ const memory = createMemory()
461
+ delete (memory as any).agentInstructions
462
+
463
+ const router = new Router(
464
+ registry,
465
+ sessions,
466
+ mockConfig,
467
+ moonLoader,
468
+ [],
469
+ undefined,
470
+ memory,
471
+ autoRecall,
472
+ )
473
+
474
+ await router.route('discord', msg('test'))
475
+
476
+ expect(spy.capturedInput.systemPrompt).not.toContain('Brain')
477
+ })
478
+ })
479
+
480
+ // ─── Output redaction ──────────────────────────────────────────
481
+
482
+ describe('output redaction — scrub secrets before sending', () => {
483
+ it('redacts known secret patterns from agent output', async () => {
484
+ const leakyAgent: Agent = {
485
+ id: 'claude',
486
+ name: 'Leaky Claude',
487
+ init: async () => {},
488
+ destroy: async () => {},
489
+ query: async function* () {
490
+ yield { type: 'text' as const, content: 'Key is sk-abcdefghijklmnopqrstuvwx' }
491
+ yield { type: 'done' as const, sessionId: 's1' }
492
+ },
493
+ health: async () => ({ ok: true }),
494
+ }
495
+ registry.registerAgent(leakyAgent, true)
496
+
497
+ const security: SecurityConfig = {
498
+ isolation: 'process',
499
+ envDefaults: ['HOME', 'PATH'],
500
+ envPassthrough: [],
501
+ envPassthroughAll: false,
502
+ outputRedactPatterns: [],
503
+ inputSanitization: {
504
+ enabled: true,
505
+ stripMarkers: true,
506
+ logSuspicious: false,
507
+ notifyAgent: false,
508
+ customPatterns: [],
509
+ },
510
+ }
511
+
512
+ const router = new Router(registry, sessions, mockConfig, moonLoader, [], security)
513
+ await router.route('discord', msg('test'))
514
+
515
+ expect(channel.lastSent!.msg.text).toContain('[REDACTED]')
516
+ expect(channel.lastSent!.msg.text).not.toContain('sk-abcdef')
517
+ })
518
+ })
519
+
520
+ // ─── Error handling ────────────────────────────────────────────
521
+
522
+ describe('error handling — graceful degradation', () => {
523
+ it('sends user-friendly error when agent throws', async () => {
524
+ const failingAgent: Agent = {
525
+ id: 'claude',
526
+ name: 'Failing Claude',
527
+ init: async () => {},
528
+ destroy: async () => {},
529
+ query: async function* () {
530
+ yield { type: 'error' as const, error: 'Agent exploded', recoverable: false }
531
+ throw new Error('Agent exploded')
532
+ },
533
+ health: async () => ({ ok: false }),
534
+ }
535
+ registry.registerAgent(failingAgent, true)
536
+
537
+ const router = new Router(registry, sessions, mockConfig, moonLoader, [])
538
+ await router.route('discord', msg('test'))
539
+
540
+ expect(channel.lastSent!.msg.text).toContain('Something went wrong')
541
+ })
542
+
543
+ it('surfaces non-recoverable agent errors to user', async () => {
544
+ const errorAgent: Agent = {
545
+ id: 'claude',
546
+ name: 'Error Claude',
547
+ init: async () => {},
548
+ destroy: async () => {},
549
+ query: async function* () {
550
+ yield { type: 'error' as const, error: 'fatal', recoverable: false }
551
+ yield { type: 'done' as const, sessionId: 's1' }
552
+ },
553
+ health: async () => ({ ok: true }),
554
+ }
555
+ registry.registerAgent(errorAgent, true)
556
+
557
+ const router = new Router(registry, sessions, mockConfig, moonLoader, [])
558
+ await router.route('discord', msg('test'))
559
+
560
+ expect(channel.lastSent!.msg.text).toContain('error occurred')
561
+ })
562
+ })
563
+
564
+ // ─── Session lifecycle — graceful close ────────────────────────
565
+
566
+ describe('session lifecycle — graceful close', () => {
567
+ it('tracks message history during route()', async () => {
568
+ registry.registerAgent(createAgent('Response'), true)
569
+ const router = new Router(registry, sessions, mockConfig, moonLoader, [])
570
+
571
+ await router.route('discord', msg('Hello'))
572
+
573
+ expect(router.getSessionMessageCount('claude:discord:chan-1')).toBe(2) // user + assistant
574
+ })
575
+
576
+ it('tracks multiple exchanges in same session', async () => {
577
+ registry.registerAgent(createAgent('Response'), true)
578
+ const router = new Router(registry, sessions, mockConfig, moonLoader, [])
579
+
580
+ await router.route('discord', msg('Hello'))
581
+ await router.route('discord', msg('How are you?'))
582
+ await router.route('discord', msg('Thanks'))
583
+
584
+ expect(router.getSessionMessageCount('claude:discord:chan-1')).toBe(6) // 3 user + 3 assistant
585
+ })
586
+
587
+ it('reports activeSessionCount', async () => {
588
+ registry.registerAgent(createAgent('ok'), true)
589
+ const router = new Router(registry, sessions, mockConfig, moonLoader, [])
590
+
591
+ expect(router.activeSessionCount).toBe(0)
592
+
593
+ await router.route('discord', msg('Hello'))
594
+
595
+ expect(router.activeSessionCount).toBe(1)
596
+ })
597
+
598
+ it('closeAllSessions calls memory.onSessionEnd with history', async () => {
599
+ let capturedContext: any = null
600
+ const memory = createMemory()
601
+ memory.onSessionEnd = async (ctx) => {
602
+ capturedContext = ctx
603
+ }
604
+ registry.registerAgent(createAgent('Response'), true)
605
+
606
+ const autoRecall = { enabled: false, limit: 5, minScore: 0.3, budget: 800 }
607
+ const router = new Router(
608
+ registry,
609
+ sessions,
610
+ mockConfig,
611
+ moonLoader,
612
+ [],
613
+ undefined,
614
+ memory,
615
+ autoRecall,
616
+ )
617
+
618
+ await router.route('discord', msg('Hello'))
619
+ await router.route('discord', msg('World'))
620
+
621
+ await router.closeAllSessions()
622
+
623
+ expect(capturedContext).not.toBeNull()
624
+ expect(capturedContext.messages).toHaveLength(4) // 2 user + 2 assistant
625
+ expect(capturedContext.messages[0].role).toBe('user')
626
+ expect(capturedContext.messages[0].content).toBe('Hello')
627
+ expect(capturedContext.messages[1].role).toBe('assistant')
628
+ })
629
+
630
+ it('closeAllSessions extracts topics from messages', async () => {
631
+ let capturedContext: any = null
632
+ const memory = createMemory()
633
+ memory.onSessionEnd = async (ctx) => {
634
+ capturedContext = ctx
635
+ }
636
+ registry.registerAgent(createAgent('Response'), true)
637
+
638
+ const autoRecall = { enabled: false, limit: 5, minScore: 0.3, budget: 800 }
639
+ const router = new Router(
640
+ registry,
641
+ sessions,
642
+ mockConfig,
643
+ moonLoader,
644
+ [],
645
+ undefined,
646
+ memory,
647
+ autoRecall,
648
+ )
649
+
650
+ await router.route('discord', msg('Tell me about hooks and memory architecture'))
651
+
652
+ await router.closeAllSessions()
653
+
654
+ expect(capturedContext.topics).toBeDefined()
655
+ expect(Array.isArray(capturedContext.topics)).toBe(true)
656
+ })
657
+
658
+ it('closeAllSessions clears history after closing', async () => {
659
+ const memory = createMemory()
660
+ memory.onSessionEnd = async () => {}
661
+ registry.registerAgent(createAgent('ok'), true)
662
+
663
+ const autoRecall = { enabled: false, limit: 5, minScore: 0.3, budget: 800 }
664
+ const router = new Router(
665
+ registry,
666
+ sessions,
667
+ mockConfig,
668
+ moonLoader,
669
+ [],
670
+ undefined,
671
+ memory,
672
+ autoRecall,
673
+ )
674
+
675
+ await router.route('discord', msg('test'))
676
+ expect(router.activeSessionCount).toBe(1)
677
+
678
+ await router.closeAllSessions()
679
+ expect(router.activeSessionCount).toBe(0)
680
+ })
681
+
682
+ it('closeAllSessions is no-op without memory provider', async () => {
683
+ registry.registerAgent(createAgent('ok'), true)
684
+ const router = new Router(registry, sessions, mockConfig, moonLoader, [])
685
+
686
+ await router.route('discord', msg('test'))
687
+
688
+ // Should not throw even without memory
689
+ await router.closeAllSessions()
690
+ expect(router.activeSessionCount).toBe(1) // not cleared since no memory
691
+ })
692
+
693
+ it('closeAllSessions handles provider errors gracefully', async () => {
694
+ const memory = createMemory()
695
+ memory.onSessionEnd = async () => {
696
+ throw new Error('Provider crashed')
697
+ }
698
+ registry.registerAgent(createAgent('ok'), true)
699
+
700
+ const autoRecall = { enabled: false, limit: 5, minScore: 0.3, budget: 800 }
701
+ const router = new Router(
702
+ registry,
703
+ sessions,
704
+ mockConfig,
705
+ moonLoader,
706
+ [],
707
+ undefined,
708
+ memory,
709
+ autoRecall,
710
+ )
711
+
712
+ await router.route('discord', msg('test'))
713
+
714
+ // Should not throw
715
+ await router.closeAllSessions()
716
+ // History should still be cleared even if provider failed
717
+ expect(router.activeSessionCount).toBe(0)
718
+ })
719
+ })
720
+
721
+ // ─── dispatchScheduled — scheduled/cron dispatch ────────────
722
+
723
+ describe('dispatchScheduled — scheduled/cron dispatch', () => {
724
+ const channels: import('../types/moon').ChannelPersona[] = [
725
+ { id: 'chan-1', name: 'orbit', platform: 'discord' },
726
+ { id: 'chan-2', name: 'research', platform: 'discord', moon: 'researcher' },
727
+ ]
728
+
729
+ it('sends static message directly to channel (kind=message)', async () => {
730
+ registry.registerAgent(createAgent(), true)
731
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
732
+
733
+ await router.dispatchScheduled('orbit', { kind: 'message', text: 'Good morning!' })
734
+
735
+ expect(channel.lastSent).not.toBeNull()
736
+ expect(channel.lastSent!.target).toBe('chan-1')
737
+ expect(channel.lastSent!.msg.text).toBe('Good morning!')
738
+ })
739
+
740
+ it('dispatches query to agent and sends response (kind=query)', async () => {
741
+ registry.registerAgent(createAgent('Scheduled response'), true)
742
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
743
+
744
+ await router.dispatchScheduled('orbit', { kind: 'query', prompt: 'Summarize the day' })
745
+
746
+ expect(channel.lastSent).not.toBeNull()
747
+ expect(channel.lastSent!.msg.text).toBe('Scheduled response')
748
+ })
749
+
750
+ it('bypasses auth — no allowedUsers check for scheduled dispatch', async () => {
751
+ // Config with strict allowedUsers — normal route would reject non-listed senders
752
+ const strictConfig = { ...mockConfig, discordAllowedUsers: ['only-this-user'] }
753
+ registry.registerAgent(createAgent('Cron response'), true)
754
+ const router = new Router(registry, sessions, strictConfig, moonLoader, channels)
755
+
756
+ // dispatchScheduled has sender.id = 'scheduler' which is NOT in allowedUsers,
757
+ // but it should still succeed because scheduled queries bypass auth
758
+ await router.dispatchScheduled('orbit', { kind: 'query', prompt: 'health check' })
759
+
760
+ expect(channel.lastSent).not.toBeNull()
761
+ expect(channel.lastSent!.msg.text).toBe('Cron response')
762
+ })
763
+
764
+ it('looks up channel by name from channelIndex', async () => {
765
+ registry.registerAgent(createAgent('Research result'), true)
766
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
767
+
768
+ await router.dispatchScheduled('research', { kind: 'query', prompt: 'Analyze data' })
769
+
770
+ // Should target chan-2 (the 'research' channel)
771
+ expect(channel.lastSent!.target).toBe('chan-2')
772
+ })
773
+
774
+ it('returns early (no crash) when channel name not found', async () => {
775
+ registry.registerAgent(createAgent(), true)
776
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
777
+
778
+ // Should not throw — just logs error and returns
779
+ await router.dispatchScheduled('nonexistent-channel', { kind: 'query', prompt: 'test' })
780
+
781
+ expect(channel.lastSent).toBeNull()
782
+ })
783
+
784
+ it('returns early when channel adapter not found in registry', async () => {
785
+ // Channel persona references platform 'telegram' but only 'discord' adapter is registered
786
+ const telegramChannels: import('../types/moon').ChannelPersona[] = [
787
+ { id: 'tg-1', name: 'tg-hub', platform: 'telegram' },
788
+ ]
789
+ registry.registerAgent(createAgent(), true)
790
+ const router = new Router(registry, sessions, mockConfig, moonLoader, telegramChannels)
791
+
792
+ await router.dispatchScheduled('tg-hub', { kind: 'query', prompt: 'test' })
793
+
794
+ // Discord channel should not receive anything — telegram adapter is missing
795
+ expect(channel.lastSent).toBeNull()
796
+ })
797
+
798
+ it('returns early when no agent is available for query dispatch', async () => {
799
+ // Do NOT register any agent
800
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
801
+
802
+ await router.dispatchScheduled('orbit', { kind: 'query', prompt: 'test' })
803
+
804
+ expect(channel.lastSent).toBeNull()
805
+ })
806
+
807
+ it('agent receives the scheduled prompt text as input', async () => {
808
+ const spy = createSpyAgent()
809
+ registry.registerAgent(spy, true)
810
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
811
+
812
+ await router.dispatchScheduled('orbit', { kind: 'query', prompt: 'What is the weather?' })
813
+
814
+ expect(spy.capturedInput).not.toBeNull()
815
+ expect(spy.capturedInput.prompt).toBe('What is the weather?')
816
+ })
817
+
818
+ it('agent input metadata includes scheduled=true and sender=Scheduler', async () => {
819
+ const spy = createSpyAgent()
820
+ registry.registerAgent(spy, true)
821
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
822
+
823
+ await router.dispatchScheduled(
824
+ 'orbit',
825
+ { kind: 'query', prompt: 'test' },
826
+ {
827
+ jobId: 'job-123',
828
+ jobName: 'morning-check',
829
+ },
830
+ )
831
+
832
+ expect(spy.capturedInput.metadata.scheduled).toBe(true)
833
+ expect(spy.capturedInput.metadata.sender.id).toBe('scheduler')
834
+ expect(spy.capturedInput.metadata.jobId).toBe('job-123')
835
+ expect(spy.capturedInput.metadata.jobName).toBe('morning-check')
836
+ })
837
+ })
838
+
839
+ // ─── recallContext — memory context formatting ────────────────
840
+
841
+ describe('recallContext — memory context formatting', () => {
842
+ const autoRecall: RecallConfig = { enabled: true, limit: 5, minScore: 0.3, budget: 800 }
843
+
844
+ it('formats recall results as "## Memory Context" section with scores', async () => {
845
+ const spy = createSpyAgent()
846
+ registry.registerAgent(spy, true)
847
+
848
+ const memory = createMemory([
849
+ { content: 'Mars likes TypeScript', score: 0.92, metadata: { type: 'fact' } },
850
+ { content: 'Project uses Bun runtime', score: 0.75, metadata: { type: 'tech' } },
851
+ ])
852
+
853
+ const router = new Router(
854
+ registry,
855
+ sessions,
856
+ mockConfig,
857
+ moonLoader,
858
+ [],
859
+ undefined,
860
+ memory,
861
+ autoRecall,
862
+ )
863
+
864
+ await router.route('discord', msg('Tell me about the project'))
865
+
866
+ const prompt = spy.capturedInput.systemPrompt
867
+ expect(prompt).toContain('## Memory Context (auto-recalled)')
868
+ expect(prompt).toContain('Mars likes TypeScript')
869
+ expect(prompt).toContain('92%')
870
+ expect(prompt).toContain('Project uses Bun runtime')
871
+ expect(prompt).toContain('75%')
872
+ })
873
+
874
+ it('returns no memory context when recall returns empty array', async () => {
875
+ const spy = createSpyAgent()
876
+ registry.registerAgent(spy, true)
877
+
878
+ const memory = createMemory([]) // empty
879
+ const router = new Router(
880
+ registry,
881
+ sessions,
882
+ mockConfig,
883
+ moonLoader,
884
+ [],
885
+ undefined,
886
+ memory,
887
+ autoRecall,
888
+ )
889
+
890
+ await router.route('discord', msg('test'))
891
+
892
+ // System prompt should NOT contain memory context
893
+ const prompt = spy.capturedInput.systemPrompt ?? ''
894
+ expect(prompt).not.toContain('Memory Context')
895
+ })
896
+
897
+ it('continues without memory context when recall throws', async () => {
898
+ const failingMemory = createMemory()
899
+ failingMemory.recall = async () => {
900
+ throw new Error('Brain offline')
901
+ }
902
+
903
+ registry.registerAgent(createAgent('Still works'), true)
904
+ const router = new Router(
905
+ registry,
906
+ sessions,
907
+ mockConfig,
908
+ moonLoader,
909
+ [],
910
+ undefined,
911
+ failingMemory,
912
+ autoRecall,
913
+ )
914
+
915
+ await router.route('discord', msg('test'))
916
+
917
+ // Should still get a response — recall failure is non-fatal
918
+ expect(channel.lastSent).not.toBeNull()
919
+ expect(channel.lastSent!.msg.text).toBe('Still works')
920
+ })
921
+
922
+ it('includes relevance scores in formatted output', async () => {
923
+ const spy = createSpyAgent()
924
+ registry.registerAgent(spy, true)
925
+
926
+ const memory = createMemory([
927
+ { content: 'Exact match fact', score: 0.99, metadata: { type: 'fact' } },
928
+ ])
929
+
930
+ const router = new Router(
931
+ registry,
932
+ sessions,
933
+ mockConfig,
934
+ moonLoader,
935
+ [],
936
+ undefined,
937
+ memory,
938
+ autoRecall,
939
+ )
940
+
941
+ await router.route('discord', msg('test'))
942
+
943
+ expect(spy.capturedInput.systemPrompt).toContain('relevance: 99%')
944
+ })
945
+
946
+ it('separates diary entries from semantic results', async () => {
947
+ const spy = createSpyAgent()
948
+ registry.registerAgent(spy, true)
949
+
950
+ const memory = createMemory([
951
+ {
952
+ content: 'Diary entry from today',
953
+ score: 0.9,
954
+ source: 'brain:diary',
955
+ metadata: { category: 'daily' },
956
+ },
957
+ { content: 'Semantic fact', score: 0.8, metadata: { type: 'fact' } },
958
+ ])
959
+
960
+ const router = new Router(
961
+ registry,
962
+ sessions,
963
+ mockConfig,
964
+ moonLoader,
965
+ [],
966
+ undefined,
967
+ memory,
968
+ autoRecall,
969
+ )
970
+
971
+ await router.route('discord', msg('test'))
972
+
973
+ const prompt = spy.capturedInput.systemPrompt
974
+ expect(prompt).toContain('### Session Diary (today)')
975
+ expect(prompt).toContain('[daily]')
976
+ expect(prompt).toContain('Diary entry from today')
977
+ expect(prompt).toContain('### Relevant Memories')
978
+ expect(prompt).toContain('Semantic fact')
979
+ })
980
+
981
+ it('respects budget limit — truncates semantic results when over budget', async () => {
982
+ const spy = createSpyAgent()
983
+ registry.registerAgent(spy, true)
984
+
985
+ // Very small budget (10 tokens = ~40 chars) with long results
986
+ const tinyBudget: RecallConfig = { enabled: true, limit: 10, minScore: 0.1, budget: 10 }
987
+
988
+ const longContent = 'A'.repeat(200)
989
+ const memory = createMemory([
990
+ { content: 'Short fact', score: 0.9, metadata: { type: 'fact' } },
991
+ { content: longContent, score: 0.7, metadata: { type: 'detail' } },
992
+ ])
993
+
994
+ const router = new Router(
995
+ registry,
996
+ sessions,
997
+ mockConfig,
998
+ moonLoader,
999
+ [],
1000
+ undefined,
1001
+ memory,
1002
+ tinyBudget,
1003
+ )
1004
+
1005
+ await router.route('discord', msg('test'))
1006
+
1007
+ const prompt = spy.capturedInput.systemPrompt
1008
+ // First result should be included (fits budget)
1009
+ expect(prompt).toContain('Short fact')
1010
+ // Second result (200 chars) should be truncated by budget
1011
+ expect(prompt).not.toContain(longContent)
1012
+ })
1013
+ })
1014
+
1015
+ // ─── Per-channel model override ──────────────────────────────
1016
+
1017
+ describe('per-channel model override — channel > moon > global', () => {
1018
+ it('channel model takes priority over moon model', async () => {
1019
+ const spy = createSpyAgent()
1020
+ registry.registerAgent(spy, true)
1021
+
1022
+ // Load moon with model override
1023
+ await moonLoader.loadAll(TMP, {}, { model: 'sonnet' })
1024
+
1025
+ const channels: import('../types/moon').ChannelPersona[] = [
1026
+ { id: 'chan-1', name: 'research', platform: 'discord', model: 'opus' },
1027
+ ]
1028
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1029
+
1030
+ await router.route('discord', msg('Hello'))
1031
+
1032
+ expect(spy.capturedInput.model).toBe('opus')
1033
+ })
1034
+
1035
+ it('falls back to moon model when channel has no model', async () => {
1036
+ const spy = createSpyAgent()
1037
+ registry.registerAgent(spy, true)
1038
+
1039
+ await moonLoader.loadAll(TMP, {}, { model: 'haiku' })
1040
+
1041
+ const channels: import('../types/moon').ChannelPersona[] = [
1042
+ { id: 'chan-1', name: 'orbit', platform: 'discord' },
1043
+ ]
1044
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1045
+
1046
+ await router.route('discord', msg('Hello'))
1047
+
1048
+ expect(spy.capturedInput.model).toBe('haiku')
1049
+ })
1050
+
1051
+ it('model is undefined when neither channel nor moon specifies one', async () => {
1052
+ const spy = createSpyAgent()
1053
+ registry.registerAgent(spy, true)
1054
+
1055
+ // Default loadAll — no model overrides
1056
+ await moonLoader.loadAll(TMP)
1057
+
1058
+ const channels: import('../types/moon').ChannelPersona[] = [
1059
+ { id: 'chan-1', name: 'orbit', platform: 'discord' },
1060
+ ]
1061
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1062
+
1063
+ await router.route('discord', msg('Hello'))
1064
+
1065
+ expect(spy.capturedInput.model).toBeUndefined()
1066
+ })
1067
+ })
1068
+
1069
+ // ─── Multi-agent routing (Phase B) ────────────────────────
1070
+
1071
+ describe('multi-agent routing — channelPersona.agentId directs to correct agent', () => {
1072
+ function createNamedAgent(id: string, response = 'ok'): Agent {
1073
+ return {
1074
+ id,
1075
+ name: `Agent ${id}`,
1076
+ init: async () => {},
1077
+ destroy: async () => {},
1078
+ query: async function* () {
1079
+ yield { type: 'text' as const, content: response }
1080
+ yield { type: 'done' as const, sessionId: `session-${id}` }
1081
+ },
1082
+ health: async () => ({ ok: true }),
1083
+ }
1084
+ }
1085
+
1086
+ it('channel with agentId routes to the named agent', async () => {
1087
+ const hermes = createNamedAgent('hermes', 'Hermes response')
1088
+ const defaultAgent = createNamedAgent('deimos', 'Deimos response')
1089
+ registry.registerAgent(defaultAgent, true)
1090
+ registry.registerAgent(hermes, false)
1091
+
1092
+ const channels: import('../types/moon').ChannelPersona[] = [
1093
+ { id: 'chan-1', name: 'notifications', platform: 'discord', agentId: 'hermes' },
1094
+ ]
1095
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1096
+
1097
+ await router.route('discord', msg('Hello'))
1098
+
1099
+ expect(channel.lastSent).not.toBeNull()
1100
+ expect(channel.lastSent!.msg.text).toBe('Hermes response')
1101
+ })
1102
+
1103
+ it('channel without agentId uses default agent', async () => {
1104
+ const hermes = createNamedAgent('hermes', 'Hermes response')
1105
+ const defaultAgent = createNamedAgent('deimos', 'Deimos response')
1106
+ registry.registerAgent(defaultAgent, true)
1107
+ registry.registerAgent(hermes, false)
1108
+
1109
+ const channels: import('../types/moon').ChannelPersona[] = [
1110
+ { id: 'chan-1', name: 'orbit', platform: 'discord' },
1111
+ ]
1112
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1113
+
1114
+ await router.route('discord', msg('Hello'))
1115
+
1116
+ expect(channel.lastSent).not.toBeNull()
1117
+ expect(channel.lastSent!.msg.text).toBe('Deimos response')
1118
+ })
1119
+
1120
+ it('RouteOptions.agentId takes priority over channelPersona.agentId', async () => {
1121
+ const hermes = createNamedAgent('hermes', 'Hermes response')
1122
+ const deimos = createNamedAgent('deimos', 'Deimos response')
1123
+ registry.registerAgent(deimos, true)
1124
+ registry.registerAgent(hermes, false)
1125
+
1126
+ // Channel says hermes, but RouteOptions says deimos
1127
+ const channels: import('../types/moon').ChannelPersona[] = [
1128
+ { id: 'chan-1', name: 'notifications', platform: 'discord', agentId: 'hermes' },
1129
+ ]
1130
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1131
+
1132
+ await router.route('discord', msg('Hello'), { agentId: 'deimos' })
1133
+
1134
+ expect(channel.lastSent!.msg.text).toBe('Deimos response')
1135
+ })
1136
+
1137
+ it('session key includes agent ID: agentId:channelId:messageChannelId', async () => {
1138
+ const hermes = createNamedAgent('hermes')
1139
+ registry.registerAgent(hermes, true)
1140
+
1141
+ const channels: import('../types/moon').ChannelPersona[] = [
1142
+ { id: 'chan-1', name: 'notifications', platform: 'discord', agentId: 'hermes' },
1143
+ ]
1144
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1145
+
1146
+ await router.route('discord', msg('Hello'))
1147
+
1148
+ // Session key should be hermes:discord:chan-1
1149
+ const session = sessions.get('hermes:discord:chan-1')
1150
+ expect(session).not.toBeNull()
1151
+ expect(session!.agentSessionId).toBe('session-hermes')
1152
+ })
1153
+
1154
+ it('two channels with different agentIds get different session namespaces', async () => {
1155
+ const hermes = createNamedAgent('hermes')
1156
+ const deimos = createNamedAgent('deimos')
1157
+ registry.registerAgent(deimos, true)
1158
+ registry.registerAgent(hermes, false)
1159
+
1160
+ const channels: import('../types/moon').ChannelPersona[] = [
1161
+ { id: 'chan-orbit', name: 'orbit', platform: 'discord', agentId: 'deimos' },
1162
+ { id: 'chan-notify', name: 'notifications', platform: 'discord', agentId: 'hermes' },
1163
+ ]
1164
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1165
+
1166
+ // Route to deimos channel (chan-orbit)
1167
+ const deimosMsg: IncomingMessage = {
1168
+ id: 'msg-1',
1169
+ channelId: 'chan-orbit',
1170
+ text: 'Hello',
1171
+ sender: { id: 'user-123', name: 'Test User' },
1172
+ timestamp: new Date(),
1173
+ }
1174
+ await router.route('discord', deimosMsg)
1175
+
1176
+ // Route to hermes channel (chan-notify)
1177
+ const hermesMsg: IncomingMessage = {
1178
+ id: 'msg-2',
1179
+ channelId: 'chan-notify',
1180
+ text: 'Notification check',
1181
+ sender: { id: 'user-123', name: 'Test User' },
1182
+ timestamp: new Date(),
1183
+ }
1184
+ await router.route('discord', hermesMsg)
1185
+
1186
+ // Both sessions exist with different namespaces
1187
+ const deimosSession = sessions.get('deimos:discord:chan-orbit')
1188
+ expect(deimosSession).not.toBeNull()
1189
+ expect(deimosSession!.agentSessionId).toBe('session-deimos')
1190
+
1191
+ const hermesSession = sessions.get('hermes:discord:chan-notify')
1192
+ expect(hermesSession).not.toBeNull()
1193
+ expect(hermesSession!.agentSessionId).toBe('session-hermes')
1194
+ })
1195
+ })
1196
+
1197
+ // ─── dispatchScheduled — multi-agent session key ──────────
1198
+
1199
+ describe('dispatchScheduled — multi-agent session key', () => {
1200
+ function createNamedAgent(id: string, response = 'ok'): Agent {
1201
+ return {
1202
+ id,
1203
+ name: `Agent ${id}`,
1204
+ init: async () => {},
1205
+ destroy: async () => {},
1206
+ query: async function* () {
1207
+ yield { type: 'text' as const, content: response }
1208
+ yield { type: 'done' as const, sessionId: `session-${id}` }
1209
+ },
1210
+ health: async () => ({ ok: true }),
1211
+ }
1212
+ }
1213
+
1214
+ it('dispatchScheduled uses channelPersona.agentId for agent selection', async () => {
1215
+ const hermes = createNamedAgent('hermes', 'Hermes scheduled')
1216
+ const deimos = createNamedAgent('deimos', 'Deimos scheduled')
1217
+ registry.registerAgent(deimos, true)
1218
+ registry.registerAgent(hermes, false)
1219
+
1220
+ const channels: import('../types/moon').ChannelPersona[] = [
1221
+ { id: 'chan-notify', name: 'notifications', platform: 'discord', agentId: 'hermes' },
1222
+ ]
1223
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1224
+
1225
+ await router.dispatchScheduled('notifications', { kind: 'query', prompt: 'test' })
1226
+
1227
+ expect(channel.lastSent).not.toBeNull()
1228
+ expect(channel.lastSent!.msg.text).toBe('Hermes scheduled')
1229
+ })
1230
+
1231
+ it('dispatchScheduled session key includes agent ID', async () => {
1232
+ const hermes = createNamedAgent('hermes')
1233
+ registry.registerAgent(hermes, true)
1234
+
1235
+ const channels: import('../types/moon').ChannelPersona[] = [
1236
+ { id: 'chan-notify', name: 'notifications', platform: 'discord', agentId: 'hermes' },
1237
+ ]
1238
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1239
+
1240
+ await router.dispatchScheduled('notifications', { kind: 'query', prompt: 'test' })
1241
+
1242
+ const session = sessions.get('hermes:discord:chan-notify')
1243
+ expect(session).not.toBeNull()
1244
+ })
1245
+ })
1246
+ })