@zooid/transport-matrix 0.12.0 → 0.14.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.
@@ -91,6 +91,29 @@ async function postTxn(
91
91
  })
92
92
  }
93
93
 
94
+ // A real agent turn ends with dev.zooid.turn.end, and that is what releases a
95
+ // held return. Tests that inject agent replies must carry the boundary too.
96
+ let turnEndCounter = 0
97
+ function postTurnEnd(
98
+ app: ReturnType<typeof makeTransport>['transport']['app'],
99
+ o: { agent: string; root: string },
100
+ ) {
101
+ return postTxn(app, {
102
+ events: [
103
+ {
104
+ type: 'dev.zooid.turn.end',
105
+ event_id: `$turn-end-${++turnEndCounter}`,
106
+ room_id: '!r:example.com',
107
+ sender: `@${o.agent}:example.com`,
108
+ content: {
109
+ agent_id: o.agent,
110
+ 'm.relates_to': { rel_type: 'm.thread', event_id: o.root },
111
+ },
112
+ },
113
+ ],
114
+ })
115
+ }
116
+
94
117
  // runTurn is fire-and-forget; let any pending microtasks drain before
95
118
  // asserting on side-effects driven by the agent prompt.
96
119
  async function settleTurn(): Promise<void> {
@@ -139,14 +162,22 @@ describe('matrix transport /transactions', () => {
139
162
  expect(res.status).toBe(200)
140
163
  await settleTurn()
141
164
  // Agent-promotion: sessionKey is the inbound event_id, NOT the room.
142
- expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com', '$root')
165
+ expect(agents.ensureSession).toHaveBeenCalledWith(
166
+ 'architect',
167
+ '$root',
168
+ '!r:example.com',
169
+ '$root',
170
+ )
143
171
  // The reply threads against the user's message.
144
172
  expect(client.sendMessage).toHaveBeenCalledWith(
145
173
  expect.objectContaining({
146
174
  roomId: '!r:example.com',
147
175
  asUserId: '@architect:example.com',
148
176
  threadRoot: '$root',
149
- content: expect.objectContaining({ msgtype: 'm.text', body: 'hello back' }),
177
+ content: expect.objectContaining({
178
+ msgtype: 'm.notice',
179
+ body: 'hello back',
180
+ }),
150
181
  }),
151
182
  )
152
183
  })
@@ -156,7 +187,10 @@ describe('matrix transport /transactions', () => {
156
187
  // response for a normal turn; opencode flushes a trailing chunk just after
157
188
  // the stopReason. The post-turn drain must wait for it instead of sending
158
189
  // the truncated buffer.
159
- const { transport, agents, client } = makeTransport({ drainQuietMs: 20, drainMaxMs: 500 })
190
+ const { transport, agents, client } = makeTransport({
191
+ drainQuietMs: 20,
192
+ drainMaxMs: 500,
193
+ })
160
194
  const events = [
161
195
  {
162
196
  type: 'm.room.message',
@@ -229,7 +263,12 @@ describe('matrix transport /transactions', () => {
229
263
  })
230
264
  await postTxn(transport.app, { events })
231
265
  await settleTurn()
232
- expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com', '$root')
266
+ expect(agents.ensureSession).toHaveBeenCalledWith(
267
+ 'architect',
268
+ '$root',
269
+ '!r:example.com',
270
+ '$root',
271
+ )
233
272
  expect(client.sendMessage).toHaveBeenCalledWith(
234
273
  expect.objectContaining({ threadRoot: '$root' }),
235
274
  )
@@ -273,7 +312,7 @@ describe('matrix transport /transactions', () => {
273
312
  formatted_body?: string
274
313
  }
275
314
  }
276
- expect(call.content.msgtype).toBe('m.text')
315
+ expect(call.content.msgtype).toBe('m.notice')
277
316
  expect(call.content.body).toBe('**bold** _italic_\n\n```ts\nconst x = 1\n```')
278
317
  expect(call.content.format).toBe('org.matrix.custom.html')
279
318
  expect(typeof call.content.formatted_body).toBe('string')
@@ -316,6 +355,39 @@ describe('matrix transport /transactions', () => {
316
355
  expect(call.content).not.toHaveProperty('format')
317
356
  })
318
357
 
358
+ it('sends agent prose as m.notice so .m.rule.suppress_notices silences the chunk storm server-side', async () => {
359
+ const { transport, agents, client } = makeTransport()
360
+ agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
361
+ agents.onEvent('architect', {
362
+ type: 'agent_message_chunk',
363
+ sessionId: 'sess-' + p.threadId,
364
+ content: { type: 'text', text: 'hello' },
365
+ })
366
+ return { stopReason: 'end_turn' as const }
367
+ })
368
+ await postTxn(transport.app, {
369
+ events: [
370
+ {
371
+ type: 'm.room.message',
372
+ event_id: '$root',
373
+ room_id: '!r:example.com',
374
+ sender: '@alice:example.com',
375
+ content: {
376
+ msgtype: 'm.text',
377
+ body: 'hi',
378
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
379
+ },
380
+ },
381
+ ],
382
+ })
383
+ await settleTurn()
384
+ expect(client.sendMessage).toHaveBeenCalledWith(
385
+ expect.objectContaining({
386
+ content: expect.objectContaining({ msgtype: 'm.notice' }),
387
+ }),
388
+ )
389
+ })
390
+
319
391
  it('emits dev.zooid.approval_request when an approval is registered', async () => {
320
392
  const { transport, approvals, client } = makeTransport()
321
393
  await postTxn(transport.app, {
@@ -336,7 +408,7 @@ describe('matrix transport /transactions', () => {
336
408
  await settleTurn()
337
409
  approvals.emit('registered', {
338
410
  approvalId: 'a1',
339
- sessionId: 'sess-$root', // session is keyed on the promoted thread root
411
+ sessionId: 'sess-$root', // session is keyed on the promoted thread root
340
412
  toolCallId: 't1',
341
413
  options: [{ optionId: 'allow_once', name: 'Allow', kind: 'allow_once' }],
342
414
  })
@@ -367,17 +439,109 @@ describe('matrix transport /transactions', () => {
367
439
  },
368
440
  ],
369
441
  })
370
- expect(approvals.resolve).toHaveBeenCalledWith(
371
- 'sess-$root',
372
- 'a1',
373
- { decision: 'allow', optionId: 'allow_once' },
442
+ expect(approvals.resolve).toHaveBeenCalledWith('sess-$root', 'a1', {
443
+ decision: 'allow',
444
+ optionId: 'allow_once',
445
+ })
446
+ })
447
+ })
448
+
449
+ describe('dev.zooid.turn.end', () => {
450
+ function topLevelMention() {
451
+ return {
452
+ type: 'm.room.message',
453
+ event_id: '$root',
454
+ room_id: '!r:example.com',
455
+ sender: '@alice:example.com',
456
+ content: {
457
+ msgtype: 'm.text',
458
+ body: 'hi',
459
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
460
+ },
461
+ }
462
+ }
463
+
464
+ it('is sent once per turn, after the final flush has settled', async () => {
465
+ const { transport, agents, client } = makeTransport()
466
+ agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
467
+ agents.onEvent('architect', {
468
+ type: 'agent_message_chunk',
469
+ sessionId: 'sess-' + p.threadId,
470
+ content: { type: 'text', text: 'hello back' },
471
+ })
472
+ return { stopReason: 'end_turn' as const }
473
+ })
474
+ await postTxn(transport.app, { events: [topLevelMention()] })
475
+ await settleTurn()
476
+
477
+ const sendMessageOrder = client.sendMessage.mock.invocationCallOrder[0]!
478
+ const turnEndCall = client.sendCustomEvent.mock.calls.find(
479
+ (c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
480
+ )
481
+ expect(turnEndCall).toBeDefined()
482
+ const turnEndIdx = client.sendCustomEvent.mock.calls.indexOf(turnEndCall!)
483
+ const turnEndOrder = client.sendCustomEvent.mock.invocationCallOrder[turnEndIdx]!
484
+ // Ordering matters: a turn.end that lands before the prose would notify
485
+ // the user to look at a room that has nothing in it yet.
486
+ expect(turnEndOrder).toBeGreaterThan(sendMessageOrder)
487
+ expect(turnEndCall![0]).toMatchObject({
488
+ roomId: '!r:example.com',
489
+ asUserId: '@architect:example.com',
490
+ content: expect.objectContaining({ produced_output: true }),
491
+ })
492
+ })
493
+
494
+ it('reports produced_output: false for a silent turn', async () => {
495
+ const { transport, agents, client } = makeTransport()
496
+ // The agent emits no chunks at all — this is exactly ZOD076's case.
497
+ agents.prompt.mockImplementation(async () => ({
498
+ stopReason: 'end_turn' as const,
499
+ }))
500
+ await postTxn(transport.app, { events: [topLevelMention()] })
501
+ await settleTurn()
502
+
503
+ const turnEndCall = client.sendCustomEvent.mock.calls.find(
504
+ (c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
374
505
  )
506
+ expect(turnEndCall![0]).toMatchObject({
507
+ content: expect.objectContaining({ produced_output: false }),
508
+ })
509
+ })
510
+
511
+ it('is still sent when the turn throws, so the room never hangs on a spinner', async () => {
512
+ const { transport, agents, client } = makeTransport()
513
+ agents.prompt.mockImplementation(async () => {
514
+ throw new Error('boom')
515
+ })
516
+ await postTxn(transport.app, { events: [topLevelMention()] })
517
+ await settleTurn()
518
+
519
+ const turnEndCall = client.sendCustomEvent.mock.calls.find(
520
+ (c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
521
+ )
522
+ expect(turnEndCall).toBeDefined()
523
+ })
524
+
525
+ it('threads to the turn root', async () => {
526
+ const { transport, agents, client } = makeTransport()
527
+ agents.prompt.mockImplementation(async () => ({
528
+ stopReason: 'end_turn' as const,
529
+ }))
530
+ await postTxn(transport.app, { events: [topLevelMention()] })
531
+ await settleTurn()
532
+
533
+ const turnEndCall = client.sendCustomEvent.mock.calls.find(
534
+ (c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
535
+ )
536
+ expect(
537
+ (turnEndCall![0] as { content: { 'm.relates_to': unknown } }).content['m.relates_to'],
538
+ ).toEqual({ rel_type: 'm.thread', event_id: '$root' })
375
539
  })
376
540
  })
377
541
 
378
542
  describe('thread implicit triggers', () => {
379
543
  it('triggers the most-recent-posting agent for a bare reply in a thread', async () => {
380
- const { transport, agents } = makeTransport()
544
+ const { transport, agents, finishPrompt } = makeTransport()
381
545
  // Turn 1: user @mentions architect at top level. Agent-promotion makes
382
546
  // $root the thread root and architect a thread participant.
383
547
  agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
@@ -425,13 +589,16 @@ describe('thread implicit triggers', () => {
425
589
  await settleTurn()
426
590
 
427
591
  // architect should be triggered implicitly because they posted in the thread.
428
- expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com', '$root')
592
+ expect(agents.ensureSession).toHaveBeenCalledWith(
593
+ 'architect',
594
+ '$root',
595
+ '!r:example.com',
596
+ '$root',
597
+ )
429
598
  })
430
599
 
431
600
  it("inherits the thread root's @mentions when no agent has posted yet", async () => {
432
- const { transport, agents } = makeTransport()
433
- // Have prompt() never resolve, so no agent reply has landed when turn 2 arrives.
434
- agents.prompt.mockImplementation(() => new Promise(() => {}))
601
+ const { transport, agents, finishPrompt } = makeTransport()
435
602
  await postTxn(transport.app, {
436
603
  events: [
437
604
  {
@@ -467,8 +634,17 @@ describe('thread implicit triggers', () => {
467
634
  ],
468
635
  })
469
636
  await settleTurn()
470
- // Inherits the root's @mention of architect.
471
- expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com', '$root')
637
+ // Per-session FIFO holds a fast follow-up until the original turn ends.
638
+ expect(agents.ensureSession).not.toHaveBeenCalled()
639
+ finishPrompt()
640
+ await settleTurn()
641
+ // It then inherits the root's @mention of architect.
642
+ expect(agents.ensureSession).toHaveBeenCalledWith(
643
+ 'architect',
644
+ '$root',
645
+ '!r:example.com',
646
+ '$root',
647
+ )
472
648
  })
473
649
 
474
650
  it('does not trigger any agent for a bare top-level message', async () => {
@@ -533,7 +709,12 @@ describe('thread implicit triggers', () => {
533
709
  ],
534
710
  })
535
711
  await settleTurn()
536
- expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com', '$root')
712
+ expect(agents.ensureSession).toHaveBeenCalledWith(
713
+ 'architect',
714
+ '$root',
715
+ '!r:example.com',
716
+ '$root',
717
+ )
537
718
  })
538
719
  })
539
720
 
@@ -608,7 +789,9 @@ describe('dev.zooid.session_reset', () => {
608
789
  event_id: '$reset',
609
790
  room_id: '!r:example.com',
610
791
  sender: '@alice:example.com',
611
- content: { 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' } },
792
+ content: {
793
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
794
+ },
612
795
  },
613
796
  ],
614
797
  })
@@ -634,7 +817,12 @@ describe('dev.zooid.session_reset', () => {
634
817
  ],
635
818
  })
636
819
  await settleTurn()
637
- expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com', '$root')
820
+ expect(agents.ensureSession).toHaveBeenCalledWith(
821
+ 'architect',
822
+ '$root',
823
+ '!r:example.com',
824
+ '$root',
825
+ )
638
826
  })
639
827
  })
640
828
 
@@ -727,10 +915,10 @@ describe('typing indicator lifecycle', () => {
727
915
  room_id: '!r:example.com',
728
916
  sender: '@user:example.com',
729
917
  content: {
730
- msgtype: 'm.text',
731
- body: 'hi',
732
- 'm.mentions': { user_ids: ['@architect:example.com'] },
733
- },
918
+ msgtype: 'm.text',
919
+ body: 'hi',
920
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
921
+ },
734
922
  },
735
923
  ],
736
924
  })
@@ -788,7 +976,9 @@ describe('presence lifecycle', () => {
788
976
  expect(seenUnavailable).toBe(true)
789
977
  finishPrompt()
790
978
  await settleTurn()
791
- const last = client.setPresence.mock.calls.at(-1)?.[0] as { presence: string }
979
+ const last = client.setPresence.mock.calls.at(-1)?.[0] as {
980
+ presence: string
981
+ }
792
982
  expect(last.presence).toBe('online')
793
983
  })
794
984
 
@@ -944,8 +1134,7 @@ describe('tool-call and plan event bridging', () => {
944
1134
  await settleTurn()
945
1135
 
946
1136
  const call = client.sendCustomEvent.mock.calls.find(
947
- ([arg]) =>
948
- (arg as { eventType: string }).eventType === 'dev.zooid.available_commands_update',
1137
+ ([arg]) => (arg as { eventType: string }).eventType === 'dev.zooid.available_commands_update',
949
1138
  )
950
1139
  expect(call).toBeDefined()
951
1140
  expect((call![0] as { content: Record<string, unknown> }).content).toMatchObject({
@@ -1085,9 +1274,7 @@ describe('agent_message_chunk message-boundary buffering', () => {
1085
1274
  (client.sendMessage.mock.calls[0]![0] as { content: { body: string } }).content.body
1086
1275
 
1087
1276
  const sentBodies = (client: { sendMessage: { mock: { calls: unknown[][] } } }) =>
1088
- client.sendMessage.mock.calls.map(
1089
- (c) => (c[0] as { content: { body: string } }).content.body,
1090
- )
1277
+ client.sendMessage.mock.calls.map((c) => (c[0] as { content: { body: string } }).content.body)
1091
1278
 
1092
1279
  it('flushes a separate Matrix message when messageId changes (opencode run-on)', async () => {
1093
1280
  // opencode streams "…it." under one message id, then "Filed:" under a NEW
@@ -1185,7 +1372,11 @@ describe('dev.zooid.interrupt handling', () => {
1185
1372
  origin_server_ts: Date.now(),
1186
1373
  room_id: '!r:example.com',
1187
1374
  sender: '@user:example.com',
1188
- content: { msgtype: 'm.text', body: 'hi', 'm.mentions': { user_ids: ['@architect:example.com'] } },
1375
+ content: {
1376
+ msgtype: 'm.text',
1377
+ body: 'hi',
1378
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
1379
+ },
1189
1380
  },
1190
1381
  ],
1191
1382
  })
@@ -1218,7 +1409,11 @@ describe('dev.zooid.interrupt handling', () => {
1218
1409
  origin_server_ts: Date.now(),
1219
1410
  room_id: '!r:example.com',
1220
1411
  sender: '@user:example.com',
1221
- content: { msgtype: 'm.text', body: 'hi', 'm.mentions': { user_ids: ['@architect:example.com'] } },
1412
+ content: {
1413
+ msgtype: 'm.text',
1414
+ body: 'hi',
1415
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
1416
+ },
1222
1417
  },
1223
1418
  ],
1224
1419
  })
@@ -1267,7 +1462,11 @@ describe('dev.zooid.interrupt handling', () => {
1267
1462
  origin_server_ts: Date.now(),
1268
1463
  room_id: '!r:example.com',
1269
1464
  sender: '@user:example.com',
1270
- content: { msgtype: 'm.text', body: 'hi', 'm.mentions': { user_ids: ['@architect:example.com'] } },
1465
+ content: {
1466
+ msgtype: 'm.text',
1467
+ body: 'hi',
1468
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
1469
+ },
1271
1470
  },
1272
1471
  ],
1273
1472
  })
@@ -1303,7 +1502,11 @@ describe('dev.zooid.interrupt handling', () => {
1303
1502
  origin_server_ts: Date.now(),
1304
1503
  room_id: '!r:example.com',
1305
1504
  sender: '@user:example.com',
1306
- content: { msgtype: 'm.text', body: 'hi', 'm.mentions': { user_ids: ['@architect:example.com'] } },
1505
+ content: {
1506
+ msgtype: 'm.text',
1507
+ body: 'hi',
1508
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
1509
+ },
1307
1510
  },
1308
1511
  ],
1309
1512
  })
@@ -1363,14 +1566,19 @@ describe('full loop integration', () => {
1363
1566
 
1364
1567
  // Turn 1: top-level mention.
1365
1568
  await postTxn(transport.app, {
1366
- events: [{
1367
- type: 'm.room.message', event_id: '$root', room_id: '!r:example.com',
1368
- sender: '@alice:example.com',
1369
- content: {
1370
- msgtype: 'm.text', body: 'hi @architect',
1371
- 'm.mentions': { user_ids: ['@architect:example.com'] },
1569
+ events: [
1570
+ {
1571
+ type: 'm.room.message',
1572
+ event_id: '$root',
1573
+ room_id: '!r:example.com',
1574
+ sender: '@alice:example.com',
1575
+ content: {
1576
+ msgtype: 'm.text',
1577
+ body: 'hi @architect',
1578
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
1579
+ },
1372
1580
  },
1373
- }],
1581
+ ],
1374
1582
  })
1375
1583
  await settleTurn()
1376
1584
  expect(client.sendMessage).toHaveBeenCalledWith(
@@ -1381,17 +1589,27 @@ describe('full loop integration', () => {
1381
1589
  agents.ensureSession.mockClear()
1382
1590
  client.sendMessage.mockClear()
1383
1591
  await postTxn(transport.app, {
1384
- events: [{
1385
- type: 'm.room.message', event_id: '$bare', room_id: '!r:example.com',
1386
- sender: '@alice:example.com',
1387
- content: {
1388
- msgtype: 'm.text', body: 'follow up',
1389
- 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
1592
+ events: [
1593
+ {
1594
+ type: 'm.room.message',
1595
+ event_id: '$bare',
1596
+ room_id: '!r:example.com',
1597
+ sender: '@alice:example.com',
1598
+ content: {
1599
+ msgtype: 'm.text',
1600
+ body: 'follow up',
1601
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
1602
+ },
1390
1603
  },
1391
- }],
1604
+ ],
1392
1605
  })
1393
1606
  await settleTurn()
1394
- expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com', '$root')
1607
+ expect(agents.ensureSession).toHaveBeenCalledWith(
1608
+ 'architect',
1609
+ '$root',
1610
+ '!r:example.com',
1611
+ '$root',
1612
+ )
1395
1613
  expect(client.sendMessage).toHaveBeenCalledWith(
1396
1614
  expect.objectContaining({ threadRoot: '$root' }),
1397
1615
  )
@@ -1406,7 +1624,10 @@ const TINY_PNG = Buffer.from(TINY_PNG_B64, 'base64')
1406
1624
 
1407
1625
  function fakeMedia() {
1408
1626
  return {
1409
- download: vi.fn(async () => ({ data: new Uint8Array(TINY_PNG), contentType: 'image/png' })),
1627
+ download: vi.fn(async () => ({
1628
+ data: new Uint8Array(TINY_PNG),
1629
+ contentType: 'image/png',
1630
+ })),
1410
1631
  upload: vi.fn(async () => ({ content_uri: 'mxc://localhost/up1' })),
1411
1632
  }
1412
1633
  }
@@ -1417,10 +1638,12 @@ const workspaceBinding = {
1417
1638
  agentWorkspacePath: '/workspace',
1418
1639
  }
1419
1640
 
1420
- function makeMediaTransport(opts: {
1421
- media?: ReturnType<typeof fakeMedia>
1422
- writeAttachmentFn?: unknown
1423
- } = {}) {
1641
+ function makeMediaTransport(
1642
+ opts: {
1643
+ media?: ReturnType<typeof fakeMedia>
1644
+ writeAttachmentFn?: unknown
1645
+ } = {},
1646
+ ) {
1424
1647
  const { reg, finishPrompt } = fakeRegistry()
1425
1648
  const approvals = fakeApprovals()
1426
1649
  const client = fakeClient()
@@ -1437,13 +1660,15 @@ function makeMediaTransport(opts: {
1437
1660
  return { transport, agents: reg, client, finishPrompt }
1438
1661
  }
1439
1662
 
1440
- function imageEvent(over: {
1441
- size?: number
1442
- mimetype?: string
1443
- msgtype?: string
1444
- body?: string
1445
- eventId?: string
1446
- } = {}) {
1663
+ function imageEvent(
1664
+ over: {
1665
+ size?: number
1666
+ mimetype?: string
1667
+ msgtype?: string
1668
+ body?: string
1669
+ eventId?: string
1670
+ } = {},
1671
+ ) {
1447
1672
  return {
1448
1673
  type: 'm.room.message',
1449
1674
  event_id: over.eventId ?? '$media1',
@@ -1496,7 +1721,11 @@ describe('inbound media', () => {
1496
1721
 
1497
1722
  expect(agents.prompt).toHaveBeenCalledOnce()
1498
1723
  const content = (agents.prompt.mock.calls[0][1] as { content: unknown[] }).content
1499
- expect(content[0]).toMatchObject({ type: 'image', data: TINY_PNG_B64, mimeType: 'image/png' })
1724
+ expect(content[0]).toMatchObject({
1725
+ type: 'image',
1726
+ data: TINY_PNG_B64,
1727
+ mimeType: 'image/png',
1728
+ })
1500
1729
  expect((content[1] as { type: string; text: string }).type).toBe('text')
1501
1730
  expect(media.download).toHaveBeenCalledOnce()
1502
1731
  })
@@ -1507,7 +1736,10 @@ describe('inbound media', () => {
1507
1736
  hostPath: '/tmp/ws/.zooid/attachments/media1/dog.png',
1508
1737
  agentPath: '/workspace/.zooid/attachments/media1/dog.png',
1509
1738
  }))
1510
- const { transport, agents } = makeMediaTransport({ media, writeAttachmentFn })
1739
+ const { transport, agents } = makeMediaTransport({
1740
+ media,
1741
+ writeAttachmentFn,
1742
+ })
1511
1743
 
1512
1744
  agents.prompt.mockResolvedValue({ stopReason: 'end_turn' as const })
1513
1745
  await postTxn(transport.app, { events: [imageEvent({ size: 600_000 })] }) // > MAX_INLINE_IMAGE_BYTES
@@ -1531,11 +1763,20 @@ describe('inbound media', () => {
1531
1763
  hostPath: '/tmp/ws/.zooid/attachments/media1/report.pdf',
1532
1764
  agentPath: '/workspace/.zooid/attachments/media1/report.pdf',
1533
1765
  }))
1534
- const { transport, agents } = makeMediaTransport({ media, writeAttachmentFn })
1766
+ const { transport, agents } = makeMediaTransport({
1767
+ media,
1768
+ writeAttachmentFn,
1769
+ })
1535
1770
 
1536
1771
  agents.prompt.mockResolvedValue({ stopReason: 'end_turn' as const })
1537
1772
  await postTxn(transport.app, {
1538
- events: [imageEvent({ msgtype: 'm.file', body: 'report.pdf', mimetype: 'application/pdf' })],
1773
+ events: [
1774
+ imageEvent({
1775
+ msgtype: 'm.file',
1776
+ body: 'report.pdf',
1777
+ mimetype: 'application/pdf',
1778
+ }),
1779
+ ],
1539
1780
  })
1540
1781
  await postTxn(transport.app, { events: [mentionMsg('read it')] })
1541
1782
  await settleTurn()
@@ -1601,7 +1842,10 @@ describe('outbound agent images', () => {
1601
1842
  await new Promise((r) => setTimeout(r, 10))
1602
1843
 
1603
1844
  expect(media.upload).toHaveBeenCalledWith(
1604
- expect.objectContaining({ contentType: 'image/png', asUserId: '@architect:example.com' }),
1845
+ expect.objectContaining({
1846
+ contentType: 'image/png',
1847
+ asUserId: '@architect:example.com',
1848
+ }),
1605
1849
  )
1606
1850
  expect(client.sendMessage).toHaveBeenCalledWith(
1607
1851
  expect.objectContaining({
@@ -1609,7 +1853,10 @@ describe('outbound agent images', () => {
1609
1853
  content: expect.objectContaining({
1610
1854
  msgtype: 'm.image',
1611
1855
  url: 'mxc://localhost/up1',
1612
- info: expect.objectContaining({ mimetype: 'image/png', size: TINY_PNG.length }),
1856
+ info: expect.objectContaining({
1857
+ mimetype: 'image/png',
1858
+ size: TINY_PNG.length,
1859
+ }),
1613
1860
  }),
1614
1861
  }),
1615
1862
  )
@@ -1653,17 +1900,19 @@ describe('ad-hoc bot invite declines', () => {
1653
1900
 
1654
1901
  it('declines an invite to the AS bot from a human, with a reason', async () => {
1655
1902
  const { transport, client } = makeTransport()
1656
- await postTxn(transport.app, { events: [invite('@zooid:example.com', '@zongshan:example.com')] })
1657
- expect(client.leaveRoom).toHaveBeenCalledWith(
1658
- '!r:example.com',
1659
- '@zooid:example.com',
1660
- { reason: expect.stringContaining('zooid.yaml') },
1661
- )
1903
+ await postTxn(transport.app, {
1904
+ events: [invite('@zooid:example.com', '@zongshan:example.com')],
1905
+ })
1906
+ expect(client.leaveRoom).toHaveBeenCalledWith('!r:example.com', '@zooid:example.com', {
1907
+ reason: expect.stringContaining('zooid.yaml'),
1908
+ })
1662
1909
  })
1663
1910
 
1664
1911
  it('declines an invite to an agent from a human', async () => {
1665
1912
  const { transport, client } = makeTransport()
1666
- await postTxn(transport.app, { events: [invite('@architect:example.com', '@zongshan:example.com')] })
1913
+ await postTxn(transport.app, {
1914
+ events: [invite('@architect:example.com', '@zongshan:example.com')],
1915
+ })
1667
1916
  expect(client.leaveRoom).toHaveBeenCalledWith(
1668
1917
  '!r:example.com',
1669
1918
  '@architect:example.com',
@@ -1673,13 +1922,17 @@ describe('ad-hoc bot invite declines', () => {
1673
1922
 
1674
1923
  it('does NOT decline a provisioning invite (inviter is our AS bot)', async () => {
1675
1924
  const { transport, client } = makeTransport()
1676
- await postTxn(transport.app, { events: [invite('@architect:example.com', '@zooid:example.com')] })
1925
+ await postTxn(transport.app, {
1926
+ events: [invite('@architect:example.com', '@zooid:example.com')],
1927
+ })
1677
1928
  expect(client.leaveRoom).not.toHaveBeenCalled()
1678
1929
  })
1679
1930
 
1680
1931
  it('ignores an invite to a human (not one of our bots)', async () => {
1681
1932
  const { transport, client } = makeTransport()
1682
- await postTxn(transport.app, { events: [invite('@dave:example.com', '@zongshan:example.com')] })
1933
+ await postTxn(transport.app, {
1934
+ events: [invite('@dave:example.com', '@zongshan:example.com')],
1935
+ })
1683
1936
  expect(client.leaveRoom).not.toHaveBeenCalled()
1684
1937
  })
1685
1938
  })
@@ -1705,7 +1958,9 @@ describe('directional agent-to-agent handoffs', () => {
1705
1958
  const approvals = fakeApprovals()
1706
1959
  const client = fakeClient()
1707
1960
  // Resolve prompts immediately so runTurn completes and `participants` append.
1708
- reg.prompt.mockImplementation(async () => ({ stopReason: 'end_turn' as const }))
1961
+ reg.prompt.mockImplementation(async () => ({
1962
+ stopReason: 'end_turn' as const,
1963
+ }))
1709
1964
  const transport = createMatrixTransport({
1710
1965
  agents: reg as never,
1711
1966
  approvals: approvals as never,
@@ -1729,7 +1984,13 @@ describe('directional agent-to-agent handoffs', () => {
1729
1984
  if (o.root) content['m.relates_to'] = { rel_type: 'm.thread', event_id: o.root }
1730
1985
  return postTxn(transport.app, {
1731
1986
  events: [
1732
- { type: 'm.room.message', event_id: o.id, room_id: '!r:example.com', sender: o.sender, content },
1987
+ {
1988
+ type: 'm.room.message',
1989
+ event_id: o.id,
1990
+ room_id: '!r:example.com',
1991
+ sender: o.sender,
1992
+ content,
1993
+ },
1733
1994
  ],
1734
1995
  })
1735
1996
  }
@@ -1738,19 +1999,33 @@ describe('directional agent-to-agent handoffs', () => {
1738
1999
  const { transport, agents } = makePairTransport()
1739
2000
 
1740
2001
  // 1. Human @mentions parent (top-level) → thread root is $root.
1741
- await post(transport, { id: '$root', sender: '@alice:example.com', mentions: ['@parent:example.com'] })
2002
+ await post(transport, {
2003
+ id: '$root',
2004
+ sender: '@alice:example.com',
2005
+ mentions: ['@parent:example.com'],
2006
+ })
1742
2007
  await settleTurn()
1743
2008
 
1744
2009
  // 2. Parent replies in-thread @mentioning sub → sub is called; caller[sub]=parent.
1745
2010
  // [[ZOD071]]: this call opens a fresh handoff arc for sub, keyed by $p1.
1746
- await post(transport, { id: '$p1', sender: '@parent:example.com', root: '$root', mentions: ['@sub:example.com'] })
2011
+ await post(transport, {
2012
+ id: '$p1',
2013
+ sender: '@parent:example.com',
2014
+ root: '$root',
2015
+ mentions: ['@sub:example.com'],
2016
+ })
1747
2017
  await settleTurn()
1748
2018
  expect(agents.ensureSession).toHaveBeenCalledWith('sub', '$root|$p1', '!r:example.com', '$root')
1749
2019
 
1750
2020
  agents.ensureSession.mockClear()
1751
2021
 
1752
2022
  // 3. Sub replies bare → bubbles UP to its caller (parent), nobody else.
1753
- await post(transport, { id: '$s1', sender: '@sub:example.com', root: '$root' })
2023
+ await post(transport, {
2024
+ id: '$s1',
2025
+ sender: '@sub:example.com',
2026
+ root: '$root',
2027
+ })
2028
+ await postTurnEnd(transport.app, { agent: 'sub', root: '$root' })
1754
2029
  await settleTurn()
1755
2030
  expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com', '$root')
1756
2031
  expect(agents.ensureSession.mock.calls.map((c) => c[0])).not.toContain('sub')
@@ -1759,9 +2034,61 @@ describe('directional agent-to-agent handoffs', () => {
1759
2034
 
1760
2035
  // 4. Parent replies bare (the "ack") → parent has no caller → routes to NOBODY.
1761
2036
  // This is the loop guard: sub is NOT re-triggered.
1762
- await post(transport, { id: '$p2', sender: '@parent:example.com', root: '$root' })
2037
+ await post(transport, {
2038
+ id: '$p2',
2039
+ sender: '@parent:example.com',
2040
+ root: '$root',
2041
+ })
2042
+ await settleTurn()
2043
+ expect(agents.ensureSession).not.toHaveBeenCalled()
2044
+ })
2045
+
2046
+ it('coalesces every message in a callee turn into one return, including an explicit @caller', async () => {
2047
+ const { transport, agents } = makePairTransport()
2048
+
2049
+ await post(transport, {
2050
+ id: '$root',
2051
+ sender: '@alice:example.com',
2052
+ mentions: ['@parent:example.com'],
2053
+ })
2054
+ await settleTurn()
2055
+ await post(transport, {
2056
+ id: '$p1',
2057
+ sender: '@parent:example.com',
2058
+ root: '$root',
2059
+ mentions: ['@sub:example.com'],
2060
+ })
2061
+ await settleTurn()
2062
+ agents.ensureSession.mockClear()
2063
+ agents.prompt.mockClear()
2064
+
2065
+ // Tool boundaries can flush several assistant messages in one turn. The
2066
+ // last one may explicitly address the caller; none may wake it early.
2067
+ await post(transport, { id: '$s1', sender: '@sub:example.com', root: '$root' })
2068
+ await post(transport, {
2069
+ id: '$s2',
2070
+ sender: '@sub:example.com',
2071
+ root: '$root',
2072
+ mentions: ['@parent:example.com'],
2073
+ })
1763
2074
  await settleTurn()
1764
2075
  expect(agents.ensureSession).not.toHaveBeenCalled()
2076
+
2077
+ await postTurnEnd(transport.app, { agent: 'sub', root: '$root' })
2078
+ await settleTurn()
2079
+ expect(agents.ensureSession).toHaveBeenCalledTimes(1)
2080
+ expect(agents.ensureSession).toHaveBeenCalledWith(
2081
+ 'parent',
2082
+ '$root',
2083
+ '!r:example.com',
2084
+ '$root',
2085
+ )
2086
+ expect(agents.prompt).toHaveBeenCalledWith(
2087
+ 'parent',
2088
+ expect.objectContaining({
2089
+ content: [expect.objectContaining({ type: 'text', text: 'x\n\nx' })],
2090
+ }),
2091
+ )
1765
2092
  })
1766
2093
 
1767
2094
  it('rebuildThreadState reconstructs caller[] from the timeline after a restart', async () => {
@@ -1780,7 +2107,11 @@ describe('directional agent-to-agent handoffs', () => {
1780
2107
  sender: '@parent:example.com',
1781
2108
  content: { 'm.mentions': { user_ids: ['@sub:example.com'] } },
1782
2109
  },
1783
- { type: 'm.room.message', sender: '@sub:example.com', content: { body: 'ack' } },
2110
+ {
2111
+ type: 'm.room.message',
2112
+ sender: '@sub:example.com',
2113
+ content: { body: 'ack' },
2114
+ },
1784
2115
  ],
1785
2116
  })),
1786
2117
  }
@@ -1850,7 +2181,6 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
1850
2181
  ],
1851
2182
  })
1852
2183
  }
1853
-
1854
2184
  const agentsCalled = (reg: ReturnType<typeof fakeRegistry>['reg']) =>
1855
2185
  reg.ensureSession.mock.calls.map((c) => c[0] as string)
1856
2186
 
@@ -1865,12 +2195,7 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
1865
2195
  mentions: ['@parent:example.com'],
1866
2196
  })
1867
2197
  await settleTurn()
1868
- expect(agents.ensureSession).toHaveBeenCalledWith(
1869
- 'parent',
1870
- '$root',
1871
- '!r:example.com',
1872
- '$root',
1873
- )
2198
+ expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com', '$root')
1874
2199
 
1875
2200
  // 2. Parent calls BOTH subs in ONE message ($p1). Each sub gets a fresh
1876
2201
  // arc session keyed by the same call event id — the composed key
@@ -1906,14 +2231,14 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
1906
2231
  // thread-level session; the sibling is untouched.
1907
2232
  gates.get('rocksteady')!()
1908
2233
  await settleTurn()
1909
- await post(transport, { id: '$r1', sender: '@rocksteady:example.com', root: '$root' })
2234
+ await post(transport, {
2235
+ id: '$r1',
2236
+ sender: '@rocksteady:example.com',
2237
+ root: '$root',
2238
+ })
2239
+ await postTurnEnd(transport.app, { agent: 'rocksteady', root: '$root' })
1910
2240
  await settleTurn()
1911
- expect(agents.ensureSession).toHaveBeenCalledWith(
1912
- 'parent',
1913
- '$root',
1914
- '!r:example.com',
1915
- '$root',
1916
- )
2241
+ expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com', '$root')
1917
2242
  expect(agentsCalled(agents)).not.toContain('bebop')
1918
2243
 
1919
2244
  agents.ensureSession.mockClear()
@@ -1923,14 +2248,14 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
1923
2248
  // SAME thread-level session (it aggregates the two results).
1924
2249
  gates.get('bebop')!()
1925
2250
  await settleTurn()
1926
- await post(transport, { id: '$b1', sender: '@bebop:example.com', root: '$root' })
2251
+ await post(transport, {
2252
+ id: '$b1',
2253
+ sender: '@bebop:example.com',
2254
+ root: '$root',
2255
+ })
2256
+ await postTurnEnd(transport.app, { agent: 'bebop', root: '$root' })
1927
2257
  await settleTurn()
1928
- expect(agents.ensureSession).toHaveBeenCalledWith(
1929
- 'parent',
1930
- '$root',
1931
- '!r:example.com',
1932
- '$root',
1933
- )
2258
+ expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com', '$root')
1934
2259
  expect(agentsCalled(agents)).not.toContain('rocksteady')
1935
2260
  })
1936
2261
 
@@ -1954,7 +2279,12 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
1954
2279
  await settleTurn()
1955
2280
  gates.get('bebop')!()
1956
2281
  await settleTurn()
1957
- await post(transport, { id: '$b1', sender: '@bebop:example.com', root: '$root' })
2282
+ await post(transport, {
2283
+ id: '$b1',
2284
+ sender: '@bebop:example.com',
2285
+ root: '$root',
2286
+ })
2287
+ await postTurnEnd(transport.app, { agent: 'bebop', root: '$root' })
1958
2288
  await settleTurn()
1959
2289
 
1960
2290
  // Second delegation: arc $p2 — a NEW session key, not a resume.
@@ -1997,7 +2327,11 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
1997
2327
 
1998
2328
  // Human "why did you do X?" — routes to bebop (last poster) and lands in
1999
2329
  // the session that DID the work, not a cold thread-level one.
2000
- await post(transport, { id: '$h1', sender: '@alice:example.com', root: '$root' })
2330
+ await post(transport, {
2331
+ id: '$h1',
2332
+ sender: '@alice:example.com',
2333
+ root: '$root',
2334
+ })
2001
2335
  await settleTurn()
2002
2336
  expect(agents.ensureSession).toHaveBeenCalledWith(
2003
2337
  'bebop',
@@ -2034,7 +2368,9 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
2034
2368
  event_id: '$reset',
2035
2369
  room_id: '!r:example.com',
2036
2370
  sender: '@alice:example.com',
2037
- content: { 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' } },
2371
+ content: {
2372
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
2373
+ },
2038
2374
  },
2039
2375
  ],
2040
2376
  })
@@ -2075,7 +2411,9 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
2075
2411
  event_id: '$reset',
2076
2412
  room_id: '!r:example.com',
2077
2413
  sender: '@alice:example.com',
2078
- content: { 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' } },
2414
+ content: {
2415
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
2416
+ },
2079
2417
  },
2080
2418
  ],
2081
2419
  })
@@ -2100,10 +2438,17 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
2100
2438
  event_id: '$p1',
2101
2439
  sender: '@parent:example.com',
2102
2440
  content: {
2103
- 'm.mentions': { user_ids: ['@bebop:example.com', '@rocksteady:example.com'] },
2441
+ 'm.mentions': {
2442
+ user_ids: ['@bebop:example.com', '@rocksteady:example.com'],
2443
+ },
2104
2444
  },
2105
2445
  },
2106
- { type: 'm.room.message', event_id: '$b1', sender: '@bebop:example.com', content: { body: 'done' } },
2446
+ {
2447
+ type: 'm.room.message',
2448
+ event_id: '$b1',
2449
+ sender: '@bebop:example.com',
2450
+ content: { body: 'done' },
2451
+ },
2107
2452
  {
2108
2453
  type: 'm.room.message',
2109
2454
  event_id: '$p2',
@@ -2114,7 +2459,71 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
2114
2459
  })),
2115
2460
  }
2116
2461
  const state = await rebuildThreadState(client as never, '!r:example.com', '$root', trio)
2117
- expect(state.handoffs).toEqual({ bebop: ['$p1', '$p2'], rocksteady: ['$p1'] })
2462
+ expect(state.handoffs).toEqual({
2463
+ bebop: ['$p1', '$p2'],
2464
+ rocksteady: ['$p1'],
2465
+ })
2118
2466
  expect(state.callers).toEqual({ bebop: 'parent', rocksteady: 'parent' })
2119
2467
  })
2120
2468
  })
2469
+
2470
+ describe('taskActions.describeRole', () => {
2471
+ function makeTaskTransport() {
2472
+ const { reg, finishPrompt } = fakeRegistry()
2473
+ const approvals = fakeApprovals()
2474
+ const client = fakeClient()
2475
+ const bindings = [
2476
+ ...baseAgents,
2477
+ {
2478
+ name: 'worker',
2479
+ userId: '@worker:example.com',
2480
+ rooms: [{ alias: '!r:example.com' }],
2481
+ trigger: 'mention' as const,
2482
+ },
2483
+ ]
2484
+ const transport = createMatrixTransport({
2485
+ agents: reg as never,
2486
+ approvals: approvals as never,
2487
+ client: client as never,
2488
+ bindings,
2489
+ hsToken: 'hs-secret',
2490
+ botUserId: '@zooid:example.com',
2491
+ drainQuietMs: 0,
2492
+ })
2493
+ return { transport, finishPrompt }
2494
+ }
2495
+
2496
+ it('describeRole reports assignee and depth for a task thread', async () => {
2497
+ const { transport } = makeTaskTransport()
2498
+ const started = await transport.taskActions.startTasks(
2499
+ {
2500
+ agentName: 'architect',
2501
+ channelId: '!r:example.com',
2502
+ threadRoot: '$parent',
2503
+ sessionKey: '$parent',
2504
+ },
2505
+ { tasks: [{ agent: 'worker', prompt: 'do the thing' }] },
2506
+ )
2507
+ const result = started.results[0]!
2508
+ expect(result.status).toBe('started')
2509
+ const threadId = (result as { thread_id: string }).thread_id
2510
+ const role = await transport.taskActions.describeRole({
2511
+ agentName: 'worker',
2512
+ channelId: '!r:example.com',
2513
+ threadRoot: threadId,
2514
+ sessionKey: threadId,
2515
+ })
2516
+ expect(role).toEqual({ is_task_assignee: true, can_start_task_threads: false })
2517
+ })
2518
+
2519
+ it('describeRole reports a plain mention as neither assignee nor capped', async () => {
2520
+ const { transport } = makeTaskTransport()
2521
+ const role = await transport.taskActions.describeRole({
2522
+ agentName: 'architect',
2523
+ channelId: '!r:example.com',
2524
+ threadRoot: '$other',
2525
+ sessionKey: '$other',
2526
+ })
2527
+ expect(role).toEqual({ is_task_assignee: false, can_start_task_threads: true })
2528
+ })
2529
+ })