@zooid/transport-matrix 0.7.4 → 0.9.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.
@@ -40,6 +40,7 @@ function fakeClient() {
40
40
  return {
41
41
  registerBot: vi.fn(async () => undefined),
42
42
  joinRoom: vi.fn(async () => undefined),
43
+ leaveRoom: vi.fn(async () => undefined),
43
44
  sendMessage: vi.fn(async () => ({ event_id: '$x' })),
44
45
  sendCustomEvent: vi.fn(async () => ({ event_id: '$x' })),
45
46
  setTyping: vi.fn(async () => {}),
@@ -66,6 +67,7 @@ function makeTransport(drain?: { drainQuietMs?: number; drainMaxMs?: number }) {
66
67
  client: client as never,
67
68
  bindings: baseAgents,
68
69
  hsToken: 'hs-secret',
70
+ botUserId: '@zooid:example.com',
69
71
  // Disable post-turn drain by default so settleTurn (microtasks) suffices.
70
72
  // Tests covering trailing-chunk behavior pass an explicit window.
71
73
  drainQuietMs: drain?.drainQuietMs ?? 0,
@@ -314,7 +316,7 @@ describe('matrix transport /transactions', () => {
314
316
  expect(call.content).not.toHaveProperty('format')
315
317
  })
316
318
 
317
- it('emits eco.zoon.approval_request when an approval is registered', async () => {
319
+ it('emits dev.zooid.approval_request when an approval is registered', async () => {
318
320
  const { transport, approvals, client } = makeTransport()
319
321
  await postTxn(transport.app, {
320
322
  events: [
@@ -341,18 +343,18 @@ describe('matrix transport /transactions', () => {
341
343
  await new Promise((r) => setImmediate(r))
342
344
  expect(client.sendCustomEvent).toHaveBeenCalledWith(
343
345
  expect.objectContaining({
344
- eventType: 'eco.zoon.approval_request',
346
+ eventType: 'dev.zooid.approval_request',
345
347
  content: expect.objectContaining({ approval_id: 'a1' }),
346
348
  }),
347
349
  )
348
350
  })
349
351
 
350
- it('resolves an approval when an eco.zoon.approval_response event arrives', async () => {
352
+ it('resolves an approval when an dev.zooid.approval_response event arrives', async () => {
351
353
  const { transport, approvals } = makeTransport()
352
354
  await postTxn(transport.app, {
353
355
  events: [
354
356
  {
355
- type: 'eco.zoon.approval_response',
357
+ type: 'dev.zooid.approval_response',
356
358
  event_id: '$resp',
357
359
  room_id: '!r:example.com',
358
360
  sender: '@alice:example.com',
@@ -535,13 +537,13 @@ describe('thread implicit triggers', () => {
535
537
  })
536
538
  })
537
539
 
538
- describe('eco.zoon.session_reset', () => {
540
+ describe('dev.zooid.session_reset', () => {
539
541
  it('ends the thread-keyed session when sent inside a thread', async () => {
540
542
  const { transport, agents } = makeTransport()
541
543
  await postTxn(transport.app, {
542
544
  events: [
543
545
  {
544
- type: 'eco.zoon.session_reset',
546
+ type: 'dev.zooid.session_reset',
545
547
  event_id: '$reset',
546
548
  room_id: '!r:example.com',
547
549
  sender: '@alice:example.com',
@@ -559,7 +561,7 @@ describe('eco.zoon.session_reset', () => {
559
561
  await postTxn(transport.app, {
560
562
  events: [
561
563
  {
562
- type: 'eco.zoon.session_reset',
564
+ type: 'dev.zooid.session_reset',
563
565
  event_id: '$reset-room',
564
566
  room_id: '!r:example.com',
565
567
  sender: '@alice:example.com',
@@ -602,7 +604,7 @@ describe('eco.zoon.session_reset', () => {
602
604
  await postTxn(transport.app, {
603
605
  events: [
604
606
  {
605
- type: 'eco.zoon.session_reset',
607
+ type: 'dev.zooid.session_reset',
606
608
  event_id: '$reset',
607
609
  room_id: '!r:example.com',
608
610
  sender: '@alice:example.com',
@@ -844,7 +846,7 @@ describe('tool-call and plan event bridging', () => {
844
846
  return { transport, agents, client, finishPrompt, sessionId: 'sess-$e6' }
845
847
  }
846
848
 
847
- it('forwards tool_call as eco.zoon.tool_call in-room under the agent bot user', async () => {
849
+ it('forwards tool_call as dev.zooid.tool_call in-room under the agent bot user', async () => {
848
850
  const { agents, client, finishPrompt, sessionId } = await startTurnAndGetSession()
849
851
  await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
850
852
  type: 'tool_call',
@@ -859,7 +861,7 @@ describe('tool-call and plan event bridging', () => {
859
861
  expect.objectContaining({
860
862
  roomId: '!r:example.com',
861
863
  asUserId: '@architect:example.com',
862
- eventType: 'eco.zoon.tool_call',
864
+ eventType: 'dev.zooid.tool_call',
863
865
  content: expect.objectContaining({
864
866
  session_id: sessionId,
865
867
  tool_call_id: 'tc-1',
@@ -873,7 +875,7 @@ describe('tool-call and plan event bridging', () => {
873
875
  await settleTurn()
874
876
  })
875
877
 
876
- it('forwards tool_call_update as eco.zoon.tool_call_update', async () => {
878
+ it('forwards tool_call_update as dev.zooid.tool_call_update', async () => {
877
879
  const { agents, client, finishPrompt, sessionId } = await startTurnAndGetSession()
878
880
  await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
879
881
  type: 'tool_call_update',
@@ -883,14 +885,14 @@ describe('tool-call and plan event bridging', () => {
883
885
  })
884
886
  await settleTurn()
885
887
  const call = client.sendCustomEvent.mock.calls.find(
886
- ([arg]) => (arg as { eventType: string }).eventType === 'eco.zoon.tool_call_update',
888
+ ([arg]) => (arg as { eventType: string }).eventType === 'dev.zooid.tool_call_update',
887
889
  )
888
890
  expect(call).toBeDefined()
889
891
  finishPrompt()
890
892
  await settleTurn()
891
893
  })
892
894
 
893
- it('forwards plan as eco.zoon.plan', async () => {
895
+ it('forwards plan as dev.zooid.plan', async () => {
894
896
  const { agents, client, finishPrompt, sessionId } = await startTurnAndGetSession()
895
897
  await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
896
898
  type: 'plan',
@@ -899,13 +901,61 @@ describe('tool-call and plan event bridging', () => {
899
901
  })
900
902
  await settleTurn()
901
903
  const call = client.sendCustomEvent.mock.calls.find(
902
- ([arg]) => (arg as { eventType: string }).eventType === 'eco.zoon.plan',
904
+ ([arg]) => (arg as { eventType: string }).eventType === 'dev.zooid.plan',
903
905
  )
904
906
  expect(call).toBeDefined()
905
907
  finishPrompt()
906
908
  await settleTurn()
907
909
  })
908
910
 
911
+ it('replays available_commands advertised during ensureSession (before the session ctx exists)', async () => {
912
+ // Regression (ZOD040 ctx race): shims advertise commands during session
913
+ // load/new — i.e. inside ensureSession, BEFORE runTurn registers the ctx.
914
+ // Without buffer-and-replay the event hits onEvent with no ctx and is
915
+ // dropped, so the command palette never fills. available_commands is only
916
+ // ever emitted at session establishment, so this is its ONLY chance.
917
+ const { transport, agents, client, finishPrompt } = makeTransport()
918
+ agents.ensureSession.mockImplementation(async (_name: string, threadId: string) => {
919
+ const sessionId = `sess-${threadId}`
920
+ await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
921
+ type: 'available_commands',
922
+ sessionId,
923
+ commands: [{ name: 'compact', description: 'Compact the context' }],
924
+ })
925
+ return sessionId
926
+ })
927
+
928
+ await postTxn(transport.app, {
929
+ events: [
930
+ {
931
+ type: 'm.room.message',
932
+ event_id: '$e7',
933
+ origin_server_ts: Date.now(),
934
+ room_id: '!r:example.com',
935
+ sender: '@user:example.com',
936
+ content: {
937
+ msgtype: 'm.text',
938
+ body: 'hi',
939
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
940
+ },
941
+ },
942
+ ],
943
+ })
944
+ await settleTurn()
945
+
946
+ const call = client.sendCustomEvent.mock.calls.find(
947
+ ([arg]) =>
948
+ (arg as { eventType: string }).eventType === 'dev.zooid.available_commands_update',
949
+ )
950
+ expect(call).toBeDefined()
951
+ expect((call![0] as { content: Record<string, unknown> }).content).toMatchObject({
952
+ session_id: 'sess-$e7',
953
+ available_commands: [{ name: 'compact', description: 'Compact the context' }],
954
+ })
955
+ finishPrompt()
956
+ await settleTurn()
957
+ })
958
+
909
959
  it('attaches m.relates_to thread when the originating message was in-thread', async () => {
910
960
  const { transport, agents, client, finishPrompt } = makeTransport()
911
961
  await postTxn(transport.app, {
@@ -935,7 +985,7 @@ describe('tool-call and plan event bridging', () => {
935
985
  })
936
986
  await settleTurn()
937
987
  const call = client.sendCustomEvent.mock.calls.find(
938
- ([arg]) => (arg as { eventType: string }).eventType === 'eco.zoon.tool_call',
988
+ ([arg]) => (arg as { eventType: string }).eventType === 'dev.zooid.tool_call',
939
989
  )
940
990
  expect((call?.[0] as { content: Record<string, unknown> }).content['m.relates_to']).toEqual({
941
991
  rel_type: 'm.thread',
@@ -1034,17 +1084,23 @@ describe('agent_message_chunk message-boundary buffering', () => {
1034
1084
  const sentBody = (client: { sendMessage: { mock: { calls: unknown[][] } } }) =>
1035
1085
  (client.sendMessage.mock.calls[0]![0] as { content: { body: string } }).content.body
1036
1086
 
1037
- it('inserts a paragraph break when messageId changes between chunks (opencode run-on)', async () => {
1038
- // The exact production failure: opencode streams "…it." under one message id,
1039
- // then "Filed:" under a NEW id with no delimiter chunk. Without a break they
1040
- // weld into "it.Filed:".
1087
+ const sentBodies = (client: { sendMessage: { mock: { calls: unknown[][] } } }) =>
1088
+ client.sendMessage.mock.calls.map(
1089
+ (c) => (c[0] as { content: { body: string } }).content.body,
1090
+ )
1091
+
1092
+ it('flushes a separate Matrix message when messageId changes (opencode run-on)', async () => {
1093
+ // opencode streams "…it." under one message id, then "Filed:" under a NEW
1094
+ // id with no delimiter chunk. Each id is a distinct assistant message, so
1095
+ // each lands as its own Matrix message rather than welding into "it.Filed:"
1096
+ // (or being buffered into a single turn-end blob).
1041
1097
  const { agents, client, finishPrompt, sessionId } = await startTurn('$mid1')
1042
1098
  await emit(agents, sessionId, 'Let me file it.', 'msg_aaa')
1043
1099
  await emit(agents, sessionId, 'Filed: done', 'msg_bbb')
1044
1100
  finishPrompt()
1045
1101
  await settleTurn()
1046
- expect(client.sendMessage).toHaveBeenCalledTimes(1)
1047
- expect(sentBody(client)).toBe('Let me file it.\n\nFiled: done')
1102
+ expect(client.sendMessage).toHaveBeenCalledTimes(2)
1103
+ expect(sentBodies(client)).toEqual(['Let me file it.', 'Filed: done'])
1048
1104
  })
1049
1105
 
1050
1106
  it('does NOT break between chunks sharing a messageId (token streaming stays intact)', async () => {
@@ -1059,14 +1115,15 @@ describe('agent_message_chunk message-boundary buffering', () => {
1059
1115
  expect(sentBody(client)).toBe('Hello world.')
1060
1116
  })
1061
1117
 
1062
- it('breaks only once across a three-message run', async () => {
1118
+ it('flushes each message of a three-message run separately', async () => {
1063
1119
  const { agents, client, finishPrompt, sessionId } = await startTurn('$mid3')
1064
1120
  await emit(agents, sessionId, 'one.', 'msg_a')
1065
1121
  await emit(agents, sessionId, 'two.', 'msg_b')
1066
1122
  await emit(agents, sessionId, 'three.', 'msg_c')
1067
1123
  finishPrompt()
1068
1124
  await settleTurn()
1069
- expect(sentBody(client)).toBe('one.\n\ntwo.\n\nthree.')
1125
+ expect(client.sendMessage).toHaveBeenCalledTimes(3)
1126
+ expect(sentBodies(client)).toEqual(['one.', 'two.', 'three.'])
1070
1127
  })
1071
1128
 
1072
1129
  it('still breaks on an empty delimiter chunk (agents that signal that way)', async () => {
@@ -1087,9 +1144,37 @@ describe('agent_message_chunk message-boundary buffering', () => {
1087
1144
  await settleTurn()
1088
1145
  expect(sentBody(client)).toBe('Hello there')
1089
1146
  })
1147
+
1148
+ it('flushes buffered text before a following plan event (interleaving)', async () => {
1149
+ // A turn that alternates prose and plan updates: each prose message must
1150
+ // land on the wire before the plan event that follows it, not be deferred
1151
+ // to turn end (the bug — everything buffered, then one blob after N plans).
1152
+ const { agents, client, finishPrompt, sessionId } = await startTurn('$mid6')
1153
+ await emit(agents, sessionId, 'Sure, making a list.', 'msg_a')
1154
+ await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
1155
+ type: 'plan',
1156
+ sessionId,
1157
+ entries: [{ content: 'Buy milk', status: 'pending' }],
1158
+ })
1159
+ await emit(agents, sessionId, 'Working through them.', 'msg_b')
1160
+ finishPrompt()
1161
+ await settleTurn()
1162
+
1163
+ expect(sentBodies(client)).toEqual(['Sure, making a list.', 'Working through them.'])
1164
+ const planIdx = client.sendCustomEvent.mock.calls.findIndex(
1165
+ ([arg]) => (arg as { eventType: string }).eventType === 'dev.zooid.plan',
1166
+ )
1167
+ expect(planIdx).toBeGreaterThanOrEqual(0)
1168
+ // Order across both mocks: msg1 → plan → msg2.
1169
+ const msg1 = client.sendMessage.mock.invocationCallOrder[0]!
1170
+ const msg2 = client.sendMessage.mock.invocationCallOrder[1]!
1171
+ const plan = client.sendCustomEvent.mock.invocationCallOrder[planIdx]!
1172
+ expect(msg1).toBeLessThan(plan)
1173
+ expect(plan).toBeLessThan(msg2)
1174
+ })
1090
1175
  })
1091
1176
 
1092
- describe('eco.zoon.interrupt handling', () => {
1177
+ describe('dev.zooid.interrupt handling', () => {
1093
1178
  it('dispatches cancelSession(agent.name, sessionId) for an interrupt that targets a tracked session', async () => {
1094
1179
  const { transport, agents, finishPrompt } = makeTransport()
1095
1180
  await postTxn(transport.app, {
@@ -1109,7 +1194,7 @@ describe('eco.zoon.interrupt handling', () => {
1109
1194
  await postTxn(transport.app, {
1110
1195
  events: [
1111
1196
  {
1112
- type: 'eco.zoon.interrupt',
1197
+ type: 'dev.zooid.interrupt',
1113
1198
  event_id: '$int',
1114
1199
  origin_server_ts: Date.now(),
1115
1200
  room_id: '!r:example.com',
@@ -1141,7 +1226,7 @@ describe('eco.zoon.interrupt handling', () => {
1141
1226
  await postTxn(transport.app, {
1142
1227
  events: [
1143
1228
  {
1144
- type: 'eco.zoon.interrupt',
1229
+ type: 'dev.zooid.interrupt',
1145
1230
  event_id: '$int2',
1146
1231
  origin_server_ts: Date.now(),
1147
1232
  room_id: '!r:example.com',
@@ -1160,7 +1245,7 @@ describe('eco.zoon.interrupt handling', () => {
1160
1245
  await postTxn(transport.app, {
1161
1246
  events: [
1162
1247
  {
1163
- type: 'eco.zoon.interrupt',
1248
+ type: 'dev.zooid.interrupt',
1164
1249
  event_id: '$int3',
1165
1250
  origin_server_ts: Date.now(),
1166
1251
  room_id: '!r:example.com',
@@ -1191,7 +1276,7 @@ describe('eco.zoon.interrupt handling', () => {
1191
1276
  await postTxn(transport.app, {
1192
1277
  events: [
1193
1278
  {
1194
- type: 'eco.zoon.interrupt',
1279
+ type: 'dev.zooid.interrupt',
1195
1280
  event_id: '$intT',
1196
1281
  origin_server_ts: Date.now(),
1197
1282
  room_id: '!r:example.com',
@@ -1226,7 +1311,7 @@ describe('eco.zoon.interrupt handling', () => {
1226
1311
  const interrupt = (id: string) => ({
1227
1312
  events: [
1228
1313
  {
1229
- type: 'eco.zoon.interrupt',
1314
+ type: 'dev.zooid.interrupt',
1230
1315
  event_id: id,
1231
1316
  origin_server_ts: Date.now(),
1232
1317
  room_id: '!r:example.com',
@@ -1249,7 +1334,7 @@ describe('eco.zoon.interrupt handling', () => {
1249
1334
  {
1250
1335
  events: [
1251
1336
  {
1252
- type: 'eco.zoon.interrupt',
1337
+ type: 'dev.zooid.interrupt',
1253
1338
  event_id: '$bad',
1254
1339
  origin_server_ts: Date.now(),
1255
1340
  room_id: '!r:example.com',
@@ -1312,3 +1397,289 @@ describe('full loop integration', () => {
1312
1397
  )
1313
1398
  })
1314
1399
  })
1400
+
1401
+ // ─── Media pipeline tests ────────────────────────────────────────────────────
1402
+
1403
+ const TINY_PNG_B64 =
1404
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
1405
+ const TINY_PNG = Buffer.from(TINY_PNG_B64, 'base64')
1406
+
1407
+ function fakeMedia() {
1408
+ return {
1409
+ download: vi.fn(async () => ({ data: new Uint8Array(TINY_PNG), contentType: 'image/png' })),
1410
+ upload: vi.fn(async () => ({ content_uri: 'mxc://localhost/up1' })),
1411
+ }
1412
+ }
1413
+
1414
+ const workspaceBinding = {
1415
+ ...baseAgents[0],
1416
+ workspaceDir: '/tmp/ws',
1417
+ agentWorkspacePath: '/workspace',
1418
+ }
1419
+
1420
+ function makeMediaTransport(opts: {
1421
+ media?: ReturnType<typeof fakeMedia>
1422
+ writeAttachmentFn?: unknown
1423
+ } = {}) {
1424
+ const { reg, finishPrompt } = fakeRegistry()
1425
+ const approvals = fakeApprovals()
1426
+ const client = fakeClient()
1427
+ const transport = createMatrixTransport({
1428
+ agents: reg as never,
1429
+ approvals: approvals as never,
1430
+ client: client as never,
1431
+ bindings: [workspaceBinding],
1432
+ hsToken: 'hs-secret',
1433
+ drainQuietMs: 0,
1434
+ media: opts.media as never,
1435
+ writeAttachmentFn: opts.writeAttachmentFn as never,
1436
+ })
1437
+ return { transport, agents: reg, client, finishPrompt }
1438
+ }
1439
+
1440
+ function imageEvent(over: {
1441
+ size?: number
1442
+ mimetype?: string
1443
+ msgtype?: string
1444
+ body?: string
1445
+ eventId?: string
1446
+ } = {}) {
1447
+ return {
1448
+ type: 'm.room.message',
1449
+ event_id: over.eventId ?? '$media1',
1450
+ room_id: '!r:example.com',
1451
+ sender: '@alice:example.com',
1452
+ content: {
1453
+ msgtype: over.msgtype ?? 'm.image',
1454
+ body: over.body ?? 'dog.png',
1455
+ url: 'mxc://localhost/abc',
1456
+ info: { mimetype: over.mimetype ?? 'image/png', size: over.size ?? 67 },
1457
+ },
1458
+ }
1459
+ }
1460
+
1461
+ function mentionMsg(body: string, eventId = '$text1') {
1462
+ return {
1463
+ type: 'm.room.message',
1464
+ event_id: eventId,
1465
+ room_id: '!r:example.com',
1466
+ sender: '@alice:example.com',
1467
+ content: {
1468
+ msgtype: 'm.text',
1469
+ body: `@architect ${body}`,
1470
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
1471
+ },
1472
+ }
1473
+ }
1474
+
1475
+ describe('inbound media', () => {
1476
+ it('media events do not trigger a turn; m.text from the same sender drains them inline', async () => {
1477
+ const media = fakeMedia()
1478
+ const { transport, agents } = makeMediaTransport({ media })
1479
+
1480
+ // Image event: no turn fired
1481
+ await postTxn(transport.app, { events: [imageEvent()] })
1482
+ await settleTurn()
1483
+ expect(agents.prompt).not.toHaveBeenCalled()
1484
+
1485
+ // m.text mention from same sender: turn fires, image block prepended
1486
+ agents.prompt.mockImplementation(async (_name: string, p: { content: unknown[] }) => {
1487
+ agents.onEvent('architect', {
1488
+ type: 'agent_message_chunk',
1489
+ sessionId: 'sess-$text1',
1490
+ content: { type: 'text', text: 'got it' },
1491
+ })
1492
+ return { stopReason: 'end_turn' as const }
1493
+ })
1494
+ await postTxn(transport.app, { events: [mentionMsg('look at this')] })
1495
+ await settleTurn()
1496
+
1497
+ expect(agents.prompt).toHaveBeenCalledOnce()
1498
+ 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' })
1500
+ expect((content[1] as { type: string; text: string }).type).toBe('text')
1501
+ expect(media.download).toHaveBeenCalledOnce()
1502
+ })
1503
+
1504
+ it('routes an oversized image to the file path with a resource_link block and prose line', async () => {
1505
+ const media = fakeMedia()
1506
+ const writeAttachmentFn = vi.fn(() => ({
1507
+ hostPath: '/tmp/ws/.zooid/attachments/media1/dog.png',
1508
+ agentPath: '/workspace/.zooid/attachments/media1/dog.png',
1509
+ }))
1510
+ const { transport, agents } = makeMediaTransport({ media, writeAttachmentFn })
1511
+
1512
+ agents.prompt.mockResolvedValue({ stopReason: 'end_turn' as const })
1513
+ await postTxn(transport.app, { events: [imageEvent({ size: 600_000 })] }) // > MAX_INLINE_IMAGE_BYTES
1514
+ await postTxn(transport.app, { events: [mentionMsg('summarize')] })
1515
+ await settleTurn()
1516
+
1517
+ const content = (agents.prompt.mock.calls[0][1] as { content: unknown[] }).content
1518
+ expect(content[0]).toMatchObject({
1519
+ type: 'resource_link',
1520
+ uri: 'file:///workspace/.zooid/attachments/media1/dog.png',
1521
+ name: 'dog.png',
1522
+ })
1523
+ expect((content[1] as { text: string }).text).toContain(
1524
+ '/workspace/.zooid/attachments/media1/dog.png',
1525
+ )
1526
+ })
1527
+
1528
+ it('routes m.file to the workspace regardless of size', async () => {
1529
+ const media = fakeMedia()
1530
+ const writeAttachmentFn = vi.fn(() => ({
1531
+ hostPath: '/tmp/ws/.zooid/attachments/media1/report.pdf',
1532
+ agentPath: '/workspace/.zooid/attachments/media1/report.pdf',
1533
+ }))
1534
+ const { transport, agents } = makeMediaTransport({ media, writeAttachmentFn })
1535
+
1536
+ agents.prompt.mockResolvedValue({ stopReason: 'end_turn' as const })
1537
+ await postTxn(transport.app, {
1538
+ events: [imageEvent({ msgtype: 'm.file', body: 'report.pdf', mimetype: 'application/pdf' })],
1539
+ })
1540
+ await postTxn(transport.app, { events: [mentionMsg('read it')] })
1541
+ await settleTurn()
1542
+
1543
+ expect(writeAttachmentFn).toHaveBeenCalledOnce()
1544
+ const content = (agents.prompt.mock.calls[0][1] as { content: unknown[] }).content
1545
+ expect((content[0] as { type: string }).type).toBe('resource_link')
1546
+ })
1547
+
1548
+ it('emits dev.zooid.error (code media_failed) when download fails, still runs the turn text-only', async () => {
1549
+ const media = fakeMedia()
1550
+ media.download.mockRejectedValueOnce(new Error('download boom'))
1551
+ const { transport, agents, client } = makeMediaTransport({ media })
1552
+
1553
+ agents.prompt.mockImplementation(async () => {
1554
+ agents.onEvent('architect', {
1555
+ type: 'agent_message_chunk',
1556
+ sessionId: 'sess-$text1',
1557
+ content: { type: 'text', text: 'ok' },
1558
+ })
1559
+ return { stopReason: 'end_turn' as const }
1560
+ })
1561
+ await postTxn(transport.app, { events: [imageEvent()] })
1562
+ await postTxn(transport.app, { events: [mentionMsg('look')] })
1563
+ await settleTurn()
1564
+
1565
+ expect(client.sendCustomEvent).toHaveBeenCalledWith(
1566
+ expect.objectContaining({
1567
+ eventType: 'dev.zooid.error',
1568
+ content: expect.objectContaining({ code: 'media_failed' }),
1569
+ }),
1570
+ )
1571
+ const content = (agents.prompt.mock.calls[0][1] as { content: unknown[] }).content
1572
+ expect(content).toHaveLength(1)
1573
+ expect((content[0] as { type: string }).type).toBe('text')
1574
+ })
1575
+ })
1576
+
1577
+ describe('outbound agent images', () => {
1578
+ it('uploads an image chunk and sends a threaded m.image as the agent user', async () => {
1579
+ const media = fakeMedia()
1580
+ const { transport, agents, client } = makeMediaTransport({ media })
1581
+
1582
+ agents.prompt.mockImplementation(async () => {
1583
+ // Emit image block during prompt
1584
+ agents.onEvent('architect', {
1585
+ type: 'agent_message_chunk',
1586
+ sessionId: 'sess-$text1',
1587
+ content: { type: 'image', data: TINY_PNG_B64, mimeType: 'image/png' },
1588
+ })
1589
+ // Also emit text block so the turn isn't empty
1590
+ agents.onEvent('architect', {
1591
+ type: 'agent_message_chunk',
1592
+ sessionId: 'sess-$text1',
1593
+ content: { type: 'text', text: 'here is the image' },
1594
+ })
1595
+ return { stopReason: 'end_turn' as const }
1596
+ })
1597
+
1598
+ await postTxn(transport.app, { events: [mentionMsg('show me an image')] })
1599
+ await settleTurn()
1600
+ // Give async upload/send a moment to settle
1601
+ await new Promise((r) => setTimeout(r, 10))
1602
+
1603
+ expect(media.upload).toHaveBeenCalledWith(
1604
+ expect.objectContaining({ contentType: 'image/png', asUserId: '@architect:example.com' }),
1605
+ )
1606
+ expect(client.sendMessage).toHaveBeenCalledWith(
1607
+ expect.objectContaining({
1608
+ asUserId: '@architect:example.com',
1609
+ content: expect.objectContaining({
1610
+ msgtype: 'm.image',
1611
+ url: 'mxc://localhost/up1',
1612
+ info: expect.objectContaining({ mimetype: 'image/png', size: TINY_PNG.length }),
1613
+ }),
1614
+ }),
1615
+ )
1616
+ })
1617
+
1618
+ it('does not throw when an audio block arrives (non-goal — warn and drop)', async () => {
1619
+ const media = fakeMedia()
1620
+ const { transport, agents } = makeMediaTransport({ media })
1621
+
1622
+ agents.prompt.mockImplementation(async () => {
1623
+ agents.onEvent('architect', {
1624
+ type: 'agent_message_chunk',
1625
+ sessionId: 'sess-$text1',
1626
+ content: { type: 'audio', data: 'AAAA', mimeType: 'audio/wav' },
1627
+ })
1628
+ agents.onEvent('architect', {
1629
+ type: 'agent_message_chunk',
1630
+ sessionId: 'sess-$text1',
1631
+ content: { type: 'text', text: 'ok' },
1632
+ })
1633
+ return { stopReason: 'end_turn' as const }
1634
+ })
1635
+
1636
+ await expect(
1637
+ postTxn(transport.app, { events: [mentionMsg('test audio')] }),
1638
+ ).resolves.not.toThrow()
1639
+ await settleTurn()
1640
+ })
1641
+ })
1642
+
1643
+ describe('ad-hoc bot invite declines', () => {
1644
+ let n = 0
1645
+ const invite = (stateKey: string, sender: string) => ({
1646
+ type: 'm.room.member',
1647
+ event_id: `$inv${++n}`,
1648
+ room_id: '!r:example.com',
1649
+ sender,
1650
+ state_key: stateKey,
1651
+ content: { membership: 'invite' },
1652
+ })
1653
+
1654
+ it('declines an invite to the AS bot from a human, with a reason', async () => {
1655
+ 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
+ )
1662
+ })
1663
+
1664
+ it('declines an invite to an agent from a human', async () => {
1665
+ const { transport, client } = makeTransport()
1666
+ await postTxn(transport.app, { events: [invite('@architect:example.com', '@zongshan:example.com')] })
1667
+ expect(client.leaveRoom).toHaveBeenCalledWith(
1668
+ '!r:example.com',
1669
+ '@architect:example.com',
1670
+ expect.objectContaining({ reason: expect.any(String) }),
1671
+ )
1672
+ })
1673
+
1674
+ it('does NOT decline a provisioning invite (inviter is our AS bot)', async () => {
1675
+ const { transport, client } = makeTransport()
1676
+ await postTxn(transport.app, { events: [invite('@architect:example.com', '@zooid:example.com')] })
1677
+ expect(client.leaveRoom).not.toHaveBeenCalled()
1678
+ })
1679
+
1680
+ it('ignores an invite to a human (not one of our bots)', async () => {
1681
+ const { transport, client } = makeTransport()
1682
+ await postTxn(transport.app, { events: [invite('@dave:example.com', '@zongshan:example.com')] })
1683
+ expect(client.leaveRoom).not.toHaveBeenCalled()
1684
+ })
1685
+ })