@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zooid/transport-matrix",
3
- "version": "0.13.0",
3
+ "version": "0.14.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.13.0",
32
- "@zooid/core": "^0.13.0"
31
+ "@zooid/acp-client": "^0.14.0",
32
+ "@zooid/core": "^0.14.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@hono/node-server": "^1.13.0",
@@ -155,7 +155,7 @@ describe('MatrixContextProvider', () => {
155
155
  ])
156
156
  })
157
157
 
158
- it('getChannelInfo returns the room name and transport: matrix', async () => {
158
+ it('getRoomInfo returns the room name and transport: matrix', async () => {
159
159
  const client = fakeClient({
160
160
  fetchRoomName: vi.fn().mockResolvedValue('engineering'),
161
161
  } as unknown as Partial<MatrixClient>)
@@ -164,10 +164,67 @@ describe('MatrixContextProvider', () => {
164
164
  asUserId: '@_zooid:hs',
165
165
  agentBots: new Map(),
166
166
  })
167
- const info = await provider.getChannelInfo('!room:hs')
167
+ const info = await provider.getRoomInfo('!room:hs')
168
168
  expect(info).toEqual({ id: '!room:hs', name: 'engineering', transport: 'matrix' })
169
169
  })
170
170
 
171
+ it('getRooms maps this agent\'s own room bindings to RoomInfo, fetching each name', async () => {
172
+ const fetchRoomName = vi.fn().mockResolvedValueOnce('general').mockResolvedValueOnce('dev')
173
+ const client = fakeClient({ fetchRoomName } as unknown as Partial<MatrixClient>)
174
+ const provider = new MatrixContextProvider({
175
+ client,
176
+ asUserId: '@architect:hs',
177
+ agentBots: new Map(),
178
+ rooms: [{ alias: '!a:hs' }, { alias: '!b:hs' }],
179
+ })
180
+ const rooms = await provider.getRooms()
181
+ expect(rooms).toEqual([
182
+ { id: '!a:hs', name: 'general', transport: 'matrix' },
183
+ { id: '!b:hs', name: 'dev', transport: 'matrix' },
184
+ ])
185
+ expect(fetchRoomName).toHaveBeenCalledWith('!a:hs', '@architect:hs')
186
+ })
187
+
188
+ it('getRooms returns an empty list when the provider has no room bindings', async () => {
189
+ const provider = new MatrixContextProvider({
190
+ client: fakeClient(),
191
+ asUserId: '@architect:hs',
192
+ agentBots: new Map(),
193
+ })
194
+ expect(await provider.getRooms()).toEqual([])
195
+ })
196
+
197
+ it('sendMessage posts as this agent into a bound room, echoing thread_id when replying in-thread', async () => {
198
+ const sendMessage = vi.fn().mockResolvedValue({ event_id: '$sent' })
199
+ const client = fakeClient({ sendMessage } as unknown as Partial<MatrixClient>)
200
+ const provider = new MatrixContextProvider({
201
+ client,
202
+ asUserId: '@architect:hs',
203
+ agentBots: new Map(),
204
+ rooms: [{ alias: '!a:hs' }],
205
+ })
206
+ const result = await provider.sendMessage({ room: '!a:hs', thread_id: '$root', text: 'noted' })
207
+ expect(result).toEqual({ event_id: '$sent', thread_id: '$root' })
208
+ expect(sendMessage).toHaveBeenCalledWith({
209
+ roomId: '!a:hs',
210
+ asUserId: '@architect:hs',
211
+ content: { msgtype: 'm.notice', body: 'noted' },
212
+ threadRoot: '$root',
213
+ })
214
+ })
215
+
216
+ it('sendMessage refuses a room this agent is not bound to', async () => {
217
+ const provider = new MatrixContextProvider({
218
+ client: fakeClient(),
219
+ asUserId: '@architect:hs',
220
+ agentBots: new Map(),
221
+ rooms: [{ alias: '!a:hs' }],
222
+ })
223
+ await expect(provider.sendMessage({ room: '!elsewhere:hs', text: 'hi' })).rejects.toThrow(
224
+ /not_in_room/,
225
+ )
226
+ })
227
+
171
228
  it('getRecentThreads returns top-level entries newest-first with bundled thread metadata, skipping thread replies', async () => {
172
229
  const client = fakeClient({
173
230
  fetchRoomMessages: vi.fn().mockResolvedValue({
@@ -311,7 +368,7 @@ describe('MatrixContextProvider', () => {
311
368
  asUserId: '@_zooid:hs',
312
369
  agentBots: new Map(),
313
370
  })
314
- const info = await provider.getChannelInfo('!room:hs')
371
+ const info = await provider.getRoomInfo('!room:hs')
315
372
  expect(info.name).toBe('!room:hs')
316
373
  })
317
374
 
@@ -433,7 +490,7 @@ describe('MatrixContextProvider — reads are impersonated as the agent', () =>
433
490
  await p.getRecentThreads('!room:hs', { limit: 10 })
434
491
  await p.getThreadHistory('!room:hs', '$root', { limit: 10 })
435
492
  await p.getChannelMembers('!room:hs')
436
- await p.getChannelInfo('!room:hs')
493
+ await p.getRoomInfo('!room:hs')
437
494
 
438
495
  expect(fetchRoomMessages.mock.calls.every(([a]) => a.asUserId === AGENT)).toBe(true)
439
496
  expect(fetchThreadRelations).toHaveBeenCalledWith(expect.objectContaining({ asUserId: AGENT }))
@@ -3,12 +3,15 @@ import type {
3
3
  HistoryOptions,
4
4
  HistoryPage,
5
5
  Member,
6
- ChannelInfo,
6
+ RoomInfo,
7
+ SendMessageInput,
8
+ SendMessageResult,
7
9
  Message,
8
10
  ThreadOverview,
9
11
  ThreadOverviewPage,
10
12
  } from '@zooid/core'
11
13
  import type { MatrixClient } from './matrix-client.js'
14
+ import type { RoomBinding } from '@zooid/core'
12
15
 
13
16
  interface MatrixMessageEvent {
14
17
  event_id: string
@@ -47,6 +50,15 @@ export interface MatrixContextProviderOpts {
47
50
  asUserId: string
48
51
  /** Map of Matrix user IDs → agent names, for is_agent / agent_name flags. */
49
52
  agentBots: Map<string, string>
53
+ /**
54
+ * This agent's own room bindings — the live array `BotPool.bootstrap`
55
+ * rewrites `.alias` on in place, so reads through this field after
56
+ * bootstrap see canonical room IDs. Backs `getRooms()` and the
57
+ * `sendMessage()` authorization check. Absent/empty = no rooms known
58
+ * (context providers built before this field existed, or in tests that
59
+ * don't exercise either method).
60
+ */
61
+ rooms?: RoomBinding[]
50
62
  }
51
63
 
52
64
  export class MatrixContextProvider implements TransportContextProvider {
@@ -213,7 +225,7 @@ export class MatrixContextProvider implements TransportContextProvider {
213
225
  })
214
226
  }
215
227
 
216
- async getChannelInfo(channelId: string): Promise<ChannelInfo> {
228
+ async getRoomInfo(channelId: string): Promise<RoomInfo> {
217
229
  const name = await this.opts.client.fetchRoomName(channelId, this.opts.asUserId)
218
230
  return {
219
231
  id: channelId,
@@ -221,4 +233,30 @@ export class MatrixContextProvider implements TransportContextProvider {
221
233
  transport: 'matrix',
222
234
  }
223
235
  }
236
+
237
+ async getRooms(): Promise<RoomInfo[]> {
238
+ const rooms = this.opts.rooms ?? []
239
+ return Promise.all(
240
+ rooms.map(async (r) => {
241
+ const name = await this.opts.client.fetchRoomName(r.alias, this.opts.asUserId)
242
+ return { id: r.alias, name: name ?? r.alias, transport: 'matrix' as const }
243
+ }),
244
+ )
245
+ }
246
+
247
+ async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {
248
+ const rooms = this.opts.rooms ?? []
249
+ if (!rooms.some((r) => r.alias === input.room)) {
250
+ throw new Error(`not_in_room: this agent is not a member of ${input.room}`)
251
+ }
252
+ const { event_id } = await this.opts.client.sendMessage({
253
+ roomId: input.room,
254
+ asUserId: this.opts.asUserId,
255
+ // m.notice, not m.text: agent prose sends as m.notice so
256
+ // .m.rule.suppress_notices silences it server-side (ZNC025 §10).
257
+ content: { msgtype: 'm.notice', body: input.text },
258
+ ...(input.thread_id ? { threadRoot: input.thread_id } : {}),
259
+ })
260
+ return { event_id, ...(input.thread_id ? { thread_id: input.thread_id } : {}) }
261
+ }
224
262
  }
package/src/index.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export { MatrixClient } from './matrix-client.js'
2
- export type { MatrixClientOptions, SendMessageInput, SendCustomEventInput } from './matrix-client.js'
2
+ export type {
3
+ MatrixClientOptions,
4
+ SendMessageInput,
5
+ SendCustomEventInput,
6
+ } from './matrix-client.js'
3
7
  export { MatrixContextProvider } from './context-provider.js'
4
8
  export type { MatrixContextProviderOpts } from './context-provider.js'
5
9
  export { renderRegistration } from './registration.js'
@@ -22,7 +26,11 @@ export { createMatrixTransport } from './transport.js'
22
26
  export type { CreateMatrixTransportOptions, MediaClientLike } from './transport.js'
23
27
  export { SyncLoop } from './sync-loop.js'
24
28
  export type { SyncLoopOptions, SyncResponse, SyncClient } from './sync-loop.js'
25
- export { ensureDefaultChannel, ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
29
+ export {
30
+ ensureDefaultChannel,
31
+ ensureWorkforceSpace,
32
+ serverNameFromMxid,
33
+ } from './space-provisioner.js'
26
34
  export type { EnsureDefaultChannelOpts, EnsureSpaceOpts } from './space-provisioner.js'
27
35
  export {
28
36
  buildWorkforceRoster,
@@ -35,9 +43,21 @@ export type {
35
43
  PublisherHandle,
36
44
  StartOpts as StartWorkforcePublisherOpts,
37
45
  } from './workforce-publisher.js'
38
- export { MediaClient, parseMxcUri, MAX_INLINE_IMAGE_BYTES, INLINE_IMAGE_MIMES, MAX_DOWNLOAD_BYTES } from './media-client.js'
46
+ export {
47
+ MediaClient,
48
+ parseMxcUri,
49
+ MAX_INLINE_IMAGE_BYTES,
50
+ INLINE_IMAGE_MIMES,
51
+ MAX_DOWNLOAD_BYTES,
52
+ } from './media-client.js'
39
53
  export type { MediaClientOptions } from './media-client.js'
40
54
  export { PendingMediaStore, MAX_MEDIA_PER_TURN } from './pending-media.js'
41
55
  export type { PendingMediaItem } from './pending-media.js'
42
56
  export { writeAttachment } from './attachments.js'
43
57
  export type { WriteAttachmentInput } from './attachments.js'
58
+ export { TaskRegistry, MAX_OPEN_TASKS_PER_ROOM } from './task-registry.js'
59
+ export type { TaskRecord, TaskPhase, PersistedTask, TaskJournal } from './task-registry.js'
60
+ export { InvocationRegistry } from './invocation-registry.js'
61
+ export { evaluateCompletion } from './task-completion.js'
62
+ export type { CompletionInputs, CompletionDecision, StopReason } from './task-completion.js'
63
+ export { checkDelegable, buildAssignmentContent, renderCompletionPrompt, renderInvocationReturn } from './task-dispatch.js'
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { InvocationRegistry } from './invocation-registry.js'
3
+
4
+ describe('InvocationRegistry', () => {
5
+ it('tracks a handoff before its Matrix event exists and resolves exactly once', () => {
6
+ const registry = new InvocationRegistry({ newId: () => 'i1' })
7
+ const invocation = registry.open({ taskId: 't1', callerAgent: 'a', callerSessionKey: '$root', calleeAgent: 'b' })
8
+ expect(registry.outstandingFor('$root')).toHaveLength(1)
9
+ registry.attachCallEvent(invocation.invocationId, '$call', '$root|$call')
10
+ expect(registry.forCalleeSession('$root|$call')).toBe(invocation)
11
+ expect(registry.resolve(invocation.invocationId)).toBe(invocation)
12
+ expect(registry.resolve(invocation.invocationId)).toBeUndefined()
13
+ })
14
+ it('detects an outstanding ancestor and cancels late returns', () => {
15
+ const registry = new InvocationRegistry({ newId: () => 'i1' })
16
+ const invocation = registry.open({ taskId: 't1', callerAgent: 'a', callerSessionKey: '$root', calleeAgent: 'b' })
17
+ registry.attachCallEvent('i1', '$call', '$root|$call')
18
+ expect(registry.isOutstandingAncestor('$root|$call', 'a')).toBe(true)
19
+ registry.cancelForTask('t1')
20
+ expect(registry.resolve('i1')).toBeUndefined()
21
+ })
22
+ })
@@ -0,0 +1,32 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { InvocationRecord } from '@zooid/core'
3
+
4
+ export class InvocationRegistry {
5
+ private readonly records = new Map<string, InvocationRecord>()
6
+ private readonly byCallee = new Map<string, string>()
7
+ private readonly byEvent = new Map<string, string>()
8
+ constructor(private readonly opts: { newId?: () => string } = {}) {}
9
+ open(input: Omit<InvocationRecord, 'invocationId' | 'state'>): InvocationRecord {
10
+ const record = { invocationId: this.opts.newId?.() ?? randomUUID(), state: 'outstanding' as const, ...input }
11
+ this.records.set(record.invocationId, record)
12
+ return record
13
+ }
14
+ attachCallEvent(id: string, eventId: string, sessionKey: string) {
15
+ const r = this.records.get(id); if (!r) return
16
+ r.callEventId = eventId; r.calleeSessionKey = sessionKey
17
+ this.byEvent.set(eventId, id); this.byCallee.set(sessionKey, id)
18
+ }
19
+ get(id: string) { return this.records.get(id) }
20
+ byCallEvent(eventId: string) { const id = this.byEvent.get(eventId); return id ? this.records.get(id) : undefined }
21
+ forCalleeSession(session: string) { const id = this.byCallee.get(session); return id ? this.records.get(id) : undefined }
22
+ outstandingFor(session: string) { return [...this.records.values()].filter(x => x.state === 'outstanding' && x.callerSessionKey === session) }
23
+ outstandingForTask(taskId: string) { return [...this.records.values()].filter(x => x.state === 'outstanding' && x.taskId === taskId) }
24
+ resolve(id: string) { const r = this.records.get(id); if (!r || r.state !== 'outstanding') return; r.state = 'returned'; return r }
25
+ cancelForTask(taskId: string) { const records = this.outstandingForTask(taskId); for (const r of records) r.state = 'cancelled'; return records }
26
+ ancestorAgents(session: string) {
27
+ const agents: string[] = [], seen = new Set([session]); let cursor = session
28
+ for (;;) { const r = this.forCalleeSession(cursor); if (!r || r.state !== 'outstanding') break; agents.push(r.callerAgent); if (seen.has(r.callerSessionKey)) break; seen.add(r.callerSessionKey); cursor = r.callerSessionKey }
29
+ return agents
30
+ }
31
+ isOutstandingAncestor(session: string, agent: string) { return this.ancestorAgents(session).includes(agent) }
32
+ }
@@ -11,6 +11,7 @@ export interface SendMessageInput {
11
11
  asUserId: string
12
12
  content: { msgtype: string; body: string; [k: string]: unknown }
13
13
  threadRoot?: string
14
+ txnId?: string
14
15
  }
15
16
 
16
17
  export interface SendCustomEventInput {
@@ -18,6 +19,7 @@ export interface SendCustomEventInput {
18
19
  asUserId: string
19
20
  eventType: string
20
21
  content: Record<string, unknown>
22
+ txnId?: string
21
23
  }
22
24
 
23
25
  export interface SetTypingInput {
@@ -51,7 +53,10 @@ export class MatrixClient {
51
53
  const r = await this.fetch(`${this.homeserver}/_matrix/client/v3/register`, {
52
54
  method: 'POST',
53
55
  headers: { Authorization: `Bearer ${this.asToken}` },
54
- body: JSON.stringify({ type: 'm.login.application_service', username: localpart }),
56
+ body: JSON.stringify({
57
+ type: 'm.login.application_service',
58
+ username: localpart,
59
+ }),
55
60
  })
56
61
  if (r.status === 200) return (await r.json()) as { user_id: string; device_id: string }
57
62
  if (r.status === 400) {
@@ -143,10 +148,7 @@ export class MatrixClient {
143
148
  return j.room_id
144
149
  }
145
150
 
146
- async createRoomRaw(opts: {
147
- asUserId: string
148
- body: Record<string, unknown>
149
- }): Promise<string> {
151
+ async createRoomRaw(opts: { asUserId: string; body: Record<string, unknown> }): Promise<string> {
150
152
  const url = `${this.homeserver}/_matrix/client/v3/createRoom?user_id=${encodeURIComponent(opts.asUserId)}`
151
153
  const r = await this.fetch(url, {
152
154
  method: 'POST',
@@ -190,11 +192,7 @@ export class MatrixClient {
190
192
  * already invited" responses idempotently so bootstrap can run on a
191
193
  * fresh AND a populated homeserver without branching.
192
194
  */
193
- async invite(opts: {
194
- roomId: string
195
- asUserId: string
196
- targetUserId: string
197
- }): Promise<void> {
195
+ async invite(opts: { roomId: string; asUserId: string; targetUserId: string }): Promise<void> {
198
196
  const url =
199
197
  `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/invite` +
200
198
  `?user_id=${encodeURIComponent(opts.asUserId)}`
@@ -229,11 +227,7 @@ export class MatrixClient {
229
227
  throw new Error(`invite(${opts.targetUserId}) failed: ${r.status}`)
230
228
  }
231
229
 
232
- async leaveRoom(
233
- roomId: string,
234
- asUserId: string,
235
- opts?: { reason?: string },
236
- ): Promise<void> {
230
+ async leaveRoom(roomId: string, asUserId: string, opts?: { reason?: string }): Promise<void> {
237
231
  const url =
238
232
  `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave` +
239
233
  `?user_id=${encodeURIComponent(asUserId)}`
@@ -263,13 +257,16 @@ export class MatrixClient {
263
257
  async sendMessage(input: SendMessageInput): Promise<{ event_id: string }> {
264
258
  const content: Record<string, unknown> = { ...input.content }
265
259
  if (input.threadRoot) {
266
- content['m.relates_to'] = { rel_type: 'm.thread', event_id: input.threadRoot }
260
+ content['m.relates_to'] = {
261
+ rel_type: 'm.thread',
262
+ event_id: input.threadRoot,
263
+ }
267
264
  }
268
- return this.sendEvent(input.roomId, input.asUserId, 'm.room.message', content)
265
+ return this.sendEvent(input.roomId, input.asUserId, 'm.room.message', content, input.txnId)
269
266
  }
270
267
 
271
268
  async sendCustomEvent(input: SendCustomEventInput): Promise<{ event_id: string }> {
272
- return this.sendEvent(input.roomId, input.asUserId, input.eventType, input.content)
269
+ return this.sendEvent(input.roomId, input.asUserId, input.eventType, input.content, input.txnId)
273
270
  }
274
271
 
275
272
  async setTyping(input: SetTypingInput): Promise<void> {
@@ -356,9 +353,14 @@ export class MatrixClient {
356
353
  user_id: opts.asUserId,
357
354
  })
358
355
  if (opts.from) params.set('from', opts.from)
356
+ // The 3-segment relations endpoint (rel_type + event_type) scopes both the
357
+ // returned chunk AND the next_batch cursor to m.room.message server-side —
358
+ // without it we'd paginate over every m.thread relation (edits, reactions,
359
+ // redactions) and toMessage()'s client-side filtering would desync from
360
+ // has_more/next_before (zooid-ai/zooid#21).
359
361
  const url =
360
362
  `${this.homeserver}/_matrix/client/v1/rooms/${encodeURIComponent(opts.roomId)}` +
361
- `/relations/${encodeURIComponent(opts.rootEventId)}/m.thread?${params.toString()}`
363
+ `/relations/${encodeURIComponent(opts.rootEventId)}/m.thread/m.room.message?${params.toString()}`
362
364
  const r = await this.fetch(url, {
363
365
  method: 'GET',
364
366
  headers: { Authorization: `Bearer ${this.asToken}` },
@@ -409,7 +411,10 @@ export class MatrixClient {
409
411
  headers: { Authorization: `Bearer ${this.asToken}` },
410
412
  })
411
413
  if (!r.ok) throw new Error(`fetchRoomMessages(${opts.roomId}) failed: ${r.status}`)
412
- return (await r.json()) as { chunk: Array<Record<string, unknown>>; end?: string }
414
+ return (await r.json()) as {
415
+ chunk: Array<Record<string, unknown>>
416
+ end?: string
417
+ }
413
418
  }
414
419
 
415
420
  async getJoinedMembers(
@@ -424,19 +429,24 @@ export class MatrixClient {
424
429
  headers: { Authorization: `Bearer ${this.asToken}` },
425
430
  })
426
431
  if (!r.ok) throw new Error(`getJoinedMembers(${roomId}) failed: ${r.status}`)
427
- return (await r.json()) as { joined: Record<string, { display_name?: string }> }
432
+ return (await r.json()) as {
433
+ joined: Record<string, { display_name?: string }>
434
+ }
428
435
  }
429
436
 
430
- async sync(opts: {
431
- asUserId: string
432
- since?: string | null
433
- timeoutMs?: number
434
- }): Promise<{
437
+ async sync(opts: { asUserId: string; since?: string | null; timeoutMs?: number }): Promise<{
435
438
  next_batch: string
436
439
  rooms: {
437
- join: Record<string, {
438
- timeline: { events: Record<string, unknown>[]; prev_batch?: string; limited?: boolean }
439
- }>
440
+ join: Record<
441
+ string,
442
+ {
443
+ timeline: {
444
+ events: Record<string, unknown>[]
445
+ prev_batch?: string
446
+ limited?: boolean
447
+ }
448
+ }
449
+ >
440
450
  }
441
451
  }> {
442
452
  const params = new URLSearchParams({
@@ -452,9 +462,16 @@ export class MatrixClient {
452
462
  return r.json() as Promise<{
453
463
  next_batch: string
454
464
  rooms: {
455
- join: Record<string, {
456
- timeline: { events: Record<string, unknown>[]; prev_batch?: string; limited?: boolean }
457
- }>
465
+ join: Record<
466
+ string,
467
+ {
468
+ timeline: {
469
+ events: Record<string, unknown>[]
470
+ prev_batch?: string
471
+ limited?: boolean
472
+ }
473
+ }
474
+ >
458
475
  }
459
476
  }>
460
477
  }
@@ -478,8 +495,9 @@ export class MatrixClient {
478
495
  asUserId: string,
479
496
  eventType: string,
480
497
  content: Record<string, unknown>,
498
+ txnId?: string,
481
499
  ): Promise<{ event_id: string }> {
482
- const txn = randomUUID()
500
+ const txn = txnId ?? randomUUID()
483
501
  const url =
484
502
  `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}` +
485
503
  `/send/${eventType}/${txn}?user_id=${encodeURIComponent(asUserId)}`
@@ -488,7 +506,13 @@ export class MatrixClient {
488
506
  headers: { Authorization: `Bearer ${this.asToken}` },
489
507
  body: JSON.stringify(content),
490
508
  })
491
- if (!r.ok) throw new Error(`sendEvent(${eventType}) failed: ${r.status}`)
509
+ if (!r.ok) {
510
+ const err = new Error(`sendEvent(${eventType}) failed: ${r.status}`) as Error & {
511
+ status?: number
512
+ }
513
+ err.status = r.status
514
+ throw err
515
+ }
492
516
  return (await r.json()) as { event_id: string }
493
517
  }
494
518
  }
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { route, isMediaMsgtype, type AgentBinding, type ThreadState } from './router.js'
2
+ import {
3
+ route,
4
+ isMediaMsgtype,
5
+ wouldCycleCallers,
6
+ type AgentBinding,
7
+ type ThreadState,
8
+ } from './router.js'
3
9
 
4
10
  const agents: AgentBinding[] = [
5
11
  {
@@ -17,7 +23,12 @@ const agents: AgentBinding[] = [
17
23
  ]
18
24
 
19
25
  function msg(
20
- overrides: Partial<{ room: string; sender: string; body: string; mentions: string[] }> = {},
26
+ overrides: Partial<{
27
+ room: string
28
+ sender: string
29
+ body: string
30
+ mentions: string[]
31
+ }> = {},
21
32
  ) {
22
33
  return {
23
34
  type: 'm.room.message',
@@ -55,7 +66,10 @@ describe('route', () => {
55
66
 
56
67
  it('skips events whose sender is the matched agent itself', () => {
57
68
  const matches = route(
58
- msg({ sender: '@architect:example.com', mentions: ['@architect:example.com'] }),
69
+ msg({
70
+ sender: '@architect:example.com',
71
+ mentions: ['@architect:example.com'],
72
+ }),
59
73
  agents,
60
74
  )
61
75
  expect(matches).toEqual([])
@@ -71,10 +85,7 @@ describe('route', () => {
71
85
  trigger: 'mention',
72
86
  },
73
87
  ]
74
- const matches = route(
75
- msg({ mentions: ['@architect:example.com', '@qa:example.com'] }),
76
- both,
77
- )
88
+ const matches = route(msg({ mentions: ['@architect:example.com', '@qa:example.com'] }), both)
78
89
  expect(matches.map((m) => m.name).sort()).toEqual(['architect', 'qa'])
79
90
  })
80
91
 
@@ -89,6 +100,86 @@ describe('route', () => {
89
100
  })
90
101
  })
91
102
 
103
+ describe('directed task routing', () => {
104
+ const agents: AgentBinding[] = [
105
+ {
106
+ name: 'supervisor',
107
+ userId: '@supervisor:hs',
108
+ rooms: [{ alias: '!r:hs' }],
109
+ trigger: 'mention',
110
+ },
111
+ {
112
+ name: 'worker',
113
+ userId: '@worker:hs',
114
+ rooms: [{ alias: '!r:hs' }],
115
+ trigger: 'mention',
116
+ },
117
+ {
118
+ name: 'eager',
119
+ userId: '@eager:hs',
120
+ rooms: [{ alias: '!r:hs' }],
121
+ trigger: 'any',
122
+ },
123
+ ]
124
+ const root = {
125
+ type: 'm.room.message',
126
+ room_id: '!r:hs',
127
+ sender: '@supervisor:hs',
128
+ content: {
129
+ msgtype: 'm.notice',
130
+ body: '@worker:hs task',
131
+ 'm.mentions': { user_ids: ['@worker:hs'] },
132
+ },
133
+ }
134
+ it('routes a task root solely to its assignee, including self assignment', () => {
135
+ expect(
136
+ route(root, agents, new Map(), { assignee: 'worker', isRoot: true }).map((x) => x.name),
137
+ ).toEqual(['worker'])
138
+ expect(
139
+ route(root, agents, new Map(), {
140
+ assignee: 'supervisor',
141
+ isRoot: true,
142
+ }).map((x) => x.name),
143
+ ).toEqual(['supervisor'])
144
+ })
145
+ it('keeps trigger:any out of a task thread while allowing human steering and explicit mentions', () => {
146
+ const state = new Map([
147
+ [
148
+ '$task',
149
+ {
150
+ participants: ['worker'],
151
+ rootMentions: ['worker'],
152
+ callers: {},
153
+ handoffs: {},
154
+ },
155
+ ],
156
+ ])
157
+ const human = {
158
+ ...root,
159
+ sender: '@alice:hs',
160
+ content: {
161
+ msgtype: 'm.text',
162
+ body: 'continue',
163
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$task' },
164
+ },
165
+ }
166
+ expect(
167
+ route(human, agents, state, { assignee: 'worker', isRoot: false }).map((x) => x.name),
168
+ ).toEqual(['worker'])
169
+ const mention = {
170
+ ...human,
171
+ sender: '@worker:hs',
172
+ content: {
173
+ ...human.content,
174
+ 'm.mentions': { user_ids: ['@supervisor:hs'] },
175
+ },
176
+ }
177
+ expect(
178
+ route(mention, agents, state, { assignee: 'worker', isRoot: false }).map((x) => x.name),
179
+ ).toEqual(['supervisor'])
180
+ })
181
+ })
182
+
92
183
  describe('media events', () => {
93
184
  it('classifies media msgtypes', () => {
94
185
  for (const t of ['m.image', 'm.file', 'm.video', 'm.audio']) {
@@ -103,7 +194,11 @@ describe('media events', () => {
103
194
  const monitorRoom = msg({ room: '!alerts:example.com', body: 'dog.jpg' })
104
195
  const mediaEvent = {
105
196
  ...monitorRoom,
106
- content: { msgtype: 'm.image', body: 'dog.jpg', url: 'mxc://localhost/abc' },
197
+ content: {
198
+ msgtype: 'm.image',
199
+ body: 'dog.jpg',
200
+ url: 'mxc://localhost/abc',
201
+ },
107
202
  }
108
203
  const matches = route(mediaEvent, agents)
109
204
  expect(matches).toEqual([])
@@ -169,7 +264,10 @@ describe('directional thread continuation (agent-to-agent handoffs)', () => {
169
264
 
170
265
  it('an explicit @mention still re-engages the sub (rule 1 wins)', () => {
171
266
  const matches = route(
172
- threadMsg({ sender: '@parent:example.com', mentions: ['@sub:example.com'] }),
267
+ threadMsg({
268
+ sender: '@parent:example.com',
269
+ mentions: ['@sub:example.com'],
270
+ }),
173
271
  pair,
174
272
  states({ participants: ['parent', 'sub'], callers: { sub: 'parent' } }),
175
273
  )
@@ -178,7 +276,10 @@ describe('directional thread continuation (agent-to-agent handoffs)', () => {
178
276
 
179
277
  it('dedupes: a sub reply that also @mentions its caller triggers the caller once', () => {
180
278
  const matches = route(
181
- threadMsg({ sender: '@sub:example.com', mentions: ['@parent:example.com'] }),
279
+ threadMsg({
280
+ sender: '@sub:example.com',
281
+ mentions: ['@parent:example.com'],
282
+ }),
182
283
  pair,
183
284
  states({ participants: ['parent'], callers: { sub: 'parent' } }),
184
285
  )
@@ -228,6 +329,24 @@ describe('directional thread continuation (agent-to-agent handoffs)', () => {
228
329
  })
229
330
  })
230
331
 
332
+ describe('caller graph cycle guard', () => {
333
+ it('rejects a reverse edge back to an existing caller', () => {
334
+ expect(wouldCycleCallers({ sub: 'parent' }, 'parent', 'sub')).toBe(true)
335
+ })
336
+
337
+ it('rejects a cycle through a deeper ancestor', () => {
338
+ expect(
339
+ wouldCycleCallers({ child: 'parent', grandchild: 'child' }, 'parent', 'grandchild'),
340
+ ).toBe(true)
341
+ })
342
+
343
+ it('allows a new downward or sibling edge', () => {
344
+ const callers = { child: 'parent' }
345
+ expect(wouldCycleCallers(callers, 'grandchild', 'child')).toBe(false)
346
+ expect(wouldCycleCallers(callers, 'sibling', 'parent')).toBe(false)
347
+ })
348
+ })
349
+
231
350
  describe('fan-out: two subs called in one message ([[ZOD071]] acceptance)', () => {
232
351
  const mk = (name: string): AgentBinding => ({
233
352
  name,