@zooid/transport-matrix 0.11.2 → 0.13.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 +12 -1
- package/dist/index.js +50 -9
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/context-provider.test.ts +118 -0
- package/src/context-provider.ts +22 -3
- package/src/event-encoders.test.ts +69 -1
- package/src/event-encoders.ts +41 -1
- package/src/transport.test.ts +122 -2
- package/src/transport.ts +43 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zooid/transport-matrix",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.13.0",
|
|
32
|
+
"@zooid/core": "^0.13.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@hono/node-server": "^1.13.0",
|
|
@@ -347,4 +347,122 @@ describe('MatrixContextProvider', () => {
|
|
|
347
347
|
expect(byId.get('$img')?.text).toBe('[image: dog.jpg]')
|
|
348
348
|
expect(byId.get('$file')?.text).toBe('[file: report.pdf]')
|
|
349
349
|
})
|
|
350
|
+
|
|
351
|
+
it('renders agent prose sent as m.notice — ZNC025 §10 switches agent output to m.notice', async () => {
|
|
352
|
+
const client = fakeClient({
|
|
353
|
+
fetchRoomMessages: vi.fn().mockResolvedValue({
|
|
354
|
+
chunk: [
|
|
355
|
+
{
|
|
356
|
+
event_id: '$e1',
|
|
357
|
+
sender: '@architect:hs',
|
|
358
|
+
origin_server_ts: 1000,
|
|
359
|
+
type: 'm.room.message',
|
|
360
|
+
content: { msgtype: 'm.notice', body: 'agent output' },
|
|
361
|
+
},
|
|
362
|
+
],
|
|
363
|
+
end: undefined,
|
|
364
|
+
}),
|
|
365
|
+
} as unknown as Partial<MatrixClient>)
|
|
366
|
+
const provider = new MatrixContextProvider({
|
|
367
|
+
client,
|
|
368
|
+
asUserId: '@_zooid:hs',
|
|
369
|
+
agentBots: new Map([['@architect:hs', 'architect']]),
|
|
370
|
+
})
|
|
371
|
+
const page = await provider.getRoomHistory('!room:hs', {})
|
|
372
|
+
expect(page.messages[0]?.text).toBe('agent output')
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('getRecentThreads keeps a thread rooted in an m.notice', async () => {
|
|
376
|
+
const client = fakeClient({
|
|
377
|
+
fetchRoomMessages: vi.fn().mockResolvedValue({
|
|
378
|
+
chunk: [
|
|
379
|
+
{
|
|
380
|
+
event_id: '$root',
|
|
381
|
+
sender: '@architect:hs',
|
|
382
|
+
origin_server_ts: 1000,
|
|
383
|
+
type: 'm.room.message',
|
|
384
|
+
content: { msgtype: 'm.notice', body: 'agent thread root' },
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
end: undefined,
|
|
388
|
+
}),
|
|
389
|
+
} as unknown as Partial<MatrixClient>)
|
|
390
|
+
const provider = new MatrixContextProvider({
|
|
391
|
+
client,
|
|
392
|
+
asUserId: '@_zooid:hs',
|
|
393
|
+
agentBots: new Map([['@architect:hs', 'architect']]),
|
|
394
|
+
})
|
|
395
|
+
const page = await provider.getRecentThreads('!room:hs', { limit: 50 })
|
|
396
|
+
expect(page.threads.map((t) => t.id)).toEqual(['$root'])
|
|
397
|
+
expect(page.threads[0]?.text).toBe('agent thread root')
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
// The authorization boundary for context reads is the homeserver, not this
|
|
402
|
+
// class: every read is impersonated as the *agent's own* Matrix user, so a room
|
|
403
|
+
// or thread the agent isn't in fails at Matrix. Nothing asserted that, and a
|
|
404
|
+
// stale doc comment claimed the opposite (that asUserId was the AS bot, which
|
|
405
|
+
// can read every room) — so a refactor could have quietly swapped in the AS
|
|
406
|
+
// user and turned a homeserver-enforced boundary into an honour system.
|
|
407
|
+
describe('MatrixContextProvider — reads are impersonated as the agent', () => {
|
|
408
|
+
const AGENT = '@dev.assistant:hs'
|
|
409
|
+
|
|
410
|
+
function provider(overrides: Partial<MatrixClient>) {
|
|
411
|
+
return new MatrixContextProvider({
|
|
412
|
+
client: fakeClient(overrides),
|
|
413
|
+
asUserId: AGENT,
|
|
414
|
+
agentBots: new Map(),
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
it('threads the agent user through every read, never a different user', async () => {
|
|
419
|
+
const fetchRoomMessages = vi.fn().mockResolvedValue({ chunk: [], end: undefined })
|
|
420
|
+
const getJoinedMembers = vi.fn().mockResolvedValue({ joined: {} })
|
|
421
|
+
const fetchRoomName = vi.fn().mockResolvedValue('room')
|
|
422
|
+
const fetchEvent = vi.fn().mockResolvedValue(null)
|
|
423
|
+
const fetchThreadRelations = vi.fn().mockResolvedValue({ chunk: [], next_batch: undefined })
|
|
424
|
+
const p = provider({
|
|
425
|
+
fetchRoomMessages,
|
|
426
|
+
getJoinedMembers,
|
|
427
|
+
fetchRoomName,
|
|
428
|
+
fetchEvent,
|
|
429
|
+
fetchThreadRelations,
|
|
430
|
+
} as unknown as Partial<MatrixClient>)
|
|
431
|
+
|
|
432
|
+
await p.getRoomHistory('!room:hs', { limit: 10 })
|
|
433
|
+
await p.getRecentThreads('!room:hs', { limit: 10 })
|
|
434
|
+
await p.getThreadHistory('!room:hs', '$root', { limit: 10 })
|
|
435
|
+
await p.getChannelMembers('!room:hs')
|
|
436
|
+
await p.getChannelInfo('!room:hs')
|
|
437
|
+
|
|
438
|
+
expect(fetchRoomMessages.mock.calls.every(([a]) => a.asUserId === AGENT)).toBe(true)
|
|
439
|
+
expect(fetchThreadRelations).toHaveBeenCalledWith(expect.objectContaining({ asUserId: AGENT }))
|
|
440
|
+
// These two take the user as a positional argument, not a field.
|
|
441
|
+
expect(getJoinedMembers).toHaveBeenCalledWith('!room:hs', AGENT)
|
|
442
|
+
expect(fetchRoomName).toHaveBeenCalledWith('!room:hs', AGENT)
|
|
443
|
+
expect(fetchEvent).toHaveBeenCalledWith('!room:hs', '$root', AGENT)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
// An agent naming a room it isn't in must fail loudly. Swallowing the error
|
|
447
|
+
// and returning `{ messages: [] }` would read as "empty room" to the model —
|
|
448
|
+
// indistinguishable from a real empty room, and it would hide the refusal.
|
|
449
|
+
it('propagates a homeserver refusal instead of returning an empty page', async () => {
|
|
450
|
+
const forbidden = new Error('fetchRoomMessages(!private:hs) failed: 403')
|
|
451
|
+
const p = provider({
|
|
452
|
+
fetchRoomMessages: vi.fn().mockRejectedValue(forbidden),
|
|
453
|
+
} as unknown as Partial<MatrixClient>)
|
|
454
|
+
|
|
455
|
+
await expect(p.getRoomHistory('!private:hs', { limit: 10 })).rejects.toThrow('403')
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it('propagates a refusal on the thread path too', async () => {
|
|
459
|
+
const p = provider({
|
|
460
|
+
fetchEvent: vi.fn().mockResolvedValue(null),
|
|
461
|
+
fetchThreadRelations: vi
|
|
462
|
+
.fn()
|
|
463
|
+
.mockRejectedValue(new Error('fetchThreadRelations($x) failed: 403')),
|
|
464
|
+
} as unknown as Partial<MatrixClient>)
|
|
465
|
+
|
|
466
|
+
await expect(p.getThreadHistory('!private:hs', '$x', { limit: 10 })).rejects.toThrow('403')
|
|
467
|
+
})
|
|
350
468
|
})
|
package/src/context-provider.ts
CHANGED
|
@@ -32,7 +32,18 @@ interface MatrixMessageEvent {
|
|
|
32
32
|
|
|
33
33
|
export interface MatrixContextProviderOpts {
|
|
34
34
|
client: MatrixClient
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* The **agent's own** Matrix user (`@{workstation}.{name}:server`), which
|
|
37
|
+
* every read is impersonated as via `?user_id=`. Not the AS bot: that would
|
|
38
|
+
* read every room on the homeserver and make this class the only thing
|
|
39
|
+
* standing between an agent and someone else's conversation.
|
|
40
|
+
*
|
|
41
|
+
* This is load-bearing. It is what makes the homeserver — not our own
|
|
42
|
+
* bookkeeping — the authorization boundary for context reads, so a room or
|
|
43
|
+
* thread the agent is not in fails at Matrix with 403/404. Anything that
|
|
44
|
+
* widens who can name a room (a CLI, a new tool parameter) is safe only
|
|
45
|
+
* while this holds.
|
|
46
|
+
*/
|
|
36
47
|
asUserId: string
|
|
37
48
|
/** Map of Matrix user IDs → agent names, for is_agent / agent_name flags. */
|
|
38
49
|
agentBots: Map<string, string>
|
|
@@ -84,7 +95,14 @@ export class MatrixContextProvider implements TransportContextProvider {
|
|
|
84
95
|
const threads: ThreadOverview[] = []
|
|
85
96
|
for (const ev of chunk as unknown as MatrixMessageEvent[]) {
|
|
86
97
|
if (ev.type !== 'm.room.message') continue
|
|
87
|
-
|
|
98
|
+
// m.notice: agent prose sends as m.notice so
|
|
99
|
+
// .m.rule.suppress_notices silences the chunk storm server-side
|
|
100
|
+
// (ZNC025 §10) — a thread root sent by an agent must still surface here.
|
|
101
|
+
if (
|
|
102
|
+
(ev.content?.msgtype !== 'm.text' && ev.content?.msgtype !== 'm.notice') ||
|
|
103
|
+
typeof ev.content.body !== 'string'
|
|
104
|
+
)
|
|
105
|
+
continue
|
|
88
106
|
const relatesTo = ev.content['m.relates_to']
|
|
89
107
|
if (relatesTo?.rel_type === 'm.thread') continue // skip thread replies
|
|
90
108
|
const agent = this.opts.agentBots.get(ev.sender)
|
|
@@ -169,7 +187,8 @@ export class MatrixContextProvider implements TransportContextProvider {
|
|
|
169
187
|
}
|
|
170
188
|
}
|
|
171
189
|
|
|
172
|
-
|
|
190
|
+
// Agent prose sends as m.notice (ZNC025 §10); m.text is human prose.
|
|
191
|
+
if ((msgtype !== 'm.text' && msgtype !== 'm.notice') || typeof body !== 'string') return null
|
|
173
192
|
return {
|
|
174
193
|
id: ev.event_id,
|
|
175
194
|
sender: ev.sender,
|
|
@@ -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/transport.test.ts
CHANGED
|
@@ -146,7 +146,7 @@ describe('matrix transport /transactions', () => {
|
|
|
146
146
|
roomId: '!r:example.com',
|
|
147
147
|
asUserId: '@architect:example.com',
|
|
148
148
|
threadRoot: '$root',
|
|
149
|
-
content: expect.objectContaining({ msgtype: 'm.
|
|
149
|
+
content: expect.objectContaining({ msgtype: 'm.notice', body: 'hello back' }),
|
|
150
150
|
}),
|
|
151
151
|
)
|
|
152
152
|
})
|
|
@@ -273,7 +273,7 @@ describe('matrix transport /transactions', () => {
|
|
|
273
273
|
formatted_body?: string
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
|
-
expect(call.content.msgtype).toBe('m.
|
|
276
|
+
expect(call.content.msgtype).toBe('m.notice')
|
|
277
277
|
expect(call.content.body).toBe('**bold** _italic_\n\n```ts\nconst x = 1\n```')
|
|
278
278
|
expect(call.content.format).toBe('org.matrix.custom.html')
|
|
279
279
|
expect(typeof call.content.formatted_body).toBe('string')
|
|
@@ -316,6 +316,37 @@ describe('matrix transport /transactions', () => {
|
|
|
316
316
|
expect(call.content).not.toHaveProperty('format')
|
|
317
317
|
})
|
|
318
318
|
|
|
319
|
+
it('sends agent prose as m.notice so .m.rule.suppress_notices silences the chunk storm server-side', async () => {
|
|
320
|
+
const { transport, agents, client } = makeTransport()
|
|
321
|
+
agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
|
|
322
|
+
agents.onEvent('architect', {
|
|
323
|
+
type: 'agent_message_chunk',
|
|
324
|
+
sessionId: 'sess-' + p.threadId,
|
|
325
|
+
content: { type: 'text', text: 'hello' },
|
|
326
|
+
})
|
|
327
|
+
return { stopReason: 'end_turn' as const }
|
|
328
|
+
})
|
|
329
|
+
await postTxn(transport.app, {
|
|
330
|
+
events: [
|
|
331
|
+
{
|
|
332
|
+
type: 'm.room.message',
|
|
333
|
+
event_id: '$root',
|
|
334
|
+
room_id: '!r:example.com',
|
|
335
|
+
sender: '@alice:example.com',
|
|
336
|
+
content: {
|
|
337
|
+
msgtype: 'm.text',
|
|
338
|
+
body: 'hi',
|
|
339
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
],
|
|
343
|
+
})
|
|
344
|
+
await settleTurn()
|
|
345
|
+
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
346
|
+
expect.objectContaining({ content: expect.objectContaining({ msgtype: 'm.notice' }) }),
|
|
347
|
+
)
|
|
348
|
+
})
|
|
349
|
+
|
|
319
350
|
it('emits dev.zooid.approval_request when an approval is registered', async () => {
|
|
320
351
|
const { transport, approvals, client } = makeTransport()
|
|
321
352
|
await postTxn(transport.app, {
|
|
@@ -375,6 +406,95 @@ describe('matrix transport /transactions', () => {
|
|
|
375
406
|
})
|
|
376
407
|
})
|
|
377
408
|
|
|
409
|
+
describe('dev.zooid.turn.end', () => {
|
|
410
|
+
function topLevelMention() {
|
|
411
|
+
return {
|
|
412
|
+
type: 'm.room.message',
|
|
413
|
+
event_id: '$root',
|
|
414
|
+
room_id: '!r:example.com',
|
|
415
|
+
sender: '@alice:example.com',
|
|
416
|
+
content: {
|
|
417
|
+
msgtype: 'm.text',
|
|
418
|
+
body: 'hi',
|
|
419
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
420
|
+
},
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
it('is sent once per turn, after the final flush has settled', async () => {
|
|
425
|
+
const { transport, agents, client } = makeTransport()
|
|
426
|
+
agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
|
|
427
|
+
agents.onEvent('architect', {
|
|
428
|
+
type: 'agent_message_chunk',
|
|
429
|
+
sessionId: 'sess-' + p.threadId,
|
|
430
|
+
content: { type: 'text', text: 'hello back' },
|
|
431
|
+
})
|
|
432
|
+
return { stopReason: 'end_turn' as const }
|
|
433
|
+
})
|
|
434
|
+
await postTxn(transport.app, { events: [topLevelMention()] })
|
|
435
|
+
await settleTurn()
|
|
436
|
+
|
|
437
|
+
const sendMessageOrder = client.sendMessage.mock.invocationCallOrder[0]!
|
|
438
|
+
const turnEndCall = client.sendCustomEvent.mock.calls.find(
|
|
439
|
+
(c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
|
|
440
|
+
)
|
|
441
|
+
expect(turnEndCall).toBeDefined()
|
|
442
|
+
const turnEndIdx = client.sendCustomEvent.mock.calls.indexOf(turnEndCall!)
|
|
443
|
+
const turnEndOrder = client.sendCustomEvent.mock.invocationCallOrder[turnEndIdx]!
|
|
444
|
+
// Ordering matters: a turn.end that lands before the prose would notify
|
|
445
|
+
// the user to look at a room that has nothing in it yet.
|
|
446
|
+
expect(turnEndOrder).toBeGreaterThan(sendMessageOrder)
|
|
447
|
+
expect(turnEndCall![0]).toMatchObject({
|
|
448
|
+
roomId: '!r:example.com',
|
|
449
|
+
asUserId: '@architect:example.com',
|
|
450
|
+
content: expect.objectContaining({ produced_output: true }),
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('reports produced_output: false for a silent turn', async () => {
|
|
455
|
+
const { transport, agents, client } = makeTransport()
|
|
456
|
+
// The agent emits no chunks at all — this is exactly ZOD076's case.
|
|
457
|
+
agents.prompt.mockImplementation(async () => ({ stopReason: 'end_turn' as const }))
|
|
458
|
+
await postTxn(transport.app, { events: [topLevelMention()] })
|
|
459
|
+
await settleTurn()
|
|
460
|
+
|
|
461
|
+
const turnEndCall = client.sendCustomEvent.mock.calls.find(
|
|
462
|
+
(c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
|
|
463
|
+
)
|
|
464
|
+
expect(turnEndCall![0]).toMatchObject({
|
|
465
|
+
content: expect.objectContaining({ produced_output: false }),
|
|
466
|
+
})
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('is still sent when the turn throws, so the room never hangs on a spinner', async () => {
|
|
470
|
+
const { transport, agents, client } = makeTransport()
|
|
471
|
+
agents.prompt.mockImplementation(async () => {
|
|
472
|
+
throw new Error('boom')
|
|
473
|
+
})
|
|
474
|
+
await postTxn(transport.app, { events: [topLevelMention()] })
|
|
475
|
+
await settleTurn()
|
|
476
|
+
|
|
477
|
+
const turnEndCall = client.sendCustomEvent.mock.calls.find(
|
|
478
|
+
(c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
|
|
479
|
+
)
|
|
480
|
+
expect(turnEndCall).toBeDefined()
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('threads to the turn root', async () => {
|
|
484
|
+
const { transport, agents, client } = makeTransport()
|
|
485
|
+
agents.prompt.mockImplementation(async () => ({ stopReason: 'end_turn' as const }))
|
|
486
|
+
await postTxn(transport.app, { events: [topLevelMention()] })
|
|
487
|
+
await settleTurn()
|
|
488
|
+
|
|
489
|
+
const turnEndCall = client.sendCustomEvent.mock.calls.find(
|
|
490
|
+
(c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
|
|
491
|
+
)
|
|
492
|
+
expect(
|
|
493
|
+
(turnEndCall![0] as { content: { 'm.relates_to': unknown } }).content['m.relates_to'],
|
|
494
|
+
).toEqual({ rel_type: 'm.thread', event_id: '$root' })
|
|
495
|
+
})
|
|
496
|
+
})
|
|
497
|
+
|
|
378
498
|
describe('thread implicit triggers', () => {
|
|
379
499
|
it('triggers the most-recent-posting agent for a bare reply in a thread', async () => {
|
|
380
500
|
const { transport, agents } = makeTransport()
|
package/src/transport.ts
CHANGED
|
@@ -7,7 +7,14 @@ import { BotPool } from './bot-pool.js'
|
|
|
7
7
|
import { route, isMediaMsgtype, type AgentBinding, type ThreadState } from './router.js'
|
|
8
8
|
import { sessionKeyFor, composeHandoffKey } from './session-keys.js'
|
|
9
9
|
import { stripMention, extractMentions } from './mentions.js'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
toToolCallBody,
|
|
12
|
+
toUpdateBody,
|
|
13
|
+
toPlanBody,
|
|
14
|
+
toAvailableCommandsBody,
|
|
15
|
+
toErrorBody,
|
|
16
|
+
toTurnEndBody,
|
|
17
|
+
} from './event-encoders.js'
|
|
11
18
|
import { classify } from '@zooid/acp-client'
|
|
12
19
|
import { toMatrixHtml } from './markdown-to-matrix-html.js'
|
|
13
20
|
import {
|
|
@@ -288,7 +295,11 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
288
295
|
text: string,
|
|
289
296
|
): { msgtype: string; body: string; [k: string]: unknown } => {
|
|
290
297
|
const content: { msgtype: string; body: string; [k: string]: unknown } = {
|
|
291
|
-
|
|
298
|
+
// m.notice, not m.text: .m.rule.suppress_notices silences the
|
|
299
|
+
// chunk-storm of agent prose server-side (ZNC025 §10) instead of every
|
|
300
|
+
// client having to filter it. dev.zooid.error carries the same tweak
|
|
301
|
+
// for the same reason.
|
|
302
|
+
msgtype: 'm.notice',
|
|
292
303
|
body: text,
|
|
293
304
|
}
|
|
294
305
|
const html = toMatrixHtml(text)
|
|
@@ -312,11 +323,17 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
312
323
|
// same turn. The buffer is cleared synchronously (before the first await),
|
|
313
324
|
// so a chunk for the *next* message that arrives during the send starts
|
|
314
325
|
// fresh. Returns true when a message was enqueued.
|
|
326
|
+
const lastFlushed = new Map<string, string>()
|
|
327
|
+
|
|
315
328
|
const flushBuffer = (sessionId: string): boolean => {
|
|
316
329
|
const ctx = sessions.get(sessionId)
|
|
317
330
|
const text = buffers.get(sessionId) ?? ''
|
|
318
331
|
if (!ctx || text.length === 0) return false
|
|
319
332
|
buffers.set(sessionId, '')
|
|
333
|
+
// Kept for turn.end's push preview: the prose goes out as `m.notice` and
|
|
334
|
+
// is deliberately silenced server-side, so turn.end is the only event that
|
|
335
|
+
// can tell the user what the agent actually said.
|
|
336
|
+
lastFlushed.set(sessionId, text)
|
|
320
337
|
flushedCounts.set(sessionId, (flushedCounts.get(sessionId) ?? 0) + 1)
|
|
321
338
|
const content = buildTextContent(text)
|
|
322
339
|
const tail = (sendQueue.get(sessionId) ?? Promise.resolve()).then(async () => {
|
|
@@ -900,21 +917,39 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
900
917
|
// Flush the final assistant message — the one with no following messageId
|
|
901
918
|
// change or out-of-band event to have triggered an earlier flush.
|
|
902
919
|
flushBuffer(sessionId)
|
|
920
|
+
} finally {
|
|
921
|
+
clearInterval(refresh)
|
|
922
|
+
await safeTyping(false)
|
|
923
|
+
await safePresence('online')
|
|
903
924
|
// Wait for every queued send (mid-turn flushes, tool/plan events, final
|
|
904
|
-
// flush) to settle before
|
|
925
|
+
// flush) to settle before announcing the turn's end — and run this even
|
|
926
|
+
// when the turn above threw, so the room never hangs on a spinner.
|
|
905
927
|
await (sendQueue.get(sessionId) ?? Promise.resolve())
|
|
906
|
-
|
|
928
|
+
const producedOutput = (flushedCounts.get(sessionId) ?? 0) > 0
|
|
929
|
+
if (!producedOutput) {
|
|
907
930
|
console.warn(
|
|
908
931
|
`[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${evt.room_id}`,
|
|
909
932
|
)
|
|
910
933
|
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
934
|
+
// Turn boundary for [[ZOD076]] and push notifications. Sent after the
|
|
935
|
+
// send queue drains so it lands *after* the prose it announces — a
|
|
936
|
+
// turn.end arriving first would notify the user to look at a room that
|
|
937
|
+
// has nothing in it yet.
|
|
938
|
+
await client
|
|
939
|
+
.sendCustomEvent({
|
|
940
|
+
roomId: evt.room_id,
|
|
941
|
+
asUserId: agent.userId,
|
|
942
|
+
eventType: 'dev.zooid.turn.end',
|
|
943
|
+
content: toTurnEndBody(
|
|
944
|
+
{ agentId: agent.name, sessionId, producedOutput, lastMessage: lastFlushed.get(sessionId) },
|
|
945
|
+
threadRoot,
|
|
946
|
+
),
|
|
947
|
+
})
|
|
948
|
+
.catch((e) => console.warn(`[matrix:${agent.name}] turn.end send failed:`, e))
|
|
915
949
|
buffers.delete(sessionId)
|
|
916
950
|
bufferMessageIds.delete(sessionId)
|
|
917
951
|
flushedCounts.delete(sessionId)
|
|
952
|
+
lastFlushed.delete(sessionId)
|
|
918
953
|
sendQueue.delete(sessionId)
|
|
919
954
|
}
|
|
920
955
|
}
|