@zooid/transport-matrix 0.8.0 → 0.9.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 +7 -0
- package/dist/index.js +118 -44
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/bot-pool.ts +1 -1
- package/src/context-provider.ts +1 -1
- package/src/event-encoders.test.ts +22 -0
- package/src/event-encoders.ts +13 -0
- package/src/matrix-client.test.ts +35 -2
- package/src/matrix-client.ts +19 -0
- package/src/transport.test.ts +161 -32
- package/src/transport.ts +177 -68
- package/src/workforce-publisher.test.ts +2 -2
- package/src/workforce-publisher.ts +1 -1
package/src/transport.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { MatrixClient } from './matrix-client.js'
|
|
|
6
6
|
import { BotPool } from './bot-pool.js'
|
|
7
7
|
import { route, isMediaMsgtype, type AgentBinding, type ThreadState } from './router.js'
|
|
8
8
|
import { stripMention, extractMentions } from './mentions.js'
|
|
9
|
-
import { toToolCallBody, toUpdateBody, toPlanBody, toErrorBody } from './event-encoders.js'
|
|
9
|
+
import { toToolCallBody, toUpdateBody, toPlanBody, toAvailableCommandsBody, toErrorBody } from './event-encoders.js'
|
|
10
10
|
import { classify } from '@zooid/acp-client'
|
|
11
11
|
import { toMatrixHtml } from './markdown-to-matrix-html.js'
|
|
12
12
|
import {
|
|
@@ -52,6 +52,10 @@ export interface CreateMatrixTransportOptions {
|
|
|
52
52
|
media?: MediaClientLike
|
|
53
53
|
/** Injected attachment writer (defaults to the real writeAttachment). */
|
|
54
54
|
writeAttachmentFn?: typeof writeAttachment
|
|
55
|
+
/** AS sender-bot MXID (@<sender_localpart>:<server>). Together with the agent
|
|
56
|
+
* bindings this forms the set of "our bot users" whose ad-hoc invites are
|
|
57
|
+
* declined. */
|
|
58
|
+
botUserId?: string
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
interface SessionContext {
|
|
@@ -67,9 +71,12 @@ interface MatrixEvent {
|
|
|
67
71
|
origin_server_ts?: number
|
|
68
72
|
room_id?: string
|
|
69
73
|
sender?: string
|
|
74
|
+
/** Present on state events (m.room.member → the affected user). */
|
|
75
|
+
state_key?: string
|
|
70
76
|
content?: Record<string, unknown> & {
|
|
71
77
|
msgtype?: string
|
|
72
78
|
body?: string
|
|
79
|
+
membership?: string
|
|
73
80
|
'm.relates_to'?: { rel_type?: string; event_id?: string }
|
|
74
81
|
}
|
|
75
82
|
}
|
|
@@ -173,7 +180,7 @@ async function sendMediaError(
|
|
|
173
180
|
.sendCustomEvent({
|
|
174
181
|
roomId: ctx.roomId,
|
|
175
182
|
asUserId: ctx.agent.userId,
|
|
176
|
-
eventType: '
|
|
183
|
+
eventType: 'dev.zooid.error',
|
|
177
184
|
content: toErrorBody(
|
|
178
185
|
{
|
|
179
186
|
kind: 'error' as const,
|
|
@@ -187,7 +194,7 @@ async function sendMediaError(
|
|
|
187
194
|
ctx.threadRoot,
|
|
188
195
|
),
|
|
189
196
|
})
|
|
190
|
-
.catch((e) => console.warn(`[matrix:${ctx.agent.name}]
|
|
197
|
+
.catch((e) => console.warn(`[matrix:${ctx.agent.name}] dev.zooid.error send failed:`, e))
|
|
191
198
|
}
|
|
192
199
|
const SEEN_EVENT_CAP = 5_000
|
|
193
200
|
|
|
@@ -216,13 +223,20 @@ function inboundThreadRoot(evt: MatrixEvent): string | undefined {
|
|
|
216
223
|
}
|
|
217
224
|
|
|
218
225
|
export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
219
|
-
const { agents, approvals, client, bindings, hsToken, adminUserId } = opts
|
|
226
|
+
const { agents, approvals, client, bindings, hsToken, adminUserId, botUserId } = opts
|
|
220
227
|
const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS
|
|
221
228
|
const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS
|
|
222
229
|
const mediaClient = opts.media
|
|
223
230
|
const writeAttachmentFn = opts.writeAttachmentFn ?? writeAttachment
|
|
224
231
|
const pendingMedia = new PendingMediaStore()
|
|
225
232
|
const pool = new BotPool(client, bindings)
|
|
233
|
+
const ourBotUserIds = new Set<string>([
|
|
234
|
+
...(botUserId ? [botUserId] : []),
|
|
235
|
+
...bindings.map((b) => b.userId),
|
|
236
|
+
])
|
|
237
|
+
const DECLINE_REASON =
|
|
238
|
+
'Bots are placed in rooms only by the zooid daemon (workforce-as-code). ' +
|
|
239
|
+
'Ad-hoc invites are declined — add the bot to the room in zooid.yaml.'
|
|
226
240
|
const sessions = new Map<string, SessionContext>()
|
|
227
241
|
const buffers = new Map<string, string>()
|
|
228
242
|
// Last messageId seen per session's buffer. opencode streams each assistant
|
|
@@ -240,38 +254,113 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
240
254
|
// Idempotency: appservice transactions are retried on 4xx/5xx/timeout, and
|
|
241
255
|
// the same event_id can arrive twice. Skip ones we've already taken.
|
|
242
256
|
const seenEventIds = new Set<string>()
|
|
257
|
+
// Messages flushed per session this turn. Lets the drain loop tell "stream
|
|
258
|
+
// not started yet" (0 flushes, empty buffer → keep waiting) from "turn done,
|
|
259
|
+
// last message already flushed mid-stream" (>0 flushes, empty buffer → stop).
|
|
260
|
+
const flushedCounts = new Map<string, number>()
|
|
261
|
+
// Commands a shim advertises during session load/new — i.e. before runTurn
|
|
262
|
+
// registers the session ctx (sessions.set). Stashed here keyed by sessionId
|
|
263
|
+
// and replayed once the ctx exists, so `available_commands_update` (which is
|
|
264
|
+
// only ever emitted at session establishment, never mid-turn) isn't dropped.
|
|
265
|
+
const pendingCommands = new Map<string, AgentEvent>()
|
|
266
|
+
|
|
267
|
+
// Build the m.text content for a chunk of assistant prose, attaching a
|
|
268
|
+
// formatted_body only when the HTML render adds rich text the plain body
|
|
269
|
+
// can't carry (marked wraps plain prose in <p>…</p>; skip that — most
|
|
270
|
+
// clients render `body` better than a stripped re-encode).
|
|
271
|
+
const buildTextContent = (
|
|
272
|
+
text: string,
|
|
273
|
+
): { msgtype: string; body: string; [k: string]: unknown } => {
|
|
274
|
+
const content: { msgtype: string; body: string; [k: string]: unknown } = {
|
|
275
|
+
msgtype: 'm.text',
|
|
276
|
+
body: text,
|
|
277
|
+
}
|
|
278
|
+
const html = toMatrixHtml(text)
|
|
279
|
+
if (html) {
|
|
280
|
+
const escapedPlain =
|
|
281
|
+
'<p>' +
|
|
282
|
+
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') +
|
|
283
|
+
'</p>'
|
|
284
|
+
const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
|
|
285
|
+
if (norm(html) !== norm(escapedPlain)) {
|
|
286
|
+
content.format = 'org.matrix.custom.html'
|
|
287
|
+
content.formatted_body = html
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return content
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Flush a session's buffered assistant text as its own Matrix message and
|
|
294
|
+
// clear the buffer. No-op on an empty buffer. The send is chained onto
|
|
295
|
+
// sendQueue so it orders correctly against tool_call/plan events from the
|
|
296
|
+
// same turn. The buffer is cleared synchronously (before the first await),
|
|
297
|
+
// so a chunk for the *next* message that arrives during the send starts
|
|
298
|
+
// fresh. Returns true when a message was enqueued.
|
|
299
|
+
const flushBuffer = (sessionId: string): boolean => {
|
|
300
|
+
const ctx = sessions.get(sessionId)
|
|
301
|
+
const text = buffers.get(sessionId) ?? ''
|
|
302
|
+
if (!ctx || text.length === 0) return false
|
|
303
|
+
buffers.set(sessionId, '')
|
|
304
|
+
flushedCounts.set(sessionId, (flushedCounts.get(sessionId) ?? 0) + 1)
|
|
305
|
+
const content = buildTextContent(text)
|
|
306
|
+
const tail = (sendQueue.get(sessionId) ?? Promise.resolve()).then(async () => {
|
|
307
|
+
try {
|
|
308
|
+
await client.sendMessage({
|
|
309
|
+
roomId: ctx.roomId,
|
|
310
|
+
asUserId: ctx.agent.userId,
|
|
311
|
+
content,
|
|
312
|
+
threadRoot: ctx.threadRoot,
|
|
313
|
+
})
|
|
314
|
+
} catch (err) {
|
|
315
|
+
console.warn(`[matrix:${ctx.agent.name}] sendMessage flush failed:`, err)
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
sendQueue.set(sessionId, tail)
|
|
319
|
+
return true
|
|
320
|
+
}
|
|
243
321
|
|
|
244
322
|
agents.onEvent = async (name, event: AgentEvent) => {
|
|
245
323
|
const ctx = sessions.get(event.sessionId)
|
|
246
324
|
if (!ctx) {
|
|
247
|
-
|
|
325
|
+
// available_commands_update is advertised during ensureSession (session
|
|
326
|
+
// load/new), before runTurn calls sessions.set — so the ctx isn't there
|
|
327
|
+
// yet. Stash the latest roster and replay it once runTurn registers the
|
|
328
|
+
// ctx. Other event types arriving without a ctx are genuinely orphaned
|
|
329
|
+
// (e.g. replayed history for a thread we're not handling) — drop them.
|
|
330
|
+
if (event.type === 'available_commands') {
|
|
331
|
+
pendingCommands.set(event.sessionId, event)
|
|
332
|
+
} else {
|
|
333
|
+
console.warn(`[matrix:${name}] no session ctx for ${event.sessionId}`)
|
|
334
|
+
}
|
|
248
335
|
return
|
|
249
336
|
}
|
|
250
337
|
|
|
251
338
|
if (event.type === 'agent_message_chunk') {
|
|
252
339
|
const block = event.content as { type?: string; text?: string; data?: string; mimeType?: string }
|
|
253
340
|
if (block.type === 'text' && typeof block.text === 'string') {
|
|
254
|
-
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
// - a change in messageId — opencode streams each assistant message
|
|
261
|
-
// under its own id and emits no delimiter chunk between them, and the
|
|
262
|
-
// first token of the new message has no leading space, so without
|
|
263
|
-
// this they weld together ("…one.🅿️").
|
|
341
|
+
// A change in ACP messageId marks the previous assistant message as
|
|
342
|
+
// complete. opencode streams each assistant message under its own id
|
|
343
|
+
// with no delimiter chunk between them, so a change here is the only
|
|
344
|
+
// boundary signal. Flush the previous message as its own Matrix
|
|
345
|
+
// message — each ACP message lands separately (and interleaves with
|
|
346
|
+
// tool_call/plan events) instead of welding into one turn-end blob.
|
|
264
347
|
const prevMessageId = bufferMessageIds.get(event.sessionId)
|
|
265
348
|
const messageChanged =
|
|
266
349
|
event.messageId !== undefined &&
|
|
267
350
|
prevMessageId !== undefined &&
|
|
268
351
|
event.messageId !== prevMessageId
|
|
269
|
-
const needsBreak =
|
|
270
|
-
current.length > 0 && (block.text === '' || messageChanged)
|
|
271
|
-
const prefix = needsBreak ? '\n\n' : ''
|
|
272
|
-
buffers.set(event.sessionId, current + prefix + block.text)
|
|
273
352
|
if (event.messageId !== undefined)
|
|
274
353
|
bufferMessageIds.set(event.sessionId, event.messageId)
|
|
354
|
+
// flushBuffer clears the buffer synchronously, so the new message's
|
|
355
|
+
// text below starts fresh.
|
|
356
|
+
if (messageChanged) flushBuffer(event.sessionId)
|
|
357
|
+
// Within a single message, tokens carry their own leading spaces, so we
|
|
358
|
+
// concatenate raw. An empty chunk (some agents emit one between blocks,
|
|
359
|
+
// e.g. after a tool call within the same message) is a paragraph break.
|
|
360
|
+
const current = buffers.get(event.sessionId) ?? ''
|
|
361
|
+
const needsBreak = current.length > 0 && block.text === ''
|
|
362
|
+
const prefix = needsBreak ? '\n\n' : ''
|
|
363
|
+
buffers.set(event.sessionId, current + prefix + block.text)
|
|
275
364
|
} else if (
|
|
276
365
|
block.type === 'image' &&
|
|
277
366
|
typeof block.data === 'string' &&
|
|
@@ -310,18 +399,27 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
310
399
|
return
|
|
311
400
|
}
|
|
312
401
|
|
|
402
|
+
// An out-of-band event (tool_call / tool_call_update / plan) after some
|
|
403
|
+
// buffered text means that assistant message is complete — flush it first
|
|
404
|
+
// so it lands before this event on the wire, preserving interleaving.
|
|
405
|
+
flushBuffer(event.sessionId)
|
|
406
|
+
|
|
313
407
|
const eventType =
|
|
314
408
|
event.type === 'tool_call'
|
|
315
|
-
? '
|
|
409
|
+
? 'dev.zooid.tool_call'
|
|
316
410
|
: event.type === 'tool_call_update'
|
|
317
|
-
? '
|
|
318
|
-
:
|
|
411
|
+
? 'dev.zooid.tool_call_update'
|
|
412
|
+
: event.type === 'available_commands'
|
|
413
|
+
? 'dev.zooid.available_commands_update'
|
|
414
|
+
: 'dev.zooid.plan'
|
|
319
415
|
const body =
|
|
320
416
|
event.type === 'tool_call'
|
|
321
417
|
? toToolCallBody(event)
|
|
322
418
|
: event.type === 'tool_call_update'
|
|
323
419
|
? toUpdateBody(event)
|
|
324
|
-
:
|
|
420
|
+
: event.type === 'available_commands'
|
|
421
|
+
? toAvailableCommandsBody(event)
|
|
422
|
+
: toPlanBody(event)
|
|
325
423
|
body['m.relates_to'] = { rel_type: 'm.thread', event_id: ctx.threadRoot }
|
|
326
424
|
const tail = (sendQueue.get(event.sessionId) ?? Promise.resolve()).then(async () => {
|
|
327
425
|
try {
|
|
@@ -362,7 +460,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
362
460
|
void client.sendCustomEvent({
|
|
363
461
|
roomId: ctx.roomId,
|
|
364
462
|
asUserId: ctx.agent.userId,
|
|
365
|
-
eventType: '
|
|
463
|
+
eventType: 'dev.zooid.approval_request',
|
|
366
464
|
content,
|
|
367
465
|
})
|
|
368
466
|
})
|
|
@@ -404,7 +502,28 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
404
502
|
)
|
|
405
503
|
continue
|
|
406
504
|
}
|
|
407
|
-
if (evt.type === '
|
|
505
|
+
if (evt.type === 'm.room.member' && evt.content?.membership === 'invite') {
|
|
506
|
+
const target = evt.state_key
|
|
507
|
+
const inviter = evt.sender
|
|
508
|
+
if (
|
|
509
|
+
target &&
|
|
510
|
+
evt.room_id &&
|
|
511
|
+
ourBotUserIds.has(target) &&
|
|
512
|
+
(!inviter || !ourBotUserIds.has(inviter))
|
|
513
|
+
) {
|
|
514
|
+
console.log(
|
|
515
|
+
`[matrix] declining ad-hoc invite for ${target} in ${evt.room_id} ` +
|
|
516
|
+
`from ${inviter ?? 'unknown'}`,
|
|
517
|
+
)
|
|
518
|
+
await client
|
|
519
|
+
.leaveRoom(evt.room_id, target, { reason: DECLINE_REASON })
|
|
520
|
+
.catch((err) =>
|
|
521
|
+
console.warn(`[matrix] leaveRoom(${evt.room_id}, ${target}) failed:`, err),
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
continue
|
|
525
|
+
}
|
|
526
|
+
if (evt.type === 'dev.zooid.session_reset') {
|
|
408
527
|
// Spec § /clear: room-scope reset is unsupported. Only thread-scoped
|
|
409
528
|
// resets carry a thread relation; drop bare room-level resets silently.
|
|
410
529
|
const relates = evt.content?.['m.relates_to'] as
|
|
@@ -413,10 +532,10 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
413
532
|
const threadRoot =
|
|
414
533
|
relates?.rel_type === 'm.thread' && relates.event_id ? relates.event_id : undefined
|
|
415
534
|
if (!threadRoot) {
|
|
416
|
-
console.log('[matrix] dropping
|
|
535
|
+
console.log('[matrix] dropping dev.zooid.session_reset without thread relation')
|
|
417
536
|
continue
|
|
418
537
|
}
|
|
419
|
-
console.log(`[matrix] inbound
|
|
538
|
+
console.log(`[matrix] inbound dev.zooid.session_reset in ${evt.room_id} thread=${threadRoot}`)
|
|
420
539
|
for (const a of bindings) {
|
|
421
540
|
agents.endSession(a.name, threadRoot)
|
|
422
541
|
}
|
|
@@ -426,7 +545,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
426
545
|
// the most-recently-posting agent under the same sessionKey.
|
|
427
546
|
continue
|
|
428
547
|
}
|
|
429
|
-
if (evt.type === '
|
|
548
|
+
if (evt.type === 'dev.zooid.interrupt') {
|
|
430
549
|
const content = (evt.content ?? {}) as { session_id?: string; reason?: string }
|
|
431
550
|
// Thread-relation form (client-friendly): /interrupt in a thread sends
|
|
432
551
|
// an empty event with `m.relates_to: thread/<root>`. Cancel every
|
|
@@ -456,7 +575,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
456
575
|
}
|
|
457
576
|
// Legacy form: explicit session_id in content.
|
|
458
577
|
if (!content.session_id) {
|
|
459
|
-
console.warn(`[matrix]
|
|
578
|
+
console.warn(`[matrix] dev.zooid.interrupt missing session_id (event_id=${evt.event_id})`)
|
|
460
579
|
continue
|
|
461
580
|
}
|
|
462
581
|
const ctx = sessions.get(content.session_id)
|
|
@@ -472,7 +591,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
472
591
|
})
|
|
473
592
|
continue
|
|
474
593
|
}
|
|
475
|
-
if (evt.type === '
|
|
594
|
+
if (evt.type === 'dev.zooid.approval_response') {
|
|
476
595
|
const content = (evt.content ?? {}) as {
|
|
477
596
|
approval_id?: string
|
|
478
597
|
session_id?: string
|
|
@@ -596,10 +715,10 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
596
715
|
.sendCustomEvent({
|
|
597
716
|
roomId: evt.room_id,
|
|
598
717
|
asUserId: a.userId,
|
|
599
|
-
eventType: '
|
|
718
|
+
eventType: 'dev.zooid.error',
|
|
600
719
|
content: body,
|
|
601
720
|
})
|
|
602
|
-
.catch((e) => console.warn(`[matrix:${a.name}]
|
|
721
|
+
.catch((e) => console.warn(`[matrix:${a.name}] dev.zooid.error send failed:`, e))
|
|
603
722
|
})
|
|
604
723
|
}
|
|
605
724
|
}
|
|
@@ -637,6 +756,15 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
637
756
|
sessions.set(sessionId, { agent, roomId: evt.room_id, threadRoot })
|
|
638
757
|
buffers.set(sessionId, '')
|
|
639
758
|
bufferMessageIds.delete(sessionId)
|
|
759
|
+
flushedCounts.set(sessionId, 0)
|
|
760
|
+
// Commands the shim advertised during ensureSession (session load/new)
|
|
761
|
+
// arrived before the ctx above existed and were stashed — replay the latest
|
|
762
|
+
// now that the session is fully registered, so the palette actually fills.
|
|
763
|
+
const stashedCommands = pendingCommands.get(sessionId)
|
|
764
|
+
if (stashedCommands) {
|
|
765
|
+
pendingCommands.delete(sessionId)
|
|
766
|
+
void agents.onEvent?.(agent.name, stashedCommands)
|
|
767
|
+
}
|
|
640
768
|
|
|
641
769
|
const roomId = evt.room_id
|
|
642
770
|
const TYPING_TTL_MS = 30_000
|
|
@@ -704,44 +832,23 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
704
832
|
while (drainQuietMs > 0 && Date.now() - drainStart < drainMaxMs) {
|
|
705
833
|
await delay(drainQuietMs)
|
|
706
834
|
const next = buffers.get(sessionId) ?? ''
|
|
707
|
-
// Stop
|
|
708
|
-
//
|
|
709
|
-
// empty buffer means the
|
|
710
|
-
|
|
835
|
+
// Stop when the buffer is quiet (unchanged) and either it holds the
|
|
836
|
+
// final message to flush, or we already flushed a message this turn
|
|
837
|
+
// (so an empty, quiet buffer means the turn is genuinely done — the
|
|
838
|
+
// last message was flushed mid-stream). An unchanged *empty* buffer
|
|
839
|
+
// with nothing flushed yet means the stream hasn't started; keep
|
|
840
|
+
// waiting up to drainMaxMs.
|
|
841
|
+
if (next === drained && (next.length > 0 || (flushedCounts.get(sessionId) ?? 0) > 0))
|
|
842
|
+
break
|
|
711
843
|
drained = next
|
|
712
844
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
// Only attach formatted_body when it adds rich-text the plain body
|
|
721
|
-
// can't carry. marked wraps plain prose in <p>…</p>; if that's all
|
|
722
|
-
// we'd add, skip — most clients render `body` better than a stripped
|
|
723
|
-
// re-encode.
|
|
724
|
-
if (html) {
|
|
725
|
-
const escapedPlain =
|
|
726
|
-
'<p>' +
|
|
727
|
-
text
|
|
728
|
-
.replace(/&/g, '&')
|
|
729
|
-
.replace(/</g, '<')
|
|
730
|
-
.replace(/>/g, '>') +
|
|
731
|
-
'</p>'
|
|
732
|
-
const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
|
|
733
|
-
if (norm(html) !== norm(escapedPlain)) {
|
|
734
|
-
content.format = 'org.matrix.custom.html'
|
|
735
|
-
content.formatted_body = html
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
await client.sendMessage({
|
|
739
|
-
roomId: evt.room_id,
|
|
740
|
-
asUserId: agent.userId,
|
|
741
|
-
content,
|
|
742
|
-
threadRoot, // every reply threads, full stop
|
|
743
|
-
})
|
|
744
|
-
} else {
|
|
845
|
+
// Flush the final assistant message — the one with no following messageId
|
|
846
|
+
// change or out-of-band event to have triggered an earlier flush.
|
|
847
|
+
flushBuffer(sessionId)
|
|
848
|
+
// Wait for every queued send (mid-turn flushes, tool/plan events, final
|
|
849
|
+
// flush) to settle before tearing the session down.
|
|
850
|
+
await (sendQueue.get(sessionId) ?? Promise.resolve())
|
|
851
|
+
if ((flushedCounts.get(sessionId) ?? 0) === 0) {
|
|
745
852
|
console.warn(
|
|
746
853
|
`[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${evt.room_id}`,
|
|
747
854
|
)
|
|
@@ -752,6 +859,8 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
752
859
|
await safePresence('online')
|
|
753
860
|
buffers.delete(sessionId)
|
|
754
861
|
bufferMessageIds.delete(sessionId)
|
|
862
|
+
flushedCounts.delete(sessionId)
|
|
863
|
+
sendQueue.delete(sessionId)
|
|
755
864
|
}
|
|
756
865
|
}
|
|
757
866
|
|
|
@@ -36,7 +36,7 @@ describe('buildWorkforceRoster', () => {
|
|
|
36
36
|
})
|
|
37
37
|
|
|
38
38
|
describe('publishWorkforce', () => {
|
|
39
|
-
it('PUTs
|
|
39
|
+
it('PUTs dev.zooid.workforce state event on the configured space', async () => {
|
|
40
40
|
const fetch = vi.fn(async () => new Response('{}', { status: 200 }))
|
|
41
41
|
const client = new MatrixClient({
|
|
42
42
|
homeserver: 'https://hs.zoon.local',
|
|
@@ -54,7 +54,7 @@ describe('publishWorkforce', () => {
|
|
|
54
54
|
expect(fetch).toHaveBeenCalledTimes(1)
|
|
55
55
|
const [url, init] = fetch.mock.calls[0]!
|
|
56
56
|
expect(url).toBe(
|
|
57
|
-
'https://hs.zoon.local/_matrix/client/v3/rooms/!space%3Azoon.local/state/
|
|
57
|
+
'https://hs.zoon.local/_matrix/client/v3/rooms/!space%3Azoon.local/state/dev.zooid.workforce/?user_id=%40zooid%3Azoon.local',
|
|
58
58
|
)
|
|
59
59
|
expect(init?.method).toBe('PUT')
|
|
60
60
|
expect(init?.headers).toMatchObject({ Authorization: 'Bearer as-tok' })
|
|
@@ -28,7 +28,7 @@ export async function publishWorkforce(opts: PublishOpts): Promise<void> {
|
|
|
28
28
|
await opts.client.sendStateEvent({
|
|
29
29
|
roomId: opts.spaceRoomId,
|
|
30
30
|
asUserId: opts.asUserId,
|
|
31
|
-
eventType: '
|
|
31
|
+
eventType: 'dev.zooid.workforce',
|
|
32
32
|
stateKey: '',
|
|
33
33
|
content: buildWorkforceRoster(opts.agents) as unknown as Record<string, unknown>,
|
|
34
34
|
})
|