@zooid/transport-matrix 0.8.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zooid/transport-matrix",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Matrix Application Service transport for zooid. Routes inbound Matrix messages to ACP agents and posts replies plus approval custom events back to threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,8 +28,8 @@
28
28
  "marked": "^18.0.4",
29
29
  "sanitize-html": "^2.17.4",
30
30
  "yaml": "^2.5.0",
31
- "@zooid/acp-client": "^0.8.0",
32
- "@zooid/core": "^0.8.0"
31
+ "@zooid/core": "^0.9.0",
32
+ "@zooid/acp-client": "^0.9.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@hono/node-server": "^1.13.0",
package/src/bot-pool.ts CHANGED
@@ -48,7 +48,7 @@ export class BotPool {
48
48
  console.warn(`[matrix] setDisplayName(${a.userId}) failed: ${(err as Error).message}`)
49
49
  }
50
50
  // Make each agent a member of the workforce space. That covers two
51
- // things at once: the eco.zoon.workforce roster is now backed by
51
+ // things at once: the dev.zooid.workforce roster is now backed by
52
52
  // actual space membership (so the Zoon client's member autocomplete
53
53
  // works across rooms), and every restricted child room's allow rule
54
54
  // is satisfied without per-room invites.
@@ -43,7 +43,7 @@ export class MatrixContextProvider implements TransportContextProvider {
43
43
 
44
44
  async getRoomHistory(channelId: string, hopts: HistoryOptions): Promise<HistoryPage> {
45
45
  // Server-side filter: only `m.room.message` events. Without this we'd
46
- // burn the page budget on reactions, `eco.zoon.*` custom events, typing
46
+ // burn the page budget on reactions, `dev.zooid.*` custom events, typing
47
47
  // notifications, etc., and routinely return empty pages with a stale
48
48
  // `has_more` cursor.
49
49
  const { chunk, end } = await this.opts.client.fetchRoomMessages({
@@ -9,6 +9,7 @@ import {
9
9
  toUpdateBody,
10
10
  toPlanBody,
11
11
  toErrorBody,
12
+ toAvailableCommandsBody,
12
13
  } from './event-encoders.js'
13
14
 
14
15
  describe('toToolCallBody', () => {
@@ -124,6 +125,27 @@ describe('toPlanBody', () => {
124
125
  })
125
126
  })
126
127
 
128
+ describe('toAvailableCommandsBody', () => {
129
+ it('encodes available_commands into the body ZNC021 decodes', () => {
130
+ expect(
131
+ toAvailableCommandsBody({
132
+ type: 'available_commands',
133
+ sessionId: 's-1',
134
+ commands: [
135
+ { name: 'plan', description: 'Switch to plan mode' },
136
+ { name: 'compact', description: 'Compact the context' },
137
+ ],
138
+ }),
139
+ ).toEqual({
140
+ session_id: 's-1',
141
+ available_commands: [
142
+ { name: 'plan', description: 'Switch to plan mode' },
143
+ { name: 'compact', description: 'Compact the context' },
144
+ ],
145
+ })
146
+ })
147
+ })
148
+
127
149
  describe('toErrorBody', () => {
128
150
  const threadRoot = '$root-event-id'
129
151
 
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ AvailableCommandsEvent,
2
3
  PlanEvent,
3
4
  TapEvent,
4
5
  ToolCallEvent,
@@ -66,6 +67,18 @@ export function toPlanBody(evt: PlanEvent): Record<string, unknown> {
66
67
  }
67
68
  }
68
69
 
70
+ export function toAvailableCommandsBody(
71
+ evt: AvailableCommandsEvent,
72
+ ): Record<string, unknown> {
73
+ return {
74
+ session_id: evt.sessionId,
75
+ available_commands: evt.commands.map((c) => ({
76
+ name: c.name,
77
+ description: c.description,
78
+ })),
79
+ }
80
+ }
81
+
69
82
  const RECOVERY_URLS: Partial<Record<string, string>> = {
70
83
  auth_missing: 'https://zooid.dev/docs/guides/run-in-container#authentication-that-carries-over',
71
84
  auth_invalid: 'https://zooid.dev/docs/guides/run-in-container#authentication-that-carries-over',
@@ -287,7 +287,7 @@ describe('MatrixClient', () => {
287
287
 
288
288
  it('sends a custom event type when content type is set', async () => {
289
289
  const fetch = fakeFetch(async ({ url, init }) => {
290
- expect(url).toMatch(/\/send\/eco\.zoon\.approval_request\//)
290
+ expect(url).toMatch(/\/send\/dev\.zooid\.approval_request\//)
291
291
  const body = JSON.parse(init.body as string)
292
292
  expect(body.approval_id).toBe('a1')
293
293
  return new Response(JSON.stringify({ event_id: '$x' }), { status: 200 })
@@ -300,7 +300,7 @@ describe('MatrixClient', () => {
300
300
  await client.sendCustomEvent({
301
301
  roomId: '!r:example.com',
302
302
  asUserId: '@architect:example.com',
303
- eventType: 'eco.zoon.approval_request',
303
+ eventType: 'dev.zooid.approval_request',
304
304
  content: { approval_id: 'a1', description: 'Run: git push' },
305
305
  })
306
306
  })
@@ -445,6 +445,39 @@ describe('MatrixClient.createRoom userPowerLevels', () => {
445
445
  })
446
446
  })
447
447
 
448
+ describe('MatrixClient.leaveRoom', () => {
449
+ it('leaves (rejects) a room as the impersonated user, with a reason', async () => {
450
+ const fetch = fakeFetch(async ({ url, init }) => {
451
+ expect(url).toBe(
452
+ 'https://hs.example.com/_matrix/client/v3/rooms/!r%3Aexample.com/leave' +
453
+ '?user_id=%40zooid%3Aexample.com',
454
+ )
455
+ expect(init.method).toBe('POST')
456
+ expect(JSON.parse(init.body as string)).toEqual({ reason: 'no thanks' })
457
+ return new Response('{}', { status: 200 })
458
+ })
459
+ const client = new MatrixClient({
460
+ homeserver: 'https://hs.example.com',
461
+ asToken: 'as-secret',
462
+ fetch: fetch as unknown as typeof globalThis.fetch,
463
+ })
464
+ await client.leaveRoom('!r:example.com', '@zooid:example.com', { reason: 'no thanks' })
465
+ })
466
+
467
+ it('sends an empty body when no reason is given', async () => {
468
+ const fetch = fakeFetch(async ({ init }) => {
469
+ expect(JSON.parse(init.body as string)).toEqual({})
470
+ return new Response('{}', { status: 200 })
471
+ })
472
+ const client = new MatrixClient({
473
+ homeserver: 'https://hs.example.com',
474
+ asToken: 'as-secret',
475
+ fetch: fetch as unknown as typeof globalThis.fetch,
476
+ })
477
+ await client.leaveRoom('!r:example.com', '@zooid:example.com')
478
+ })
479
+ })
480
+
448
481
  describe('MatrixClient.createRoom restricted', () => {
449
482
  it('injects a restricted join rule referencing the space when restrictedToSpaceId is set', async () => {
450
483
  const fetch = fakeFetch(async ({ url, init }) => {
@@ -222,6 +222,25 @@ export class MatrixClient {
222
222
  throw new Error(`invite(${opts.targetUserId}) failed: ${r.status}`)
223
223
  }
224
224
 
225
+ async leaveRoom(
226
+ roomId: string,
227
+ asUserId: string,
228
+ opts?: { reason?: string },
229
+ ): Promise<void> {
230
+ const url =
231
+ `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave` +
232
+ `?user_id=${encodeURIComponent(asUserId)}`
233
+ const r = await this.fetch(url, {
234
+ method: 'POST',
235
+ headers: {
236
+ Authorization: `Bearer ${this.asToken}`,
237
+ 'content-type': 'application/json',
238
+ },
239
+ body: JSON.stringify(opts?.reason ? { reason: opts.reason } : {}),
240
+ })
241
+ if (!r.ok) throw new Error(`leaveRoom(${roomId}, ${asUserId}) failed: ${r.status}`)
242
+ }
243
+
225
244
  async joinRoom(roomIdOrAlias: string, asUserId: string): Promise<void> {
226
245
  const url =
227
246
  `${this.homeserver}/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}` +
@@ -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',
@@ -1460,7 +1545,7 @@ describe('inbound media', () => {
1460
1545
  expect((content[0] as { type: string }).type).toBe('resource_link')
1461
1546
  })
1462
1547
 
1463
- it('emits eco.zoon.error (code media_failed) when download fails, still runs the turn text-only', async () => {
1548
+ it('emits dev.zooid.error (code media_failed) when download fails, still runs the turn text-only', async () => {
1464
1549
  const media = fakeMedia()
1465
1550
  media.download.mockRejectedValueOnce(new Error('download boom'))
1466
1551
  const { transport, agents, client } = makeMediaTransport({ media })
@@ -1479,7 +1564,7 @@ describe('inbound media', () => {
1479
1564
 
1480
1565
  expect(client.sendCustomEvent).toHaveBeenCalledWith(
1481
1566
  expect.objectContaining({
1482
- eventType: 'eco.zoon.error',
1567
+ eventType: 'dev.zooid.error',
1483
1568
  content: expect.objectContaining({ code: 'media_failed' }),
1484
1569
  }),
1485
1570
  )
@@ -1554,3 +1639,47 @@ describe('outbound agent images', () => {
1554
1639
  await settleTurn()
1555
1640
  })
1556
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
+ })