@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/dist/index.d.ts +85 -1
- package/dist/index.js +312 -197
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/bot-pool.test.ts +130 -1
- package/src/bot-pool.ts +27 -9
- package/src/identity.test.ts +52 -0
- package/src/identity.ts +27 -0
- package/src/index.ts +11 -0
- package/src/matrix-client.test.ts +66 -0
- package/src/matrix-client.ts +40 -1
- package/src/registration.test.ts +20 -0
- package/src/router.test.ts +117 -1
- package/src/router.ts +25 -7
- package/src/sync-loop.test.ts +113 -0
- package/src/sync-loop.ts +74 -0
- package/src/transport.test.ts +107 -1
- package/src/transport.ts +290 -251
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zooid/transport-matrix",
|
|
3
|
-
"version": "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/
|
|
32
|
-
"@zooid/
|
|
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",
|
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 = {
|
|
@@ -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({
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
})
|
package/src/identity.ts
ADDED
|
@@ -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 }) => {
|
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)}`,
|
|
@@ -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)}` +
|
package/src/registration.test.ts
CHANGED
|
@@ -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
|
+
})
|
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
|
}
|