@zooid/transport-matrix 0.12.0 → 0.14.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.12.0",
3
+ "version": "0.14.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.12.0",
32
- "@zooid/core": "^0.12.0"
31
+ "@zooid/acp-client": "^0.14.0",
32
+ "@zooid/core": "^0.14.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@hono/node-server": "^1.13.0",
@@ -155,7 +155,7 @@ describe('MatrixContextProvider', () => {
155
155
  ])
156
156
  })
157
157
 
158
- it('getChannelInfo returns the room name and transport: matrix', async () => {
158
+ it('getRoomInfo returns the room name and transport: matrix', async () => {
159
159
  const client = fakeClient({
160
160
  fetchRoomName: vi.fn().mockResolvedValue('engineering'),
161
161
  } as unknown as Partial<MatrixClient>)
@@ -164,10 +164,67 @@ describe('MatrixContextProvider', () => {
164
164
  asUserId: '@_zooid:hs',
165
165
  agentBots: new Map(),
166
166
  })
167
- const info = await provider.getChannelInfo('!room:hs')
167
+ const info = await provider.getRoomInfo('!room:hs')
168
168
  expect(info).toEqual({ id: '!room:hs', name: 'engineering', transport: 'matrix' })
169
169
  })
170
170
 
171
+ it('getRooms maps this agent\'s own room bindings to RoomInfo, fetching each name', async () => {
172
+ const fetchRoomName = vi.fn().mockResolvedValueOnce('general').mockResolvedValueOnce('dev')
173
+ const client = fakeClient({ fetchRoomName } as unknown as Partial<MatrixClient>)
174
+ const provider = new MatrixContextProvider({
175
+ client,
176
+ asUserId: '@architect:hs',
177
+ agentBots: new Map(),
178
+ rooms: [{ alias: '!a:hs' }, { alias: '!b:hs' }],
179
+ })
180
+ const rooms = await provider.getRooms()
181
+ expect(rooms).toEqual([
182
+ { id: '!a:hs', name: 'general', transport: 'matrix' },
183
+ { id: '!b:hs', name: 'dev', transport: 'matrix' },
184
+ ])
185
+ expect(fetchRoomName).toHaveBeenCalledWith('!a:hs', '@architect:hs')
186
+ })
187
+
188
+ it('getRooms returns an empty list when the provider has no room bindings', async () => {
189
+ const provider = new MatrixContextProvider({
190
+ client: fakeClient(),
191
+ asUserId: '@architect:hs',
192
+ agentBots: new Map(),
193
+ })
194
+ expect(await provider.getRooms()).toEqual([])
195
+ })
196
+
197
+ it('sendMessage posts as this agent into a bound room, echoing thread_id when replying in-thread', async () => {
198
+ const sendMessage = vi.fn().mockResolvedValue({ event_id: '$sent' })
199
+ const client = fakeClient({ sendMessage } as unknown as Partial<MatrixClient>)
200
+ const provider = new MatrixContextProvider({
201
+ client,
202
+ asUserId: '@architect:hs',
203
+ agentBots: new Map(),
204
+ rooms: [{ alias: '!a:hs' }],
205
+ })
206
+ const result = await provider.sendMessage({ room: '!a:hs', thread_id: '$root', text: 'noted' })
207
+ expect(result).toEqual({ event_id: '$sent', thread_id: '$root' })
208
+ expect(sendMessage).toHaveBeenCalledWith({
209
+ roomId: '!a:hs',
210
+ asUserId: '@architect:hs',
211
+ content: { msgtype: 'm.notice', body: 'noted' },
212
+ threadRoot: '$root',
213
+ })
214
+ })
215
+
216
+ it('sendMessage refuses a room this agent is not bound to', async () => {
217
+ const provider = new MatrixContextProvider({
218
+ client: fakeClient(),
219
+ asUserId: '@architect:hs',
220
+ agentBots: new Map(),
221
+ rooms: [{ alias: '!a:hs' }],
222
+ })
223
+ await expect(provider.sendMessage({ room: '!elsewhere:hs', text: 'hi' })).rejects.toThrow(
224
+ /not_in_room/,
225
+ )
226
+ })
227
+
171
228
  it('getRecentThreads returns top-level entries newest-first with bundled thread metadata, skipping thread replies', async () => {
172
229
  const client = fakeClient({
173
230
  fetchRoomMessages: vi.fn().mockResolvedValue({
@@ -311,7 +368,7 @@ describe('MatrixContextProvider', () => {
311
368
  asUserId: '@_zooid:hs',
312
369
  agentBots: new Map(),
313
370
  })
314
- const info = await provider.getChannelInfo('!room:hs')
371
+ const info = await provider.getRoomInfo('!room:hs')
315
372
  expect(info.name).toBe('!room:hs')
316
373
  })
317
374
 
@@ -347,4 +404,122 @@ describe('MatrixContextProvider', () => {
347
404
  expect(byId.get('$img')?.text).toBe('[image: dog.jpg]')
348
405
  expect(byId.get('$file')?.text).toBe('[file: report.pdf]')
349
406
  })
407
+
408
+ it('renders agent prose sent as m.notice — ZNC025 §10 switches agent output to m.notice', async () => {
409
+ const client = fakeClient({
410
+ fetchRoomMessages: vi.fn().mockResolvedValue({
411
+ chunk: [
412
+ {
413
+ event_id: '$e1',
414
+ sender: '@architect:hs',
415
+ origin_server_ts: 1000,
416
+ type: 'm.room.message',
417
+ content: { msgtype: 'm.notice', body: 'agent output' },
418
+ },
419
+ ],
420
+ end: undefined,
421
+ }),
422
+ } as unknown as Partial<MatrixClient>)
423
+ const provider = new MatrixContextProvider({
424
+ client,
425
+ asUserId: '@_zooid:hs',
426
+ agentBots: new Map([['@architect:hs', 'architect']]),
427
+ })
428
+ const page = await provider.getRoomHistory('!room:hs', {})
429
+ expect(page.messages[0]?.text).toBe('agent output')
430
+ })
431
+
432
+ it('getRecentThreads keeps a thread rooted in an m.notice', async () => {
433
+ const client = fakeClient({
434
+ fetchRoomMessages: vi.fn().mockResolvedValue({
435
+ chunk: [
436
+ {
437
+ event_id: '$root',
438
+ sender: '@architect:hs',
439
+ origin_server_ts: 1000,
440
+ type: 'm.room.message',
441
+ content: { msgtype: 'm.notice', body: 'agent thread root' },
442
+ },
443
+ ],
444
+ end: undefined,
445
+ }),
446
+ } as unknown as Partial<MatrixClient>)
447
+ const provider = new MatrixContextProvider({
448
+ client,
449
+ asUserId: '@_zooid:hs',
450
+ agentBots: new Map([['@architect:hs', 'architect']]),
451
+ })
452
+ const page = await provider.getRecentThreads('!room:hs', { limit: 50 })
453
+ expect(page.threads.map((t) => t.id)).toEqual(['$root'])
454
+ expect(page.threads[0]?.text).toBe('agent thread root')
455
+ })
456
+ })
457
+
458
+ // The authorization boundary for context reads is the homeserver, not this
459
+ // class: every read is impersonated as the *agent's own* Matrix user, so a room
460
+ // or thread the agent isn't in fails at Matrix. Nothing asserted that, and a
461
+ // stale doc comment claimed the opposite (that asUserId was the AS bot, which
462
+ // can read every room) — so a refactor could have quietly swapped in the AS
463
+ // user and turned a homeserver-enforced boundary into an honour system.
464
+ describe('MatrixContextProvider — reads are impersonated as the agent', () => {
465
+ const AGENT = '@dev.assistant:hs'
466
+
467
+ function provider(overrides: Partial<MatrixClient>) {
468
+ return new MatrixContextProvider({
469
+ client: fakeClient(overrides),
470
+ asUserId: AGENT,
471
+ agentBots: new Map(),
472
+ })
473
+ }
474
+
475
+ it('threads the agent user through every read, never a different user', async () => {
476
+ const fetchRoomMessages = vi.fn().mockResolvedValue({ chunk: [], end: undefined })
477
+ const getJoinedMembers = vi.fn().mockResolvedValue({ joined: {} })
478
+ const fetchRoomName = vi.fn().mockResolvedValue('room')
479
+ const fetchEvent = vi.fn().mockResolvedValue(null)
480
+ const fetchThreadRelations = vi.fn().mockResolvedValue({ chunk: [], next_batch: undefined })
481
+ const p = provider({
482
+ fetchRoomMessages,
483
+ getJoinedMembers,
484
+ fetchRoomName,
485
+ fetchEvent,
486
+ fetchThreadRelations,
487
+ } as unknown as Partial<MatrixClient>)
488
+
489
+ await p.getRoomHistory('!room:hs', { limit: 10 })
490
+ await p.getRecentThreads('!room:hs', { limit: 10 })
491
+ await p.getThreadHistory('!room:hs', '$root', { limit: 10 })
492
+ await p.getChannelMembers('!room:hs')
493
+ await p.getRoomInfo('!room:hs')
494
+
495
+ expect(fetchRoomMessages.mock.calls.every(([a]) => a.asUserId === AGENT)).toBe(true)
496
+ expect(fetchThreadRelations).toHaveBeenCalledWith(expect.objectContaining({ asUserId: AGENT }))
497
+ // These two take the user as a positional argument, not a field.
498
+ expect(getJoinedMembers).toHaveBeenCalledWith('!room:hs', AGENT)
499
+ expect(fetchRoomName).toHaveBeenCalledWith('!room:hs', AGENT)
500
+ expect(fetchEvent).toHaveBeenCalledWith('!room:hs', '$root', AGENT)
501
+ })
502
+
503
+ // An agent naming a room it isn't in must fail loudly. Swallowing the error
504
+ // and returning `{ messages: [] }` would read as "empty room" to the model —
505
+ // indistinguishable from a real empty room, and it would hide the refusal.
506
+ it('propagates a homeserver refusal instead of returning an empty page', async () => {
507
+ const forbidden = new Error('fetchRoomMessages(!private:hs) failed: 403')
508
+ const p = provider({
509
+ fetchRoomMessages: vi.fn().mockRejectedValue(forbidden),
510
+ } as unknown as Partial<MatrixClient>)
511
+
512
+ await expect(p.getRoomHistory('!private:hs', { limit: 10 })).rejects.toThrow('403')
513
+ })
514
+
515
+ it('propagates a refusal on the thread path too', async () => {
516
+ const p = provider({
517
+ fetchEvent: vi.fn().mockResolvedValue(null),
518
+ fetchThreadRelations: vi
519
+ .fn()
520
+ .mockRejectedValue(new Error('fetchThreadRelations($x) failed: 403')),
521
+ } as unknown as Partial<MatrixClient>)
522
+
523
+ await expect(p.getThreadHistory('!private:hs', '$x', { limit: 10 })).rejects.toThrow('403')
524
+ })
350
525
  })
@@ -3,12 +3,15 @@ import type {
3
3
  HistoryOptions,
4
4
  HistoryPage,
5
5
  Member,
6
- ChannelInfo,
6
+ RoomInfo,
7
+ SendMessageInput,
8
+ SendMessageResult,
7
9
  Message,
8
10
  ThreadOverview,
9
11
  ThreadOverviewPage,
10
12
  } from '@zooid/core'
11
13
  import type { MatrixClient } from './matrix-client.js'
14
+ import type { RoomBinding } from '@zooid/core'
12
15
 
13
16
  interface MatrixMessageEvent {
14
17
  event_id: string
@@ -32,10 +35,30 @@ interface MatrixMessageEvent {
32
35
 
33
36
  export interface MatrixContextProviderOpts {
34
37
  client: MatrixClient
35
- /** AS sender_localpart user (read access). */
38
+ /**
39
+ * The **agent's own** Matrix user (`@{workstation}.{name}:server`), which
40
+ * every read is impersonated as via `?user_id=`. Not the AS bot: that would
41
+ * read every room on the homeserver and make this class the only thing
42
+ * standing between an agent and someone else's conversation.
43
+ *
44
+ * This is load-bearing. It is what makes the homeserver — not our own
45
+ * bookkeeping — the authorization boundary for context reads, so a room or
46
+ * thread the agent is not in fails at Matrix with 403/404. Anything that
47
+ * widens who can name a room (a CLI, a new tool parameter) is safe only
48
+ * while this holds.
49
+ */
36
50
  asUserId: string
37
51
  /** Map of Matrix user IDs → agent names, for is_agent / agent_name flags. */
38
52
  agentBots: Map<string, string>
53
+ /**
54
+ * This agent's own room bindings — the live array `BotPool.bootstrap`
55
+ * rewrites `.alias` on in place, so reads through this field after
56
+ * bootstrap see canonical room IDs. Backs `getRooms()` and the
57
+ * `sendMessage()` authorization check. Absent/empty = no rooms known
58
+ * (context providers built before this field existed, or in tests that
59
+ * don't exercise either method).
60
+ */
61
+ rooms?: RoomBinding[]
39
62
  }
40
63
 
41
64
  export class MatrixContextProvider implements TransportContextProvider {
@@ -84,7 +107,14 @@ export class MatrixContextProvider implements TransportContextProvider {
84
107
  const threads: ThreadOverview[] = []
85
108
  for (const ev of chunk as unknown as MatrixMessageEvent[]) {
86
109
  if (ev.type !== 'm.room.message') continue
87
- if (ev.content?.msgtype !== 'm.text' || typeof ev.content.body !== 'string') continue
110
+ // m.notice: agent prose sends as m.notice so
111
+ // .m.rule.suppress_notices silences the chunk storm server-side
112
+ // (ZNC025 §10) — a thread root sent by an agent must still surface here.
113
+ if (
114
+ (ev.content?.msgtype !== 'm.text' && ev.content?.msgtype !== 'm.notice') ||
115
+ typeof ev.content.body !== 'string'
116
+ )
117
+ continue
88
118
  const relatesTo = ev.content['m.relates_to']
89
119
  if (relatesTo?.rel_type === 'm.thread') continue // skip thread replies
90
120
  const agent = this.opts.agentBots.get(ev.sender)
@@ -169,7 +199,8 @@ export class MatrixContextProvider implements TransportContextProvider {
169
199
  }
170
200
  }
171
201
 
172
- if (msgtype !== 'm.text' || typeof body !== 'string') return null
202
+ // Agent prose sends as m.notice (ZNC025 §10); m.text is human prose.
203
+ if ((msgtype !== 'm.text' && msgtype !== 'm.notice') || typeof body !== 'string') return null
173
204
  return {
174
205
  id: ev.event_id,
175
206
  sender: ev.sender,
@@ -194,7 +225,7 @@ export class MatrixContextProvider implements TransportContextProvider {
194
225
  })
195
226
  }
196
227
 
197
- async getChannelInfo(channelId: string): Promise<ChannelInfo> {
228
+ async getRoomInfo(channelId: string): Promise<RoomInfo> {
198
229
  const name = await this.opts.client.fetchRoomName(channelId, this.opts.asUserId)
199
230
  return {
200
231
  id: channelId,
@@ -202,4 +233,30 @@ export class MatrixContextProvider implements TransportContextProvider {
202
233
  transport: 'matrix',
203
234
  }
204
235
  }
236
+
237
+ async getRooms(): Promise<RoomInfo[]> {
238
+ const rooms = this.opts.rooms ?? []
239
+ return Promise.all(
240
+ rooms.map(async (r) => {
241
+ const name = await this.opts.client.fetchRoomName(r.alias, this.opts.asUserId)
242
+ return { id: r.alias, name: name ?? r.alias, transport: 'matrix' as const }
243
+ }),
244
+ )
245
+ }
246
+
247
+ async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {
248
+ const rooms = this.opts.rooms ?? []
249
+ if (!rooms.some((r) => r.alias === input.room)) {
250
+ throw new Error(`not_in_room: this agent is not a member of ${input.room}`)
251
+ }
252
+ const { event_id } = await this.opts.client.sendMessage({
253
+ roomId: input.room,
254
+ asUserId: this.opts.asUserId,
255
+ // m.notice, not m.text: agent prose sends as m.notice so
256
+ // .m.rule.suppress_notices silences it server-side (ZNC025 §10).
257
+ content: { msgtype: 'm.notice', body: input.text },
258
+ ...(input.thread_id ? { threadRoot: input.thread_id } : {}),
259
+ })
260
+ return { event_id, ...(input.thread_id ? { thread_id: input.thread_id } : {}) }
261
+ }
205
262
  }
@@ -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
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export { MatrixClient } from './matrix-client.js'
2
- export type { MatrixClientOptions, SendMessageInput, SendCustomEventInput } from './matrix-client.js'
2
+ export type {
3
+ MatrixClientOptions,
4
+ SendMessageInput,
5
+ SendCustomEventInput,
6
+ } from './matrix-client.js'
3
7
  export { MatrixContextProvider } from './context-provider.js'
4
8
  export type { MatrixContextProviderOpts } from './context-provider.js'
5
9
  export { renderRegistration } from './registration.js'
@@ -22,7 +26,11 @@ export { createMatrixTransport } from './transport.js'
22
26
  export type { CreateMatrixTransportOptions, MediaClientLike } from './transport.js'
23
27
  export { SyncLoop } from './sync-loop.js'
24
28
  export type { SyncLoopOptions, SyncResponse, SyncClient } from './sync-loop.js'
25
- export { ensureDefaultChannel, ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
29
+ export {
30
+ ensureDefaultChannel,
31
+ ensureWorkforceSpace,
32
+ serverNameFromMxid,
33
+ } from './space-provisioner.js'
26
34
  export type { EnsureDefaultChannelOpts, EnsureSpaceOpts } from './space-provisioner.js'
27
35
  export {
28
36
  buildWorkforceRoster,
@@ -35,9 +43,21 @@ export type {
35
43
  PublisherHandle,
36
44
  StartOpts as StartWorkforcePublisherOpts,
37
45
  } from './workforce-publisher.js'
38
- export { MediaClient, parseMxcUri, MAX_INLINE_IMAGE_BYTES, INLINE_IMAGE_MIMES, MAX_DOWNLOAD_BYTES } from './media-client.js'
46
+ export {
47
+ MediaClient,
48
+ parseMxcUri,
49
+ MAX_INLINE_IMAGE_BYTES,
50
+ INLINE_IMAGE_MIMES,
51
+ MAX_DOWNLOAD_BYTES,
52
+ } from './media-client.js'
39
53
  export type { MediaClientOptions } from './media-client.js'
40
54
  export { PendingMediaStore, MAX_MEDIA_PER_TURN } from './pending-media.js'
41
55
  export type { PendingMediaItem } from './pending-media.js'
42
56
  export { writeAttachment } from './attachments.js'
43
57
  export type { WriteAttachmentInput } from './attachments.js'
58
+ export { TaskRegistry, MAX_OPEN_TASKS_PER_ROOM } from './task-registry.js'
59
+ export type { TaskRecord, TaskPhase, PersistedTask, TaskJournal } from './task-registry.js'
60
+ export { InvocationRegistry } from './invocation-registry.js'
61
+ export { evaluateCompletion } from './task-completion.js'
62
+ export type { CompletionInputs, CompletionDecision, StopReason } from './task-completion.js'
63
+ export { checkDelegable, buildAssignmentContent, renderCompletionPrompt, renderInvocationReturn } from './task-dispatch.js'
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { InvocationRegistry } from './invocation-registry.js'
3
+
4
+ describe('InvocationRegistry', () => {
5
+ it('tracks a handoff before its Matrix event exists and resolves exactly once', () => {
6
+ const registry = new InvocationRegistry({ newId: () => 'i1' })
7
+ const invocation = registry.open({ taskId: 't1', callerAgent: 'a', callerSessionKey: '$root', calleeAgent: 'b' })
8
+ expect(registry.outstandingFor('$root')).toHaveLength(1)
9
+ registry.attachCallEvent(invocation.invocationId, '$call', '$root|$call')
10
+ expect(registry.forCalleeSession('$root|$call')).toBe(invocation)
11
+ expect(registry.resolve(invocation.invocationId)).toBe(invocation)
12
+ expect(registry.resolve(invocation.invocationId)).toBeUndefined()
13
+ })
14
+ it('detects an outstanding ancestor and cancels late returns', () => {
15
+ const registry = new InvocationRegistry({ newId: () => 'i1' })
16
+ const invocation = registry.open({ taskId: 't1', callerAgent: 'a', callerSessionKey: '$root', calleeAgent: 'b' })
17
+ registry.attachCallEvent('i1', '$call', '$root|$call')
18
+ expect(registry.isOutstandingAncestor('$root|$call', 'a')).toBe(true)
19
+ registry.cancelForTask('t1')
20
+ expect(registry.resolve('i1')).toBeUndefined()
21
+ })
22
+ })
@@ -0,0 +1,32 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { InvocationRecord } from '@zooid/core'
3
+
4
+ export class InvocationRegistry {
5
+ private readonly records = new Map<string, InvocationRecord>()
6
+ private readonly byCallee = new Map<string, string>()
7
+ private readonly byEvent = new Map<string, string>()
8
+ constructor(private readonly opts: { newId?: () => string } = {}) {}
9
+ open(input: Omit<InvocationRecord, 'invocationId' | 'state'>): InvocationRecord {
10
+ const record = { invocationId: this.opts.newId?.() ?? randomUUID(), state: 'outstanding' as const, ...input }
11
+ this.records.set(record.invocationId, record)
12
+ return record
13
+ }
14
+ attachCallEvent(id: string, eventId: string, sessionKey: string) {
15
+ const r = this.records.get(id); if (!r) return
16
+ r.callEventId = eventId; r.calleeSessionKey = sessionKey
17
+ this.byEvent.set(eventId, id); this.byCallee.set(sessionKey, id)
18
+ }
19
+ get(id: string) { return this.records.get(id) }
20
+ byCallEvent(eventId: string) { const id = this.byEvent.get(eventId); return id ? this.records.get(id) : undefined }
21
+ forCalleeSession(session: string) { const id = this.byCallee.get(session); return id ? this.records.get(id) : undefined }
22
+ outstandingFor(session: string) { return [...this.records.values()].filter(x => x.state === 'outstanding' && x.callerSessionKey === session) }
23
+ outstandingForTask(taskId: string) { return [...this.records.values()].filter(x => x.state === 'outstanding' && x.taskId === taskId) }
24
+ resolve(id: string) { const r = this.records.get(id); if (!r || r.state !== 'outstanding') return; r.state = 'returned'; return r }
25
+ cancelForTask(taskId: string) { const records = this.outstandingForTask(taskId); for (const r of records) r.state = 'cancelled'; return records }
26
+ ancestorAgents(session: string) {
27
+ const agents: string[] = [], seen = new Set([session]); let cursor = session
28
+ for (;;) { const r = this.forCalleeSession(cursor); if (!r || r.state !== 'outstanding') break; agents.push(r.callerAgent); if (seen.has(r.callerSessionKey)) break; seen.add(r.callerSessionKey); cursor = r.callerSessionKey }
29
+ return agents
30
+ }
31
+ isOutstandingAncestor(session: string, agent: string) { return this.ancestorAgents(session).includes(agent) }
32
+ }