@zooid/transport-matrix 0.7.1 → 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.
@@ -80,6 +80,20 @@ export class MatrixClient {
80
80
  /** Optional `m.room.name`. When set, sent in the createRoom body so the
81
81
  * room has a display name from the moment it exists. */
82
82
  name?: string
83
+ /** When set, the room is created with a `restricted` join rule whose allow
84
+ * condition references this space room ID — i.e. joinable by space members
85
+ * only, rather than the whole homeserver. */
86
+ restrictedToSpaceId?: string
87
+ /** Explicit room version. Restricted join rules require v8+; omit to use the
88
+ * homeserver default (modern Tuwunel defaults to v10/v11). */
89
+ roomVersion?: string
90
+ /**
91
+ * Seeds `m.room.power_levels.users` at creation via
92
+ * `power_level_content_override.users`. The caller owns the full map —
93
+ * typically the AS bot at 100, plus operator and any agents with
94
+ * declared PLs. Empty/absent → no override (the preset's defaults apply).
95
+ */
96
+ userPowerLevels?: Record<string, number>
83
97
  }): Promise<string> {
84
98
  const body: Record<string, unknown> = {
85
99
  room_alias_name: opts.roomAliasName,
@@ -87,6 +101,22 @@ export class MatrixClient {
87
101
  preset: opts.preset ?? 'public_chat',
88
102
  }
89
103
  if (opts.name !== undefined) body.name = opts.name
104
+ if (opts.roomVersion !== undefined) body.room_version = opts.roomVersion
105
+ if (opts.restrictedToSpaceId !== undefined) {
106
+ body.initial_state = [
107
+ {
108
+ type: 'm.room.join_rules',
109
+ state_key: '',
110
+ content: {
111
+ join_rule: 'restricted',
112
+ allow: [{ type: 'm.room_membership', room_id: opts.restrictedToSpaceId }],
113
+ },
114
+ },
115
+ ]
116
+ }
117
+ if (opts.userPowerLevels && Object.keys(opts.userPowerLevels).length > 0) {
118
+ body.power_level_content_override = { users: opts.userPowerLevels }
119
+ }
90
120
  const r = await this.fetch(
91
121
  `${this.homeserver}/_matrix/client/v3/createRoom?user_id=${encodeURIComponent(opts.senderUserId)}`,
92
122
  {
@@ -147,6 +177,40 @@ export class MatrixClient {
147
177
  return (await r.json()) as { event_id: string }
148
178
  }
149
179
 
180
+ /**
181
+ * Invite a user to a room. Sent as the inviter (`asUserId`) — that user
182
+ * needs invite power in the room. Tolerates the "already in room /
183
+ * already invited" responses idempotently so bootstrap can run on a
184
+ * fresh AND a populated homeserver without branching.
185
+ */
186
+ async invite(opts: {
187
+ roomId: string
188
+ asUserId: string
189
+ targetUserId: string
190
+ }): Promise<void> {
191
+ const url =
192
+ `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/invite` +
193
+ `?user_id=${encodeURIComponent(opts.asUserId)}`
194
+ const r = await this.fetch(url, {
195
+ method: 'POST',
196
+ headers: {
197
+ Authorization: `Bearer ${this.asToken}`,
198
+ 'content-type': 'application/json',
199
+ },
200
+ body: JSON.stringify({ user_id: opts.targetUserId }),
201
+ })
202
+ if (r.ok) return
203
+ if (r.status === 403) {
204
+ // Tuwunel/Synapse use M_FORBIDDEN both for permission errors AND for
205
+ // "already a member / already invited". Inspect the body so we only
206
+ // swallow the idempotent case.
207
+ const body = await r.text()
208
+ if (/already (in the room|invited|a member|joined)/i.test(body)) return
209
+ throw new Error(`invite(${opts.targetUserId}) failed: 403 ${body}`)
210
+ }
211
+ throw new Error(`invite(${opts.targetUserId}) failed: ${r.status}`)
212
+ }
213
+
150
214
  async joinRoom(roomIdOrAlias: string, asUserId: string): Promise<void> {
151
215
  const url =
152
216
  `${this.homeserver}/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}` +
@@ -5,13 +5,13 @@ const agents: AgentBinding[] = [
5
5
  {
6
6
  name: 'architect',
7
7
  userId: '@architect:example.com',
8
- rooms: ['!room1:example.com'],
8
+ rooms: [{ alias: '!room1:example.com' }],
9
9
  trigger: 'mention',
10
10
  },
11
11
  {
12
12
  name: 'monitor',
13
13
  userId: '@monitor:example.com',
14
- rooms: ['!alerts:example.com'],
14
+ rooms: [{ alias: '!alerts:example.com' }],
15
15
  trigger: 'any',
16
16
  },
17
17
  ]
@@ -67,7 +67,7 @@ describe('route', () => {
67
67
  {
68
68
  name: 'qa',
69
69
  userId: '@qa:example.com',
70
- rooms: ['!room1:example.com'],
70
+ rooms: [{ alias: '!room1:example.com' }],
71
71
  trigger: 'mention',
72
72
  },
73
73
  ]
package/src/router.ts CHANGED
@@ -1,11 +1,20 @@
1
+ import type { RoomBinding } from '@zooid/core'
1
2
  import { extractMentions } from './mentions.js'
2
3
 
4
+ export type { RoomBinding }
5
+
3
6
  export interface AgentBinding {
4
7
  name: string
5
8
  userId: string
6
9
  /** Optional human-readable display name. Falls back to the user_id localpart. */
7
10
  displayName?: string
8
- rooms: string[]
11
+ /**
12
+ * Rooms this agent is bound to. Each entry's `alias` starts out as the
13
+ * configured `#alias` (or `!id`) and is rewritten to the canonical room
14
+ * ID by `BotPool.bootstrap`. Optional `powerLevel` is seeded into the
15
+ * room's `m.room.power_levels.users` at room creation only.
16
+ */
17
+ rooms: RoomBinding[]
9
18
  trigger: 'mention' | 'any'
10
19
  }
11
20
 
@@ -47,7 +56,7 @@ export function route(
47
56
 
48
57
  for (const a of agents) {
49
58
  if (event.sender === a.userId) continue
50
- if (!a.rooms.includes(event.room_id ?? '')) continue
59
+ if (!a.rooms.some((r) => r.alias === event.room_id)) continue
51
60
  if (a.trigger === 'any') {
52
61
  matches.push(a)
53
62
  continue
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
2
  import { MatrixClient } from './matrix-client.js'
3
- import { ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
3
+ import { ensureDefaultChannel, ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
4
4
 
5
5
  function clientWithFetches(...handlers: Array<(url: string, init?: RequestInit) => Response>) {
6
6
  let i = 0
@@ -74,6 +74,196 @@ describe('ensureWorkforceSpace', () => {
74
74
  })
75
75
  })
76
76
 
77
+ describe('ensureWorkforceSpace privacy', () => {
78
+ it('creates the space invite-only (overriding any public preset)', async () => {
79
+ const { client } = clientWithFetches(
80
+ () => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
81
+ (url, init) => {
82
+ expect(url).toContain('/_matrix/client/v3/createRoom')
83
+ const body = JSON.parse(init!.body as string)
84
+ expect(body.creation_content).toEqual({ type: 'm.space' })
85
+ expect(body.initial_state).toContainEqual({
86
+ type: 'm.room.join_rules',
87
+ state_key: '',
88
+ content: { join_rule: 'invite' },
89
+ })
90
+ return new Response(JSON.stringify({ room_id: '!space:hs.zoon.local' }), { status: 200 })
91
+ },
92
+ )
93
+ const id = await ensureWorkforceSpace({
94
+ client,
95
+ asUserId: '@zooid:hs.zoon.local',
96
+ serverName: 'hs.zoon.local',
97
+ spaceLocalpart: 'dev',
98
+ preset: 'public_chat',
99
+ })
100
+ expect(id).toBe('!space:hs.zoon.local')
101
+ })
102
+ })
103
+
104
+ describe('ensureWorkforceSpace admins', () => {
105
+ it('emits power_level_content_override with bot + admins at 100 on creation', async () => {
106
+ const { client } = clientWithFetches(
107
+ () => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
108
+ (url, init) => {
109
+ expect(url).toContain('/_matrix/client/v3/createRoom')
110
+ const body = JSON.parse(init!.body as string)
111
+ expect(body.power_level_content_override).toEqual({
112
+ users: {
113
+ '@zooid:hs.zoon.local': 100,
114
+ '@admin:hs.zoon.local': 100,
115
+ },
116
+ })
117
+ return new Response(JSON.stringify({ room_id: '!space:hs.zoon.local' }), { status: 200 })
118
+ },
119
+ )
120
+ await ensureWorkforceSpace({
121
+ client,
122
+ asUserId: '@zooid:hs.zoon.local',
123
+ serverName: 'hs.zoon.local',
124
+ spaceLocalpart: 'dev',
125
+ preset: 'public_chat',
126
+ admins: ['@admin:hs.zoon.local'],
127
+ })
128
+ })
129
+
130
+ it('invites the admins so they can actually enter the invite-only space', async () => {
131
+ const { client } = clientWithFetches(
132
+ () => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
133
+ (_url, init) => {
134
+ const body = JSON.parse(init!.body as string)
135
+ expect(body.invite).toEqual(['@admin:hs.zoon.local'])
136
+ return new Response(JSON.stringify({ room_id: '!space:hs.zoon.local' }), { status: 200 })
137
+ },
138
+ )
139
+ await ensureWorkforceSpace({
140
+ client,
141
+ asUserId: '@zooid:hs.zoon.local',
142
+ serverName: 'hs.zoon.local',
143
+ spaceLocalpart: 'dev',
144
+ preset: 'public_chat',
145
+ admins: ['@admin:hs.zoon.local'],
146
+ })
147
+ })
148
+
149
+ it('omits the override when admins is empty/absent', async () => {
150
+ const { client } = clientWithFetches(
151
+ () => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
152
+ (_url, init) => {
153
+ const body = JSON.parse(init!.body as string)
154
+ expect(body.power_level_content_override).toBeUndefined()
155
+ return new Response(JSON.stringify({ room_id: '!space:hs.zoon.local' }), { status: 200 })
156
+ },
157
+ )
158
+ await ensureWorkforceSpace({
159
+ client,
160
+ asUserId: '@zooid:hs.zoon.local',
161
+ serverName: 'hs.zoon.local',
162
+ spaceLocalpart: 'dev',
163
+ preset: 'public_chat',
164
+ })
165
+ })
166
+ })
167
+
168
+ describe('ensureDefaultChannel', () => {
169
+ it('returns the existing #general room when its alias resolves', async () => {
170
+ const { client, fetch } = clientWithFetches((url) => {
171
+ expect(url).toContain('/_matrix/client/v3/directory/room/%23general%3Ahs.zoon.local')
172
+ return new Response(JSON.stringify({ room_id: '!gen:hs.zoon.local' }), { status: 200 })
173
+ })
174
+ const id = await ensureDefaultChannel({
175
+ client,
176
+ asUserId: '@zooid:hs.zoon.local',
177
+ serverName: 'hs.zoon.local',
178
+ spaceId: '!space:hs.zoon.local',
179
+ channelLocalpart: 'general',
180
+ })
181
+ expect(id).toBe('!gen:hs.zoon.local')
182
+ expect(fetch).toHaveBeenCalledTimes(1)
183
+ })
184
+
185
+ it('creates a restricted #general and attaches it to the space when absent', async () => {
186
+ const { client, fetch } = clientWithFetches(
187
+ () => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
188
+ (url, init) => {
189
+ expect(url).toContain('/_matrix/client/v3/createRoom')
190
+ const body = JSON.parse(init!.body as string)
191
+ expect(body.room_alias_name).toBe('general')
192
+ expect(body.initial_state).toContainEqual({
193
+ type: 'm.room.join_rules',
194
+ state_key: '',
195
+ content: {
196
+ join_rule: 'restricted',
197
+ allow: [{ type: 'm.room_membership', room_id: '!space:hs.zoon.local' }],
198
+ },
199
+ })
200
+ return new Response(JSON.stringify({ room_id: '!gen:hs.zoon.local' }), { status: 200 })
201
+ },
202
+ (url, init) => {
203
+ expect(url).toContain(
204
+ '/_matrix/client/v3/rooms/!space%3Ahs.zoon.local/state/m.space.child/!gen%3Ahs.zoon.local',
205
+ )
206
+ expect(JSON.parse(init!.body as string)).toMatchObject({ via: ['hs.zoon.local'] })
207
+ return new Response(JSON.stringify({ event_id: '$e' }), { status: 200 })
208
+ },
209
+ )
210
+ const id = await ensureDefaultChannel({
211
+ client,
212
+ asUserId: '@zooid:hs.zoon.local',
213
+ serverName: 'hs.zoon.local',
214
+ spaceId: '!space:hs.zoon.local',
215
+ channelLocalpart: 'general',
216
+ })
217
+ expect(id).toBe('!gen:hs.zoon.local')
218
+ expect(fetch).toHaveBeenCalledTimes(3)
219
+ })
220
+ })
221
+
222
+ describe('ensureDefaultChannel admins', () => {
223
+ it('seeds operator + bot at PL 100 in the default channel on creation', async () => {
224
+ const { client } = clientWithFetches(
225
+ () => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
226
+ (url, init) => {
227
+ expect(url).toContain('/_matrix/client/v3/createRoom')
228
+ const body = JSON.parse(init!.body as string)
229
+ expect(body.power_level_content_override).toEqual({
230
+ users: {
231
+ '@zooid:hs.zoon.local': 100,
232
+ '@admin:hs.zoon.local': 100,
233
+ },
234
+ })
235
+ return new Response(JSON.stringify({ room_id: '!gen:hs.zoon.local' }), { status: 200 })
236
+ },
237
+ () => new Response(JSON.stringify({ event_id: '$e' }), { status: 200 }),
238
+ )
239
+ await ensureDefaultChannel({
240
+ client,
241
+ asUserId: '@zooid:hs.zoon.local',
242
+ serverName: 'hs.zoon.local',
243
+ spaceId: '!space:hs.zoon.local',
244
+ admins: ['@admin:hs.zoon.local'],
245
+ })
246
+ })
247
+
248
+ it('omits the override when admins is empty/absent', async () => {
249
+ const { client } = clientWithFetches(
250
+ () => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
251
+ (_url, init) => {
252
+ const body = JSON.parse(init!.body as string)
253
+ expect(body.power_level_content_override).toBeUndefined()
254
+ return new Response(JSON.stringify({ room_id: '!gen:hs.zoon.local' }), { status: 200 })
255
+ },
256
+ () => new Response(JSON.stringify({ event_id: '$e' }), { status: 200 }),
257
+ )
258
+ await ensureDefaultChannel({
259
+ client,
260
+ asUserId: '@zooid:hs.zoon.local',
261
+ serverName: 'hs.zoon.local',
262
+ spaceId: '!space:hs.zoon.local',
263
+ })
264
+ })
265
+ })
266
+
77
267
  describe('serverNameFromMxid', () => {
78
268
  it('returns the part after the first colon', () => {
79
269
  expect(serverNameFromMxid('@zooid:zoon.local')).toBe('zoon.local')
@@ -6,6 +6,13 @@ export interface EnsureSpaceOpts {
6
6
  serverName: string
7
7
  spaceLocalpart: string
8
8
  preset: 'public_chat' | 'private_chat'
9
+ /**
10
+ * Operator MXIDs to seed at PL 100 in the space's `m.room.power_levels`
11
+ * at creation. The AS bot is always included. Empty/absent → no override
12
+ * (the preset's PL defaults apply). Only consulted on first creation —
13
+ * if the alias already resolves we return the existing room untouched.
14
+ */
15
+ admins?: string[]
9
16
  }
10
17
 
11
18
  export async function ensureWorkforceSpace(opts: EnsureSpaceOpts): Promise<string> {
@@ -14,15 +21,78 @@ export async function ensureWorkforceSpace(opts: EnsureSpaceOpts): Promise<strin
14
21
  if (existing) return existing
15
22
 
16
23
  const display = opts.spaceLocalpart.charAt(0).toUpperCase() + opts.spaceLocalpart.slice(1)
17
- return opts.client.createRoomRaw({
24
+ const body: Record<string, unknown> = {
25
+ room_alias_name: opts.spaceLocalpart,
26
+ name: display,
27
+ preset: opts.preset,
28
+ creation_content: { type: 'm.space' },
29
+ // A workspace is joined by invitation, not self-service. Pin the space's
30
+ // join rule to invite regardless of preset so it can't be walked into
31
+ // (which would otherwise satisfy every restricted child room's allow).
32
+ initial_state: [{ type: 'm.room.join_rules', state_key: '', content: { join_rule: 'invite' } }],
33
+ }
34
+ if (opts.admins && opts.admins.length > 0) {
35
+ // Invite each admin so they actually become members — PL 100 alone does
36
+ // not grant membership in an invite-only space.
37
+ body.invite = opts.admins
38
+ const users: Record<string, number> = { [opts.asUserId]: 100 }
39
+ for (const a of opts.admins) users[a] = 100
40
+ body.power_level_content_override = { users }
41
+ }
42
+ return opts.client.createRoomRaw({ asUserId: opts.asUserId, body })
43
+ }
44
+
45
+ export interface EnsureDefaultChannelOpts {
46
+ client: MatrixClient
47
+ asUserId: string
48
+ serverName: string
49
+ spaceId: string
50
+ /** Localpart of the default channel; defaults to `general`. */
51
+ channelLocalpart?: string
52
+ /**
53
+ * Operator MXIDs to seed at PL 100 in the channel's `m.room.power_levels`
54
+ * at creation. The AS bot is always included. Empty/absent → no override
55
+ * (the preset's PL defaults apply). Only consulted on first creation —
56
+ * if the alias already resolves we return the existing room untouched.
57
+ */
58
+ admins?: string[]
59
+ }
60
+
61
+ /**
62
+ * Ensure a space has a default channel (`#general` by default), restricted to
63
+ * the space's members and attached as an `m.space.child`. Idempotent: returns
64
+ * the existing room if the alias already resolves. Has no agent — it's the
65
+ * human landing room, so it is created here at provisioning time rather than
66
+ * via the agent-room path.
67
+ */
68
+ export async function ensureDefaultChannel(opts: EnsureDefaultChannelOpts): Promise<string> {
69
+ const localpart = opts.channelLocalpart ?? 'general'
70
+ const alias = `#${localpart}:${opts.serverName}`
71
+ const existing = await opts.client.resolveAlias(alias)
72
+ if (existing) return existing
73
+
74
+ let userPowerLevels: Record<string, number> | undefined
75
+ if (opts.admins && opts.admins.length > 0) {
76
+ userPowerLevels = { [opts.asUserId]: 100 }
77
+ for (const a of opts.admins) userPowerLevels[a] = 100
78
+ }
79
+
80
+ const roomId = await opts.client.createRoom({
81
+ roomAliasName: localpart,
82
+ invite: [],
83
+ senderUserId: opts.asUserId,
84
+ name: localpart.charAt(0).toUpperCase() + localpart.slice(1),
85
+ restrictedToSpaceId: opts.spaceId,
86
+ ...(userPowerLevels ? { userPowerLevels } : {}),
87
+ })
88
+ await opts.client.sendStateEvent({
89
+ roomId: opts.spaceId,
18
90
  asUserId: opts.asUserId,
19
- body: {
20
- room_alias_name: opts.spaceLocalpart,
21
- name: display,
22
- preset: opts.preset,
23
- creation_content: { type: 'm.space' },
24
- },
91
+ eventType: 'm.space.child',
92
+ stateKey: roomId,
93
+ content: { via: [opts.serverName] },
25
94
  })
95
+ return roomId
26
96
  }
27
97
 
28
98
  export function serverNameFromMxid(mxid: string): string {
@@ -51,12 +51,12 @@ const baseAgents = [
51
51
  {
52
52
  name: 'architect',
53
53
  userId: '@architect:example.com',
54
- rooms: ['!r:example.com'],
54
+ rooms: [{ alias: '!r:example.com' }],
55
55
  trigger: 'mention' as const,
56
56
  },
57
57
  ]
58
58
 
59
- function makeTransport() {
59
+ function makeTransport(drain?: { drainQuietMs?: number; drainMaxMs?: number }) {
60
60
  const { reg, finishPrompt } = fakeRegistry()
61
61
  const approvals = fakeApprovals()
62
62
  const client = fakeClient()
@@ -66,6 +66,10 @@ function makeTransport() {
66
66
  client: client as never,
67
67
  bindings: baseAgents,
68
68
  hsToken: 'hs-secret',
69
+ // Disable post-turn drain by default so settleTurn (microtasks) suffices.
70
+ // Tests covering trailing-chunk behavior pass an explicit window.
71
+ drainQuietMs: drain?.drainQuietMs ?? 0,
72
+ drainMaxMs: drain?.drainMaxMs,
69
73
  })
70
74
  return { transport, agents: reg, approvals, client, finishPrompt }
71
75
  }
@@ -145,6 +149,58 @@ describe('matrix transport /transactions', () => {
145
149
  )
146
150
  })
147
151
 
152
+ it('drains trailing agent_message_chunks that arrive after prompt() resolves', async () => {
153
+ // ACP doesn't guarantee all session/update chunks precede the prompt
154
+ // response for a normal turn; opencode flushes a trailing chunk just after
155
+ // the stopReason. The post-turn drain must wait for it instead of sending
156
+ // the truncated buffer.
157
+ const { transport, agents, client } = makeTransport({ drainQuietMs: 20, drainMaxMs: 500 })
158
+ const events = [
159
+ {
160
+ type: 'm.room.message',
161
+ event_id: '$root',
162
+ room_id: '!r:example.com',
163
+ sender: '@alice:example.com',
164
+ content: {
165
+ msgtype: 'm.text',
166
+ body: 'hi',
167
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
168
+ },
169
+ },
170
+ ]
171
+ agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
172
+ const sessionId = 'sess-' + p.threadId
173
+ // First chunk arrives during the turn…
174
+ agents.onEvent('architect', {
175
+ type: 'agent_message_chunk',
176
+ sessionId,
177
+ content: { type: 'text', text: 'Hi.' },
178
+ })
179
+ // …a second chunk lands shortly AFTER the prompt response resolves.
180
+ setTimeout(() => {
181
+ agents.onEvent('architect', {
182
+ type: 'agent_message_chunk',
183
+ sessionId,
184
+ content: { type: 'text', text: ' How can I help?' },
185
+ })
186
+ }, 5)
187
+ return { stopReason: 'end_turn' as const }
188
+ })
189
+
190
+ const res = await postTxn(transport.app, { events })
191
+ expect(res.status).toBe(200)
192
+ // Wait past the drain window for the turn to finalize.
193
+ await new Promise((r) => setTimeout(r, 150))
194
+
195
+ // Exactly one message, containing BOTH the early and the late chunk.
196
+ expect(client.sendMessage).toHaveBeenCalledTimes(1)
197
+ expect(client.sendMessage).toHaveBeenCalledWith(
198
+ expect.objectContaining({
199
+ content: expect.objectContaining({ body: 'Hi. How can I help?' }),
200
+ }),
201
+ )
202
+ })
203
+
148
204
  it('uses an in-thread message event_id as the thread root when one is set', async () => {
149
205
  const { transport, agents, client } = makeTransport()
150
206
  const events = [
package/src/transport.ts CHANGED
@@ -18,6 +18,12 @@ export interface CreateMatrixTransportOptions {
18
18
  hsToken: string
19
19
  /** Admin Matrix user ID. When set, BotPool.bootstrap invites this user into rooms it creates. */
20
20
  adminUserId?: string
21
+ /** Post-turn drain: keep collecting trailing `agent_message_chunk`s until the
22
+ * buffer is quiet for this long before flushing. Defaults to `DRAIN_QUIET_MS`.
23
+ * Set to 0 to disable the drain (e.g. in tests). */
24
+ drainQuietMs?: number
25
+ /** Hard cap on the post-turn drain. Defaults to `DRAIN_MAX_MS`. */
26
+ drainMaxMs?: number
21
27
  }
22
28
 
23
29
  interface SessionContext {
@@ -43,6 +49,19 @@ interface MatrixEvent {
43
49
  const STARTUP_GRACE_MS = 5_000
44
50
  const SEEN_EVENT_CAP = 5_000
45
51
 
52
+ // ACP only guarantees that an agent flushes pending `session/update`
53
+ // notifications before the `session/prompt` response in the *cancellation*
54
+ // path; for a normal turn the ordering is unspecified. Some agents (e.g.
55
+ // opencode) emit trailing `agent_message_chunk`s a few ms after the stopReason
56
+ // response, so finalizing the moment `prompt()` resolves truncates the reply.
57
+ // After the turn resolves we wait for the buffer to stay unchanged for
58
+ // DRAIN_QUIET_MS (debounce — re-arms on each late chunk) before flushing,
59
+ // capped at DRAIN_MAX_MS so a misbehaving stream can't hang the turn.
60
+ const DRAIN_QUIET_MS = 300
61
+ const DRAIN_MAX_MS = 3_000
62
+
63
+ const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
64
+
46
65
  function inboundThreadRoot(evt: MatrixEvent): string | undefined {
47
66
  const r = evt.content?.['m.relates_to']
48
67
  return r?.rel_type === 'm.thread' && r.event_id ? r.event_id : undefined
@@ -50,6 +69,8 @@ function inboundThreadRoot(evt: MatrixEvent): string | undefined {
50
69
 
51
70
  export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
52
71
  const { agents, approvals, client, bindings, hsToken, adminUserId } = opts
72
+ const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS
73
+ const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS
53
74
  const pool = new BotPool(client, bindings)
54
75
  const sessions = new Map<string, SessionContext>()
55
76
  const buffers = new Map<string, string>()
@@ -418,6 +439,17 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
418
439
  channelId: evt.room_id,
419
440
  content: [{ type: 'text', text: promptText }],
420
441
  })
442
+ // Drain: the prompt promise resolves on the stopReason response, but
443
+ // trailing chunks may still arrive (see DRAIN_* above). Wait until the
444
+ // buffer is quiet for DRAIN_QUIET_MS, re-arming on each new chunk.
445
+ const drainStart = Date.now()
446
+ let drained = buffers.get(sessionId) ?? ''
447
+ while (drainQuietMs > 0 && Date.now() - drainStart < drainMaxMs) {
448
+ await delay(drainQuietMs)
449
+ const next = buffers.get(sessionId) ?? ''
450
+ if (next === drained) break
451
+ drained = next
452
+ }
421
453
  const text = buffers.get(sessionId) ?? ''
422
454
  if (text.length > 0) {
423
455
  const html = toMatrixHtml(text)
@@ -464,7 +496,13 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
464
496
 
465
497
  return {
466
498
  app,
467
- bootstrap: async (bootstrapOpts: { spaceRoomId?: string; asUserId?: string } = {}) => {
499
+ bootstrap: async (
500
+ bootstrapOpts: {
501
+ spaceRoomId?: string
502
+ asUserId?: string
503
+ adminUserIds?: string[]
504
+ } = {},
505
+ ) => {
468
506
  await pool.bootstrap({ adminUserId, ...bootstrapOpts })
469
507
  await Promise.allSettled(
470
508
  bindings.map((b) =>
@@ -494,7 +532,7 @@ export async function rebuildThreadState(
494
532
  // Impersonate an agent that's actually a member of this room (AS reads
495
533
  // require room membership). Falling through to the first binding would
496
534
  // 403 if that agent never joined the target room.
497
- const asUser = (bindings.find((b) => b.rooms.includes(roomId)) ?? bindings[0])?.userId
535
+ const asUser = (bindings.find((b) => b.rooms.some((r) => r.alias === roomId)) ?? bindings[0])?.userId
498
536
  if (!asUser) return state
499
537
 
500
538
  const root = await client.fetchEvent(roomId, rootEventId, asUser)
@@ -4,8 +4,18 @@ import { buildWorkforceRoster, publishWorkforce } from './workforce-publisher.js
4
4
  import type { AgentBinding } from './router.js'
5
5
 
6
6
  const agents: AgentBinding[] = [
7
- { name: 'planner', userId: '@planner:zoon.local', rooms: ['!eng:zoon.local'], trigger: 'mention' },
8
- { name: 'reviewer', userId: '@reviewer:zoon.local', rooms: ['!eng:zoon.local', '!review:zoon.local'], trigger: 'any' },
7
+ {
8
+ name: 'planner',
9
+ userId: '@planner:zoon.local',
10
+ rooms: [{ alias: '!eng:zoon.local' }],
11
+ trigger: 'mention',
12
+ },
13
+ {
14
+ name: 'reviewer',
15
+ userId: '@reviewer:zoon.local',
16
+ rooms: [{ alias: '!eng:zoon.local' }, { alias: '!review:zoon.local' }],
17
+ trigger: 'any',
18
+ },
9
19
  ]
10
20
 
11
21
  describe('buildWorkforceRoster', () => {
@@ -9,7 +9,11 @@ export interface WorkforceRoster {
9
9
  export function buildWorkforceRoster(agents: AgentBinding[]): WorkforceRoster {
10
10
  return {
11
11
  version: 1,
12
- agents: agents.map((a) => ({ user_id: a.userId, name: a.name, rooms: a.rooms })),
12
+ agents: agents.map((a) => ({
13
+ user_id: a.userId,
14
+ name: a.name,
15
+ rooms: a.rooms.map((r) => r.alias),
16
+ })),
13
17
  }
14
18
  }
15
19