@zooid/transport-matrix 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +78 -4
- package/dist/index.js +194 -14
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/bot-pool.test.ts +200 -13
- package/src/bot-pool.ts +73 -5
- package/src/event-encoders.test.ts +86 -0
- package/src/event-encoders.ts +29 -0
- package/src/index.ts +2 -2
- package/src/matrix-client.test.ts +160 -0
- package/src/matrix-client.ts +64 -0
- package/src/router.test.ts +3 -3
- package/src/router.ts +11 -2
- package/src/space-provisioner.test.ts +191 -1
- package/src/space-provisioner.ts +77 -7
- package/src/transport.test.ts +58 -2
- package/src/transport.ts +72 -4
- package/src/workforce-publisher.test.ts +12 -2
- package/src/workforce-publisher.ts +5 -1
package/src/transport.test.ts
CHANGED
|
@@ -51,12 +51,12 @@ const baseAgents = [
|
|
|
51
51
|
{
|
|
52
52
|
name: 'architect',
|
|
53
53
|
userId: '@architect:example.com',
|
|
54
|
-
rooms: ['!r:example.com'],
|
|
54
|
+
rooms: [{ alias: '!r:example.com' }],
|
|
55
55
|
trigger: 'mention' as const,
|
|
56
56
|
},
|
|
57
57
|
]
|
|
58
58
|
|
|
59
|
-
function makeTransport() {
|
|
59
|
+
function makeTransport(drain?: { drainQuietMs?: number; drainMaxMs?: number }) {
|
|
60
60
|
const { reg, finishPrompt } = fakeRegistry()
|
|
61
61
|
const approvals = fakeApprovals()
|
|
62
62
|
const client = fakeClient()
|
|
@@ -66,6 +66,10 @@ function makeTransport() {
|
|
|
66
66
|
client: client as never,
|
|
67
67
|
bindings: baseAgents,
|
|
68
68
|
hsToken: 'hs-secret',
|
|
69
|
+
// Disable post-turn drain by default so settleTurn (microtasks) suffices.
|
|
70
|
+
// Tests covering trailing-chunk behavior pass an explicit window.
|
|
71
|
+
drainQuietMs: drain?.drainQuietMs ?? 0,
|
|
72
|
+
drainMaxMs: drain?.drainMaxMs,
|
|
69
73
|
})
|
|
70
74
|
return { transport, agents: reg, approvals, client, finishPrompt }
|
|
71
75
|
}
|
|
@@ -145,6 +149,58 @@ describe('matrix transport /transactions', () => {
|
|
|
145
149
|
)
|
|
146
150
|
})
|
|
147
151
|
|
|
152
|
+
it('drains trailing agent_message_chunks that arrive after prompt() resolves', async () => {
|
|
153
|
+
// ACP doesn't guarantee all session/update chunks precede the prompt
|
|
154
|
+
// response for a normal turn; opencode flushes a trailing chunk just after
|
|
155
|
+
// the stopReason. The post-turn drain must wait for it instead of sending
|
|
156
|
+
// the truncated buffer.
|
|
157
|
+
const { transport, agents, client } = makeTransport({ drainQuietMs: 20, drainMaxMs: 500 })
|
|
158
|
+
const events = [
|
|
159
|
+
{
|
|
160
|
+
type: 'm.room.message',
|
|
161
|
+
event_id: '$root',
|
|
162
|
+
room_id: '!r:example.com',
|
|
163
|
+
sender: '@alice:example.com',
|
|
164
|
+
content: {
|
|
165
|
+
msgtype: 'm.text',
|
|
166
|
+
body: 'hi',
|
|
167
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
]
|
|
171
|
+
agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
|
|
172
|
+
const sessionId = 'sess-' + p.threadId
|
|
173
|
+
// First chunk arrives during the turn…
|
|
174
|
+
agents.onEvent('architect', {
|
|
175
|
+
type: 'agent_message_chunk',
|
|
176
|
+
sessionId,
|
|
177
|
+
content: { type: 'text', text: 'Hi.' },
|
|
178
|
+
})
|
|
179
|
+
// …a second chunk lands shortly AFTER the prompt response resolves.
|
|
180
|
+
setTimeout(() => {
|
|
181
|
+
agents.onEvent('architect', {
|
|
182
|
+
type: 'agent_message_chunk',
|
|
183
|
+
sessionId,
|
|
184
|
+
content: { type: 'text', text: ' How can I help?' },
|
|
185
|
+
})
|
|
186
|
+
}, 5)
|
|
187
|
+
return { stopReason: 'end_turn' as const }
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const res = await postTxn(transport.app, { events })
|
|
191
|
+
expect(res.status).toBe(200)
|
|
192
|
+
// Wait past the drain window for the turn to finalize.
|
|
193
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
194
|
+
|
|
195
|
+
// Exactly one message, containing BOTH the early and the late chunk.
|
|
196
|
+
expect(client.sendMessage).toHaveBeenCalledTimes(1)
|
|
197
|
+
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
198
|
+
expect.objectContaining({
|
|
199
|
+
content: expect.objectContaining({ body: 'Hi. How can I help?' }),
|
|
200
|
+
}),
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
|
|
148
204
|
it('uses an in-thread message event_id as the thread root when one is set', async () => {
|
|
149
205
|
const { transport, agents, client } = makeTransport()
|
|
150
206
|
const events = [
|
package/src/transport.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { MatrixClient } from './matrix-client.js'
|
|
|
6
6
|
import { BotPool } from './bot-pool.js'
|
|
7
7
|
import { route, type AgentBinding, type ThreadState } from './router.js'
|
|
8
8
|
import { stripMention, extractMentions } from './mentions.js'
|
|
9
|
-
import { toToolCallBody, toUpdateBody, toPlanBody } from './event-encoders.js'
|
|
9
|
+
import { toToolCallBody, toUpdateBody, toPlanBody, toErrorBody } from './event-encoders.js'
|
|
10
|
+
import { classify } from '@zooid/acp-client'
|
|
10
11
|
import { toMatrixHtml } from './markdown-to-matrix-html.js'
|
|
11
12
|
|
|
12
13
|
export interface CreateMatrixTransportOptions {
|
|
@@ -17,6 +18,12 @@ export interface CreateMatrixTransportOptions {
|
|
|
17
18
|
hsToken: string
|
|
18
19
|
/** Admin Matrix user ID. When set, BotPool.bootstrap invites this user into rooms it creates. */
|
|
19
20
|
adminUserId?: string
|
|
21
|
+
/** Post-turn drain: keep collecting trailing `agent_message_chunk`s until the
|
|
22
|
+
* buffer is quiet for this long before flushing. Defaults to `DRAIN_QUIET_MS`.
|
|
23
|
+
* Set to 0 to disable the drain (e.g. in tests). */
|
|
24
|
+
drainQuietMs?: number
|
|
25
|
+
/** Hard cap on the post-turn drain. Defaults to `DRAIN_MAX_MS`. */
|
|
26
|
+
drainMaxMs?: number
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
interface SessionContext {
|
|
@@ -42,6 +49,19 @@ interface MatrixEvent {
|
|
|
42
49
|
const STARTUP_GRACE_MS = 5_000
|
|
43
50
|
const SEEN_EVENT_CAP = 5_000
|
|
44
51
|
|
|
52
|
+
// ACP only guarantees that an agent flushes pending `session/update`
|
|
53
|
+
// notifications before the `session/prompt` response in the *cancellation*
|
|
54
|
+
// path; for a normal turn the ordering is unspecified. Some agents (e.g.
|
|
55
|
+
// opencode) emit trailing `agent_message_chunk`s a few ms after the stopReason
|
|
56
|
+
// response, so finalizing the moment `prompt()` resolves truncates the reply.
|
|
57
|
+
// After the turn resolves we wait for the buffer to stay unchanged for
|
|
58
|
+
// DRAIN_QUIET_MS (debounce — re-arms on each late chunk) before flushing,
|
|
59
|
+
// capped at DRAIN_MAX_MS so a misbehaving stream can't hang the turn.
|
|
60
|
+
const DRAIN_QUIET_MS = 300
|
|
61
|
+
const DRAIN_MAX_MS = 3_000
|
|
62
|
+
|
|
63
|
+
const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
|
64
|
+
|
|
45
65
|
function inboundThreadRoot(evt: MatrixEvent): string | undefined {
|
|
46
66
|
const r = evt.content?.['m.relates_to']
|
|
47
67
|
return r?.rel_type === 'm.thread' && r.event_id ? r.event_id : undefined
|
|
@@ -49,6 +69,8 @@ function inboundThreadRoot(evt: MatrixEvent): string | undefined {
|
|
|
49
69
|
|
|
50
70
|
export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
51
71
|
const { agents, approvals, client, bindings, hsToken, adminUserId } = opts
|
|
72
|
+
const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS
|
|
73
|
+
const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS
|
|
52
74
|
const pool = new BotPool(client, bindings)
|
|
53
75
|
const sessions = new Map<string, SessionContext>()
|
|
54
76
|
const buffers = new Map<string, string>()
|
|
@@ -74,7 +96,11 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
74
96
|
if (event.type === 'agent_message_chunk') {
|
|
75
97
|
const block = event.content as { type?: string; text?: string }
|
|
76
98
|
if (block.type === 'text' && typeof block.text === 'string') {
|
|
77
|
-
|
|
99
|
+
const current = buffers.get(event.sessionId) ?? ''
|
|
100
|
+
// An empty chunk signals a new text block starting (e.g. after a tool call).
|
|
101
|
+
// Insert a paragraph break so consecutive blocks don't run together.
|
|
102
|
+
const prefix = block.text === '' && current.length > 0 ? '\n\n' : ''
|
|
103
|
+
buffers.set(event.sessionId, current + prefix + block.text)
|
|
78
104
|
} else {
|
|
79
105
|
console.warn(`[matrix:${name}] dropped chunk block type=${block.type}`, block)
|
|
80
106
|
}
|
|
@@ -323,6 +349,31 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
323
349
|
})
|
|
324
350
|
.catch((err) => {
|
|
325
351
|
console.error(`[matrix] runTurn failed for ${a.name}:`, err)
|
|
352
|
+
const c = classify(err)
|
|
353
|
+
const threadRoot = inboundThreadRoot(evt) ?? evt.event_id
|
|
354
|
+
if (!threadRoot || !evt.room_id) return
|
|
355
|
+
const body = toErrorBody(
|
|
356
|
+
{
|
|
357
|
+
kind: 'error',
|
|
358
|
+
agentId: a.name,
|
|
359
|
+
sessionId: null,
|
|
360
|
+
turnId: null,
|
|
361
|
+
code: c.code,
|
|
362
|
+
message: err instanceof Error ? err.message : String(err),
|
|
363
|
+
detail: err instanceof Error && err.stack ? err.stack.slice(0, 2000) : undefined,
|
|
364
|
+
transient: c.transient,
|
|
365
|
+
acp_error: c.acp_error,
|
|
366
|
+
},
|
|
367
|
+
threadRoot,
|
|
368
|
+
)
|
|
369
|
+
void client
|
|
370
|
+
.sendCustomEvent({
|
|
371
|
+
roomId: evt.room_id,
|
|
372
|
+
asUserId: a.userId,
|
|
373
|
+
eventType: 'eco.zoon.error',
|
|
374
|
+
content: body,
|
|
375
|
+
})
|
|
376
|
+
.catch((e) => console.warn(`[matrix:${a.name}] eco.zoon.error send failed:`, e))
|
|
326
377
|
})
|
|
327
378
|
}
|
|
328
379
|
}
|
|
@@ -388,6 +439,17 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
388
439
|
channelId: evt.room_id,
|
|
389
440
|
content: [{ type: 'text', text: promptText }],
|
|
390
441
|
})
|
|
442
|
+
// Drain: the prompt promise resolves on the stopReason response, but
|
|
443
|
+
// trailing chunks may still arrive (see DRAIN_* above). Wait until the
|
|
444
|
+
// buffer is quiet for DRAIN_QUIET_MS, re-arming on each new chunk.
|
|
445
|
+
const drainStart = Date.now()
|
|
446
|
+
let drained = buffers.get(sessionId) ?? ''
|
|
447
|
+
while (drainQuietMs > 0 && Date.now() - drainStart < drainMaxMs) {
|
|
448
|
+
await delay(drainQuietMs)
|
|
449
|
+
const next = buffers.get(sessionId) ?? ''
|
|
450
|
+
if (next === drained) break
|
|
451
|
+
drained = next
|
|
452
|
+
}
|
|
391
453
|
const text = buffers.get(sessionId) ?? ''
|
|
392
454
|
if (text.length > 0) {
|
|
393
455
|
const html = toMatrixHtml(text)
|
|
@@ -434,7 +496,13 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
434
496
|
|
|
435
497
|
return {
|
|
436
498
|
app,
|
|
437
|
-
bootstrap: async (
|
|
499
|
+
bootstrap: async (
|
|
500
|
+
bootstrapOpts: {
|
|
501
|
+
spaceRoomId?: string
|
|
502
|
+
asUserId?: string
|
|
503
|
+
adminUserIds?: string[]
|
|
504
|
+
} = {},
|
|
505
|
+
) => {
|
|
438
506
|
await pool.bootstrap({ adminUserId, ...bootstrapOpts })
|
|
439
507
|
await Promise.allSettled(
|
|
440
508
|
bindings.map((b) =>
|
|
@@ -464,7 +532,7 @@ export async function rebuildThreadState(
|
|
|
464
532
|
// Impersonate an agent that's actually a member of this room (AS reads
|
|
465
533
|
// require room membership). Falling through to the first binding would
|
|
466
534
|
// 403 if that agent never joined the target room.
|
|
467
|
-
const asUser = (bindings.find((b) => b.rooms.
|
|
535
|
+
const asUser = (bindings.find((b) => b.rooms.some((r) => r.alias === roomId)) ?? bindings[0])?.userId
|
|
468
536
|
if (!asUser) return state
|
|
469
537
|
|
|
470
538
|
const root = await client.fetchEvent(roomId, rootEventId, asUser)
|
|
@@ -4,8 +4,18 @@ import { buildWorkforceRoster, publishWorkforce } from './workforce-publisher.js
|
|
|
4
4
|
import type { AgentBinding } from './router.js'
|
|
5
5
|
|
|
6
6
|
const agents: AgentBinding[] = [
|
|
7
|
-
{
|
|
8
|
-
|
|
7
|
+
{
|
|
8
|
+
name: 'planner',
|
|
9
|
+
userId: '@planner:zoon.local',
|
|
10
|
+
rooms: [{ alias: '!eng:zoon.local' }],
|
|
11
|
+
trigger: 'mention',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'reviewer',
|
|
15
|
+
userId: '@reviewer:zoon.local',
|
|
16
|
+
rooms: [{ alias: '!eng:zoon.local' }, { alias: '!review:zoon.local' }],
|
|
17
|
+
trigger: 'any',
|
|
18
|
+
},
|
|
9
19
|
]
|
|
10
20
|
|
|
11
21
|
describe('buildWorkforceRoster', () => {
|
|
@@ -9,7 +9,11 @@ export interface WorkforceRoster {
|
|
|
9
9
|
export function buildWorkforceRoster(agents: AgentBinding[]): WorkforceRoster {
|
|
10
10
|
return {
|
|
11
11
|
version: 1,
|
|
12
|
-
agents: agents.map((a) => ({
|
|
12
|
+
agents: agents.map((a) => ({
|
|
13
|
+
user_id: a.userId,
|
|
14
|
+
name: a.name,
|
|
15
|
+
rooms: a.rooms.map((r) => r.alias),
|
|
16
|
+
})),
|
|
13
17
|
}
|
|
14
18
|
}
|
|
15
19
|
|