@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/dist/index.d.ts +164 -5
- package/dist/index.js +946 -101
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/context-provider.test.ts +178 -3
- package/src/context-provider.ts +62 -5
- package/src/event-encoders.test.ts +69 -1
- package/src/event-encoders.ts +41 -1
- package/src/index.ts +23 -3
- package/src/invocation-registry.test.ts +22 -0
- package/src/invocation-registry.ts +32 -0
- package/src/matrix-client.ts +58 -34
- package/src/router.test.ts +129 -10
- package/src/router.ts +78 -2
- package/src/task-completion.test.ts +23 -0
- package/src/task-completion.ts +37 -0
- package/src/task-dispatch.test.ts +53 -0
- package/src/task-dispatch.ts +68 -0
- package/src/task-envelope.test.ts +27 -0
- package/src/task-registry.test.ts +50 -0
- package/src/task-registry.ts +152 -0
- package/src/transport.test.ts +520 -111
- package/src/transport.ts +760 -116
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zooid/transport-matrix",
|
|
3
|
-
"version": "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.
|
|
32
|
-
"@zooid/core": "^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('
|
|
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.
|
|
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.
|
|
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
|
})
|
package/src/context-provider.ts
CHANGED
|
@@ -3,12 +3,15 @@ import type {
|
|
|
3
3
|
HistoryOptions,
|
|
4
4
|
HistoryPage,
|
|
5
5
|
Member,
|
|
6
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
})
|
package/src/event-encoders.ts
CHANGED
|
@@ -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:
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
+
}
|