@zooid/transport-matrix 0.9.1 → 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.1",
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/acp-client": "^0.9.1",
32
- "@zooid/core": "^0.9.1"
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 = {
package/src/bot-pool.ts CHANGED
@@ -95,14 +95,26 @@ export class BotPool {
95
95
  this.agents,
96
96
  room,
97
97
  )
98
- resolved = await this.client.createRoom({
99
- roomAliasName: aliasLocalpart,
100
- invite: opts.adminUserId ? [opts.adminUserId] : [],
101
- senderUserId: sender,
102
- name: aliasLocalpart,
103
- ...(opts.spaceRoomId ? { restrictedToSpaceId: opts.spaceRoomId } : {}),
104
- ...(userPowerLevels ? { userPowerLevels } : {}),
105
- })
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
+ }
106
118
  }
107
119
  aliasToId.set(room, resolved)
108
120
  }
@@ -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(
@@ -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)}`,
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi } from 'vitest'
2
2
  import { EventEmitter } from 'node:events'
3
- import { createMatrixTransport } from './transport.js'
3
+ import { createMatrixTransport, rebuildThreadState } from './transport.js'
4
4
 
5
5
  function fakeRegistry() {
6
6
  let resolvePrompt: (() => void) | undefined
@@ -1683,3 +1683,109 @@ describe('ad-hoc bot invite declines', () => {
1683
1683
  expect(client.leaveRoom).not.toHaveBeenCalled()
1684
1684
  })
1685
1685
  })
1686
+
1687
+ describe('directional agent-to-agent handoffs', () => {
1688
+ const parentSub = [
1689
+ {
1690
+ name: 'parent',
1691
+ userId: '@parent:example.com',
1692
+ rooms: [{ alias: '!r:example.com' }],
1693
+ trigger: 'mention' as const,
1694
+ },
1695
+ {
1696
+ name: 'sub',
1697
+ userId: '@sub:example.com',
1698
+ rooms: [{ alias: '!r:example.com' }],
1699
+ trigger: 'mention' as const,
1700
+ },
1701
+ ]
1702
+
1703
+ function makePairTransport() {
1704
+ const { reg } = fakeRegistry()
1705
+ const approvals = fakeApprovals()
1706
+ const client = fakeClient()
1707
+ // Resolve prompts immediately so runTurn completes and `participants` append.
1708
+ reg.prompt.mockImplementation(async () => ({ stopReason: 'end_turn' as const }))
1709
+ const transport = createMatrixTransport({
1710
+ agents: reg as never,
1711
+ approvals: approvals as never,
1712
+ client: client as never,
1713
+ bindings: parentSub,
1714
+ hsToken: 'hs-secret',
1715
+ botUserId: '@zooid:example.com',
1716
+ drainQuietMs: 0,
1717
+ })
1718
+ return { transport, agents: reg, client }
1719
+ }
1720
+
1721
+ // Post one m.room.message. Top-level unless `root` is given (then it's a
1722
+ // thread reply on that root). `mentions` sets m.mentions.user_ids.
1723
+ function post(
1724
+ transport: ReturnType<typeof makePairTransport>['transport'],
1725
+ o: { id: string; sender: string; root?: string; mentions?: string[] },
1726
+ ) {
1727
+ const content: Record<string, unknown> = { msgtype: 'm.text', body: 'x' }
1728
+ if (o.mentions) content['m.mentions'] = { user_ids: o.mentions }
1729
+ if (o.root) content['m.relates_to'] = { rel_type: 'm.thread', event_id: o.root }
1730
+ return postTxn(transport.app, {
1731
+ events: [
1732
+ { type: 'm.room.message', event_id: o.id, room_id: '!r:example.com', sender: o.sender, content },
1733
+ ],
1734
+ })
1735
+ }
1736
+
1737
+ it("a parent's ack does not re-trigger the sub, but the sub's reply notifies the parent", async () => {
1738
+ const { transport, agents } = makePairTransport()
1739
+
1740
+ // 1. Human @mentions parent (top-level) → thread root is $root.
1741
+ await post(transport, { id: '$root', sender: '@alice:example.com', mentions: ['@parent:example.com'] })
1742
+ await settleTurn()
1743
+
1744
+ // 2. Parent replies in-thread @mentioning sub → sub is called; caller[sub]=parent.
1745
+ await post(transport, { id: '$p1', sender: '@parent:example.com', root: '$root', mentions: ['@sub:example.com'] })
1746
+ await settleTurn()
1747
+ expect(agents.ensureSession).toHaveBeenCalledWith('sub', '$root', '!r:example.com')
1748
+
1749
+ agents.ensureSession.mockClear()
1750
+
1751
+ // 3. Sub replies bare → bubbles UP to its caller (parent), nobody else.
1752
+ await post(transport, { id: '$s1', sender: '@sub:example.com', root: '$root' })
1753
+ await settleTurn()
1754
+ expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com')
1755
+ expect(agents.ensureSession).not.toHaveBeenCalledWith('sub', '$root', '!r:example.com')
1756
+
1757
+ agents.ensureSession.mockClear()
1758
+
1759
+ // 4. Parent replies bare (the "ack") → parent has no caller → routes to NOBODY.
1760
+ // This is the loop guard: sub is NOT re-triggered.
1761
+ await post(transport, { id: '$p2', sender: '@parent:example.com', root: '$root' })
1762
+ await settleTurn()
1763
+ expect(agents.ensureSession).not.toHaveBeenCalled()
1764
+ })
1765
+
1766
+ it('rebuildThreadState reconstructs caller[] from the timeline after a restart', async () => {
1767
+ const client = {
1768
+ // root: human @mentions parent (no caller — human sender).
1769
+ fetchEvent: vi.fn(async () => ({
1770
+ type: 'm.room.message',
1771
+ sender: '@alice:example.com',
1772
+ content: { 'm.mentions': { user_ids: ['@parent:example.com'] } },
1773
+ })),
1774
+ // thread: parent @mentions sub (caller[sub]=parent), then sub replies bare.
1775
+ fetchThreadRelations: vi.fn(async () => ({
1776
+ chunk: [
1777
+ {
1778
+ type: 'm.room.message',
1779
+ sender: '@parent:example.com',
1780
+ content: { 'm.mentions': { user_ids: ['@sub:example.com'] } },
1781
+ },
1782
+ { type: 'm.room.message', sender: '@sub:example.com', content: { body: 'ack' } },
1783
+ ],
1784
+ })),
1785
+ }
1786
+ const state = await rebuildThreadState(client as never, '!r:example.com', '$root', parentSub)
1787
+ expect(state.callers).toEqual({ sub: 'parent' })
1788
+ expect(state.rootMentions).toEqual(['parent', 'sub'])
1789
+ expect(state.participants).toEqual(['parent', 'sub'])
1790
+ })
1791
+ })
package/src/transport.ts CHANGED
@@ -670,14 +670,16 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
670
670
  if (matches.length > 0 && promotedRoot) {
671
671
  let st = threadStates.get(promotedRoot)
672
672
  if (!st) {
673
- st = { participants: [], rootMentions: [] }
673
+ st = { participants: [], rootMentions: [], callers: {} }
674
674
  threadStates.set(promotedRoot, st)
675
675
  }
676
676
  const msgMentions = new Set(extractMentions(evt as never))
677
+ const senderAgent = bindings.find((b) => b.userId === evt.sender)
677
678
  for (const a of bindings) {
678
- if (msgMentions.has(a.userId) && !st.rootMentions.includes(a.name)) {
679
- st.rootMentions.push(a.name)
680
- }
679
+ if (!msgMentions.has(a.userId)) continue
680
+ if (!st.rootMentions.includes(a.name)) st.rootMentions.push(a.name)
681
+ // Call edge: the (agent) sender is the caller of every agent it @mentions.
682
+ if (senderAgent && a.name !== senderAgent.name) st.callers[a.name] = senderAgent.name
681
683
  }
682
684
  }
683
685
  for (const a of matches) {
@@ -687,7 +689,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
687
689
  if (!promotedRoot) return
688
690
  let st = threadStates.get(promotedRoot)
689
691
  if (!st) {
690
- st = { participants: [], rootMentions: [] }
692
+ st = { participants: [], rootMentions: [], callers: {} }
691
693
  threadStates.set(promotedRoot, st)
692
694
  }
693
695
  if (st.participants.at(-1) !== a.name) st.participants.push(a.name)
@@ -932,7 +934,7 @@ export async function rebuildThreadState(
932
934
  rootEventId: string,
933
935
  bindings: AgentBinding[],
934
936
  ): Promise<ThreadState> {
935
- const state: ThreadState = { participants: [], rootMentions: [] }
937
+ const state: ThreadState = { participants: [], rootMentions: [], callers: {} }
936
938
  // Impersonate an agent that's actually a member of this room (AS reads
937
939
  // require room membership). Falling through to the first binding would
938
940
  // 403 if that agent never joined the target room.
@@ -942,10 +944,12 @@ export async function rebuildThreadState(
942
944
  const root = await client.fetchEvent(roomId, rootEventId, asUser)
943
945
  if (root) {
944
946
  const rootMentions = new Set(extractMentions(root as never))
947
+ const rootSender = (root as { sender?: string }).sender
948
+ const rootSenderAgent = rootSender ? bindings.find((b) => b.userId === rootSender) : undefined
945
949
  for (const a of bindings) {
946
- if (rootMentions.has(a.userId) && !state.rootMentions.includes(a.name)) {
947
- state.rootMentions.push(a.name)
948
- }
950
+ if (!rootMentions.has(a.userId)) continue
951
+ if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name)
952
+ if (rootSenderAgent && a.name !== rootSenderAgent.name) state.callers[a.name] = rootSenderAgent.name
949
953
  }
950
954
  }
951
955
 
@@ -957,15 +961,16 @@ export async function rebuildThreadState(
957
961
  // Also seed root-mentions from any subsequent agent @mentions in the thread.
958
962
  for (const ev of thread) {
959
963
  const mentions = new Set(extractMentions(ev as never))
964
+ const evSender = (ev as { sender?: string }).sender
965
+ const evSenderAgent = evSender ? bindings.find((b) => b.userId === evSender) : undefined
960
966
  for (const a of bindings) {
961
- if (mentions.has(a.userId) && !state.rootMentions.includes(a.name)) {
962
- state.rootMentions.push(a.name)
963
- }
967
+ if (!mentions.has(a.userId)) continue
968
+ if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name)
969
+ if (evSenderAgent && a.name !== evSenderAgent.name) state.callers[a.name] = evSenderAgent.name
964
970
  }
965
- const sender = (ev as { sender?: string }).sender
966
971
  const type = (ev as { type?: string }).type
967
- if (type === 'm.room.message' && sender) {
968
- const a = bindings.find((b) => b.userId === sender)
972
+ if (type === 'm.room.message' && evSender) {
973
+ const a = bindings.find((b) => b.userId === evSender)
969
974
  if (a && state.participants.at(-1) !== a.name) state.participants.push(a.name)
970
975
  }
971
976
  }