@zooid/transport-matrix 0.9.0 → 0.10.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.9.0",
3
+ "version": "0.10.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/core": "^0.9.0",
32
- "@zooid/acp-client": "^0.9.0"
31
+ "@zooid/acp-client": "^0.10.0",
32
+ "@zooid/core": "^0.10.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@hono/node-server": "^1.13.0",
@@ -107,6 +107,129 @@ describe('BotPool.bootstrap — create-if-missing rooms', () => {
107
107
  ])
108
108
  })
109
109
 
110
+ it('joins the existing room when createRoom loses a race (alias taken)', async () => {
111
+ // Another agent/process created the room first: the alias didn't resolve at
112
+ // first check, createRoom then fails (alias in use), and a re-resolve finds
113
+ // it. We must join that room — not leave an orphan.
114
+ const calls: string[] = []
115
+ let resolveCount = 0
116
+ const client = {
117
+ registerBot: async () => {},
118
+ setDisplayName: async () => {},
119
+ resolveAlias: async (a: string) => {
120
+ resolveCount++
121
+ // First check: not found (we'll try to create). After the failed
122
+ // create, the re-resolve finds the room the concurrent creator made.
123
+ const r = resolveCount === 1 ? null : '!raced:localhost'
124
+ calls.push(`resolve:${a}->${r ?? 'null'}`)
125
+ return r
126
+ },
127
+ createRoom: async () => {
128
+ calls.push('create:THROWS(alias in use)')
129
+ throw new Error('createRoom(welcome) failed: 409 M_ROOM_IN_USE')
130
+ },
131
+ joinRoom: async (room: string, asUser: string) => {
132
+ calls.push(`join:${asUser}->${room}`)
133
+ },
134
+ }
135
+ const pool = new BotPool(client as never, [
136
+ {
137
+ name: 'echo',
138
+ userId: '@echo:localhost',
139
+ rooms: [{ alias: '#welcome:localhost' }],
140
+ trigger: 'mention',
141
+ },
142
+ ])
143
+ await pool.bootstrap({ adminUserId: '@admin:localhost' })
144
+ expect(calls).toEqual([
145
+ 'resolve:#welcome:localhost->null',
146
+ 'create:THROWS(alias in use)',
147
+ 'resolve:#welcome:localhost->!raced:localhost',
148
+ 'join:@echo:localhost->!raced:localhost',
149
+ ])
150
+ })
151
+
152
+ it('two pools racing the same room → exactly one room, both join it, no orphans', async () => {
153
+ // Models the real bug: two agents in *separate* daemon processes bootstrap
154
+ // the same workforce concurrently. They share one stateful homeserver where
155
+ // an alias can be claimed exactly once. Each pool's first directory read is
156
+ // stale (returns null — it read before anyone claimed), so both proceed to
157
+ // createRoom; the second create loses (alias in use) and must recover by
158
+ // re-resolving and joining, not orphaning.
159
+ const aliasToRoom = new Map<string, string>()
160
+ let roomsCreated = 0
161
+ let n = 0
162
+ const makeClient = () => {
163
+ let staleRead = true
164
+ const joins: string[] = []
165
+ return {
166
+ joins,
167
+ registerBot: async () => {},
168
+ setDisplayName: async () => {},
169
+ invite: async () => {},
170
+ sendStateEvent: async () => ({ event_id: '$x' }),
171
+ resolveAlias: async (a: string) => {
172
+ if (staleRead) {
173
+ staleRead = false
174
+ return null
175
+ }
176
+ return aliasToRoom.get(a) ?? null
177
+ },
178
+ createRoom: async (o: { roomAliasName: string }) => {
179
+ const alias = `#${o.roomAliasName}:s`
180
+ if (aliasToRoom.has(alias)) throw new Error('createRoom(shared) failed: 409 M_ROOM_IN_USE')
181
+ const id = `!room${++n}:s`
182
+ aliasToRoom.set(alias, id)
183
+ roomsCreated++
184
+ return id
185
+ },
186
+ joinRoom: async (room: string, user: string) => {
187
+ joins.push(`${user}->${room}`)
188
+ },
189
+ }
190
+ }
191
+ const a = makeClient()
192
+ const b = makeClient()
193
+ const poolA = new BotPool(a as never, [
194
+ { name: 'a', userId: '@a:s', rooms: [{ alias: '#shared:s' }], trigger: 'mention' },
195
+ ])
196
+ const poolB = new BotPool(b as never, [
197
+ { name: 'b', userId: '@b:s', rooms: [{ alias: '#shared:s' }], trigger: 'mention' },
198
+ ])
199
+ await poolA.bootstrap({ adminUserId: '@admin:s' })
200
+ await poolB.bootstrap({ adminUserId: '@admin:s' })
201
+
202
+ // Exactly one room exists — the loser recovered instead of orphaning.
203
+ expect(roomsCreated).toBe(1)
204
+ const theRoom = aliasToRoom.get('#shared:s')
205
+ // Both agents joined that one room.
206
+ expect(a.joins).toEqual([`@a:s->${theRoom}`])
207
+ expect(b.joins).toEqual([`@b:s->${theRoom}`])
208
+ })
209
+
210
+ it('rethrows when createRoom fails and the alias still does not resolve', async () => {
211
+ const client = {
212
+ registerBot: async () => {},
213
+ setDisplayName: async () => {},
214
+ resolveAlias: async () => null, // never resolves — genuine creation failure
215
+ createRoom: async () => {
216
+ throw new Error('createRoom(welcome) failed: 500 boom')
217
+ },
218
+ joinRoom: vi.fn(async () => undefined),
219
+ }
220
+ const pool = new BotPool(client as never, [
221
+ {
222
+ name: 'echo',
223
+ userId: '@echo:localhost',
224
+ rooms: [{ alias: '#welcome:localhost' }],
225
+ trigger: 'mention',
226
+ },
227
+ ])
228
+ // bootstrap swallows per-room errors (logs); the room is never joined.
229
+ await pool.bootstrap({ adminUserId: '@admin:localhost' })
230
+ expect(client.joinRoom).not.toHaveBeenCalled()
231
+ })
232
+
110
233
  it('skips create when the alias already resolves', async () => {
111
234
  const calls: string[] = []
112
235
  const client = {
@@ -248,8 +371,14 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
248
371
  ],
249
372
  )
250
373
  await pool.bootstrap({ spaceRoomId: '!space:zoon.local', asUserId: '@zooid:zoon.local' })
374
+ // Created BY the AS sender bot (room owner/admin), never by the agent —
375
+ // agents join as plain members, never room admins.
251
376
  expect(createRoom).toHaveBeenCalledWith(
252
- expect.objectContaining({ roomAliasName: 'design', restrictedToSpaceId: '!space:zoon.local' }),
377
+ expect.objectContaining({
378
+ roomAliasName: 'design',
379
+ restrictedToSpaceId: '!space:zoon.local',
380
+ senderUserId: '@zooid:zoon.local',
381
+ }),
253
382
  )
254
383
  })
255
384
 
package/src/bot-pool.ts CHANGED
@@ -82,21 +82,39 @@ export class BotPool {
82
82
  } else {
83
83
  const colon = room.indexOf(':')
84
84
  const aliasLocalpart = colon > 1 ? room.slice(1, colon) : room.slice(1)
85
- const sender = opts.adminUserId ?? a.userId
85
+ // Workstation-created rooms are owned by the AS sender bot (PL
86
+ // 100, already seeded in the power override below). Agents join
87
+ // as plain members and must never be room admins. The human
88
+ // operator is invited + seeded at PL 100, but can't be the
89
+ // creator under a workstation-scoped namespace — the AS token
90
+ // can't impersonate a non-namespaced human.
91
+ const sender = opts.asUserId ?? opts.adminUserId ?? a.userId
86
92
  const userPowerLevels = buildUserPowerLevels(
87
93
  opts.asUserId,
88
94
  opts.adminUserIds,
89
95
  this.agents,
90
96
  room,
91
97
  )
92
- resolved = await this.client.createRoom({
93
- roomAliasName: aliasLocalpart,
94
- invite: opts.adminUserId ? [opts.adminUserId] : [],
95
- senderUserId: sender,
96
- name: aliasLocalpart,
97
- ...(opts.spaceRoomId ? { restrictedToSpaceId: opts.spaceRoomId } : {}),
98
- ...(userPowerLevels ? { userPowerLevels } : {}),
99
- })
98
+ try {
99
+ resolved = await this.client.createRoom({
100
+ roomAliasName: aliasLocalpart,
101
+ invite: opts.adminUserId ? [opts.adminUserId] : [],
102
+ senderUserId: sender,
103
+ name: aliasLocalpart,
104
+ ...(opts.spaceRoomId ? { restrictedToSpaceId: opts.spaceRoomId } : {}),
105
+ ...(userPowerLevels ? { userPowerLevels } : {}),
106
+ })
107
+ } catch (err) {
108
+ // Another agent — or another daemon process bootstrapping the
109
+ // same workforce — may have created this room concurrently, so
110
+ // its alias is now taken (createRoom fails with the alias in
111
+ // use). Re-resolve and join the existing room rather than
112
+ // leaving an orphan. Only rethrow if the alias still doesn't
113
+ // resolve (a genuine creation failure).
114
+ const raced = await this.client.resolveAlias(room)
115
+ if (!raced) throw err
116
+ resolved = raced
117
+ }
100
118
  }
101
119
  aliasToId.set(room, resolved)
102
120
  }
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ isValidWorkstation,
4
+ isValidAgentKey,
5
+ agentMxid,
6
+ splitAgentLocalpart,
7
+ workstationUserNamespace,
8
+ } from './identity.js'
9
+
10
+ describe('workstation / agent grammar', () => {
11
+ it('accepts dot-free lowercase-alnum-hyphen', () => {
12
+ expect(isValidWorkstation('laptop')).toBe(true)
13
+ expect(isValidWorkstation('ec2-prod')).toBe(true)
14
+ expect(isValidAgentKey('docs')).toBe(true)
15
+ })
16
+ it('rejects dots (the workstation/agent separator) and bad chars', () => {
17
+ expect(isValidWorkstation('lap.top')).toBe(false) // dot is the boundary, never inside
18
+ expect(isValidWorkstation('Laptop')).toBe(false) // strict ASCII lowercase
19
+ expect(isValidWorkstation('lap_top')).toBe(false)
20
+ expect(isValidWorkstation('')).toBe(false)
21
+ expect(isValidAgentKey('a.b')).toBe(false)
22
+ })
23
+ })
24
+
25
+ describe('agentMxid', () => {
26
+ it('builds @{workstation}.{agent}:server', () => {
27
+ expect(agentMxid('laptop', 'assistant', 'zoon.eco')).toBe('@laptop.assistant:zoon.eco')
28
+ })
29
+ it('throws on invalid parts', () => {
30
+ expect(() => agentMxid('lap.top', 'assistant', 'zoon.eco')).toThrow()
31
+ expect(() => agentMxid('laptop', 'a.b', 'zoon.eco')).toThrow()
32
+ })
33
+ })
34
+
35
+ describe('splitAgentLocalpart — split on FIRST dot', () => {
36
+ it('recovers (workstation, agent)', () => {
37
+ expect(splitAgentLocalpart('laptop.assistant')).toEqual({
38
+ workstation: 'laptop',
39
+ agent: 'assistant',
40
+ })
41
+ })
42
+ it('throws when there is no separator', () => {
43
+ expect(() => splitAgentLocalpart('zooid')).toThrow()
44
+ })
45
+ })
46
+
47
+ describe('workstationUserNamespace', () => {
48
+ it('emits an escaped-dot exclusive regex for the workstation', () => {
49
+ // The literal dot must be escaped so @{workstation}.* does not also match @{workstation}X*
50
+ expect(workstationUserNamespace('laptop', 'zoon.eco')).toBe('@laptop\\..*:zoon.eco')
51
+ })
52
+ })
@@ -0,0 +1,27 @@
1
+ // A workstation id (and an agent key) must be "slug-shaped": kebab-case,
2
+ // MXID/alias-safe. SLUG_RE names the *format*; the domain noun is workstation.
3
+ export const SLUG_RE = /^[a-z0-9-]+$/
4
+ export const AGENT_KEY_RE = /^[a-z0-9-]+$/
5
+
6
+ export const isValidWorkstation = (s: string): boolean => SLUG_RE.test(s)
7
+ export const isValidAgentKey = (s: string): boolean => AGENT_KEY_RE.test(s)
8
+
9
+ export function agentMxid(workstation: string, agent: string, serverName: string): string {
10
+ if (!isValidWorkstation(workstation))
11
+ throw new Error(`invalid workstation: ${JSON.stringify(workstation)}`)
12
+ if (!isValidAgentKey(agent)) throw new Error(`invalid agent key: ${JSON.stringify(agent)}`)
13
+ return `@${workstation}.${agent}:${serverName}`
14
+ }
15
+
16
+ export function splitAgentLocalpart(localpart: string): { workstation: string; agent: string } {
17
+ const i = localpart.indexOf('.') // first dot is the boundary; workstation/agent are dot-free
18
+ if (i <= 0 || i === localpart.length - 1)
19
+ throw new Error(`not a workstation.agent localpart: ${localpart}`)
20
+ return { workstation: localpart.slice(0, i), agent: localpart.slice(i + 1) }
21
+ }
22
+
23
+ export function workstationUserNamespace(workstation: string, serverName: string): string {
24
+ if (!isValidWorkstation(workstation))
25
+ throw new Error(`invalid workstation: ${JSON.stringify(workstation)}`)
26
+ return `@${workstation}\\..*:${serverName}` // escaped dot — exclusive to this workstation's agents
27
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,15 @@ export { MatrixContextProvider } from './context-provider.js'
4
4
  export type { MatrixContextProviderOpts } from './context-provider.js'
5
5
  export { renderRegistration } from './registration.js'
6
6
  export type { MatrixTransportConfig } from './registration.js'
7
+ export {
8
+ isValidWorkstation,
9
+ isValidAgentKey,
10
+ agentMxid,
11
+ splitAgentLocalpart,
12
+ workstationUserNamespace,
13
+ SLUG_RE,
14
+ AGENT_KEY_RE,
15
+ } from './identity.js'
7
16
  export { extractMentions } from './mentions.js'
8
17
  export type { MaybeMessage } from './mentions.js'
9
18
  export { route, isMediaMsgtype, MEDIA_MSGTYPES } from './router.js'
@@ -11,6 +20,8 @@ export type { AgentBinding, RouteMatch } from './router.js'
11
20
  export { BotPool } from './bot-pool.js'
12
21
  export { createMatrixTransport } from './transport.js'
13
22
  export type { CreateMatrixTransportOptions, MediaClientLike } from './transport.js'
23
+ export { SyncLoop } from './sync-loop.js'
24
+ export type { SyncLoopOptions, SyncResponse, SyncClient } from './sync-loop.js'
14
25
  export { ensureDefaultChannel, ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
15
26
  export type { EnsureDefaultChannelOpts, EnsureSpaceOpts } from './space-provisioner.js'
16
27
  export {
@@ -160,6 +160,49 @@ describe('MatrixClient', () => {
160
160
  })
161
161
  })
162
162
 
163
+ it('createRoom keeps the creator at PL 100 when a power-level override omits them', async () => {
164
+ // power_level_content_override.users REPLACES the default { creator: 100 }
165
+ // map; if the creator is left out they drop to 0 and can't claim the alias.
166
+ const fetch = fakeFetch(async ({ init }) => {
167
+ const body = JSON.parse(init.body as string)
168
+ expect(body.power_level_content_override.users).toEqual({
169
+ '@zooid:localhost': 100,
170
+ '@creator:localhost': 100,
171
+ })
172
+ return new Response(JSON.stringify({ room_id: '!new:localhost' }), { status: 200 })
173
+ })
174
+ const client = new MatrixClient({
175
+ homeserver: 'https://hs.example.com',
176
+ asToken: 'as-secret',
177
+ fetch: fetch as unknown as typeof globalThis.fetch,
178
+ })
179
+ await client.createRoom({
180
+ roomAliasName: 'welcome',
181
+ invite: [],
182
+ senderUserId: '@creator:localhost',
183
+ userPowerLevels: { '@zooid:localhost': 100 },
184
+ })
185
+ })
186
+
187
+ it('createRoom does not override an explicit power level already set for the creator', async () => {
188
+ const fetch = fakeFetch(async ({ init }) => {
189
+ const body = JSON.parse(init.body as string)
190
+ expect(body.power_level_content_override.users['@creator:localhost']).toBe(50)
191
+ return new Response(JSON.stringify({ room_id: '!new:localhost' }), { status: 200 })
192
+ })
193
+ const client = new MatrixClient({
194
+ homeserver: 'https://hs.example.com',
195
+ asToken: 'as-secret',
196
+ fetch: fetch as unknown as typeof globalThis.fetch,
197
+ })
198
+ await client.createRoom({
199
+ roomAliasName: 'welcome',
200
+ invite: [],
201
+ senderUserId: '@creator:localhost',
202
+ userPowerLevels: { '@creator:localhost': 50 },
203
+ })
204
+ })
205
+
163
206
  it('setDisplayName PUTs the displayname endpoint impersonating the user', async () => {
164
207
  const fetch = fakeFetch(async ({ url, init }) => {
165
208
  expect(url).toBe(
@@ -478,6 +521,29 @@ describe('MatrixClient.leaveRoom', () => {
478
521
  })
479
522
  })
480
523
 
524
+ describe('MatrixClient.sync (impersonated)', () => {
525
+ it('GETs /sync with ?user_id=, as_token bearer, since + timeout', async () => {
526
+ const fetch = vi.fn(async () =>
527
+ new Response(JSON.stringify({ next_batch: 's2', rooms: { join: {} } }), { status: 200 }),
528
+ )
529
+ const c = new MatrixClient({
530
+ homeserver: 'https://zoon.eco',
531
+ asToken: 'as-tok',
532
+ fetch: fetch as unknown as typeof globalThis.fetch,
533
+ })
534
+ const out = await c.sync({ asUserId: '@laptop.docs:zoon.eco', since: 's1', timeoutMs: 30000 })
535
+
536
+ expect(out.next_batch).toBe('s2')
537
+ const url = new URL(fetch.mock.calls[0]![0] as string)
538
+ expect(url.pathname).toBe('/_matrix/client/v3/sync')
539
+ expect(url.searchParams.get('user_id')).toBe('@laptop.docs:zoon.eco')
540
+ expect(url.searchParams.get('since')).toBe('s1')
541
+ expect(url.searchParams.get('timeout')).toBe('30000')
542
+ const headers = (fetch.mock.calls[0]![1] as RequestInit).headers as Record<string, string>
543
+ expect(headers.Authorization).toBe('Bearer as-tok')
544
+ })
545
+ })
546
+
481
547
  describe('MatrixClient.createRoom restricted', () => {
482
548
  it('injects a restricted join rule referencing the space when restrictedToSpaceId is set', async () => {
483
549
  const fetch = fakeFetch(async ({ url, init }) => {
@@ -115,7 +115,14 @@ export class MatrixClient {
115
115
  ]
116
116
  }
117
117
  if (opts.userPowerLevels && Object.keys(opts.userPowerLevels).length > 0) {
118
- body.power_level_content_override = { users: opts.userPowerLevels }
118
+ // `power_level_content_override.users` REPLACES the default `{ creator: 100 }`
119
+ // map rather than merging into it. If the creator (senderUserId) is omitted
120
+ // here they drop to `users_default` (0) and can't set `m.room.canonical_alias`
121
+ // (PL 50) during creation — the createRoom call then 403s and leaves an
122
+ // aliasless orphan room. Always keep the creator at admin power.
123
+ const users = { ...opts.userPowerLevels }
124
+ if (users[opts.senderUserId] === undefined) users[opts.senderUserId] = 100
125
+ body.power_level_content_override = { users }
119
126
  }
120
127
  const r = await this.fetch(
121
128
  `${this.homeserver}/_matrix/client/v3/createRoom?user_id=${encodeURIComponent(opts.senderUserId)}`,
@@ -420,6 +427,38 @@ export class MatrixClient {
420
427
  return (await r.json()) as { joined: Record<string, { display_name?: string }> }
421
428
  }
422
429
 
430
+ async sync(opts: {
431
+ asUserId: string
432
+ since?: string | null
433
+ timeoutMs?: number
434
+ }): Promise<{
435
+ next_batch: string
436
+ rooms: {
437
+ join: Record<string, {
438
+ timeline: { events: Record<string, unknown>[]; prev_batch?: string; limited?: boolean }
439
+ }>
440
+ }
441
+ }> {
442
+ const params = new URLSearchParams({
443
+ user_id: opts.asUserId,
444
+ timeout: String(opts.timeoutMs ?? 30_000),
445
+ })
446
+ if (opts.since) params.set('since', opts.since)
447
+ const url = `${this.homeserver}/_matrix/client/v3/sync?${params.toString()}`
448
+ const r = await this.fetch(url, {
449
+ headers: { Authorization: `Bearer ${this.asToken}` },
450
+ })
451
+ if (!r.ok) throw new Error(`sync(${opts.asUserId}) failed: ${r.status}`)
452
+ return r.json() as Promise<{
453
+ next_batch: string
454
+ rooms: {
455
+ join: Record<string, {
456
+ timeline: { events: Record<string, unknown>[]; prev_batch?: string; limited?: boolean }
457
+ }>
458
+ }
459
+ }>
460
+ }
461
+
423
462
  async fetchRoomName(roomId: string, asUserId: string): Promise<string | null> {
424
463
  const url =
425
464
  `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}` +
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import { parse } from 'yaml'
2
3
  import { renderRegistration, type MatrixTransportConfig } from './registration.js'
3
4
 
4
5
  const baseConfig: MatrixTransportConfig = {
@@ -39,3 +40,22 @@ describe('renderRegistration', () => {
39
40
  expect(yaml).toContain('rate_limited: false')
40
41
  })
41
42
  })
43
+
44
+ it('renders a workstation-scoped EXCLUSIVE registration', () => {
45
+ const y = parse(
46
+ renderRegistration({
47
+ id: 'laptop',
48
+ url: 'http://10.0.1.5:9099',
49
+ homeserver: 'https://zoon.eco',
50
+ asToken: 'as-x',
51
+ hsToken: 'hs-x',
52
+ senderLocalpart: 'laptop',
53
+ userNamespace: '@laptop\\..*:zoon.eco',
54
+ exclusive: true,
55
+ }),
56
+ )
57
+ expect(y.sender_localpart).toBe('laptop')
58
+ expect(y.url).toBe('http://10.0.1.5:9099')
59
+ expect(y.namespaces.users[0]).toEqual({ exclusive: true, regex: '@laptop\\..*:zoon.eco' })
60
+ expect(y.namespaces.rooms).toEqual([]) // never fence by room id
61
+ })
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { route, isMediaMsgtype, type AgentBinding } from './router.js'
2
+ import { route, isMediaMsgtype, type AgentBinding, type ThreadState } from './router.js'
3
3
 
4
4
  const agents: AgentBinding[] = [
5
5
  {
@@ -109,3 +109,119 @@ describe('media events', () => {
109
109
  expect(matches).toEqual([])
110
110
  })
111
111
  })
112
+
113
+ describe('directional thread continuation (agent-to-agent handoffs)', () => {
114
+ const parent: AgentBinding = {
115
+ name: 'parent',
116
+ userId: '@parent:example.com',
117
+ rooms: [{ alias: '!room1:example.com' }],
118
+ trigger: 'mention',
119
+ }
120
+ const sub: AgentBinding = {
121
+ name: 'sub',
122
+ userId: '@sub:example.com',
123
+ rooms: [{ alias: '!room1:example.com' }],
124
+ trigger: 'mention',
125
+ }
126
+ const pair = [parent, sub]
127
+
128
+ // A bare (or mentioning) reply inside the thread rooted at $root.
129
+ function threadMsg(o: { sender: string; mentions?: string[] }) {
130
+ return {
131
+ type: 'm.room.message',
132
+ room_id: '!room1:example.com',
133
+ sender: o.sender,
134
+ event_id: '$evt',
135
+ content: {
136
+ msgtype: 'm.text',
137
+ body: 'reply',
138
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
139
+ ...(o.mentions ? { 'm.mentions': { user_ids: o.mentions } } : {}),
140
+ },
141
+ }
142
+ }
143
+
144
+ function states(s: Partial<ThreadState>): Map<string, ThreadState> {
145
+ return new Map([['$root', { participants: [], rootMentions: [], callers: {}, ...s }]])
146
+ }
147
+
148
+ it("routes a sub's bare reply up to its caller (parent notified)", () => {
149
+ const matches = route(
150
+ threadMsg({ sender: '@sub:example.com' }),
151
+ pair,
152
+ states({ participants: ['parent'], callers: { sub: 'parent' } }),
153
+ )
154
+ expect(matches.map((m) => m.name)).toEqual(['parent'])
155
+ })
156
+
157
+ it('does NOT re-trigger a callee when its parent posts a bare reply (loop guard)', () => {
158
+ // parent is the sender; sub is its callee. parent has no caller of its own,
159
+ // so its bare reply routes to nobody — the loop dies here.
160
+ const matches = route(
161
+ threadMsg({ sender: '@parent:example.com' }),
162
+ pair,
163
+ states({ participants: ['parent', 'sub'], callers: { sub: 'parent' } }),
164
+ )
165
+ expect(matches).toEqual([])
166
+ })
167
+
168
+ it('an explicit @mention still re-engages the sub (rule 1 wins)', () => {
169
+ const matches = route(
170
+ threadMsg({ sender: '@parent:example.com', mentions: ['@sub:example.com'] }),
171
+ pair,
172
+ states({ participants: ['parent', 'sub'], callers: { sub: 'parent' } }),
173
+ )
174
+ expect(matches.map((m) => m.name)).toEqual(['sub'])
175
+ })
176
+
177
+ it('dedupes: a sub reply that also @mentions its caller triggers the caller once', () => {
178
+ const matches = route(
179
+ threadMsg({ sender: '@sub:example.com', mentions: ['@parent:example.com'] }),
180
+ pair,
181
+ states({ participants: ['parent'], callers: { sub: 'parent' } }),
182
+ )
183
+ expect(matches.map((m) => m.name)).toEqual(['parent'])
184
+ })
185
+
186
+ it('bubbles a 3-level chain one hop at a time (grandchild → child, not parent)', () => {
187
+ const child: AgentBinding = {
188
+ name: 'child',
189
+ userId: '@child:example.com',
190
+ rooms: [{ alias: '!room1:example.com' }],
191
+ trigger: 'mention',
192
+ }
193
+ const grand: AgentBinding = {
194
+ name: 'grand',
195
+ userId: '@grand:example.com',
196
+ rooms: [{ alias: '!room1:example.com' }],
197
+ trigger: 'mention',
198
+ }
199
+ const matches = route(
200
+ threadMsg({ sender: '@grand:example.com' }),
201
+ [parent, child, grand],
202
+ states({
203
+ participants: ['parent', 'child'],
204
+ callers: { child: 'parent', grand: 'child' },
205
+ }),
206
+ )
207
+ expect(matches.map((m) => m.name)).toEqual(['child'])
208
+ })
209
+
210
+ it('a human bare reply still continues with the most-recent-posting agent (unchanged)', () => {
211
+ const matches = route(
212
+ threadMsg({ sender: '@alice:example.com' }),
213
+ pair,
214
+ states({ participants: ['parent', 'sub'], callers: { sub: 'parent' } }),
215
+ )
216
+ expect(matches.map((m) => m.name)).toEqual(['sub'])
217
+ })
218
+
219
+ it('an agent with no caller (human-initiated) returns to nobody', () => {
220
+ const matches = route(
221
+ threadMsg({ sender: '@parent:example.com' }),
222
+ pair,
223
+ states({ participants: ['parent'], callers: {} }),
224
+ )
225
+ expect(matches).toEqual([])
226
+ })
227
+ })
package/src/router.ts CHANGED
@@ -33,6 +33,14 @@ export interface ThreadState {
33
33
  participants: string[]
34
34
  /** Agent names @mentioned in the thread root event (or subsequently). */
35
35
  rootMentions: string[]
36
+ /**
37
+ * Agent-to-agent call edges: sub-agent name → the agent that @mentioned
38
+ * (called) it in this thread. A sub's bare reply bubbles up to its caller;
39
+ * a caller never implicitly re-triggers its callee. Makes agent↔agent
40
+ * acknowledgement loops structurally impossible. See [[ZOD039]] §
41
+ * Implicit triggers → Directional continuation.
42
+ */
43
+ callers: Record<string, string>
36
44
  }
37
45
 
38
46
  interface MaybeEvent {
@@ -77,14 +85,24 @@ export function route(
77
85
  matches.push(a)
78
86
  continue
79
87
  }
80
- // Implicit trigger in a thread: most-recent-poster, or root-mention
81
- // inheritance if no agent has posted yet.
88
+ // Implicit trigger in a thread.
82
89
  if (threadState) {
83
- const lastPoster = threadState.participants.at(-1)
84
- if (lastPoster) {
85
- if (lastPoster === a.name) matches.push(a)
86
- } else if (threadState.rootMentions.includes(a.name)) {
87
- matches.push(a)
90
+ const senderAgent = agents.find((x) => x.userId === event.sender)
91
+ if (senderAgent) {
92
+ // Agent reply = a "return": route only to the agent that called the
93
+ // sender (its caller), never to a callee. Directional continuation
94
+ // keeps agent↔agent handoffs from looping — the call graph is a tree
95
+ // rooted at the human, so returns only ever walk up.
96
+ if (threadState.callers[senderAgent.name] === a.name) matches.push(a)
97
+ } else {
98
+ // Human (or non-agent) follow-up: continue with the most-recent-posting
99
+ // agent, or inherit the root mention if no agent has posted yet.
100
+ const lastPoster = threadState.participants.at(-1)
101
+ if (lastPoster) {
102
+ if (lastPoster === a.name) matches.push(a)
103
+ } else if (threadState.rootMentions.includes(a.name)) {
104
+ matches.push(a)
105
+ }
88
106
  }
89
107
  }
90
108
  }