@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.
@@ -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
@@ -6,7 +6,8 @@ import { MatrixClient } from './matrix-client.js'
6
6
  import { BotPool } from './bot-pool.js'
7
7
  import { route, type AgentBinding, type ThreadState } from './router.js'
8
8
  import { stripMention, extractMentions } from './mentions.js'
9
- import { toToolCallBody, toUpdateBody, toPlanBody } from './event-encoders.js'
9
+ import { toToolCallBody, toUpdateBody, toPlanBody, toErrorBody } from './event-encoders.js'
10
+ import { classify } from '@zooid/acp-client'
10
11
  import { toMatrixHtml } from './markdown-to-matrix-html.js'
11
12
 
12
13
  export interface CreateMatrixTransportOptions {
@@ -17,6 +18,12 @@ export interface CreateMatrixTransportOptions {
17
18
  hsToken: string
18
19
  /** Admin Matrix user ID. When set, BotPool.bootstrap invites this user into rooms it creates. */
19
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
20
27
  }
21
28
 
22
29
  interface SessionContext {
@@ -42,6 +49,19 @@ interface MatrixEvent {
42
49
  const STARTUP_GRACE_MS = 5_000
43
50
  const SEEN_EVENT_CAP = 5_000
44
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
+
45
65
  function inboundThreadRoot(evt: MatrixEvent): string | undefined {
46
66
  const r = evt.content?.['m.relates_to']
47
67
  return r?.rel_type === 'm.thread' && r.event_id ? r.event_id : undefined
@@ -49,6 +69,8 @@ function inboundThreadRoot(evt: MatrixEvent): string | undefined {
49
69
 
50
70
  export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
51
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
52
74
  const pool = new BotPool(client, bindings)
53
75
  const sessions = new Map<string, SessionContext>()
54
76
  const buffers = new Map<string, string>()
@@ -74,7 +96,11 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
74
96
  if (event.type === 'agent_message_chunk') {
75
97
  const block = event.content as { type?: string; text?: string }
76
98
  if (block.type === 'text' && typeof block.text === 'string') {
77
- buffers.set(event.sessionId, (buffers.get(event.sessionId) ?? '') + block.text)
99
+ const current = buffers.get(event.sessionId) ?? ''
100
+ // An empty chunk signals a new text block starting (e.g. after a tool call).
101
+ // Insert a paragraph break so consecutive blocks don't run together.
102
+ const prefix = block.text === '' && current.length > 0 ? '\n\n' : ''
103
+ buffers.set(event.sessionId, current + prefix + block.text)
78
104
  } else {
79
105
  console.warn(`[matrix:${name}] dropped chunk block type=${block.type}`, block)
80
106
  }
@@ -323,6 +349,31 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
323
349
  })
324
350
  .catch((err) => {
325
351
  console.error(`[matrix] runTurn failed for ${a.name}:`, err)
352
+ const c = classify(err)
353
+ const threadRoot = inboundThreadRoot(evt) ?? evt.event_id
354
+ if (!threadRoot || !evt.room_id) return
355
+ const body = toErrorBody(
356
+ {
357
+ kind: 'error',
358
+ agentId: a.name,
359
+ sessionId: null,
360
+ turnId: null,
361
+ code: c.code,
362
+ message: err instanceof Error ? err.message : String(err),
363
+ detail: err instanceof Error && err.stack ? err.stack.slice(0, 2000) : undefined,
364
+ transient: c.transient,
365
+ acp_error: c.acp_error,
366
+ },
367
+ threadRoot,
368
+ )
369
+ void client
370
+ .sendCustomEvent({
371
+ roomId: evt.room_id,
372
+ asUserId: a.userId,
373
+ eventType: 'eco.zoon.error',
374
+ content: body,
375
+ })
376
+ .catch((e) => console.warn(`[matrix:${a.name}] eco.zoon.error send failed:`, e))
326
377
  })
327
378
  }
328
379
  }
@@ -388,6 +439,17 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
388
439
  channelId: evt.room_id,
389
440
  content: [{ type: 'text', text: promptText }],
390
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
+ }
391
453
  const text = buffers.get(sessionId) ?? ''
392
454
  if (text.length > 0) {
393
455
  const html = toMatrixHtml(text)
@@ -434,7 +496,13 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
434
496
 
435
497
  return {
436
498
  app,
437
- bootstrap: async (bootstrapOpts: { spaceRoomId?: string; asUserId?: string } = {}) => {
499
+ bootstrap: async (
500
+ bootstrapOpts: {
501
+ spaceRoomId?: string
502
+ asUserId?: string
503
+ adminUserIds?: string[]
504
+ } = {},
505
+ ) => {
438
506
  await pool.bootstrap({ adminUserId, ...bootstrapOpts })
439
507
  await Promise.allSettled(
440
508
  bindings.map((b) =>
@@ -464,7 +532,7 @@ export async function rebuildThreadState(
464
532
  // Impersonate an agent that's actually a member of this room (AS reads
465
533
  // require room membership). Falling through to the first binding would
466
534
  // 403 if that agent never joined the target room.
467
- 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
468
536
  if (!asUser) return state
469
537
 
470
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