@zooid/transport-matrix 0.7.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.
@@ -0,0 +1,1164 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { EventEmitter } from 'node:events'
3
+ import { createMatrixTransport } from './transport.js'
4
+
5
+ function fakeRegistry() {
6
+ let resolvePrompt: (() => void) | undefined
7
+ const promptPending = new Promise<void>((r) => {
8
+ resolvePrompt = r
9
+ })
10
+ const reg = {
11
+ hasAgent: vi.fn(() => true),
12
+ ensureSession: vi.fn(
13
+ async (_name: string, threadId: string, _roomId: string) => `sess-${threadId}`,
14
+ ),
15
+ endSession: vi.fn(),
16
+ cancelSession: vi.fn(async () => {}),
17
+ prompt: vi.fn(async () => {
18
+ await promptPending
19
+ return { stopReason: 'end_turn' as const }
20
+ }),
21
+ stopAll: vi.fn(async () => {}),
22
+ getApprovalTimeoutMs: vi.fn(() => 0),
23
+ onEvent: vi.fn() as unknown as (n: string, e: unknown) => void,
24
+ onApprovalRequest: vi.fn(async () => ({ decision: 'cancel' as const })),
25
+ }
26
+ return { reg, finishPrompt: () => resolvePrompt!() }
27
+ }
28
+
29
+ function fakeApprovals() {
30
+ const e = new EventEmitter()
31
+ return Object.assign(e, {
32
+ register: vi.fn(),
33
+ resolve: vi.fn(() => true),
34
+ cancelSession: vi.fn(),
35
+ listPending: vi.fn(() => []),
36
+ })
37
+ }
38
+
39
+ function fakeClient() {
40
+ return {
41
+ registerBot: vi.fn(async () => undefined),
42
+ joinRoom: vi.fn(async () => undefined),
43
+ sendMessage: vi.fn(async () => ({ event_id: '$x' })),
44
+ sendCustomEvent: vi.fn(async () => ({ event_id: '$x' })),
45
+ setTyping: vi.fn(async () => {}),
46
+ setPresence: vi.fn(async () => {}),
47
+ }
48
+ }
49
+
50
+ const baseAgents = [
51
+ {
52
+ name: 'architect',
53
+ userId: '@architect:example.com',
54
+ rooms: ['!r:example.com'],
55
+ trigger: 'mention' as const,
56
+ },
57
+ ]
58
+
59
+ function makeTransport() {
60
+ const { reg, finishPrompt } = fakeRegistry()
61
+ const approvals = fakeApprovals()
62
+ const client = fakeClient()
63
+ const transport = createMatrixTransport({
64
+ agents: reg as never,
65
+ approvals: approvals as never,
66
+ client: client as never,
67
+ bindings: baseAgents,
68
+ hsToken: 'hs-secret',
69
+ })
70
+ return { transport, agents: reg, approvals, client, finishPrompt }
71
+ }
72
+
73
+ let txnCounter = 0
74
+ async function postTxn(
75
+ app: ReturnType<typeof makeTransport>['transport']['app'],
76
+ body: unknown,
77
+ auth = 'Bearer hs-secret',
78
+ ) {
79
+ // Each call uses a fresh txnId so the in-memory event-id dedup never
80
+ // misfires across tests that share a transport.
81
+ return app.request(`/_matrix/app/v1/transactions/txn${++txnCounter}`, {
82
+ method: 'PUT',
83
+ headers: { Authorization: auth, 'content-type': 'application/json' },
84
+ body: JSON.stringify(body),
85
+ })
86
+ }
87
+
88
+ // runTurn is fire-and-forget; let any pending microtasks drain before
89
+ // asserting on side-effects driven by the agent prompt.
90
+ async function settleTurn(): Promise<void> {
91
+ for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r))
92
+ }
93
+
94
+ describe('matrix transport /transactions', () => {
95
+ it('rejects requests with a wrong hs_token', async () => {
96
+ const { transport } = makeTransport()
97
+ const res = await postTxn(transport.app, { events: [] }, 'Bearer wrong')
98
+ expect(res.status).toBe(403)
99
+ })
100
+
101
+ it('returns 200 {} on an empty event list', async () => {
102
+ const { transport } = makeTransport()
103
+ const res = await postTxn(transport.app, { events: [] })
104
+ expect(res.status).toBe(200)
105
+ expect(await res.json()).toEqual({})
106
+ })
107
+
108
+ it('replies in a thread rooted on the inbound event when the message is top-level', async () => {
109
+ const { transport, agents, client } = makeTransport()
110
+ const events = [
111
+ {
112
+ type: 'm.room.message',
113
+ event_id: '$root',
114
+ room_id: '!r:example.com',
115
+ sender: '@alice:example.com',
116
+ content: {
117
+ msgtype: 'm.text',
118
+ body: 'hi',
119
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
120
+ },
121
+ },
122
+ ]
123
+ agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
124
+ agents.onEvent('architect', {
125
+ type: 'agent_message_chunk',
126
+ sessionId: 'sess-' + p.threadId,
127
+ content: { type: 'text', text: 'hello back' },
128
+ })
129
+ return { stopReason: 'end_turn' as const }
130
+ })
131
+
132
+ const res = await postTxn(transport.app, { events })
133
+ expect(res.status).toBe(200)
134
+ await settleTurn()
135
+ // Agent-promotion: sessionKey is the inbound event_id, NOT the room.
136
+ expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com')
137
+ // The reply threads against the user's message.
138
+ expect(client.sendMessage).toHaveBeenCalledWith(
139
+ expect.objectContaining({
140
+ roomId: '!r:example.com',
141
+ asUserId: '@architect:example.com',
142
+ threadRoot: '$root',
143
+ content: expect.objectContaining({ msgtype: 'm.text', body: 'hello back' }),
144
+ }),
145
+ )
146
+ })
147
+
148
+ it('uses an in-thread message event_id as the thread root when one is set', async () => {
149
+ const { transport, agents, client } = makeTransport()
150
+ const events = [
151
+ {
152
+ type: 'm.room.message',
153
+ event_id: '$reply',
154
+ room_id: '!r:example.com',
155
+ sender: '@alice:example.com',
156
+ content: {
157
+ msgtype: 'm.text',
158
+ body: 'follow up',
159
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
160
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
161
+ },
162
+ },
163
+ ]
164
+ agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
165
+ agents.onEvent('architect', {
166
+ type: 'agent_message_chunk',
167
+ sessionId: 'sess-' + p.threadId,
168
+ content: { type: 'text', text: 'reply' },
169
+ })
170
+ return { stopReason: 'end_turn' as const }
171
+ })
172
+ await postTxn(transport.app, { events })
173
+ await settleTurn()
174
+ expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com')
175
+ expect(client.sendMessage).toHaveBeenCalledWith(
176
+ expect.objectContaining({ threadRoot: '$root' }),
177
+ )
178
+ })
179
+
180
+ it('attaches formatted_body when agent text contains markdown', async () => {
181
+ const { transport, agents, client } = makeTransport()
182
+ agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
183
+ agents.onEvent('architect', {
184
+ type: 'agent_message_chunk',
185
+ sessionId: 'sess-' + p.threadId,
186
+ content: {
187
+ type: 'text',
188
+ text: '**bold** _italic_\n\n```ts\nconst x = 1\n```',
189
+ },
190
+ })
191
+ return { stopReason: 'end_turn' as const }
192
+ })
193
+ await postTxn(transport.app, {
194
+ events: [
195
+ {
196
+ type: 'm.room.message',
197
+ event_id: '$root',
198
+ room_id: '!r:example.com',
199
+ sender: '@alice:example.com',
200
+ content: {
201
+ msgtype: 'm.text',
202
+ body: 'hi',
203
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
204
+ },
205
+ },
206
+ ],
207
+ })
208
+ await settleTurn()
209
+ expect(client.sendMessage).toHaveBeenCalledTimes(1)
210
+ const call = client.sendMessage.mock.calls[0]![0] as {
211
+ content: {
212
+ msgtype: string
213
+ body: string
214
+ format?: string
215
+ formatted_body?: string
216
+ }
217
+ }
218
+ expect(call.content.msgtype).toBe('m.text')
219
+ expect(call.content.body).toBe('**bold** _italic_\n\n```ts\nconst x = 1\n```')
220
+ expect(call.content.format).toBe('org.matrix.custom.html')
221
+ expect(typeof call.content.formatted_body).toBe('string')
222
+ expect(call.content.formatted_body).toContain('<strong>bold</strong>')
223
+ expect(call.content.formatted_body).toContain('<code class="language-ts">')
224
+ })
225
+
226
+ it('omits formatted_body when agent text has no markdown features', async () => {
227
+ const { transport, agents, client } = makeTransport()
228
+ agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
229
+ agents.onEvent('architect', {
230
+ type: 'agent_message_chunk',
231
+ sessionId: 'sess-' + p.threadId,
232
+ content: { type: 'text', text: 'just plain text' },
233
+ })
234
+ return { stopReason: 'end_turn' as const }
235
+ })
236
+ await postTxn(transport.app, {
237
+ events: [
238
+ {
239
+ type: 'm.room.message',
240
+ event_id: '$root',
241
+ room_id: '!r:example.com',
242
+ sender: '@alice:example.com',
243
+ content: {
244
+ msgtype: 'm.text',
245
+ body: 'hi',
246
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
247
+ },
248
+ },
249
+ ],
250
+ })
251
+ await settleTurn()
252
+ expect(client.sendMessage).toHaveBeenCalledTimes(1)
253
+ const call = client.sendMessage.mock.calls[0]![0] as {
254
+ content: Record<string, unknown>
255
+ }
256
+ expect(call.content.body).toBe('just plain text')
257
+ expect(call.content).not.toHaveProperty('formatted_body')
258
+ expect(call.content).not.toHaveProperty('format')
259
+ })
260
+
261
+ it('emits eco.zoon.approval_request when an approval is registered', async () => {
262
+ const { transport, approvals, client } = makeTransport()
263
+ await postTxn(transport.app, {
264
+ events: [
265
+ {
266
+ type: 'm.room.message',
267
+ event_id: '$root',
268
+ room_id: '!r:example.com',
269
+ sender: '@alice:example.com',
270
+ content: {
271
+ msgtype: 'm.text',
272
+ body: 'hi',
273
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
274
+ },
275
+ },
276
+ ],
277
+ })
278
+ await settleTurn()
279
+ approvals.emit('registered', {
280
+ approvalId: 'a1',
281
+ sessionId: 'sess-$root', // session is keyed on the promoted thread root
282
+ toolCallId: 't1',
283
+ options: [{ optionId: 'allow_once', name: 'Allow', kind: 'allow_once' }],
284
+ })
285
+ await new Promise((r) => setImmediate(r))
286
+ expect(client.sendCustomEvent).toHaveBeenCalledWith(
287
+ expect.objectContaining({
288
+ eventType: 'eco.zoon.approval_request',
289
+ content: expect.objectContaining({ approval_id: 'a1' }),
290
+ }),
291
+ )
292
+ })
293
+
294
+ it('resolves an approval when an eco.zoon.approval_response event arrives', async () => {
295
+ const { transport, approvals } = makeTransport()
296
+ await postTxn(transport.app, {
297
+ events: [
298
+ {
299
+ type: 'eco.zoon.approval_response',
300
+ event_id: '$resp',
301
+ room_id: '!r:example.com',
302
+ sender: '@alice:example.com',
303
+ content: {
304
+ approval_id: 'a1',
305
+ session_id: 'sess-$root',
306
+ decision: 'allow',
307
+ option_id: 'allow_once',
308
+ },
309
+ },
310
+ ],
311
+ })
312
+ expect(approvals.resolve).toHaveBeenCalledWith(
313
+ 'sess-$root',
314
+ 'a1',
315
+ { decision: 'allow', optionId: 'allow_once' },
316
+ )
317
+ })
318
+ })
319
+
320
+ describe('thread implicit triggers', () => {
321
+ it('triggers the most-recent-posting agent for a bare reply in a thread', async () => {
322
+ const { transport, agents } = makeTransport()
323
+ // Turn 1: user @mentions architect at top level. Agent-promotion makes
324
+ // $root the thread root and architect a thread participant.
325
+ agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
326
+ agents.onEvent('architect', {
327
+ type: 'agent_message_chunk',
328
+ sessionId: 'sess-' + p.threadId,
329
+ content: { type: 'text', text: 'hi' },
330
+ })
331
+ return { stopReason: 'end_turn' as const }
332
+ })
333
+ await postTxn(transport.app, {
334
+ events: [
335
+ {
336
+ type: 'm.room.message',
337
+ event_id: '$root',
338
+ room_id: '!r:example.com',
339
+ sender: '@alice:example.com',
340
+ content: {
341
+ msgtype: 'm.text',
342
+ body: 'hi',
343
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
344
+ },
345
+ },
346
+ ],
347
+ })
348
+ await settleTurn()
349
+ agents.ensureSession.mockClear()
350
+
351
+ // Turn 2: user replies in the thread WITHOUT @mention.
352
+ await postTxn(transport.app, {
353
+ events: [
354
+ {
355
+ type: 'm.room.message',
356
+ event_id: '$reply2',
357
+ room_id: '!r:example.com',
358
+ sender: '@alice:example.com',
359
+ content: {
360
+ msgtype: 'm.text',
361
+ body: 'follow up',
362
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
363
+ },
364
+ },
365
+ ],
366
+ })
367
+ await settleTurn()
368
+
369
+ // architect should be triggered implicitly because they posted in the thread.
370
+ expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com')
371
+ })
372
+
373
+ it("inherits the thread root's @mentions when no agent has posted yet", async () => {
374
+ const { transport, agents } = makeTransport()
375
+ // Have prompt() never resolve, so no agent reply has landed when turn 2 arrives.
376
+ agents.prompt.mockImplementation(() => new Promise(() => {}))
377
+ await postTxn(transport.app, {
378
+ events: [
379
+ {
380
+ type: 'm.room.message',
381
+ event_id: '$root',
382
+ room_id: '!r:example.com',
383
+ sender: '@alice:example.com',
384
+ content: {
385
+ msgtype: 'm.text',
386
+ body: 'long question',
387
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
388
+ },
389
+ },
390
+ ],
391
+ })
392
+ await settleTurn()
393
+ agents.ensureSession.mockClear()
394
+
395
+ // User adds a clarifying reply in-thread before architect has replied.
396
+ await postTxn(transport.app, {
397
+ events: [
398
+ {
399
+ type: 'm.room.message',
400
+ event_id: '$clarify',
401
+ room_id: '!r:example.com',
402
+ sender: '@alice:example.com',
403
+ content: {
404
+ msgtype: 'm.text',
405
+ body: 'btw focus on the auth module',
406
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
407
+ },
408
+ },
409
+ ],
410
+ })
411
+ await settleTurn()
412
+ // Inherits the root's @mention of architect.
413
+ expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com')
414
+ })
415
+
416
+ it('does not trigger any agent for a bare top-level message', async () => {
417
+ const { transport, agents } = makeTransport()
418
+ await postTxn(transport.app, {
419
+ events: [
420
+ {
421
+ type: 'm.room.message',
422
+ event_id: '$bare',
423
+ room_id: '!r:example.com',
424
+ sender: '@alice:example.com',
425
+ content: { msgtype: 'm.text', body: 'just thinking out loud' },
426
+ },
427
+ ],
428
+ })
429
+ await settleTurn()
430
+ expect(agents.ensureSession).not.toHaveBeenCalled()
431
+ })
432
+
433
+ it('an explicit @mention in a thread always triggers the named agent', async () => {
434
+ const { transport, agents } = makeTransport()
435
+ agents.prompt.mockImplementationOnce(async (_n: string, p: { threadId: string }) => {
436
+ agents.onEvent('architect', {
437
+ type: 'agent_message_chunk',
438
+ sessionId: 'sess-' + p.threadId,
439
+ content: { type: 'text', text: 'hi' },
440
+ })
441
+ return { stopReason: 'end_turn' as const }
442
+ })
443
+ await postTxn(transport.app, {
444
+ events: [
445
+ {
446
+ type: 'm.room.message',
447
+ event_id: '$root',
448
+ room_id: '!r:example.com',
449
+ sender: '@alice:example.com',
450
+ content: {
451
+ msgtype: 'm.text',
452
+ body: 'hi',
453
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
454
+ },
455
+ },
456
+ ],
457
+ })
458
+ await settleTurn()
459
+
460
+ agents.ensureSession.mockClear()
461
+ await postTxn(transport.app, {
462
+ events: [
463
+ {
464
+ type: 'm.room.message',
465
+ event_id: '$reply',
466
+ room_id: '!r:example.com',
467
+ sender: '@alice:example.com',
468
+ content: {
469
+ msgtype: 'm.text',
470
+ body: 'one more thing',
471
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
472
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
473
+ },
474
+ },
475
+ ],
476
+ })
477
+ await settleTurn()
478
+ expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com')
479
+ })
480
+ })
481
+
482
+ describe('eco.zoon.session_reset', () => {
483
+ it('ends the thread-keyed session when sent inside a thread', async () => {
484
+ const { transport, agents } = makeTransport()
485
+ await postTxn(transport.app, {
486
+ events: [
487
+ {
488
+ type: 'eco.zoon.session_reset',
489
+ event_id: '$reset',
490
+ room_id: '!r:example.com',
491
+ sender: '@alice:example.com',
492
+ content: {
493
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
494
+ },
495
+ },
496
+ ],
497
+ })
498
+ expect(agents.endSession).toHaveBeenCalledWith('architect', '$root')
499
+ })
500
+
501
+ it('is a no-op when sent at room scope (no thread relation)', async () => {
502
+ const { transport, agents } = makeTransport()
503
+ await postTxn(transport.app, {
504
+ events: [
505
+ {
506
+ type: 'eco.zoon.session_reset',
507
+ event_id: '$reset-room',
508
+ room_id: '!r:example.com',
509
+ sender: '@alice:example.com',
510
+ content: {},
511
+ },
512
+ ],
513
+ })
514
+ expect(agents.endSession).not.toHaveBeenCalled()
515
+ })
516
+
517
+ it('preserves thread routing state — bare reply after /clear still triggers the same agent', async () => {
518
+ const { transport, agents } = makeTransport()
519
+ // Turn 1: user @mentions architect — architect becomes a participant.
520
+ agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
521
+ agents.onEvent('architect', {
522
+ type: 'agent_message_chunk',
523
+ sessionId: 'sess-' + p.threadId,
524
+ content: { type: 'text', text: 'ok' },
525
+ })
526
+ return { stopReason: 'end_turn' as const }
527
+ })
528
+ await postTxn(transport.app, {
529
+ events: [
530
+ {
531
+ type: 'm.room.message',
532
+ event_id: '$root',
533
+ room_id: '!r:example.com',
534
+ sender: '@alice:example.com',
535
+ content: {
536
+ msgtype: 'm.text',
537
+ body: 'hi',
538
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
539
+ },
540
+ },
541
+ ],
542
+ })
543
+ await settleTurn()
544
+
545
+ // /clear in the thread.
546
+ await postTxn(transport.app, {
547
+ events: [
548
+ {
549
+ type: 'eco.zoon.session_reset',
550
+ event_id: '$reset',
551
+ room_id: '!r:example.com',
552
+ sender: '@alice:example.com',
553
+ content: { 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' } },
554
+ },
555
+ ],
556
+ })
557
+ expect(agents.endSession).toHaveBeenCalledWith('architect', '$root')
558
+
559
+ agents.ensureSession.mockClear()
560
+
561
+ // Bare follow-up — no @mention — must still route to architect (most-recent-poster
562
+ // rule survives /clear; only the agent's session memory is wiped).
563
+ await postTxn(transport.app, {
564
+ events: [
565
+ {
566
+ type: 'm.room.message',
567
+ event_id: '$followup',
568
+ room_id: '!r:example.com',
569
+ sender: '@alice:example.com',
570
+ content: {
571
+ msgtype: 'm.text',
572
+ body: 'still here?',
573
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
574
+ },
575
+ },
576
+ ],
577
+ })
578
+ await settleTurn()
579
+ expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com')
580
+ })
581
+ })
582
+
583
+ describe('typing indicator lifecycle', () => {
584
+ it('sets typing=true at runTurn start and typing=false in finally on success', async () => {
585
+ const { transport, client, finishPrompt } = makeTransport()
586
+ await postTxn(transport.app, {
587
+ events: [
588
+ {
589
+ type: 'm.room.message',
590
+ event_id: '$e1',
591
+ origin_server_ts: Date.now(),
592
+ room_id: '!r:example.com',
593
+ sender: '@user:example.com',
594
+ content: {
595
+ msgtype: 'm.text',
596
+ body: 'hi',
597
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
598
+ },
599
+ },
600
+ ],
601
+ })
602
+ await settleTurn()
603
+ expect(client.setTyping).toHaveBeenCalledWith(
604
+ expect.objectContaining({
605
+ roomId: '!r:example.com',
606
+ asUserId: '@architect:example.com',
607
+ typing: true,
608
+ timeoutMs: 30_000,
609
+ }),
610
+ )
611
+ finishPrompt()
612
+ await settleTurn()
613
+ expect(client.setTyping).toHaveBeenLastCalledWith(
614
+ expect.objectContaining({
615
+ roomId: '!r:example.com',
616
+ asUserId: '@architect:example.com',
617
+ typing: false,
618
+ }),
619
+ )
620
+ })
621
+
622
+ it('clears typing in finally even when prompt rejects', async () => {
623
+ const { reg } = fakeRegistry()
624
+ reg.prompt = vi.fn(async () => {
625
+ throw new Error('boom')
626
+ })
627
+ const approvals = fakeApprovals()
628
+ const client = fakeClient()
629
+ const transport = createMatrixTransport({
630
+ agents: reg as never,
631
+ approvals: approvals as never,
632
+ client: client as never,
633
+ bindings: baseAgents,
634
+ hsToken: 'hs-secret',
635
+ })
636
+ await postTxn(transport.app, {
637
+ events: [
638
+ {
639
+ type: 'm.room.message',
640
+ event_id: '$e2',
641
+ origin_server_ts: Date.now(),
642
+ room_id: '!r:example.com',
643
+ sender: '@user:example.com',
644
+ content: {
645
+ msgtype: 'm.text',
646
+ body: 'hi',
647
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
648
+ },
649
+ },
650
+ ],
651
+ })
652
+ await settleTurn()
653
+ const offCalls = client.setTyping.mock.calls.filter(
654
+ ([arg]) => (arg as { typing: boolean }).typing === false,
655
+ )
656
+ expect(offCalls.length).toBeGreaterThan(0)
657
+ })
658
+
659
+ it('refreshes typing every 25s while the prompt is in flight', async () => {
660
+ vi.useFakeTimers()
661
+ try {
662
+ const { transport, client, finishPrompt } = makeTransport()
663
+ await postTxn(transport.app, {
664
+ events: [
665
+ {
666
+ type: 'm.room.message',
667
+ event_id: '$e3',
668
+ origin_server_ts: Date.now(),
669
+ room_id: '!r:example.com',
670
+ sender: '@user:example.com',
671
+ content: {
672
+ msgtype: 'm.text',
673
+ body: 'hi',
674
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
675
+ },
676
+ },
677
+ ],
678
+ })
679
+ await vi.advanceTimersByTimeAsync(0)
680
+ const beforeTickCount = client.setTyping.mock.calls.length
681
+ await vi.advanceTimersByTimeAsync(25_000)
682
+ const afterTickCount = client.setTyping.mock.calls.length
683
+ expect(afterTickCount).toBeGreaterThan(beforeTickCount)
684
+ const lastTickArg = client.setTyping.mock.calls[afterTickCount - 1][0] as {
685
+ typing: boolean
686
+ }
687
+ expect(lastTickArg.typing).toBe(true)
688
+ finishPrompt()
689
+ await vi.runOnlyPendingTimersAsync()
690
+ } finally {
691
+ vi.useRealTimers()
692
+ }
693
+ })
694
+ })
695
+
696
+ describe('presence lifecycle', () => {
697
+ it('sets every agent online during bootstrap', async () => {
698
+ const { transport, client } = makeTransport()
699
+ await transport.bootstrap()
700
+ expect(client.setPresence).toHaveBeenCalledWith(
701
+ expect.objectContaining({
702
+ asUserId: '@architect:example.com',
703
+ presence: 'online',
704
+ }),
705
+ )
706
+ })
707
+
708
+ it('flips to unavailable around runTurn and back to online in finally', async () => {
709
+ const { transport, client, finishPrompt } = makeTransport()
710
+ await postTxn(transport.app, {
711
+ events: [
712
+ {
713
+ type: 'm.room.message',
714
+ event_id: '$e4',
715
+ origin_server_ts: Date.now(),
716
+ room_id: '!r:example.com',
717
+ sender: '@user:example.com',
718
+ content: {
719
+ msgtype: 'm.text',
720
+ body: 'hi',
721
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
722
+ },
723
+ },
724
+ ],
725
+ })
726
+ await settleTurn()
727
+ const seenUnavailable = client.setPresence.mock.calls.some(
728
+ ([arg]) => (arg as { presence: string }).presence === 'unavailable',
729
+ )
730
+ expect(seenUnavailable).toBe(true)
731
+ finishPrompt()
732
+ await settleTurn()
733
+ const last = client.setPresence.mock.calls.at(-1)?.[0] as { presence: string }
734
+ expect(last.presence).toBe('online')
735
+ })
736
+
737
+ it('does not abort runTurn when setPresence rejects', async () => {
738
+ const { transport, agents, client, finishPrompt } = makeTransport()
739
+ client.setPresence.mockRejectedValue(new Error('hs hiccup'))
740
+ await postTxn(transport.app, {
741
+ events: [
742
+ {
743
+ type: 'm.room.message',
744
+ event_id: '$e5',
745
+ origin_server_ts: Date.now(),
746
+ room_id: '!r:example.com',
747
+ sender: '@user:example.com',
748
+ content: {
749
+ msgtype: 'm.text',
750
+ body: 'hi',
751
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
752
+ },
753
+ },
754
+ ],
755
+ })
756
+ await settleTurn()
757
+ await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
758
+ type: 'agent_message_chunk',
759
+ sessionId: 'sess-$e5',
760
+ content: { type: 'text', text: 'reply' },
761
+ })
762
+ finishPrompt()
763
+ await settleTurn()
764
+ expect(client.sendMessage).toHaveBeenCalled()
765
+ })
766
+ })
767
+
768
+ describe('tool-call and plan event bridging', () => {
769
+ async function startTurnAndGetSession() {
770
+ const { transport, agents, client, finishPrompt } = makeTransport()
771
+ await postTxn(transport.app, {
772
+ events: [
773
+ {
774
+ type: 'm.room.message',
775
+ event_id: '$e6',
776
+ origin_server_ts: Date.now(),
777
+ room_id: '!r:example.com',
778
+ sender: '@user:example.com',
779
+ content: {
780
+ msgtype: 'm.text',
781
+ body: 'hi',
782
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
783
+ },
784
+ },
785
+ ],
786
+ })
787
+ await settleTurn()
788
+ return { transport, agents, client, finishPrompt, sessionId: 'sess-$e6' }
789
+ }
790
+
791
+ it('forwards tool_call as eco.zoon.tool_call in-room under the agent bot user', async () => {
792
+ const { agents, client, finishPrompt, sessionId } = await startTurnAndGetSession()
793
+ await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
794
+ type: 'tool_call',
795
+ sessionId,
796
+ toolCallId: 'tc-1',
797
+ title: 'Run tests',
798
+ kind: 'execute',
799
+ status: 'pending',
800
+ })
801
+ await settleTurn()
802
+ expect(client.sendCustomEvent).toHaveBeenCalledWith(
803
+ expect.objectContaining({
804
+ roomId: '!r:example.com',
805
+ asUserId: '@architect:example.com',
806
+ eventType: 'eco.zoon.tool_call',
807
+ content: expect.objectContaining({
808
+ session_id: sessionId,
809
+ tool_call_id: 'tc-1',
810
+ title: 'Run tests',
811
+ kind: 'execute',
812
+ status: 'pending',
813
+ }),
814
+ }),
815
+ )
816
+ finishPrompt()
817
+ await settleTurn()
818
+ })
819
+
820
+ it('forwards tool_call_update as eco.zoon.tool_call_update', async () => {
821
+ const { agents, client, finishPrompt, sessionId } = await startTurnAndGetSession()
822
+ await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
823
+ type: 'tool_call_update',
824
+ sessionId,
825
+ toolCallId: 'tc-1',
826
+ status: 'completed',
827
+ })
828
+ await settleTurn()
829
+ const call = client.sendCustomEvent.mock.calls.find(
830
+ ([arg]) => (arg as { eventType: string }).eventType === 'eco.zoon.tool_call_update',
831
+ )
832
+ expect(call).toBeDefined()
833
+ finishPrompt()
834
+ await settleTurn()
835
+ })
836
+
837
+ it('forwards plan as eco.zoon.plan', async () => {
838
+ const { agents, client, finishPrompt, sessionId } = await startTurnAndGetSession()
839
+ await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
840
+ type: 'plan',
841
+ sessionId,
842
+ entries: [{ content: 'a', priority: 'high', status: 'pending' }],
843
+ })
844
+ await settleTurn()
845
+ const call = client.sendCustomEvent.mock.calls.find(
846
+ ([arg]) => (arg as { eventType: string }).eventType === 'eco.zoon.plan',
847
+ )
848
+ expect(call).toBeDefined()
849
+ finishPrompt()
850
+ await settleTurn()
851
+ })
852
+
853
+ it('attaches m.relates_to thread when the originating message was in-thread', async () => {
854
+ const { transport, agents, client, finishPrompt } = makeTransport()
855
+ await postTxn(transport.app, {
856
+ events: [
857
+ {
858
+ type: 'm.room.message',
859
+ event_id: '$e7',
860
+ origin_server_ts: Date.now(),
861
+ room_id: '!r:example.com',
862
+ sender: '@user:example.com',
863
+ content: {
864
+ msgtype: 'm.text',
865
+ body: 'hi',
866
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
867
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
868
+ },
869
+ },
870
+ ],
871
+ })
872
+ await settleTurn()
873
+ const sessionId = 'sess-$root'
874
+ await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
875
+ type: 'tool_call',
876
+ sessionId,
877
+ toolCallId: 'tc-1',
878
+ title: 'x',
879
+ })
880
+ await settleTurn()
881
+ const call = client.sendCustomEvent.mock.calls.find(
882
+ ([arg]) => (arg as { eventType: string }).eventType === 'eco.zoon.tool_call',
883
+ )
884
+ expect((call?.[0] as { content: Record<string, unknown> }).content['m.relates_to']).toEqual({
885
+ rel_type: 'm.thread',
886
+ event_id: '$root',
887
+ })
888
+ finishPrompt()
889
+ await settleTurn()
890
+ })
891
+
892
+ it('drops events with unknown sessionId', async () => {
893
+ const { agents, client, finishPrompt } = await startTurnAndGetSession()
894
+ const before = client.sendCustomEvent.mock.calls.length
895
+ await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
896
+ type: 'tool_call',
897
+ sessionId: 'sess-unknown',
898
+ toolCallId: 'tc-1',
899
+ title: 'x',
900
+ })
901
+ await settleTurn()
902
+ expect(client.sendCustomEvent.mock.calls.length).toBe(before)
903
+ finishPrompt()
904
+ await settleTurn()
905
+ })
906
+
907
+ it('still buffers agent_message_chunk into the final m.room.message', async () => {
908
+ const { transport, agents, client, finishPrompt } = makeTransport()
909
+ await postTxn(transport.app, {
910
+ events: [
911
+ {
912
+ type: 'm.room.message',
913
+ event_id: '$e8',
914
+ origin_server_ts: Date.now(),
915
+ room_id: '!r:example.com',
916
+ sender: '@user:example.com',
917
+ content: {
918
+ msgtype: 'm.text',
919
+ body: 'hi',
920
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
921
+ },
922
+ },
923
+ ],
924
+ })
925
+ await settleTurn()
926
+ const sessionId = 'sess-$e8'
927
+ await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
928
+ type: 'agent_message_chunk',
929
+ sessionId,
930
+ content: { type: 'text', text: 'hello world' },
931
+ })
932
+ finishPrompt()
933
+ await settleTurn()
934
+ expect(client.sendMessage).toHaveBeenCalledWith(
935
+ expect.objectContaining({
936
+ content: expect.objectContaining({ body: 'hello world' }),
937
+ }),
938
+ )
939
+ })
940
+ })
941
+
942
+ describe('eco.zoon.interrupt handling', () => {
943
+ it('dispatches cancelSession(agent.name, sessionId) for an interrupt that targets a tracked session', async () => {
944
+ const { transport, agents, finishPrompt } = makeTransport()
945
+ await postTxn(transport.app, {
946
+ events: [
947
+ {
948
+ type: 'm.room.message',
949
+ event_id: '$start',
950
+ origin_server_ts: Date.now(),
951
+ room_id: '!r:example.com',
952
+ sender: '@user:example.com',
953
+ content: { msgtype: 'm.text', body: 'hi', 'm.mentions': { user_ids: ['@architect:example.com'] } },
954
+ },
955
+ ],
956
+ })
957
+ await settleTurn()
958
+
959
+ await postTxn(transport.app, {
960
+ events: [
961
+ {
962
+ type: 'eco.zoon.interrupt',
963
+ event_id: '$int',
964
+ origin_server_ts: Date.now(),
965
+ room_id: '!r:example.com',
966
+ sender: '@user:example.com',
967
+ content: { session_id: 'sess-$start', reason: 'user_initiated' },
968
+ },
969
+ ],
970
+ })
971
+ expect(agents.cancelSession).toHaveBeenCalledWith('architect', 'sess-$start')
972
+ finishPrompt()
973
+ await settleTurn()
974
+ })
975
+
976
+ it('drops interrupts with no session_id', async () => {
977
+ const { transport, agents, finishPrompt } = makeTransport()
978
+ await postTxn(transport.app, {
979
+ events: [
980
+ {
981
+ type: 'm.room.message',
982
+ event_id: '$s2',
983
+ origin_server_ts: Date.now(),
984
+ room_id: '!r:example.com',
985
+ sender: '@user:example.com',
986
+ content: { msgtype: 'm.text', body: 'hi', 'm.mentions': { user_ids: ['@architect:example.com'] } },
987
+ },
988
+ ],
989
+ })
990
+ await settleTurn()
991
+ await postTxn(transport.app, {
992
+ events: [
993
+ {
994
+ type: 'eco.zoon.interrupt',
995
+ event_id: '$int2',
996
+ origin_server_ts: Date.now(),
997
+ room_id: '!r:example.com',
998
+ sender: '@user:example.com',
999
+ content: {},
1000
+ },
1001
+ ],
1002
+ })
1003
+ expect(agents.cancelSession).not.toHaveBeenCalled()
1004
+ finishPrompt()
1005
+ await settleTurn()
1006
+ })
1007
+
1008
+ it('drops interrupts whose session_id is not tracked', async () => {
1009
+ const { transport, agents } = makeTransport()
1010
+ await postTxn(transport.app, {
1011
+ events: [
1012
+ {
1013
+ type: 'eco.zoon.interrupt',
1014
+ event_id: '$int3',
1015
+ origin_server_ts: Date.now(),
1016
+ room_id: '!r:example.com',
1017
+ sender: '@user:example.com',
1018
+ content: { session_id: 'sess-unknown' },
1019
+ },
1020
+ ],
1021
+ })
1022
+ expect(agents.cancelSession).not.toHaveBeenCalled()
1023
+ })
1024
+
1025
+ it('cancels the matching session when interrupt carries a thread relation (no session_id)', async () => {
1026
+ const { transport, agents, finishPrompt } = makeTransport()
1027
+ await postTxn(transport.app, {
1028
+ events: [
1029
+ {
1030
+ type: 'm.room.message',
1031
+ event_id: '$threadRoot',
1032
+ origin_server_ts: Date.now(),
1033
+ room_id: '!r:example.com',
1034
+ sender: '@user:example.com',
1035
+ content: { msgtype: 'm.text', body: 'hi', 'm.mentions': { user_ids: ['@architect:example.com'] } },
1036
+ },
1037
+ ],
1038
+ })
1039
+ await settleTurn()
1040
+
1041
+ await postTxn(transport.app, {
1042
+ events: [
1043
+ {
1044
+ type: 'eco.zoon.interrupt',
1045
+ event_id: '$intT',
1046
+ origin_server_ts: Date.now(),
1047
+ room_id: '!r:example.com',
1048
+ sender: '@user:example.com',
1049
+ content: {
1050
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$threadRoot' },
1051
+ reason: 'user_initiated',
1052
+ },
1053
+ },
1054
+ ],
1055
+ })
1056
+ expect(agents.cancelSession).toHaveBeenCalledWith('architect', 'sess-$threadRoot')
1057
+ finishPrompt()
1058
+ await settleTurn()
1059
+ })
1060
+
1061
+ it('is idempotent — a second interrupt for the same session re-invokes cancelSession', async () => {
1062
+ const { transport, agents, finishPrompt } = makeTransport()
1063
+ await postTxn(transport.app, {
1064
+ events: [
1065
+ {
1066
+ type: 'm.room.message',
1067
+ event_id: '$s4',
1068
+ origin_server_ts: Date.now(),
1069
+ room_id: '!r:example.com',
1070
+ sender: '@user:example.com',
1071
+ content: { msgtype: 'm.text', body: 'hi', 'm.mentions': { user_ids: ['@architect:example.com'] } },
1072
+ },
1073
+ ],
1074
+ })
1075
+ await settleTurn()
1076
+ const interrupt = (id: string) => ({
1077
+ events: [
1078
+ {
1079
+ type: 'eco.zoon.interrupt',
1080
+ event_id: id,
1081
+ origin_server_ts: Date.now(),
1082
+ room_id: '!r:example.com',
1083
+ sender: '@user:example.com',
1084
+ content: { session_id: 'sess-$s4' },
1085
+ },
1086
+ ],
1087
+ })
1088
+ await postTxn(transport.app, interrupt('$intA'))
1089
+ await postTxn(transport.app, interrupt('$intB'))
1090
+ expect(agents.cancelSession).toHaveBeenCalledTimes(2)
1091
+ finishPrompt()
1092
+ await settleTurn()
1093
+ })
1094
+
1095
+ it('rejects interrupts on the AS endpoint without a valid hsToken', async () => {
1096
+ const { transport } = makeTransport()
1097
+ const r = await postTxn(
1098
+ transport.app,
1099
+ {
1100
+ events: [
1101
+ {
1102
+ type: 'eco.zoon.interrupt',
1103
+ event_id: '$bad',
1104
+ origin_server_ts: Date.now(),
1105
+ room_id: '!r:example.com',
1106
+ sender: '@user:example.com',
1107
+ content: { session_id: 'sess-x' },
1108
+ },
1109
+ ],
1110
+ },
1111
+ 'Bearer wrong-secret',
1112
+ )
1113
+ expect(r.status).toBe(403)
1114
+ })
1115
+ })
1116
+
1117
+ describe('full loop integration', () => {
1118
+ it('top-level @mention → in-thread reply → bare follow-up triggers same agent', async () => {
1119
+ const { transport, agents, client } = makeTransport()
1120
+ agents.prompt.mockImplementation(async (_n: string, p: { threadId: string }) => {
1121
+ agents.onEvent('architect', {
1122
+ type: 'agent_message_chunk',
1123
+ sessionId: 'sess-' + p.threadId,
1124
+ content: { type: 'text', text: 'reply ' + p.threadId.slice(0, 6) },
1125
+ })
1126
+ return { stopReason: 'end_turn' as const }
1127
+ })
1128
+
1129
+ // Turn 1: top-level mention.
1130
+ await postTxn(transport.app, {
1131
+ events: [{
1132
+ type: 'm.room.message', event_id: '$root', room_id: '!r:example.com',
1133
+ sender: '@alice:example.com',
1134
+ content: {
1135
+ msgtype: 'm.text', body: 'hi @architect',
1136
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
1137
+ },
1138
+ }],
1139
+ })
1140
+ await settleTurn()
1141
+ expect(client.sendMessage).toHaveBeenCalledWith(
1142
+ expect.objectContaining({ threadRoot: '$root' }),
1143
+ )
1144
+
1145
+ // Turn 2: bare reply in thread — implicit trigger, same session.
1146
+ agents.ensureSession.mockClear()
1147
+ client.sendMessage.mockClear()
1148
+ await postTxn(transport.app, {
1149
+ events: [{
1150
+ type: 'm.room.message', event_id: '$bare', room_id: '!r:example.com',
1151
+ sender: '@alice:example.com',
1152
+ content: {
1153
+ msgtype: 'm.text', body: 'follow up',
1154
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
1155
+ },
1156
+ }],
1157
+ })
1158
+ await settleTurn()
1159
+ expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com')
1160
+ expect(client.sendMessage).toHaveBeenCalledWith(
1161
+ expect.objectContaining({ threadRoot: '$root' }),
1162
+ )
1163
+ })
1164
+ })