@zooid/transport-matrix 0.11.2 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zooid/transport-matrix",
3
- "version": "0.11.2",
3
+ "version": "0.13.0",
4
4
  "description": "Matrix Application Service transport for zooid. Routes inbound Matrix messages to ACP agents and posts replies plus approval custom events back to threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,8 +28,8 @@
28
28
  "marked": "^18.0.4",
29
29
  "sanitize-html": "^2.17.4",
30
30
  "yaml": "^2.5.0",
31
- "@zooid/acp-client": "^0.11.2",
32
- "@zooid/core": "^0.11.2"
31
+ "@zooid/acp-client": "^0.13.0",
32
+ "@zooid/core": "^0.13.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@hono/node-server": "^1.13.0",
@@ -347,4 +347,122 @@ describe('MatrixContextProvider', () => {
347
347
  expect(byId.get('$img')?.text).toBe('[image: dog.jpg]')
348
348
  expect(byId.get('$file')?.text).toBe('[file: report.pdf]')
349
349
  })
350
+
351
+ it('renders agent prose sent as m.notice — ZNC025 §10 switches agent output to m.notice', async () => {
352
+ const client = fakeClient({
353
+ fetchRoomMessages: vi.fn().mockResolvedValue({
354
+ chunk: [
355
+ {
356
+ event_id: '$e1',
357
+ sender: '@architect:hs',
358
+ origin_server_ts: 1000,
359
+ type: 'm.room.message',
360
+ content: { msgtype: 'm.notice', body: 'agent output' },
361
+ },
362
+ ],
363
+ end: undefined,
364
+ }),
365
+ } as unknown as Partial<MatrixClient>)
366
+ const provider = new MatrixContextProvider({
367
+ client,
368
+ asUserId: '@_zooid:hs',
369
+ agentBots: new Map([['@architect:hs', 'architect']]),
370
+ })
371
+ const page = await provider.getRoomHistory('!room:hs', {})
372
+ expect(page.messages[0]?.text).toBe('agent output')
373
+ })
374
+
375
+ it('getRecentThreads keeps a thread rooted in an m.notice', async () => {
376
+ const client = fakeClient({
377
+ fetchRoomMessages: vi.fn().mockResolvedValue({
378
+ chunk: [
379
+ {
380
+ event_id: '$root',
381
+ sender: '@architect:hs',
382
+ origin_server_ts: 1000,
383
+ type: 'm.room.message',
384
+ content: { msgtype: 'm.notice', body: 'agent thread root' },
385
+ },
386
+ ],
387
+ end: undefined,
388
+ }),
389
+ } as unknown as Partial<MatrixClient>)
390
+ const provider = new MatrixContextProvider({
391
+ client,
392
+ asUserId: '@_zooid:hs',
393
+ agentBots: new Map([['@architect:hs', 'architect']]),
394
+ })
395
+ const page = await provider.getRecentThreads('!room:hs', { limit: 50 })
396
+ expect(page.threads.map((t) => t.id)).toEqual(['$root'])
397
+ expect(page.threads[0]?.text).toBe('agent thread root')
398
+ })
399
+ })
400
+
401
+ // The authorization boundary for context reads is the homeserver, not this
402
+ // class: every read is impersonated as the *agent's own* Matrix user, so a room
403
+ // or thread the agent isn't in fails at Matrix. Nothing asserted that, and a
404
+ // stale doc comment claimed the opposite (that asUserId was the AS bot, which
405
+ // can read every room) — so a refactor could have quietly swapped in the AS
406
+ // user and turned a homeserver-enforced boundary into an honour system.
407
+ describe('MatrixContextProvider — reads are impersonated as the agent', () => {
408
+ const AGENT = '@dev.assistant:hs'
409
+
410
+ function provider(overrides: Partial<MatrixClient>) {
411
+ return new MatrixContextProvider({
412
+ client: fakeClient(overrides),
413
+ asUserId: AGENT,
414
+ agentBots: new Map(),
415
+ })
416
+ }
417
+
418
+ it('threads the agent user through every read, never a different user', async () => {
419
+ const fetchRoomMessages = vi.fn().mockResolvedValue({ chunk: [], end: undefined })
420
+ const getJoinedMembers = vi.fn().mockResolvedValue({ joined: {} })
421
+ const fetchRoomName = vi.fn().mockResolvedValue('room')
422
+ const fetchEvent = vi.fn().mockResolvedValue(null)
423
+ const fetchThreadRelations = vi.fn().mockResolvedValue({ chunk: [], next_batch: undefined })
424
+ const p = provider({
425
+ fetchRoomMessages,
426
+ getJoinedMembers,
427
+ fetchRoomName,
428
+ fetchEvent,
429
+ fetchThreadRelations,
430
+ } as unknown as Partial<MatrixClient>)
431
+
432
+ await p.getRoomHistory('!room:hs', { limit: 10 })
433
+ await p.getRecentThreads('!room:hs', { limit: 10 })
434
+ await p.getThreadHistory('!room:hs', '$root', { limit: 10 })
435
+ await p.getChannelMembers('!room:hs')
436
+ await p.getChannelInfo('!room:hs')
437
+
438
+ expect(fetchRoomMessages.mock.calls.every(([a]) => a.asUserId === AGENT)).toBe(true)
439
+ expect(fetchThreadRelations).toHaveBeenCalledWith(expect.objectContaining({ asUserId: AGENT }))
440
+ // These two take the user as a positional argument, not a field.
441
+ expect(getJoinedMembers).toHaveBeenCalledWith('!room:hs', AGENT)
442
+ expect(fetchRoomName).toHaveBeenCalledWith('!room:hs', AGENT)
443
+ expect(fetchEvent).toHaveBeenCalledWith('!room:hs', '$root', AGENT)
444
+ })
445
+
446
+ // An agent naming a room it isn't in must fail loudly. Swallowing the error
447
+ // and returning `{ messages: [] }` would read as "empty room" to the model —
448
+ // indistinguishable from a real empty room, and it would hide the refusal.
449
+ it('propagates a homeserver refusal instead of returning an empty page', async () => {
450
+ const forbidden = new Error('fetchRoomMessages(!private:hs) failed: 403')
451
+ const p = provider({
452
+ fetchRoomMessages: vi.fn().mockRejectedValue(forbidden),
453
+ } as unknown as Partial<MatrixClient>)
454
+
455
+ await expect(p.getRoomHistory('!private:hs', { limit: 10 })).rejects.toThrow('403')
456
+ })
457
+
458
+ it('propagates a refusal on the thread path too', async () => {
459
+ const p = provider({
460
+ fetchEvent: vi.fn().mockResolvedValue(null),
461
+ fetchThreadRelations: vi
462
+ .fn()
463
+ .mockRejectedValue(new Error('fetchThreadRelations($x) failed: 403')),
464
+ } as unknown as Partial<MatrixClient>)
465
+
466
+ await expect(p.getThreadHistory('!private:hs', '$x', { limit: 10 })).rejects.toThrow('403')
467
+ })
350
468
  })
@@ -32,7 +32,18 @@ interface MatrixMessageEvent {
32
32
 
33
33
  export interface MatrixContextProviderOpts {
34
34
  client: MatrixClient
35
- /** AS sender_localpart user (read access). */
35
+ /**
36
+ * The **agent's own** Matrix user (`@{workstation}.{name}:server`), which
37
+ * every read is impersonated as via `?user_id=`. Not the AS bot: that would
38
+ * read every room on the homeserver and make this class the only thing
39
+ * standing between an agent and someone else's conversation.
40
+ *
41
+ * This is load-bearing. It is what makes the homeserver — not our own
42
+ * bookkeeping — the authorization boundary for context reads, so a room or
43
+ * thread the agent is not in fails at Matrix with 403/404. Anything that
44
+ * widens who can name a room (a CLI, a new tool parameter) is safe only
45
+ * while this holds.
46
+ */
36
47
  asUserId: string
37
48
  /** Map of Matrix user IDs → agent names, for is_agent / agent_name flags. */
38
49
  agentBots: Map<string, string>
@@ -84,7 +95,14 @@ export class MatrixContextProvider implements TransportContextProvider {
84
95
  const threads: ThreadOverview[] = []
85
96
  for (const ev of chunk as unknown as MatrixMessageEvent[]) {
86
97
  if (ev.type !== 'm.room.message') continue
87
- if (ev.content?.msgtype !== 'm.text' || typeof ev.content.body !== 'string') continue
98
+ // m.notice: agent prose sends as m.notice so
99
+ // .m.rule.suppress_notices silences the chunk storm server-side
100
+ // (ZNC025 §10) — a thread root sent by an agent must still surface here.
101
+ if (
102
+ (ev.content?.msgtype !== 'm.text' && ev.content?.msgtype !== 'm.notice') ||
103
+ typeof ev.content.body !== 'string'
104
+ )
105
+ continue
88
106
  const relatesTo = ev.content['m.relates_to']
89
107
  if (relatesTo?.rel_type === 'm.thread') continue // skip thread replies
90
108
  const agent = this.opts.agentBots.get(ev.sender)
@@ -169,7 +187,8 @@ export class MatrixContextProvider implements TransportContextProvider {
169
187
  }
170
188
  }
171
189
 
172
- if (msgtype !== 'm.text' || typeof body !== 'string') return null
190
+ // Agent prose sends as m.notice (ZNC025 §10); m.text is human prose.
191
+ if ((msgtype !== 'm.text' && msgtype !== 'm.notice') || typeof body !== 'string') return null
173
192
  return {
174
193
  id: ev.event_id,
175
194
  sender: ev.sender,
@@ -10,6 +10,7 @@ import {
10
10
  toPlanBody,
11
11
  toErrorBody,
12
12
  toAvailableCommandsBody,
13
+ toTurnEndBody,
13
14
  } from './event-encoders.js'
14
15
 
15
16
  describe('toToolCallBody', () => {
@@ -165,7 +166,6 @@ describe('toErrorBody', () => {
165
166
  threadRoot,
166
167
  )
167
168
  expect(body).toMatchObject({
168
- msgtype: 'm.notice',
169
169
  body: '⚠ [auth_missing] Authentication required',
170
170
  session_id: 'sess-1',
171
171
  turn_id: 'turn-1',
@@ -179,6 +179,14 @@ describe('toErrorBody', () => {
179
179
  expect(body.recovery).toMatch(/^https:\/\/zooid\.dev\/docs\//)
180
180
  })
181
181
 
182
+ it('carries no msgtype — dev.zooid.error is not m.room.message, so the field is meaningless', () => {
183
+ const body = toErrorBody(
184
+ { kind: 'error', agentId: 'a', sessionId: 's', turnId: 't', code: 'auth_missing', message: 'x', transient: false },
185
+ threadRoot,
186
+ )
187
+ expect(body).not.toHaveProperty('msgtype')
188
+ })
189
+
182
190
  it('truncates message to 250 chars and detail to 2000 chars', () => {
183
191
  const body = toErrorBody(
184
192
  {
@@ -230,3 +238,63 @@ describe('toErrorBody', () => {
230
238
  expect(body.session_id).toBeUndefined()
231
239
  })
232
240
  })
241
+
242
+ describe('toTurnEndBody', () => {
243
+ it('carries the produced_output flag ZOD076 reads', () => {
244
+ expect(toTurnEndBody({ agentId: 'claude', sessionId: 's1', producedOutput: true }, '$root')).toEqual({
245
+ body: 'claude finished',
246
+ agent_id: 'claude',
247
+ session_id: 's1',
248
+ produced_output: true,
249
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
250
+ })
251
+ })
252
+
253
+ it('carries a preview of the final message — the prose itself never pushes', () => {
254
+ // Agent prose goes out as m.notice and is silenced by
255
+ // .m.rule.suppress_notices, so without this the only notification the user
256
+ // gets says an agent finished and nothing about what it said.
257
+ const out = toTurnEndBody(
258
+ { agentId: 'claude', sessionId: 's1', producedOutput: true, lastMessage: 'the deploy is green' },
259
+ '$root',
260
+ )
261
+ expect(out.last_message).toBe('the deploy is green')
262
+ // body stays the turn-boundary summary a generic Matrix client renders.
263
+ expect(out.body).toBe('claude finished')
264
+ })
265
+
266
+ it('collapses whitespace and truncates a long final message', () => {
267
+ const out = toTurnEndBody(
268
+ {
269
+ agentId: 'claude',
270
+ sessionId: 's1',
271
+ producedOutput: true,
272
+ lastMessage: ' line one\n\nline two ' + 'x'.repeat(400),
273
+ },
274
+ '$root',
275
+ )
276
+ const preview = out.last_message as string
277
+ expect(preview.length).toBe(140)
278
+ expect(preview.startsWith('line one line two ')).toBe(true)
279
+ expect(preview).not.toContain('\n')
280
+ })
281
+
282
+ it('omits last_message entirely when the turn produced nothing', () => {
283
+ const out = toTurnEndBody({ agentId: 'claude', sessionId: 's1', producedOutput: false }, '$root')
284
+ expect('last_message' in out).toBe(false)
285
+ })
286
+
287
+ it('marks an empty turn', () => {
288
+ const out = toTurnEndBody(
289
+ { agentId: 'claude', sessionId: 's1', producedOutput: false },
290
+ '$root',
291
+ )
292
+ expect(out.produced_output).toBe(false)
293
+ expect(out.body).toBe('claude finished without output')
294
+ })
295
+
296
+ it('carries no msgtype — a vestigial m.notice here would collide with .m.rule.suppress_notices', () => {
297
+ const out = toTurnEndBody({ agentId: 'a', sessionId: 's', producedOutput: true }, '$r')
298
+ expect(out).not.toHaveProperty('msgtype')
299
+ })
300
+ })
@@ -91,7 +91,11 @@ type ErrorTap = Extract<TapEvent, { kind: 'error' }>
91
91
  export function toErrorBody(evt: ErrorTap, threadRoot: string): Record<string, unknown> {
92
92
  const msg = evt.message.slice(0, 250)
93
93
  const out: Record<string, unknown> = {
94
- msgtype: 'm.notice',
94
+ // No msgtype: dev.zooid.error is not m.room.message, so the field is
95
+ // meaningless here — it was a vestige of copying the message-body shape.
96
+ // Its presence used to force careful push-rule `before` positioning
97
+ // (ZNC025 §10); that positioning is kept regardless, since it also
98
+ // protects rules for event types that never carried the field.
95
99
  body: `⚠ [${evt.code}] ${msg}`,
96
100
  code: evt.code,
97
101
  message: msg,
@@ -106,3 +110,39 @@ export function toErrorBody(evt: ErrorTap, threadRoot: string): Record<string, u
106
110
  if (recovery) out.recovery = recovery
107
111
  return out
108
112
  }
113
+
114
+ export interface TurnEnd {
115
+ agentId: string
116
+ sessionId: string
117
+ producedOutput: boolean
118
+ /** The turn's final assistant message, for the push notification's preview. */
119
+ lastMessage?: string
120
+ }
121
+
122
+ /** Push payloads are size-capped, and a notification body is glanceable or useless. */
123
+ const PREVIEW_MAX = 140
124
+
125
+ /**
126
+ * Turn-boundary marker for [[ZOD076]] and push notifications. Carries no
127
+ * `msgtype` — a vestigial one (as `toErrorBody` used to carry) would collide
128
+ * with `.m.rule.suppress_notices`'s type-agnostic match and silently swallow
129
+ * the event before the [[ZNC025]] agent push rule ever sees it.
130
+ */
131
+ export function toTurnEndBody(evt: TurnEnd, threadRoot: string): Record<string, unknown> {
132
+ const preview = evt.lastMessage?.trim().replace(/\s+/g, ' ')
133
+ return {
134
+ // `body` stays the turn-boundary summary: it is what a generic Matrix
135
+ // client renders for this event, and the prose is already its own message
136
+ // in the timeline. The preview below exists only for the push, which
137
+ // cannot see that message — agent prose is `m.notice`, deliberately
138
+ // silenced by `.m.rule.suppress_notices` so a chatty turn doesn't fire one
139
+ // push per chunk ([[ZNC025]] §10). Without it the only notification the
140
+ // user gets says an agent finished and nothing about what it said.
141
+ body: evt.producedOutput ? `${evt.agentId} finished` : `${evt.agentId} finished without output`,
142
+ ...(preview ? { last_message: preview.slice(0, PREVIEW_MAX) } : {}),
143
+ agent_id: evt.agentId,
144
+ session_id: evt.sessionId,
145
+ produced_output: evt.producedOutput,
146
+ 'm.relates_to': { rel_type: 'm.thread', event_id: threadRoot },
147
+ }
148
+ }
@@ -146,7 +146,7 @@ describe('matrix transport /transactions', () => {
146
146
  roomId: '!r:example.com',
147
147
  asUserId: '@architect:example.com',
148
148
  threadRoot: '$root',
149
- content: expect.objectContaining({ msgtype: 'm.text', body: 'hello back' }),
149
+ content: expect.objectContaining({ msgtype: 'm.notice', body: 'hello back' }),
150
150
  }),
151
151
  )
152
152
  })
@@ -273,7 +273,7 @@ describe('matrix transport /transactions', () => {
273
273
  formatted_body?: string
274
274
  }
275
275
  }
276
- expect(call.content.msgtype).toBe('m.text')
276
+ expect(call.content.msgtype).toBe('m.notice')
277
277
  expect(call.content.body).toBe('**bold** _italic_\n\n```ts\nconst x = 1\n```')
278
278
  expect(call.content.format).toBe('org.matrix.custom.html')
279
279
  expect(typeof call.content.formatted_body).toBe('string')
@@ -316,6 +316,37 @@ describe('matrix transport /transactions', () => {
316
316
  expect(call.content).not.toHaveProperty('format')
317
317
  })
318
318
 
319
+ it('sends agent prose as m.notice so .m.rule.suppress_notices silences the chunk storm server-side', async () => {
320
+ const { transport, agents, client } = makeTransport()
321
+ agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
322
+ agents.onEvent('architect', {
323
+ type: 'agent_message_chunk',
324
+ sessionId: 'sess-' + p.threadId,
325
+ content: { type: 'text', text: 'hello' },
326
+ })
327
+ return { stopReason: 'end_turn' as const }
328
+ })
329
+ await postTxn(transport.app, {
330
+ events: [
331
+ {
332
+ type: 'm.room.message',
333
+ event_id: '$root',
334
+ room_id: '!r:example.com',
335
+ sender: '@alice:example.com',
336
+ content: {
337
+ msgtype: 'm.text',
338
+ body: 'hi',
339
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
340
+ },
341
+ },
342
+ ],
343
+ })
344
+ await settleTurn()
345
+ expect(client.sendMessage).toHaveBeenCalledWith(
346
+ expect.objectContaining({ content: expect.objectContaining({ msgtype: 'm.notice' }) }),
347
+ )
348
+ })
349
+
319
350
  it('emits dev.zooid.approval_request when an approval is registered', async () => {
320
351
  const { transport, approvals, client } = makeTransport()
321
352
  await postTxn(transport.app, {
@@ -375,6 +406,95 @@ describe('matrix transport /transactions', () => {
375
406
  })
376
407
  })
377
408
 
409
+ describe('dev.zooid.turn.end', () => {
410
+ function topLevelMention() {
411
+ return {
412
+ type: 'm.room.message',
413
+ event_id: '$root',
414
+ room_id: '!r:example.com',
415
+ sender: '@alice:example.com',
416
+ content: {
417
+ msgtype: 'm.text',
418
+ body: 'hi',
419
+ 'm.mentions': { user_ids: ['@architect:example.com'] },
420
+ },
421
+ }
422
+ }
423
+
424
+ it('is sent once per turn, after the final flush has settled', async () => {
425
+ const { transport, agents, client } = makeTransport()
426
+ agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
427
+ agents.onEvent('architect', {
428
+ type: 'agent_message_chunk',
429
+ sessionId: 'sess-' + p.threadId,
430
+ content: { type: 'text', text: 'hello back' },
431
+ })
432
+ return { stopReason: 'end_turn' as const }
433
+ })
434
+ await postTxn(transport.app, { events: [topLevelMention()] })
435
+ await settleTurn()
436
+
437
+ const sendMessageOrder = client.sendMessage.mock.invocationCallOrder[0]!
438
+ const turnEndCall = client.sendCustomEvent.mock.calls.find(
439
+ (c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
440
+ )
441
+ expect(turnEndCall).toBeDefined()
442
+ const turnEndIdx = client.sendCustomEvent.mock.calls.indexOf(turnEndCall!)
443
+ const turnEndOrder = client.sendCustomEvent.mock.invocationCallOrder[turnEndIdx]!
444
+ // Ordering matters: a turn.end that lands before the prose would notify
445
+ // the user to look at a room that has nothing in it yet.
446
+ expect(turnEndOrder).toBeGreaterThan(sendMessageOrder)
447
+ expect(turnEndCall![0]).toMatchObject({
448
+ roomId: '!r:example.com',
449
+ asUserId: '@architect:example.com',
450
+ content: expect.objectContaining({ produced_output: true }),
451
+ })
452
+ })
453
+
454
+ it('reports produced_output: false for a silent turn', async () => {
455
+ const { transport, agents, client } = makeTransport()
456
+ // The agent emits no chunks at all — this is exactly ZOD076's case.
457
+ agents.prompt.mockImplementation(async () => ({ stopReason: 'end_turn' as const }))
458
+ await postTxn(transport.app, { events: [topLevelMention()] })
459
+ await settleTurn()
460
+
461
+ const turnEndCall = client.sendCustomEvent.mock.calls.find(
462
+ (c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
463
+ )
464
+ expect(turnEndCall![0]).toMatchObject({
465
+ content: expect.objectContaining({ produced_output: false }),
466
+ })
467
+ })
468
+
469
+ it('is still sent when the turn throws, so the room never hangs on a spinner', async () => {
470
+ const { transport, agents, client } = makeTransport()
471
+ agents.prompt.mockImplementation(async () => {
472
+ throw new Error('boom')
473
+ })
474
+ await postTxn(transport.app, { events: [topLevelMention()] })
475
+ await settleTurn()
476
+
477
+ const turnEndCall = client.sendCustomEvent.mock.calls.find(
478
+ (c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
479
+ )
480
+ expect(turnEndCall).toBeDefined()
481
+ })
482
+
483
+ it('threads to the turn root', async () => {
484
+ const { transport, agents, client } = makeTransport()
485
+ agents.prompt.mockImplementation(async () => ({ stopReason: 'end_turn' as const }))
486
+ await postTxn(transport.app, { events: [topLevelMention()] })
487
+ await settleTurn()
488
+
489
+ const turnEndCall = client.sendCustomEvent.mock.calls.find(
490
+ (c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
491
+ )
492
+ expect(
493
+ (turnEndCall![0] as { content: { 'm.relates_to': unknown } }).content['m.relates_to'],
494
+ ).toEqual({ rel_type: 'm.thread', event_id: '$root' })
495
+ })
496
+ })
497
+
378
498
  describe('thread implicit triggers', () => {
379
499
  it('triggers the most-recent-posting agent for a bare reply in a thread', async () => {
380
500
  const { transport, agents } = makeTransport()
package/src/transport.ts CHANGED
@@ -7,7 +7,14 @@ import { BotPool } from './bot-pool.js'
7
7
  import { route, isMediaMsgtype, type AgentBinding, type ThreadState } from './router.js'
8
8
  import { sessionKeyFor, composeHandoffKey } from './session-keys.js'
9
9
  import { stripMention, extractMentions } from './mentions.js'
10
- import { toToolCallBody, toUpdateBody, toPlanBody, toAvailableCommandsBody, toErrorBody } from './event-encoders.js'
10
+ import {
11
+ toToolCallBody,
12
+ toUpdateBody,
13
+ toPlanBody,
14
+ toAvailableCommandsBody,
15
+ toErrorBody,
16
+ toTurnEndBody,
17
+ } from './event-encoders.js'
11
18
  import { classify } from '@zooid/acp-client'
12
19
  import { toMatrixHtml } from './markdown-to-matrix-html.js'
13
20
  import {
@@ -288,7 +295,11 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
288
295
  text: string,
289
296
  ): { msgtype: string; body: string; [k: string]: unknown } => {
290
297
  const content: { msgtype: string; body: string; [k: string]: unknown } = {
291
- msgtype: 'm.text',
298
+ // m.notice, not m.text: .m.rule.suppress_notices silences the
299
+ // chunk-storm of agent prose server-side (ZNC025 §10) instead of every
300
+ // client having to filter it. dev.zooid.error carries the same tweak
301
+ // for the same reason.
302
+ msgtype: 'm.notice',
292
303
  body: text,
293
304
  }
294
305
  const html = toMatrixHtml(text)
@@ -312,11 +323,17 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
312
323
  // same turn. The buffer is cleared synchronously (before the first await),
313
324
  // so a chunk for the *next* message that arrives during the send starts
314
325
  // fresh. Returns true when a message was enqueued.
326
+ const lastFlushed = new Map<string, string>()
327
+
315
328
  const flushBuffer = (sessionId: string): boolean => {
316
329
  const ctx = sessions.get(sessionId)
317
330
  const text = buffers.get(sessionId) ?? ''
318
331
  if (!ctx || text.length === 0) return false
319
332
  buffers.set(sessionId, '')
333
+ // Kept for turn.end's push preview: the prose goes out as `m.notice` and
334
+ // is deliberately silenced server-side, so turn.end is the only event that
335
+ // can tell the user what the agent actually said.
336
+ lastFlushed.set(sessionId, text)
320
337
  flushedCounts.set(sessionId, (flushedCounts.get(sessionId) ?? 0) + 1)
321
338
  const content = buildTextContent(text)
322
339
  const tail = (sendQueue.get(sessionId) ?? Promise.resolve()).then(async () => {
@@ -900,21 +917,39 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
900
917
  // Flush the final assistant message — the one with no following messageId
901
918
  // change or out-of-band event to have triggered an earlier flush.
902
919
  flushBuffer(sessionId)
920
+ } finally {
921
+ clearInterval(refresh)
922
+ await safeTyping(false)
923
+ await safePresence('online')
903
924
  // Wait for every queued send (mid-turn flushes, tool/plan events, final
904
- // flush) to settle before tearing the session down.
925
+ // flush) to settle before announcing the turn's end — and run this even
926
+ // when the turn above threw, so the room never hangs on a spinner.
905
927
  await (sendQueue.get(sessionId) ?? Promise.resolve())
906
- if ((flushedCounts.get(sessionId) ?? 0) === 0) {
928
+ const producedOutput = (flushedCounts.get(sessionId) ?? 0) > 0
929
+ if (!producedOutput) {
907
930
  console.warn(
908
931
  `[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${evt.room_id}`,
909
932
  )
910
933
  }
911
- } finally {
912
- clearInterval(refresh)
913
- await safeTyping(false)
914
- await safePresence('online')
934
+ // Turn boundary for [[ZOD076]] and push notifications. Sent after the
935
+ // send queue drains so it lands *after* the prose it announces — a
936
+ // turn.end arriving first would notify the user to look at a room that
937
+ // has nothing in it yet.
938
+ await client
939
+ .sendCustomEvent({
940
+ roomId: evt.room_id,
941
+ asUserId: agent.userId,
942
+ eventType: 'dev.zooid.turn.end',
943
+ content: toTurnEndBody(
944
+ { agentId: agent.name, sessionId, producedOutput, lastMessage: lastFlushed.get(sessionId) },
945
+ threadRoot,
946
+ ),
947
+ })
948
+ .catch((e) => console.warn(`[matrix:${agent.name}] turn.end send failed:`, e))
915
949
  buffers.delete(sessionId)
916
950
  bufferMessageIds.delete(sessionId)
917
951
  flushedCounts.delete(sessionId)
952
+ lastFlushed.delete(sessionId)
918
953
  sendQueue.delete(sessionId)
919
954
  }
920
955
  }