@zooid/transport-matrix 0.7.0 → 0.7.2
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 +78 -4
- package/dist/index.js +194 -14
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/bot-pool.test.ts +200 -13
- package/src/bot-pool.ts +73 -5
- package/src/event-encoders.test.ts +86 -0
- package/src/event-encoders.ts +29 -0
- package/src/index.ts +2 -2
- package/src/matrix-client.test.ts +160 -0
- package/src/matrix-client.ts +64 -0
- package/src/router.test.ts +3 -3
- package/src/router.ts +11 -2
- package/src/space-provisioner.test.ts +191 -1
- package/src/space-provisioner.ts +77 -7
- package/src/transport.test.ts +58 -2
- package/src/transport.ts +72 -4
- package/src/workforce-publisher.test.ts +12 -2
- package/src/workforce-publisher.ts +5 -1
package/src/bot-pool.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { AgentBinding } from './router.js'
|
|
|
5
5
|
function fakeClient() {
|
|
6
6
|
return {
|
|
7
7
|
registerBot: vi.fn(async () => undefined),
|
|
8
|
+
invite: vi.fn(async () => undefined),
|
|
8
9
|
joinRoom: vi.fn(async () => undefined),
|
|
9
10
|
resolveAlias: vi.fn(async (_a: string) => '!existing:example.com' as string | null),
|
|
10
11
|
createRoom: vi.fn(async () => '!new:example.com'),
|
|
@@ -18,13 +19,13 @@ const agents: AgentBinding[] = [
|
|
|
18
19
|
{
|
|
19
20
|
name: 'architect',
|
|
20
21
|
userId: '@architect:example.com',
|
|
21
|
-
rooms: ['!r1:example.com', '!r2:example.com'],
|
|
22
|
+
rooms: [{ alias: '!r1:example.com' }, { alias: '!r2:example.com' }],
|
|
22
23
|
trigger: 'mention',
|
|
23
24
|
},
|
|
24
25
|
{
|
|
25
26
|
name: 'monitor',
|
|
26
27
|
userId: '@monitor:example.com',
|
|
27
|
-
rooms: ['!alerts:example.com'],
|
|
28
|
+
rooms: [{ alias: '!alerts:example.com' }],
|
|
28
29
|
trigger: 'any',
|
|
29
30
|
},
|
|
30
31
|
]
|
|
@@ -90,7 +91,12 @@ describe('BotPool.bootstrap — create-if-missing rooms', () => {
|
|
|
90
91
|
},
|
|
91
92
|
}
|
|
92
93
|
const pool = new BotPool(client as never, [
|
|
93
|
-
{
|
|
94
|
+
{
|
|
95
|
+
name: 'echo',
|
|
96
|
+
userId: '@echo:localhost',
|
|
97
|
+
rooms: [{ alias: '#welcome:localhost' }],
|
|
98
|
+
trigger: 'mention',
|
|
99
|
+
},
|
|
94
100
|
])
|
|
95
101
|
await pool.bootstrap({ adminUserId: '@admin:localhost' })
|
|
96
102
|
|
|
@@ -115,13 +121,18 @@ describe('BotPool.bootstrap — create-if-missing rooms', () => {
|
|
|
115
121
|
},
|
|
116
122
|
}
|
|
117
123
|
const pool = new BotPool(client as never, [
|
|
118
|
-
{
|
|
124
|
+
{
|
|
125
|
+
name: 'echo',
|
|
126
|
+
userId: '@echo:localhost',
|
|
127
|
+
rooms: [{ alias: '#welcome:localhost' }],
|
|
128
|
+
trigger: 'mention',
|
|
129
|
+
},
|
|
119
130
|
])
|
|
120
131
|
await pool.bootstrap({ adminUserId: '@admin:localhost' })
|
|
121
132
|
expect(calls).toEqual(['join:@echo:localhost->!existing:localhost'])
|
|
122
133
|
})
|
|
123
134
|
|
|
124
|
-
it(
|
|
135
|
+
it("rewrites the binding's rooms array with resolved IDs so the router can match", async () => {
|
|
125
136
|
const client = {
|
|
126
137
|
registerBot: async () => {},
|
|
127
138
|
setDisplayName: async () => {},
|
|
@@ -132,12 +143,12 @@ describe('BotPool.bootstrap — create-if-missing rooms', () => {
|
|
|
132
143
|
const binding: AgentBinding = {
|
|
133
144
|
name: 'echo',
|
|
134
145
|
userId: '@echo:localhost',
|
|
135
|
-
rooms: ['#welcome:localhost'],
|
|
146
|
+
rooms: [{ alias: '#welcome:localhost' }],
|
|
136
147
|
trigger: 'mention',
|
|
137
148
|
}
|
|
138
149
|
const pool = new BotPool(client as never, [binding])
|
|
139
150
|
await pool.bootstrap({ adminUserId: '@admin:localhost' })
|
|
140
|
-
expect(binding.rooms).toEqual(['!resolved:localhost'])
|
|
151
|
+
expect(binding.rooms).toEqual([{ alias: '!resolved:localhost' }])
|
|
141
152
|
})
|
|
142
153
|
|
|
143
154
|
it('passes through room IDs (starting with !) without resolving or creating', async () => {
|
|
@@ -156,7 +167,12 @@ describe('BotPool.bootstrap — create-if-missing rooms', () => {
|
|
|
156
167
|
},
|
|
157
168
|
}
|
|
158
169
|
const pool = new BotPool(client as never, [
|
|
159
|
-
{
|
|
170
|
+
{
|
|
171
|
+
name: 'echo',
|
|
172
|
+
userId: '@echo:localhost',
|
|
173
|
+
rooms: [{ alias: '!abc:localhost' }],
|
|
174
|
+
trigger: 'mention',
|
|
175
|
+
},
|
|
160
176
|
])
|
|
161
177
|
await pool.bootstrap({ adminUserId: '@admin:localhost' })
|
|
162
178
|
expect(calls).toEqual(['join:@echo:localhost->!abc:localhost'])
|
|
@@ -176,6 +192,7 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
|
|
|
176
192
|
const pool = new BotPool(
|
|
177
193
|
{
|
|
178
194
|
registerBot,
|
|
195
|
+
invite: vi.fn(async () => undefined),
|
|
179
196
|
joinRoom,
|
|
180
197
|
resolveAlias,
|
|
181
198
|
createRoom,
|
|
@@ -186,7 +203,7 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
|
|
|
186
203
|
{
|
|
187
204
|
name: 'planner',
|
|
188
205
|
userId: '@planner:zoon.local',
|
|
189
|
-
rooms: ['#welcome:zoon.local'],
|
|
206
|
+
rooms: [{ alias: '#welcome:zoon.local' }],
|
|
190
207
|
trigger: 'mention',
|
|
191
208
|
},
|
|
192
209
|
],
|
|
@@ -209,11 +226,39 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
|
|
|
209
226
|
)
|
|
210
227
|
})
|
|
211
228
|
|
|
229
|
+
it('creates agent rooms restricted to the workforce space when spaceRoomId is set', async () => {
|
|
230
|
+
const createRoom = vi.fn(async () => '!created:zoon.local')
|
|
231
|
+
const pool = new BotPool(
|
|
232
|
+
{
|
|
233
|
+
registerBot: vi.fn(async () => undefined),
|
|
234
|
+
invite: vi.fn(async () => undefined),
|
|
235
|
+
joinRoom: vi.fn(async () => undefined),
|
|
236
|
+
resolveAlias: vi.fn(async () => null), // alias unknown → must create
|
|
237
|
+
createRoom,
|
|
238
|
+
sendStateEvent: vi.fn(async () => ({ event_id: '$ev' })),
|
|
239
|
+
setDisplayName: vi.fn(async () => undefined),
|
|
240
|
+
} as never,
|
|
241
|
+
[
|
|
242
|
+
{
|
|
243
|
+
name: 'planner',
|
|
244
|
+
userId: '@planner:zoon.local',
|
|
245
|
+
rooms: [{ alias: '#design:zoon.local' }],
|
|
246
|
+
trigger: 'mention',
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
)
|
|
250
|
+
await pool.bootstrap({ spaceRoomId: '!space:zoon.local', asUserId: '@zooid:zoon.local' })
|
|
251
|
+
expect(createRoom).toHaveBeenCalledWith(
|
|
252
|
+
expect.objectContaining({ roomAliasName: 'design', restrictedToSpaceId: '!space:zoon.local' }),
|
|
253
|
+
)
|
|
254
|
+
})
|
|
255
|
+
|
|
212
256
|
it('does nothing extra when spaceRoomId is omitted', async () => {
|
|
213
257
|
const sendStateEvent = vi.fn(async () => ({ event_id: '$ev' }))
|
|
214
258
|
const pool = new BotPool(
|
|
215
259
|
{
|
|
216
260
|
registerBot: vi.fn(async () => undefined),
|
|
261
|
+
invite: vi.fn(async () => undefined),
|
|
217
262
|
joinRoom: vi.fn(async () => undefined),
|
|
218
263
|
resolveAlias: vi.fn(async () => '!r:zoon.local'),
|
|
219
264
|
createRoom: vi.fn(async () => '!r:zoon.local'),
|
|
@@ -224,7 +269,7 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
|
|
|
224
269
|
{
|
|
225
270
|
name: 'planner',
|
|
226
271
|
userId: '@planner:zoon.local',
|
|
227
|
-
rooms: ['#r:zoon.local'],
|
|
272
|
+
rooms: [{ alias: '#r:zoon.local' }],
|
|
228
273
|
trigger: 'mention',
|
|
229
274
|
},
|
|
230
275
|
],
|
|
@@ -238,6 +283,7 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
|
|
|
238
283
|
const pool = new BotPool(
|
|
239
284
|
{
|
|
240
285
|
registerBot: vi.fn(async () => undefined),
|
|
286
|
+
invite: vi.fn(async () => undefined),
|
|
241
287
|
joinRoom: vi.fn(async () => undefined),
|
|
242
288
|
resolveAlias: vi.fn(async () => '!shared:zoon.local'),
|
|
243
289
|
createRoom: vi.fn(async () => '!shared:zoon.local'),
|
|
@@ -245,8 +291,8 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
|
|
|
245
291
|
setDisplayName: vi.fn(async () => undefined),
|
|
246
292
|
} as never,
|
|
247
293
|
[
|
|
248
|
-
{ name: 'a', userId: '@a:zoon.local', rooms: ['#shared:zoon.local'], trigger: 'any' },
|
|
249
|
-
{ name: 'b', userId: '@b:zoon.local', rooms: ['#shared:zoon.local'], trigger: 'any' },
|
|
294
|
+
{ name: 'a', userId: '@a:zoon.local', rooms: [{ alias: '#shared:zoon.local' }], trigger: 'any' },
|
|
295
|
+
{ name: 'b', userId: '@b:zoon.local', rooms: [{ alias: '#shared:zoon.local' }], trigger: 'any' },
|
|
250
296
|
],
|
|
251
297
|
)
|
|
252
298
|
await pool.bootstrap({ spaceRoomId: '!space:zoon.local', asUserId: '@zooid:zoon.local' })
|
|
@@ -257,6 +303,142 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
|
|
|
257
303
|
})
|
|
258
304
|
})
|
|
259
305
|
|
|
306
|
+
describe('BotPool.bootstrap agent space membership', () => {
|
|
307
|
+
it('invites the agent to the space (via the AS bot) then joins it as the agent', async () => {
|
|
308
|
+
const invite = vi.fn(async () => undefined)
|
|
309
|
+
const joinRoom = vi.fn(async () => undefined)
|
|
310
|
+
const pool = new BotPool(
|
|
311
|
+
{
|
|
312
|
+
registerBot: vi.fn(async () => undefined),
|
|
313
|
+
invite,
|
|
314
|
+
joinRoom,
|
|
315
|
+
resolveAlias: vi.fn(async () => '!r:zoon.local'),
|
|
316
|
+
createRoom: vi.fn(async () => '!r:zoon.local'),
|
|
317
|
+
sendStateEvent: vi.fn(async () => ({ event_id: '$ev' })),
|
|
318
|
+
setDisplayName: vi.fn(async () => undefined),
|
|
319
|
+
} as never,
|
|
320
|
+
[
|
|
321
|
+
{
|
|
322
|
+
name: 'planner',
|
|
323
|
+
userId: '@planner:zoon.local',
|
|
324
|
+
rooms: [{ alias: '#r:zoon.local' }],
|
|
325
|
+
trigger: 'mention',
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
)
|
|
329
|
+
await pool.bootstrap({
|
|
330
|
+
spaceRoomId: '!space:zoon.local',
|
|
331
|
+
asUserId: '@zooid:zoon.local',
|
|
332
|
+
})
|
|
333
|
+
expect(invite).toHaveBeenCalledWith({
|
|
334
|
+
roomId: '!space:zoon.local',
|
|
335
|
+
asUserId: '@zooid:zoon.local',
|
|
336
|
+
targetUserId: '@planner:zoon.local',
|
|
337
|
+
})
|
|
338
|
+
expect(joinRoom).toHaveBeenCalledWith('!space:zoon.local', '@planner:zoon.local')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('skips space invite+join when spaceRoomId is not provided', async () => {
|
|
342
|
+
const invite = vi.fn(async () => undefined)
|
|
343
|
+
const pool = new BotPool(
|
|
344
|
+
{
|
|
345
|
+
registerBot: vi.fn(async () => undefined),
|
|
346
|
+
invite,
|
|
347
|
+
joinRoom: vi.fn(async () => undefined),
|
|
348
|
+
resolveAlias: vi.fn(async () => '!r:zoon.local'),
|
|
349
|
+
createRoom: vi.fn(async () => '!r:zoon.local'),
|
|
350
|
+
sendStateEvent: vi.fn(async () => ({ event_id: '$ev' })),
|
|
351
|
+
setDisplayName: vi.fn(async () => undefined),
|
|
352
|
+
} as never,
|
|
353
|
+
[
|
|
354
|
+
{
|
|
355
|
+
name: 'planner',
|
|
356
|
+
userId: '@planner:zoon.local',
|
|
357
|
+
rooms: [{ alias: '#r:zoon.local' }],
|
|
358
|
+
trigger: 'mention',
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
)
|
|
362
|
+
await pool.bootstrap({})
|
|
363
|
+
expect(invite).not.toHaveBeenCalled()
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
describe('BotPool.bootstrap creation-time power levels', () => {
|
|
368
|
+
it('merges admin (PL 100) and per-agent declared PLs into createRoom userPowerLevels', async () => {
|
|
369
|
+
const createRoom = vi.fn(async () => '!created:zoon.local')
|
|
370
|
+
const pool = new BotPool(
|
|
371
|
+
{
|
|
372
|
+
registerBot: vi.fn(async () => undefined),
|
|
373
|
+
invite: vi.fn(async () => undefined),
|
|
374
|
+
joinRoom: vi.fn(async () => undefined),
|
|
375
|
+
resolveAlias: vi.fn(async () => null), // force create
|
|
376
|
+
createRoom,
|
|
377
|
+
sendStateEvent: vi.fn(async () => ({ event_id: '$ev' })),
|
|
378
|
+
setDisplayName: vi.fn(async () => undefined),
|
|
379
|
+
} as never,
|
|
380
|
+
[
|
|
381
|
+
{
|
|
382
|
+
name: 'planner',
|
|
383
|
+
userId: '@planner:zoon.local',
|
|
384
|
+
rooms: [{ alias: '#design:zoon.local' }], // default PL
|
|
385
|
+
trigger: 'mention',
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: 'mod',
|
|
389
|
+
userId: '@mod:zoon.local',
|
|
390
|
+
rooms: [{ alias: '#design:zoon.local', powerLevel: 50 }], // moderator
|
|
391
|
+
trigger: 'mention',
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
)
|
|
395
|
+
await pool.bootstrap({
|
|
396
|
+
asUserId: '@zooid:zoon.local',
|
|
397
|
+
spaceRoomId: '!space:zoon.local',
|
|
398
|
+
adminUserIds: ['@admin:zoon.local'],
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
// First createRoom carries the merged map:
|
|
402
|
+
// bot @ 100, admin @ 100, mod @ 50 (planner has no declared PL → absent)
|
|
403
|
+
const firstCall = createRoom.mock.calls[0]?.[0] as {
|
|
404
|
+
userPowerLevels?: Record<string, number>
|
|
405
|
+
}
|
|
406
|
+
expect(firstCall.userPowerLevels).toEqual({
|
|
407
|
+
'@zooid:zoon.local': 100,
|
|
408
|
+
'@admin:zoon.local': 100,
|
|
409
|
+
'@mod:zoon.local': 50,
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('omits userPowerLevels when no admin and no agent declares a PL for the room', async () => {
|
|
414
|
+
const createRoom = vi.fn(async () => '!r:zoon.local')
|
|
415
|
+
const pool = new BotPool(
|
|
416
|
+
{
|
|
417
|
+
registerBot: vi.fn(async () => undefined),
|
|
418
|
+
invite: vi.fn(async () => undefined),
|
|
419
|
+
joinRoom: vi.fn(async () => undefined),
|
|
420
|
+
resolveAlias: vi.fn(async () => null),
|
|
421
|
+
createRoom,
|
|
422
|
+
sendStateEvent: vi.fn(async () => ({ event_id: '$ev' })),
|
|
423
|
+
setDisplayName: vi.fn(async () => undefined),
|
|
424
|
+
} as never,
|
|
425
|
+
[
|
|
426
|
+
{
|
|
427
|
+
name: 'plain',
|
|
428
|
+
userId: '@plain:zoon.local',
|
|
429
|
+
rooms: [{ alias: '#plain:zoon.local' }],
|
|
430
|
+
trigger: 'mention',
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
)
|
|
434
|
+
await pool.bootstrap({ spaceRoomId: '!space:zoon.local' })
|
|
435
|
+
const opts = createRoom.mock.calls[0]?.[0] as {
|
|
436
|
+
userPowerLevels?: Record<string, number>
|
|
437
|
+
}
|
|
438
|
+
expect(opts.userPowerLevels).toBeUndefined()
|
|
439
|
+
})
|
|
440
|
+
})
|
|
441
|
+
|
|
260
442
|
describe('BotPool.bootstrap — display name', () => {
|
|
261
443
|
it('falls back to the user_id localpart when no displayName is supplied', async () => {
|
|
262
444
|
const client = fakeClient()
|
|
@@ -297,7 +479,12 @@ describe('BotPool.bootstrap — display name', () => {
|
|
|
297
479
|
setDisplayName: vi.fn(async () => undefined),
|
|
298
480
|
}
|
|
299
481
|
const pool = new BotPool(client as never, [
|
|
300
|
-
{
|
|
482
|
+
{
|
|
483
|
+
name: 'echo',
|
|
484
|
+
userId: '@echo:localhost',
|
|
485
|
+
rooms: [{ alias: '#welcome:localhost' }],
|
|
486
|
+
trigger: 'mention',
|
|
487
|
+
},
|
|
301
488
|
])
|
|
302
489
|
await pool.bootstrap({ adminUserId: '@admin:localhost' })
|
|
303
490
|
expect(createRoom).toHaveBeenCalledWith(
|
package/src/bot-pool.ts
CHANGED
|
@@ -9,13 +9,25 @@ export interface BootstrapOpts {
|
|
|
9
9
|
spaceRoomId?: string
|
|
10
10
|
/** AS bot user ID. Required when spaceRoomId is set; sender of the m.space.child write. */
|
|
11
11
|
asUserId?: string
|
|
12
|
+
/**
|
|
13
|
+
* Operator MXIDs seeded at PL 100 in every agent room this pool creates.
|
|
14
|
+
* Applied via `power_level_content_override.users` at room creation only —
|
|
15
|
+
* never reconciled. Empty/absent = no operator entries.
|
|
16
|
+
*/
|
|
17
|
+
adminUserIds?: string[]
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
export class BotPool {
|
|
15
21
|
constructor(
|
|
16
22
|
private readonly client: Pick<
|
|
17
23
|
MatrixClient,
|
|
18
|
-
|
|
24
|
+
| 'registerBot'
|
|
25
|
+
| 'invite'
|
|
26
|
+
| 'joinRoom'
|
|
27
|
+
| 'resolveAlias'
|
|
28
|
+
| 'createRoom'
|
|
29
|
+
| 'sendStateEvent'
|
|
30
|
+
| 'setDisplayName'
|
|
19
31
|
>,
|
|
20
32
|
private readonly agents: AgentBinding[],
|
|
21
33
|
) {}
|
|
@@ -35,8 +47,28 @@ export class BotPool {
|
|
|
35
47
|
} catch (err) {
|
|
36
48
|
console.warn(`[matrix] setDisplayName(${a.userId}) failed: ${(err as Error).message}`)
|
|
37
49
|
}
|
|
50
|
+
// Make each agent a member of the workforce space. That covers two
|
|
51
|
+
// things at once: the eco.zoon.workforce roster is now backed by
|
|
52
|
+
// actual space membership (so the Zoon client's member autocomplete
|
|
53
|
+
// works across rooms), and every restricted child room's allow rule
|
|
54
|
+
// is satisfied without per-room invites.
|
|
55
|
+
if (opts.spaceRoomId && opts.asUserId) {
|
|
56
|
+
try {
|
|
57
|
+
await this.client.invite({
|
|
58
|
+
roomId: opts.spaceRoomId,
|
|
59
|
+
asUserId: opts.asUserId,
|
|
60
|
+
targetUserId: a.userId,
|
|
61
|
+
})
|
|
62
|
+
await this.client.joinRoom(opts.spaceRoomId, a.userId)
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.warn(
|
|
65
|
+
`[matrix] space membership for ${a.userId} failed: ${(err as Error).message}`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
38
69
|
for (let i = 0; i < a.rooms.length; i++) {
|
|
39
|
-
const
|
|
70
|
+
const binding = a.rooms[i]!
|
|
71
|
+
const room = binding.alias
|
|
40
72
|
try {
|
|
41
73
|
let resolved = room
|
|
42
74
|
if (room.startsWith('#')) {
|
|
@@ -51,19 +83,29 @@ export class BotPool {
|
|
|
51
83
|
const colon = room.indexOf(':')
|
|
52
84
|
const aliasLocalpart = colon > 1 ? room.slice(1, colon) : room.slice(1)
|
|
53
85
|
const sender = opts.adminUserId ?? a.userId
|
|
86
|
+
const userPowerLevels = buildUserPowerLevels(
|
|
87
|
+
opts.asUserId,
|
|
88
|
+
opts.adminUserIds,
|
|
89
|
+
this.agents,
|
|
90
|
+
room,
|
|
91
|
+
)
|
|
54
92
|
resolved = await this.client.createRoom({
|
|
55
93
|
roomAliasName: aliasLocalpart,
|
|
56
94
|
invite: opts.adminUserId ? [opts.adminUserId] : [],
|
|
57
95
|
senderUserId: sender,
|
|
58
96
|
name: aliasLocalpart,
|
|
97
|
+
...(opts.spaceRoomId ? { restrictedToSpaceId: opts.spaceRoomId } : {}),
|
|
98
|
+
...(userPowerLevels ? { userPowerLevels } : {}),
|
|
59
99
|
})
|
|
60
100
|
}
|
|
61
101
|
aliasToId.set(room, resolved)
|
|
62
102
|
}
|
|
63
103
|
}
|
|
64
|
-
//
|
|
65
|
-
// matches on event.room_id) sees a hit when Tuwunel
|
|
66
|
-
|
|
104
|
+
// Rewrite the binding's alias to the canonical room ID so the
|
|
105
|
+
// router (which matches on event.room_id) sees a hit when Tuwunel
|
|
106
|
+
// pushes events. The declared powerLevel was a creation-time
|
|
107
|
+
// seed — it has no role after bootstrap.
|
|
108
|
+
binding.alias = resolved
|
|
67
109
|
await this.client.joinRoom(resolved, a.userId)
|
|
68
110
|
|
|
69
111
|
if (
|
|
@@ -110,3 +152,29 @@ function localpart(userId: string): string {
|
|
|
110
152
|
if (!m) throw new Error(`bad user id: ${userId}`)
|
|
111
153
|
return m[1]
|
|
112
154
|
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build the `userPowerLevels` map for a room about to be created. Always
|
|
158
|
+
* seeds the AS bot (when known) and any operator admins at PL 100; layers
|
|
159
|
+
* per-agent declared PLs on top so agents land at moderator (or whatever
|
|
160
|
+
* they declared) without a follow-up state write. Returns undefined when
|
|
161
|
+
* the map would be empty — `createRoom` then omits the override entirely
|
|
162
|
+
* and the preset's defaults apply.
|
|
163
|
+
*/
|
|
164
|
+
function buildUserPowerLevels(
|
|
165
|
+
asUserId: string | undefined,
|
|
166
|
+
admins: string[] | undefined,
|
|
167
|
+
agents: AgentBinding[],
|
|
168
|
+
roomAlias: string,
|
|
169
|
+
): Record<string, number> | undefined {
|
|
170
|
+
const users: Record<string, number> = {}
|
|
171
|
+
if (asUserId) users[asUserId] = 100
|
|
172
|
+
if (admins) for (const a of admins) users[a] = 100
|
|
173
|
+
for (const a of agents) {
|
|
174
|
+
for (const r of a.rooms) {
|
|
175
|
+
if (r.alias !== roomAlias) continue
|
|
176
|
+
if (r.powerLevel !== undefined) users[a.userId] = r.powerLevel
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return Object.keys(users).length > 0 ? users : undefined
|
|
180
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
toToolCallBody,
|
|
9
9
|
toUpdateBody,
|
|
10
10
|
toPlanBody,
|
|
11
|
+
toErrorBody,
|
|
11
12
|
} from './event-encoders.js'
|
|
12
13
|
|
|
13
14
|
describe('toToolCallBody', () => {
|
|
@@ -122,3 +123,88 @@ describe('toPlanBody', () => {
|
|
|
122
123
|
})
|
|
123
124
|
})
|
|
124
125
|
})
|
|
126
|
+
|
|
127
|
+
describe('toErrorBody', () => {
|
|
128
|
+
const threadRoot = '$root-event-id'
|
|
129
|
+
|
|
130
|
+
it('encodes a full error TapEvent including acp_error and recovery URL', () => {
|
|
131
|
+
const body = toErrorBody(
|
|
132
|
+
{
|
|
133
|
+
kind: 'error',
|
|
134
|
+
agentId: 'alice',
|
|
135
|
+
sessionId: 'sess-1',
|
|
136
|
+
turnId: 'turn-1',
|
|
137
|
+
code: 'auth_missing',
|
|
138
|
+
message: 'Authentication required',
|
|
139
|
+
detail: 'claude-agent-acp returned RequestError on session/prompt',
|
|
140
|
+
transient: false,
|
|
141
|
+
acp_error: { code: -32000, message: 'Authentication required' },
|
|
142
|
+
},
|
|
143
|
+
threadRoot,
|
|
144
|
+
)
|
|
145
|
+
expect(body).toMatchObject({
|
|
146
|
+
msgtype: 'm.notice',
|
|
147
|
+
body: '⚠ [auth_missing] Authentication required',
|
|
148
|
+
session_id: 'sess-1',
|
|
149
|
+
turn_id: 'turn-1',
|
|
150
|
+
code: 'auth_missing',
|
|
151
|
+
message: 'Authentication required',
|
|
152
|
+
detail: 'claude-agent-acp returned RequestError on session/prompt',
|
|
153
|
+
transient: false,
|
|
154
|
+
acp_error: { code: -32000, message: 'Authentication required' },
|
|
155
|
+
'm.relates_to': { rel_type: 'm.thread', event_id: '$root-event-id' },
|
|
156
|
+
})
|
|
157
|
+
expect(body.recovery).toMatch(/^https:\/\/zooid\.dev\/docs\//)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('truncates message to 250 chars and detail to 2000 chars', () => {
|
|
161
|
+
const body = toErrorBody(
|
|
162
|
+
{
|
|
163
|
+
kind: 'error',
|
|
164
|
+
agentId: 'a',
|
|
165
|
+
sessionId: 's',
|
|
166
|
+
turnId: 't',
|
|
167
|
+
code: 'internal',
|
|
168
|
+
message: 'x'.repeat(500),
|
|
169
|
+
detail: 'y'.repeat(5000),
|
|
170
|
+
transient: false,
|
|
171
|
+
},
|
|
172
|
+
threadRoot,
|
|
173
|
+
)
|
|
174
|
+
expect((body.message as string).length).toBe(250)
|
|
175
|
+
expect((body.detail as string).length).toBe(2000)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('omits turn_id when null and omits acp_error when undefined', () => {
|
|
179
|
+
const body = toErrorBody(
|
|
180
|
+
{
|
|
181
|
+
kind: 'error',
|
|
182
|
+
agentId: 'a',
|
|
183
|
+
sessionId: 's',
|
|
184
|
+
turnId: null,
|
|
185
|
+
code: 'container_exit',
|
|
186
|
+
message: 'Container exited',
|
|
187
|
+
transient: true,
|
|
188
|
+
},
|
|
189
|
+
threadRoot,
|
|
190
|
+
)
|
|
191
|
+
expect(body.turn_id).toBeUndefined()
|
|
192
|
+
expect(body.acp_error).toBeUndefined()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('omits session_id when null (failure preceded session/new)', () => {
|
|
196
|
+
const body = toErrorBody(
|
|
197
|
+
{
|
|
198
|
+
kind: 'error',
|
|
199
|
+
agentId: 'a',
|
|
200
|
+
sessionId: null,
|
|
201
|
+
turnId: null,
|
|
202
|
+
code: 'image_pull_failed',
|
|
203
|
+
message: 'pull failed',
|
|
204
|
+
transient: true,
|
|
205
|
+
},
|
|
206
|
+
threadRoot,
|
|
207
|
+
)
|
|
208
|
+
expect(body.session_id).toBeUndefined()
|
|
209
|
+
})
|
|
210
|
+
})
|
package/src/event-encoders.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
PlanEvent,
|
|
3
|
+
TapEvent,
|
|
3
4
|
ToolCallEvent,
|
|
4
5
|
ToolCallUpdateEvent,
|
|
5
6
|
} from '@zooid/acp-client'
|
|
@@ -64,3 +65,31 @@ export function toPlanBody(evt: PlanEvent): Record<string, unknown> {
|
|
|
64
65
|
entries: evt.entries,
|
|
65
66
|
}
|
|
66
67
|
}
|
|
68
|
+
|
|
69
|
+
const RECOVERY_URLS: Partial<Record<string, string>> = {
|
|
70
|
+
auth_missing: 'https://zooid.dev/docs/guides/run-in-container#authentication-that-carries-over',
|
|
71
|
+
auth_invalid: 'https://zooid.dev/docs/guides/run-in-container#authentication-that-carries-over',
|
|
72
|
+
mount_failed: 'https://zooid.dev/docs/guides/run-in-container#what-you-get-for-free',
|
|
73
|
+
image_pull_failed: 'https://zooid.dev/docs/guides/run-in-container#skipping-the-image-prepull',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type ErrorTap = Extract<TapEvent, { kind: 'error' }>
|
|
77
|
+
|
|
78
|
+
export function toErrorBody(evt: ErrorTap, threadRoot: string): Record<string, unknown> {
|
|
79
|
+
const msg = evt.message.slice(0, 250)
|
|
80
|
+
const out: Record<string, unknown> = {
|
|
81
|
+
msgtype: 'm.notice',
|
|
82
|
+
body: `⚠ [${evt.code}] ${msg}`,
|
|
83
|
+
code: evt.code,
|
|
84
|
+
message: msg,
|
|
85
|
+
transient: evt.transient,
|
|
86
|
+
'm.relates_to': { rel_type: 'm.thread', event_id: threadRoot },
|
|
87
|
+
}
|
|
88
|
+
if (evt.sessionId) out.session_id = evt.sessionId
|
|
89
|
+
if (evt.turnId) out.turn_id = evt.turnId
|
|
90
|
+
if (evt.detail) out.detail = evt.detail.slice(0, 2000)
|
|
91
|
+
if (evt.acp_error) out.acp_error = evt.acp_error
|
|
92
|
+
const recovery = RECOVERY_URLS[evt.code]
|
|
93
|
+
if (recovery) out.recovery = recovery
|
|
94
|
+
return out
|
|
95
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,8 +11,8 @@ export type { AgentBinding, RouteMatch } from './router.js'
|
|
|
11
11
|
export { BotPool } from './bot-pool.js'
|
|
12
12
|
export { createMatrixTransport } from './transport.js'
|
|
13
13
|
export type { CreateMatrixTransportOptions } from './transport.js'
|
|
14
|
-
export { ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
|
|
15
|
-
export type { EnsureSpaceOpts } from './space-provisioner.js'
|
|
14
|
+
export { ensureDefaultChannel, ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
|
|
15
|
+
export type { EnsureDefaultChannelOpts, EnsureSpaceOpts } from './space-provisioner.js'
|
|
16
16
|
export {
|
|
17
17
|
buildWorkforceRoster,
|
|
18
18
|
publishWorkforce,
|