@zooid/transport-matrix 0.9.1 → 0.11.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/dist/index.d.ts +8 -0
- package/dist/index.js +46 -29
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/bot-pool.test.ts +123 -0
- package/src/bot-pool.ts +20 -8
- package/src/matrix-client.test.ts +43 -0
- package/src/matrix-client.ts +8 -1
- package/src/router.test.ts +117 -1
- package/src/router.ts +25 -7
- package/src/transport.test.ts +107 -1
- package/src/transport.ts +20 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zooid/transport-matrix",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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.
|
|
32
|
-
"@zooid/core": "^0.
|
|
31
|
+
"@zooid/acp-client": "^0.11.0",
|
|
32
|
+
"@zooid/core": "^0.11.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@hono/node-server": "^1.13.0",
|
package/src/bot-pool.test.ts
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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(
|
package/src/matrix-client.ts
CHANGED
|
@@ -115,7 +115,14 @@ export class MatrixClient {
|
|
|
115
115
|
]
|
|
116
116
|
}
|
|
117
117
|
if (opts.userPowerLevels && Object.keys(opts.userPowerLevels).length > 0) {
|
|
118
|
-
|
|
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)}`,
|
package/src/router.test.ts
CHANGED
|
@@ -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
|
|
81
|
-
// inheritance if no agent has posted yet.
|
|
88
|
+
// Implicit trigger in a thread.
|
|
82
89
|
if (threadState) {
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
}
|
package/src/transport.test.ts
CHANGED
|
@@ -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)
|
|
679
|
-
|
|
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)
|
|
947
|
-
|
|
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)
|
|
962
|
-
|
|
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' &&
|
|
968
|
-
const a = bindings.find((b) => b.userId ===
|
|
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
|
}
|