@zooid/transport-matrix 0.13.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 +152 -4
- package/dist/index.js +898 -94
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/context-provider.test.ts +61 -4
- package/src/context-provider.ts +40 -2
- 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 +403 -114
- package/src/transport.ts +719 -110
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
|
|
|
@@ -433,7 +490,7 @@ describe('MatrixContextProvider — reads are impersonated as the agent', () =>
|
|
|
433
490
|
await p.getRecentThreads('!room:hs', { limit: 10 })
|
|
434
491
|
await p.getThreadHistory('!room:hs', '$root', { limit: 10 })
|
|
435
492
|
await p.getChannelMembers('!room:hs')
|
|
436
|
-
await p.
|
|
493
|
+
await p.getRoomInfo('!room:hs')
|
|
437
494
|
|
|
438
495
|
expect(fetchRoomMessages.mock.calls.every(([a]) => a.asUserId === AGENT)).toBe(true)
|
|
439
496
|
expect(fetchThreadRelations).toHaveBeenCalledWith(expect.objectContaining({ asUserId: AGENT }))
|
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
|
|
@@ -47,6 +50,15 @@ export interface MatrixContextProviderOpts {
|
|
|
47
50
|
asUserId: string
|
|
48
51
|
/** Map of Matrix user IDs → agent names, for is_agent / agent_name flags. */
|
|
49
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[]
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
export class MatrixContextProvider implements TransportContextProvider {
|
|
@@ -213,7 +225,7 @@ export class MatrixContextProvider implements TransportContextProvider {
|
|
|
213
225
|
})
|
|
214
226
|
}
|
|
215
227
|
|
|
216
|
-
async
|
|
228
|
+
async getRoomInfo(channelId: string): Promise<RoomInfo> {
|
|
217
229
|
const name = await this.opts.client.fetchRoomName(channelId, this.opts.asUserId)
|
|
218
230
|
return {
|
|
219
231
|
id: channelId,
|
|
@@ -221,4 +233,30 @@ export class MatrixContextProvider implements TransportContextProvider {
|
|
|
221
233
|
transport: 'matrix',
|
|
222
234
|
}
|
|
223
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
|
+
}
|
|
224
262
|
}
|
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
|
+
}
|
package/src/matrix-client.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface SendMessageInput {
|
|
|
11
11
|
asUserId: string
|
|
12
12
|
content: { msgtype: string; body: string; [k: string]: unknown }
|
|
13
13
|
threadRoot?: string
|
|
14
|
+
txnId?: string
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface SendCustomEventInput {
|
|
@@ -18,6 +19,7 @@ export interface SendCustomEventInput {
|
|
|
18
19
|
asUserId: string
|
|
19
20
|
eventType: string
|
|
20
21
|
content: Record<string, unknown>
|
|
22
|
+
txnId?: string
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export interface SetTypingInput {
|
|
@@ -51,7 +53,10 @@ export class MatrixClient {
|
|
|
51
53
|
const r = await this.fetch(`${this.homeserver}/_matrix/client/v3/register`, {
|
|
52
54
|
method: 'POST',
|
|
53
55
|
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
54
|
-
body: JSON.stringify({
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
type: 'm.login.application_service',
|
|
58
|
+
username: localpart,
|
|
59
|
+
}),
|
|
55
60
|
})
|
|
56
61
|
if (r.status === 200) return (await r.json()) as { user_id: string; device_id: string }
|
|
57
62
|
if (r.status === 400) {
|
|
@@ -143,10 +148,7 @@ export class MatrixClient {
|
|
|
143
148
|
return j.room_id
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
async createRoomRaw(opts: {
|
|
147
|
-
asUserId: string
|
|
148
|
-
body: Record<string, unknown>
|
|
149
|
-
}): Promise<string> {
|
|
151
|
+
async createRoomRaw(opts: { asUserId: string; body: Record<string, unknown> }): Promise<string> {
|
|
150
152
|
const url = `${this.homeserver}/_matrix/client/v3/createRoom?user_id=${encodeURIComponent(opts.asUserId)}`
|
|
151
153
|
const r = await this.fetch(url, {
|
|
152
154
|
method: 'POST',
|
|
@@ -190,11 +192,7 @@ export class MatrixClient {
|
|
|
190
192
|
* already invited" responses idempotently so bootstrap can run on a
|
|
191
193
|
* fresh AND a populated homeserver without branching.
|
|
192
194
|
*/
|
|
193
|
-
async invite(opts: {
|
|
194
|
-
roomId: string
|
|
195
|
-
asUserId: string
|
|
196
|
-
targetUserId: string
|
|
197
|
-
}): Promise<void> {
|
|
195
|
+
async invite(opts: { roomId: string; asUserId: string; targetUserId: string }): Promise<void> {
|
|
198
196
|
const url =
|
|
199
197
|
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/invite` +
|
|
200
198
|
`?user_id=${encodeURIComponent(opts.asUserId)}`
|
|
@@ -229,11 +227,7 @@ export class MatrixClient {
|
|
|
229
227
|
throw new Error(`invite(${opts.targetUserId}) failed: ${r.status}`)
|
|
230
228
|
}
|
|
231
229
|
|
|
232
|
-
async leaveRoom(
|
|
233
|
-
roomId: string,
|
|
234
|
-
asUserId: string,
|
|
235
|
-
opts?: { reason?: string },
|
|
236
|
-
): Promise<void> {
|
|
230
|
+
async leaveRoom(roomId: string, asUserId: string, opts?: { reason?: string }): Promise<void> {
|
|
237
231
|
const url =
|
|
238
232
|
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave` +
|
|
239
233
|
`?user_id=${encodeURIComponent(asUserId)}`
|
|
@@ -263,13 +257,16 @@ export class MatrixClient {
|
|
|
263
257
|
async sendMessage(input: SendMessageInput): Promise<{ event_id: string }> {
|
|
264
258
|
const content: Record<string, unknown> = { ...input.content }
|
|
265
259
|
if (input.threadRoot) {
|
|
266
|
-
content['m.relates_to'] = {
|
|
260
|
+
content['m.relates_to'] = {
|
|
261
|
+
rel_type: 'm.thread',
|
|
262
|
+
event_id: input.threadRoot,
|
|
263
|
+
}
|
|
267
264
|
}
|
|
268
|
-
return this.sendEvent(input.roomId, input.asUserId, 'm.room.message', content)
|
|
265
|
+
return this.sendEvent(input.roomId, input.asUserId, 'm.room.message', content, input.txnId)
|
|
269
266
|
}
|
|
270
267
|
|
|
271
268
|
async sendCustomEvent(input: SendCustomEventInput): Promise<{ event_id: string }> {
|
|
272
|
-
return this.sendEvent(input.roomId, input.asUserId, input.eventType, input.content)
|
|
269
|
+
return this.sendEvent(input.roomId, input.asUserId, input.eventType, input.content, input.txnId)
|
|
273
270
|
}
|
|
274
271
|
|
|
275
272
|
async setTyping(input: SetTypingInput): Promise<void> {
|
|
@@ -356,9 +353,14 @@ export class MatrixClient {
|
|
|
356
353
|
user_id: opts.asUserId,
|
|
357
354
|
})
|
|
358
355
|
if (opts.from) params.set('from', opts.from)
|
|
356
|
+
// The 3-segment relations endpoint (rel_type + event_type) scopes both the
|
|
357
|
+
// returned chunk AND the next_batch cursor to m.room.message server-side —
|
|
358
|
+
// without it we'd paginate over every m.thread relation (edits, reactions,
|
|
359
|
+
// redactions) and toMessage()'s client-side filtering would desync from
|
|
360
|
+
// has_more/next_before (zooid-ai/zooid#21).
|
|
359
361
|
const url =
|
|
360
362
|
`${this.homeserver}/_matrix/client/v1/rooms/${encodeURIComponent(opts.roomId)}` +
|
|
361
|
-
`/relations/${encodeURIComponent(opts.rootEventId)}/m.thread?${params.toString()}`
|
|
363
|
+
`/relations/${encodeURIComponent(opts.rootEventId)}/m.thread/m.room.message?${params.toString()}`
|
|
362
364
|
const r = await this.fetch(url, {
|
|
363
365
|
method: 'GET',
|
|
364
366
|
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
@@ -409,7 +411,10 @@ export class MatrixClient {
|
|
|
409
411
|
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
410
412
|
})
|
|
411
413
|
if (!r.ok) throw new Error(`fetchRoomMessages(${opts.roomId}) failed: ${r.status}`)
|
|
412
|
-
return (await r.json()) as {
|
|
414
|
+
return (await r.json()) as {
|
|
415
|
+
chunk: Array<Record<string, unknown>>
|
|
416
|
+
end?: string
|
|
417
|
+
}
|
|
413
418
|
}
|
|
414
419
|
|
|
415
420
|
async getJoinedMembers(
|
|
@@ -424,19 +429,24 @@ export class MatrixClient {
|
|
|
424
429
|
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
425
430
|
})
|
|
426
431
|
if (!r.ok) throw new Error(`getJoinedMembers(${roomId}) failed: ${r.status}`)
|
|
427
|
-
return (await r.json()) as {
|
|
432
|
+
return (await r.json()) as {
|
|
433
|
+
joined: Record<string, { display_name?: string }>
|
|
434
|
+
}
|
|
428
435
|
}
|
|
429
436
|
|
|
430
|
-
async sync(opts: {
|
|
431
|
-
asUserId: string
|
|
432
|
-
since?: string | null
|
|
433
|
-
timeoutMs?: number
|
|
434
|
-
}): Promise<{
|
|
437
|
+
async sync(opts: { asUserId: string; since?: string | null; timeoutMs?: number }): Promise<{
|
|
435
438
|
next_batch: string
|
|
436
439
|
rooms: {
|
|
437
|
-
join: Record<
|
|
438
|
-
|
|
439
|
-
|
|
440
|
+
join: Record<
|
|
441
|
+
string,
|
|
442
|
+
{
|
|
443
|
+
timeline: {
|
|
444
|
+
events: Record<string, unknown>[]
|
|
445
|
+
prev_batch?: string
|
|
446
|
+
limited?: boolean
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
>
|
|
440
450
|
}
|
|
441
451
|
}> {
|
|
442
452
|
const params = new URLSearchParams({
|
|
@@ -452,9 +462,16 @@ export class MatrixClient {
|
|
|
452
462
|
return r.json() as Promise<{
|
|
453
463
|
next_batch: string
|
|
454
464
|
rooms: {
|
|
455
|
-
join: Record<
|
|
456
|
-
|
|
457
|
-
|
|
465
|
+
join: Record<
|
|
466
|
+
string,
|
|
467
|
+
{
|
|
468
|
+
timeline: {
|
|
469
|
+
events: Record<string, unknown>[]
|
|
470
|
+
prev_batch?: string
|
|
471
|
+
limited?: boolean
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
>
|
|
458
475
|
}
|
|
459
476
|
}>
|
|
460
477
|
}
|
|
@@ -478,8 +495,9 @@ export class MatrixClient {
|
|
|
478
495
|
asUserId: string,
|
|
479
496
|
eventType: string,
|
|
480
497
|
content: Record<string, unknown>,
|
|
498
|
+
txnId?: string,
|
|
481
499
|
): Promise<{ event_id: string }> {
|
|
482
|
-
const txn = randomUUID()
|
|
500
|
+
const txn = txnId ?? randomUUID()
|
|
483
501
|
const url =
|
|
484
502
|
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}` +
|
|
485
503
|
`/send/${eventType}/${txn}?user_id=${encodeURIComponent(asUserId)}`
|
|
@@ -488,7 +506,13 @@ export class MatrixClient {
|
|
|
488
506
|
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
489
507
|
body: JSON.stringify(content),
|
|
490
508
|
})
|
|
491
|
-
if (!r.ok)
|
|
509
|
+
if (!r.ok) {
|
|
510
|
+
const err = new Error(`sendEvent(${eventType}) failed: ${r.status}`) as Error & {
|
|
511
|
+
status?: number
|
|
512
|
+
}
|
|
513
|
+
err.status = r.status
|
|
514
|
+
throw err
|
|
515
|
+
}
|
|
492
516
|
return (await r.json()) as { event_id: string }
|
|
493
517
|
}
|
|
494
518
|
}
|
package/src/router.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
route,
|
|
4
|
+
isMediaMsgtype,
|
|
5
|
+
wouldCycleCallers,
|
|
6
|
+
type AgentBinding,
|
|
7
|
+
type ThreadState,
|
|
8
|
+
} from './router.js'
|
|
3
9
|
|
|
4
10
|
const agents: AgentBinding[] = [
|
|
5
11
|
{
|
|
@@ -17,7 +23,12 @@ const agents: AgentBinding[] = [
|
|
|
17
23
|
]
|
|
18
24
|
|
|
19
25
|
function msg(
|
|
20
|
-
overrides: Partial<{
|
|
26
|
+
overrides: Partial<{
|
|
27
|
+
room: string
|
|
28
|
+
sender: string
|
|
29
|
+
body: string
|
|
30
|
+
mentions: string[]
|
|
31
|
+
}> = {},
|
|
21
32
|
) {
|
|
22
33
|
return {
|
|
23
34
|
type: 'm.room.message',
|
|
@@ -55,7 +66,10 @@ describe('route', () => {
|
|
|
55
66
|
|
|
56
67
|
it('skips events whose sender is the matched agent itself', () => {
|
|
57
68
|
const matches = route(
|
|
58
|
-
msg({
|
|
69
|
+
msg({
|
|
70
|
+
sender: '@architect:example.com',
|
|
71
|
+
mentions: ['@architect:example.com'],
|
|
72
|
+
}),
|
|
59
73
|
agents,
|
|
60
74
|
)
|
|
61
75
|
expect(matches).toEqual([])
|
|
@@ -71,10 +85,7 @@ describe('route', () => {
|
|
|
71
85
|
trigger: 'mention',
|
|
72
86
|
},
|
|
73
87
|
]
|
|
74
|
-
const matches = route(
|
|
75
|
-
msg({ mentions: ['@architect:example.com', '@qa:example.com'] }),
|
|
76
|
-
both,
|
|
77
|
-
)
|
|
88
|
+
const matches = route(msg({ mentions: ['@architect:example.com', '@qa:example.com'] }), both)
|
|
78
89
|
expect(matches.map((m) => m.name).sort()).toEqual(['architect', 'qa'])
|
|
79
90
|
})
|
|
80
91
|
|
|
@@ -89,6 +100,86 @@ describe('route', () => {
|
|
|
89
100
|
})
|
|
90
101
|
})
|
|
91
102
|
|
|
103
|
+
describe('directed task routing', () => {
|
|
104
|
+
const agents: AgentBinding[] = [
|
|
105
|
+
{
|
|
106
|
+
name: 'supervisor',
|
|
107
|
+
userId: '@supervisor:hs',
|
|
108
|
+
rooms: [{ alias: '!r:hs' }],
|
|
109
|
+
trigger: 'mention',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'worker',
|
|
113
|
+
userId: '@worker:hs',
|
|
114
|
+
rooms: [{ alias: '!r:hs' }],
|
|
115
|
+
trigger: 'mention',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'eager',
|
|
119
|
+
userId: '@eager:hs',
|
|
120
|
+
rooms: [{ alias: '!r:hs' }],
|
|
121
|
+
trigger: 'any',
|
|
122
|
+
},
|
|
123
|
+
]
|
|
124
|
+
const root = {
|
|
125
|
+
type: 'm.room.message',
|
|
126
|
+
room_id: '!r:hs',
|
|
127
|
+
sender: '@supervisor:hs',
|
|
128
|
+
content: {
|
|
129
|
+
msgtype: 'm.notice',
|
|
130
|
+
body: '@worker:hs task',
|
|
131
|
+
'm.mentions': { user_ids: ['@worker:hs'] },
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
it('routes a task root solely to its assignee, including self assignment', () => {
|
|
135
|
+
expect(
|
|
136
|
+
route(root, agents, new Map(), { assignee: 'worker', isRoot: true }).map((x) => x.name),
|
|
137
|
+
).toEqual(['worker'])
|
|
138
|
+
expect(
|
|
139
|
+
route(root, agents, new Map(), {
|
|
140
|
+
assignee: 'supervisor',
|
|
141
|
+
isRoot: true,
|
|
142
|
+
}).map((x) => x.name),
|
|
143
|
+
).toEqual(['supervisor'])
|
|
144
|
+
})
|
|
145
|
+
it('keeps trigger:any out of a task thread while allowing human steering and explicit mentions', () => {
|
|
146
|
+
const state = new Map([
|
|
147
|
+
[
|
|
148
|
+
'$task',
|
|
149
|
+
{
|
|
150
|
+
participants: ['worker'],
|
|
151
|
+
rootMentions: ['worker'],
|
|
152
|
+
callers: {},
|
|
153
|
+
handoffs: {},
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
])
|
|
157
|
+
const human = {
|
|
158
|
+
...root,
|
|
159
|
+
sender: '@alice:hs',
|
|
160
|
+
content: {
|
|
161
|
+
msgtype: 'm.text',
|
|
162
|
+
body: 'continue',
|
|
163
|
+
'm.relates_to': { rel_type: 'm.thread', event_id: '$task' },
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
expect(
|
|
167
|
+
route(human, agents, state, { assignee: 'worker', isRoot: false }).map((x) => x.name),
|
|
168
|
+
).toEqual(['worker'])
|
|
169
|
+
const mention = {
|
|
170
|
+
...human,
|
|
171
|
+
sender: '@worker:hs',
|
|
172
|
+
content: {
|
|
173
|
+
...human.content,
|
|
174
|
+
'm.mentions': { user_ids: ['@supervisor:hs'] },
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
expect(
|
|
178
|
+
route(mention, agents, state, { assignee: 'worker', isRoot: false }).map((x) => x.name),
|
|
179
|
+
).toEqual(['supervisor'])
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
92
183
|
describe('media events', () => {
|
|
93
184
|
it('classifies media msgtypes', () => {
|
|
94
185
|
for (const t of ['m.image', 'm.file', 'm.video', 'm.audio']) {
|
|
@@ -103,7 +194,11 @@ describe('media events', () => {
|
|
|
103
194
|
const monitorRoom = msg({ room: '!alerts:example.com', body: 'dog.jpg' })
|
|
104
195
|
const mediaEvent = {
|
|
105
196
|
...monitorRoom,
|
|
106
|
-
content: {
|
|
197
|
+
content: {
|
|
198
|
+
msgtype: 'm.image',
|
|
199
|
+
body: 'dog.jpg',
|
|
200
|
+
url: 'mxc://localhost/abc',
|
|
201
|
+
},
|
|
107
202
|
}
|
|
108
203
|
const matches = route(mediaEvent, agents)
|
|
109
204
|
expect(matches).toEqual([])
|
|
@@ -169,7 +264,10 @@ describe('directional thread continuation (agent-to-agent handoffs)', () => {
|
|
|
169
264
|
|
|
170
265
|
it('an explicit @mention still re-engages the sub (rule 1 wins)', () => {
|
|
171
266
|
const matches = route(
|
|
172
|
-
threadMsg({
|
|
267
|
+
threadMsg({
|
|
268
|
+
sender: '@parent:example.com',
|
|
269
|
+
mentions: ['@sub:example.com'],
|
|
270
|
+
}),
|
|
173
271
|
pair,
|
|
174
272
|
states({ participants: ['parent', 'sub'], callers: { sub: 'parent' } }),
|
|
175
273
|
)
|
|
@@ -178,7 +276,10 @@ describe('directional thread continuation (agent-to-agent handoffs)', () => {
|
|
|
178
276
|
|
|
179
277
|
it('dedupes: a sub reply that also @mentions its caller triggers the caller once', () => {
|
|
180
278
|
const matches = route(
|
|
181
|
-
threadMsg({
|
|
279
|
+
threadMsg({
|
|
280
|
+
sender: '@sub:example.com',
|
|
281
|
+
mentions: ['@parent:example.com'],
|
|
282
|
+
}),
|
|
182
283
|
pair,
|
|
183
284
|
states({ participants: ['parent'], callers: { sub: 'parent' } }),
|
|
184
285
|
)
|
|
@@ -228,6 +329,24 @@ describe('directional thread continuation (agent-to-agent handoffs)', () => {
|
|
|
228
329
|
})
|
|
229
330
|
})
|
|
230
331
|
|
|
332
|
+
describe('caller graph cycle guard', () => {
|
|
333
|
+
it('rejects a reverse edge back to an existing caller', () => {
|
|
334
|
+
expect(wouldCycleCallers({ sub: 'parent' }, 'parent', 'sub')).toBe(true)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('rejects a cycle through a deeper ancestor', () => {
|
|
338
|
+
expect(
|
|
339
|
+
wouldCycleCallers({ child: 'parent', grandchild: 'child' }, 'parent', 'grandchild'),
|
|
340
|
+
).toBe(true)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('allows a new downward or sibling edge', () => {
|
|
344
|
+
const callers = { child: 'parent' }
|
|
345
|
+
expect(wouldCycleCallers(callers, 'grandchild', 'child')).toBe(false)
|
|
346
|
+
expect(wouldCycleCallers(callers, 'sibling', 'parent')).toBe(false)
|
|
347
|
+
})
|
|
348
|
+
})
|
|
349
|
+
|
|
231
350
|
describe('fan-out: two subs called in one message ([[ZOD071]] acceptance)', () => {
|
|
232
351
|
const mk = (name: string): AgentBinding => ({
|
|
233
352
|
name,
|