@zooid/transport-matrix 0.13.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.notice', 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
  )
@@ -343,7 +382,9 @@ describe('matrix transport /transactions', () => {
343
382
  })
344
383
  await settleTurn()
345
384
  expect(client.sendMessage).toHaveBeenCalledWith(
346
- expect.objectContaining({ content: expect.objectContaining({ msgtype: 'm.notice' }) }),
385
+ expect.objectContaining({
386
+ content: expect.objectContaining({ msgtype: 'm.notice' }),
387
+ }),
347
388
  )
348
389
  })
349
390
 
@@ -367,7 +408,7 @@ describe('matrix transport /transactions', () => {
367
408
  await settleTurn()
368
409
  approvals.emit('registered', {
369
410
  approvalId: 'a1',
370
- sessionId: 'sess-$root', // session is keyed on the promoted thread root
411
+ sessionId: 'sess-$root', // session is keyed on the promoted thread root
371
412
  toolCallId: 't1',
372
413
  options: [{ optionId: 'allow_once', name: 'Allow', kind: 'allow_once' }],
373
414
  })
@@ -398,11 +439,10 @@ describe('matrix transport /transactions', () => {
398
439
  },
399
440
  ],
400
441
  })
401
- expect(approvals.resolve).toHaveBeenCalledWith(
402
- 'sess-$root',
403
- 'a1',
404
- { decision: 'allow', optionId: 'allow_once' },
405
- )
442
+ expect(approvals.resolve).toHaveBeenCalledWith('sess-$root', 'a1', {
443
+ decision: 'allow',
444
+ optionId: 'allow_once',
445
+ })
406
446
  })
407
447
  })
408
448
 
@@ -454,7 +494,9 @@ describe('dev.zooid.turn.end', () => {
454
494
  it('reports produced_output: false for a silent turn', async () => {
455
495
  const { transport, agents, client } = makeTransport()
456
496
  // The agent emits no chunks at all — this is exactly ZOD076's case.
457
- agents.prompt.mockImplementation(async () => ({ stopReason: 'end_turn' as const }))
497
+ agents.prompt.mockImplementation(async () => ({
498
+ stopReason: 'end_turn' as const,
499
+ }))
458
500
  await postTxn(transport.app, { events: [topLevelMention()] })
459
501
  await settleTurn()
460
502
 
@@ -482,7 +524,9 @@ describe('dev.zooid.turn.end', () => {
482
524
 
483
525
  it('threads to the turn root', async () => {
484
526
  const { transport, agents, client } = makeTransport()
485
- agents.prompt.mockImplementation(async () => ({ stopReason: 'end_turn' as const }))
527
+ agents.prompt.mockImplementation(async () => ({
528
+ stopReason: 'end_turn' as const,
529
+ }))
486
530
  await postTxn(transport.app, { events: [topLevelMention()] })
487
531
  await settleTurn()
488
532
 
@@ -497,7 +541,7 @@ describe('dev.zooid.turn.end', () => {
497
541
 
498
542
  describe('thread implicit triggers', () => {
499
543
  it('triggers the most-recent-posting agent for a bare reply in a thread', async () => {
500
- const { transport, agents } = makeTransport()
544
+ const { transport, agents, finishPrompt } = makeTransport()
501
545
  // Turn 1: user @mentions architect at top level. Agent-promotion makes
502
546
  // $root the thread root and architect a thread participant.
503
547
  agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
@@ -545,13 +589,16 @@ describe('thread implicit triggers', () => {
545
589
  await settleTurn()
546
590
 
547
591
  // architect should be triggered implicitly because they posted in the thread.
548
- 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
+ )
549
598
  })
550
599
 
551
600
  it("inherits the thread root's @mentions when no agent has posted yet", async () => {
552
- const { transport, agents } = makeTransport()
553
- // Have prompt() never resolve, so no agent reply has landed when turn 2 arrives.
554
- agents.prompt.mockImplementation(() => new Promise(() => {}))
601
+ const { transport, agents, finishPrompt } = makeTransport()
555
602
  await postTxn(transport.app, {
556
603
  events: [
557
604
  {
@@ -587,8 +634,17 @@ describe('thread implicit triggers', () => {
587
634
  ],
588
635
  })
589
636
  await settleTurn()
590
- // Inherits the root's @mention of architect.
591
- 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
+ )
592
648
  })
593
649
 
594
650
  it('does not trigger any agent for a bare top-level message', async () => {
@@ -653,7 +709,12 @@ describe('thread implicit triggers', () => {
653
709
  ],
654
710
  })
655
711
  await settleTurn()
656
- 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
+ )
657
718
  })
658
719
  })
659
720
 
@@ -728,7 +789,9 @@ describe('dev.zooid.session_reset', () => {
728
789
  event_id: '$reset',
729
790
  room_id: '!r:example.com',
730
791
  sender: '@alice:example.com',
731
- 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
+ },
732
795
  },
733
796
  ],
734
797
  })
@@ -754,7 +817,12 @@ describe('dev.zooid.session_reset', () => {
754
817
  ],
755
818
  })
756
819
  await settleTurn()
757
- 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
+ )
758
826
  })
759
827
  })
760
828
 
@@ -847,10 +915,10 @@ describe('typing indicator lifecycle', () => {
847
915
  room_id: '!r:example.com',
848
916
  sender: '@user:example.com',
849
917
  content: {
850
- msgtype: 'm.text',
851
- body: 'hi',
852
- 'm.mentions': { user_ids: ['@architect:example.com'] },
853
- },
918
+ msgtype: 'm.text',
919
+ body: 'hi',
920
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
921
+ },
854
922
  },
855
923
  ],
856
924
  })
@@ -908,7 +976,9 @@ describe('presence lifecycle', () => {
908
976
  expect(seenUnavailable).toBe(true)
909
977
  finishPrompt()
910
978
  await settleTurn()
911
- 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
+ }
912
982
  expect(last.presence).toBe('online')
913
983
  })
914
984
 
@@ -1064,8 +1134,7 @@ describe('tool-call and plan event bridging', () => {
1064
1134
  await settleTurn()
1065
1135
 
1066
1136
  const call = client.sendCustomEvent.mock.calls.find(
1067
- ([arg]) =>
1068
- (arg as { eventType: string }).eventType === 'dev.zooid.available_commands_update',
1137
+ ([arg]) => (arg as { eventType: string }).eventType === 'dev.zooid.available_commands_update',
1069
1138
  )
1070
1139
  expect(call).toBeDefined()
1071
1140
  expect((call![0] as { content: Record<string, unknown> }).content).toMatchObject({
@@ -1205,9 +1274,7 @@ describe('agent_message_chunk message-boundary buffering', () => {
1205
1274
  (client.sendMessage.mock.calls[0]![0] as { content: { body: string } }).content.body
1206
1275
 
1207
1276
  const sentBodies = (client: { sendMessage: { mock: { calls: unknown[][] } } }) =>
1208
- client.sendMessage.mock.calls.map(
1209
- (c) => (c[0] as { content: { body: string } }).content.body,
1210
- )
1277
+ client.sendMessage.mock.calls.map((c) => (c[0] as { content: { body: string } }).content.body)
1211
1278
 
1212
1279
  it('flushes a separate Matrix message when messageId changes (opencode run-on)', async () => {
1213
1280
  // opencode streams "…it." under one message id, then "Filed:" under a NEW
@@ -1305,7 +1372,11 @@ describe('dev.zooid.interrupt handling', () => {
1305
1372
  origin_server_ts: Date.now(),
1306
1373
  room_id: '!r:example.com',
1307
1374
  sender: '@user:example.com',
1308
- 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
+ },
1309
1380
  },
1310
1381
  ],
1311
1382
  })
@@ -1338,7 +1409,11 @@ describe('dev.zooid.interrupt handling', () => {
1338
1409
  origin_server_ts: Date.now(),
1339
1410
  room_id: '!r:example.com',
1340
1411
  sender: '@user:example.com',
1341
- 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
+ },
1342
1417
  },
1343
1418
  ],
1344
1419
  })
@@ -1387,7 +1462,11 @@ describe('dev.zooid.interrupt handling', () => {
1387
1462
  origin_server_ts: Date.now(),
1388
1463
  room_id: '!r:example.com',
1389
1464
  sender: '@user:example.com',
1390
- 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
+ },
1391
1470
  },
1392
1471
  ],
1393
1472
  })
@@ -1423,7 +1502,11 @@ describe('dev.zooid.interrupt handling', () => {
1423
1502
  origin_server_ts: Date.now(),
1424
1503
  room_id: '!r:example.com',
1425
1504
  sender: '@user:example.com',
1426
- 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
+ },
1427
1510
  },
1428
1511
  ],
1429
1512
  })
@@ -1483,14 +1566,19 @@ describe('full loop integration', () => {
1483
1566
 
1484
1567
  // Turn 1: top-level mention.
1485
1568
  await postTxn(transport.app, {
1486
- events: [{
1487
- type: 'm.room.message', event_id: '$root', room_id: '!r:example.com',
1488
- sender: '@alice:example.com',
1489
- content: {
1490
- msgtype: 'm.text', body: 'hi @architect',
1491
- '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
+ },
1492
1580
  },
1493
- }],
1581
+ ],
1494
1582
  })
1495
1583
  await settleTurn()
1496
1584
  expect(client.sendMessage).toHaveBeenCalledWith(
@@ -1501,17 +1589,27 @@ describe('full loop integration', () => {
1501
1589
  agents.ensureSession.mockClear()
1502
1590
  client.sendMessage.mockClear()
1503
1591
  await postTxn(transport.app, {
1504
- events: [{
1505
- type: 'm.room.message', event_id: '$bare', room_id: '!r:example.com',
1506
- sender: '@alice:example.com',
1507
- content: {
1508
- msgtype: 'm.text', body: 'follow up',
1509
- '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
+ },
1510
1603
  },
1511
- }],
1604
+ ],
1512
1605
  })
1513
1606
  await settleTurn()
1514
- 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
+ )
1515
1613
  expect(client.sendMessage).toHaveBeenCalledWith(
1516
1614
  expect.objectContaining({ threadRoot: '$root' }),
1517
1615
  )
@@ -1526,7 +1624,10 @@ const TINY_PNG = Buffer.from(TINY_PNG_B64, 'base64')
1526
1624
 
1527
1625
  function fakeMedia() {
1528
1626
  return {
1529
- 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
+ })),
1530
1631
  upload: vi.fn(async () => ({ content_uri: 'mxc://localhost/up1' })),
1531
1632
  }
1532
1633
  }
@@ -1537,10 +1638,12 @@ const workspaceBinding = {
1537
1638
  agentWorkspacePath: '/workspace',
1538
1639
  }
1539
1640
 
1540
- function makeMediaTransport(opts: {
1541
- media?: ReturnType<typeof fakeMedia>
1542
- writeAttachmentFn?: unknown
1543
- } = {}) {
1641
+ function makeMediaTransport(
1642
+ opts: {
1643
+ media?: ReturnType<typeof fakeMedia>
1644
+ writeAttachmentFn?: unknown
1645
+ } = {},
1646
+ ) {
1544
1647
  const { reg, finishPrompt } = fakeRegistry()
1545
1648
  const approvals = fakeApprovals()
1546
1649
  const client = fakeClient()
@@ -1557,13 +1660,15 @@ function makeMediaTransport(opts: {
1557
1660
  return { transport, agents: reg, client, finishPrompt }
1558
1661
  }
1559
1662
 
1560
- function imageEvent(over: {
1561
- size?: number
1562
- mimetype?: string
1563
- msgtype?: string
1564
- body?: string
1565
- eventId?: string
1566
- } = {}) {
1663
+ function imageEvent(
1664
+ over: {
1665
+ size?: number
1666
+ mimetype?: string
1667
+ msgtype?: string
1668
+ body?: string
1669
+ eventId?: string
1670
+ } = {},
1671
+ ) {
1567
1672
  return {
1568
1673
  type: 'm.room.message',
1569
1674
  event_id: over.eventId ?? '$media1',
@@ -1616,7 +1721,11 @@ describe('inbound media', () => {
1616
1721
 
1617
1722
  expect(agents.prompt).toHaveBeenCalledOnce()
1618
1723
  const content = (agents.prompt.mock.calls[0][1] as { content: unknown[] }).content
1619
- 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
+ })
1620
1729
  expect((content[1] as { type: string; text: string }).type).toBe('text')
1621
1730
  expect(media.download).toHaveBeenCalledOnce()
1622
1731
  })
@@ -1627,7 +1736,10 @@ describe('inbound media', () => {
1627
1736
  hostPath: '/tmp/ws/.zooid/attachments/media1/dog.png',
1628
1737
  agentPath: '/workspace/.zooid/attachments/media1/dog.png',
1629
1738
  }))
1630
- const { transport, agents } = makeMediaTransport({ media, writeAttachmentFn })
1739
+ const { transport, agents } = makeMediaTransport({
1740
+ media,
1741
+ writeAttachmentFn,
1742
+ })
1631
1743
 
1632
1744
  agents.prompt.mockResolvedValue({ stopReason: 'end_turn' as const })
1633
1745
  await postTxn(transport.app, { events: [imageEvent({ size: 600_000 })] }) // > MAX_INLINE_IMAGE_BYTES
@@ -1651,11 +1763,20 @@ describe('inbound media', () => {
1651
1763
  hostPath: '/tmp/ws/.zooid/attachments/media1/report.pdf',
1652
1764
  agentPath: '/workspace/.zooid/attachments/media1/report.pdf',
1653
1765
  }))
1654
- const { transport, agents } = makeMediaTransport({ media, writeAttachmentFn })
1766
+ const { transport, agents } = makeMediaTransport({
1767
+ media,
1768
+ writeAttachmentFn,
1769
+ })
1655
1770
 
1656
1771
  agents.prompt.mockResolvedValue({ stopReason: 'end_turn' as const })
1657
1772
  await postTxn(transport.app, {
1658
- 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
+ ],
1659
1780
  })
1660
1781
  await postTxn(transport.app, { events: [mentionMsg('read it')] })
1661
1782
  await settleTurn()
@@ -1721,7 +1842,10 @@ describe('outbound agent images', () => {
1721
1842
  await new Promise((r) => setTimeout(r, 10))
1722
1843
 
1723
1844
  expect(media.upload).toHaveBeenCalledWith(
1724
- expect.objectContaining({ contentType: 'image/png', asUserId: '@architect:example.com' }),
1845
+ expect.objectContaining({
1846
+ contentType: 'image/png',
1847
+ asUserId: '@architect:example.com',
1848
+ }),
1725
1849
  )
1726
1850
  expect(client.sendMessage).toHaveBeenCalledWith(
1727
1851
  expect.objectContaining({
@@ -1729,7 +1853,10 @@ describe('outbound agent images', () => {
1729
1853
  content: expect.objectContaining({
1730
1854
  msgtype: 'm.image',
1731
1855
  url: 'mxc://localhost/up1',
1732
- info: expect.objectContaining({ mimetype: 'image/png', size: TINY_PNG.length }),
1856
+ info: expect.objectContaining({
1857
+ mimetype: 'image/png',
1858
+ size: TINY_PNG.length,
1859
+ }),
1733
1860
  }),
1734
1861
  }),
1735
1862
  )
@@ -1773,17 +1900,19 @@ describe('ad-hoc bot invite declines', () => {
1773
1900
 
1774
1901
  it('declines an invite to the AS bot from a human, with a reason', async () => {
1775
1902
  const { transport, client } = makeTransport()
1776
- await postTxn(transport.app, { events: [invite('@zooid:example.com', '@zongshan:example.com')] })
1777
- expect(client.leaveRoom).toHaveBeenCalledWith(
1778
- '!r:example.com',
1779
- '@zooid:example.com',
1780
- { reason: expect.stringContaining('zooid.yaml') },
1781
- )
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
+ })
1782
1909
  })
1783
1910
 
1784
1911
  it('declines an invite to an agent from a human', async () => {
1785
1912
  const { transport, client } = makeTransport()
1786
- 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
+ })
1787
1916
  expect(client.leaveRoom).toHaveBeenCalledWith(
1788
1917
  '!r:example.com',
1789
1918
  '@architect:example.com',
@@ -1793,13 +1922,17 @@ describe('ad-hoc bot invite declines', () => {
1793
1922
 
1794
1923
  it('does NOT decline a provisioning invite (inviter is our AS bot)', async () => {
1795
1924
  const { transport, client } = makeTransport()
1796
- 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
+ })
1797
1928
  expect(client.leaveRoom).not.toHaveBeenCalled()
1798
1929
  })
1799
1930
 
1800
1931
  it('ignores an invite to a human (not one of our bots)', async () => {
1801
1932
  const { transport, client } = makeTransport()
1802
- 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
+ })
1803
1936
  expect(client.leaveRoom).not.toHaveBeenCalled()
1804
1937
  })
1805
1938
  })
@@ -1825,7 +1958,9 @@ describe('directional agent-to-agent handoffs', () => {
1825
1958
  const approvals = fakeApprovals()
1826
1959
  const client = fakeClient()
1827
1960
  // Resolve prompts immediately so runTurn completes and `participants` append.
1828
- reg.prompt.mockImplementation(async () => ({ stopReason: 'end_turn' as const }))
1961
+ reg.prompt.mockImplementation(async () => ({
1962
+ stopReason: 'end_turn' as const,
1963
+ }))
1829
1964
  const transport = createMatrixTransport({
1830
1965
  agents: reg as never,
1831
1966
  approvals: approvals as never,
@@ -1849,7 +1984,13 @@ describe('directional agent-to-agent handoffs', () => {
1849
1984
  if (o.root) content['m.relates_to'] = { rel_type: 'm.thread', event_id: o.root }
1850
1985
  return postTxn(transport.app, {
1851
1986
  events: [
1852
- { 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
+ },
1853
1994
  ],
1854
1995
  })
1855
1996
  }
@@ -1858,19 +1999,33 @@ describe('directional agent-to-agent handoffs', () => {
1858
1999
  const { transport, agents } = makePairTransport()
1859
2000
 
1860
2001
  // 1. Human @mentions parent (top-level) → thread root is $root.
1861
- 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
+ })
1862
2007
  await settleTurn()
1863
2008
 
1864
2009
  // 2. Parent replies in-thread @mentioning sub → sub is called; caller[sub]=parent.
1865
2010
  // [[ZOD071]]: this call opens a fresh handoff arc for sub, keyed by $p1.
1866
- 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
+ })
1867
2017
  await settleTurn()
1868
2018
  expect(agents.ensureSession).toHaveBeenCalledWith('sub', '$root|$p1', '!r:example.com', '$root')
1869
2019
 
1870
2020
  agents.ensureSession.mockClear()
1871
2021
 
1872
2022
  // 3. Sub replies bare → bubbles UP to its caller (parent), nobody else.
1873
- 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' })
1874
2029
  await settleTurn()
1875
2030
  expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com', '$root')
1876
2031
  expect(agents.ensureSession.mock.calls.map((c) => c[0])).not.toContain('sub')
@@ -1879,11 +2034,63 @@ describe('directional agent-to-agent handoffs', () => {
1879
2034
 
1880
2035
  // 4. Parent replies bare (the "ack") → parent has no caller → routes to NOBODY.
1881
2036
  // This is the loop guard: sub is NOT re-triggered.
1882
- 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
+ })
1883
2042
  await settleTurn()
1884
2043
  expect(agents.ensureSession).not.toHaveBeenCalled()
1885
2044
  })
1886
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
+ })
2074
+ await settleTurn()
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
+ )
2092
+ })
2093
+
1887
2094
  it('rebuildThreadState reconstructs caller[] from the timeline after a restart', async () => {
1888
2095
  const client = {
1889
2096
  // root: human @mentions parent (no caller — human sender).
@@ -1900,7 +2107,11 @@ describe('directional agent-to-agent handoffs', () => {
1900
2107
  sender: '@parent:example.com',
1901
2108
  content: { 'm.mentions': { user_ids: ['@sub:example.com'] } },
1902
2109
  },
1903
- { 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
+ },
1904
2115
  ],
1905
2116
  })),
1906
2117
  }
@@ -1970,7 +2181,6 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
1970
2181
  ],
1971
2182
  })
1972
2183
  }
1973
-
1974
2184
  const agentsCalled = (reg: ReturnType<typeof fakeRegistry>['reg']) =>
1975
2185
  reg.ensureSession.mock.calls.map((c) => c[0] as string)
1976
2186
 
@@ -1985,12 +2195,7 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
1985
2195
  mentions: ['@parent:example.com'],
1986
2196
  })
1987
2197
  await settleTurn()
1988
- expect(agents.ensureSession).toHaveBeenCalledWith(
1989
- 'parent',
1990
- '$root',
1991
- '!r:example.com',
1992
- '$root',
1993
- )
2198
+ expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com', '$root')
1994
2199
 
1995
2200
  // 2. Parent calls BOTH subs in ONE message ($p1). Each sub gets a fresh
1996
2201
  // arc session keyed by the same call event id — the composed key
@@ -2026,14 +2231,14 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
2026
2231
  // thread-level session; the sibling is untouched.
2027
2232
  gates.get('rocksteady')!()
2028
2233
  await settleTurn()
2029
- 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' })
2030
2240
  await settleTurn()
2031
- expect(agents.ensureSession).toHaveBeenCalledWith(
2032
- 'parent',
2033
- '$root',
2034
- '!r:example.com',
2035
- '$root',
2036
- )
2241
+ expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com', '$root')
2037
2242
  expect(agentsCalled(agents)).not.toContain('bebop')
2038
2243
 
2039
2244
  agents.ensureSession.mockClear()
@@ -2043,14 +2248,14 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
2043
2248
  // SAME thread-level session (it aggregates the two results).
2044
2249
  gates.get('bebop')!()
2045
2250
  await settleTurn()
2046
- 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' })
2047
2257
  await settleTurn()
2048
- expect(agents.ensureSession).toHaveBeenCalledWith(
2049
- 'parent',
2050
- '$root',
2051
- '!r:example.com',
2052
- '$root',
2053
- )
2258
+ expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com', '$root')
2054
2259
  expect(agentsCalled(agents)).not.toContain('rocksteady')
2055
2260
  })
2056
2261
 
@@ -2074,7 +2279,12 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
2074
2279
  await settleTurn()
2075
2280
  gates.get('bebop')!()
2076
2281
  await settleTurn()
2077
- 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' })
2078
2288
  await settleTurn()
2079
2289
 
2080
2290
  // Second delegation: arc $p2 — a NEW session key, not a resume.
@@ -2117,7 +2327,11 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
2117
2327
 
2118
2328
  // Human "why did you do X?" — routes to bebop (last poster) and lands in
2119
2329
  // the session that DID the work, not a cold thread-level one.
2120
- 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
+ })
2121
2335
  await settleTurn()
2122
2336
  expect(agents.ensureSession).toHaveBeenCalledWith(
2123
2337
  'bebop',
@@ -2154,7 +2368,9 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
2154
2368
  event_id: '$reset',
2155
2369
  room_id: '!r:example.com',
2156
2370
  sender: '@alice:example.com',
2157
- 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
+ },
2158
2374
  },
2159
2375
  ],
2160
2376
  })
@@ -2195,7 +2411,9 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
2195
2411
  event_id: '$reset',
2196
2412
  room_id: '!r:example.com',
2197
2413
  sender: '@alice:example.com',
2198
- 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
+ },
2199
2417
  },
2200
2418
  ],
2201
2419
  })
@@ -2220,10 +2438,17 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
2220
2438
  event_id: '$p1',
2221
2439
  sender: '@parent:example.com',
2222
2440
  content: {
2223
- 'm.mentions': { user_ids: ['@bebop:example.com', '@rocksteady:example.com'] },
2441
+ 'm.mentions': {
2442
+ user_ids: ['@bebop:example.com', '@rocksteady:example.com'],
2443
+ },
2224
2444
  },
2225
2445
  },
2226
- { 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
+ },
2227
2452
  {
2228
2453
  type: 'm.room.message',
2229
2454
  event_id: '$p2',
@@ -2234,7 +2459,71 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
2234
2459
  })),
2235
2460
  }
2236
2461
  const state = await rebuildThreadState(client as never, '!r:example.com', '$root', trio)
2237
- expect(state.handoffs).toEqual({ bebop: ['$p1', '$p2'], rocksteady: ['$p1'] })
2462
+ expect(state.handoffs).toEqual({
2463
+ bebop: ['$p1', '$p2'],
2464
+ rocksteady: ['$p1'],
2465
+ })
2238
2466
  expect(state.callers).toEqual({ bebop: 'parent', rocksteady: 'parent' })
2239
2467
  })
2240
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
+ })