@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zooid/transport-matrix",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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/
|
|
32
|
-
"@zooid/
|
|
31
|
+
"@zooid/core": "^0.9.0",
|
|
32
|
+
"@zooid/acp-client": "^0.9.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@hono/node-server": "^1.13.0",
|
package/src/bot-pool.ts
CHANGED
|
@@ -48,7 +48,7 @@ export class BotPool {
|
|
|
48
48
|
console.warn(`[matrix] setDisplayName(${a.userId}) failed: ${(err as Error).message}`)
|
|
49
49
|
}
|
|
50
50
|
// Make each agent a member of the workforce space. That covers two
|
|
51
|
-
// things at once: the
|
|
51
|
+
// things at once: the dev.zooid.workforce roster is now backed by
|
|
52
52
|
// actual space membership (so the Zoon client's member autocomplete
|
|
53
53
|
// works across rooms), and every restricted child room's allow rule
|
|
54
54
|
// is satisfied without per-room invites.
|
package/src/context-provider.ts
CHANGED
|
@@ -43,7 +43,7 @@ export class MatrixContextProvider implements TransportContextProvider {
|
|
|
43
43
|
|
|
44
44
|
async getRoomHistory(channelId: string, hopts: HistoryOptions): Promise<HistoryPage> {
|
|
45
45
|
// Server-side filter: only `m.room.message` events. Without this we'd
|
|
46
|
-
// burn the page budget on reactions, `
|
|
46
|
+
// burn the page budget on reactions, `dev.zooid.*` custom events, typing
|
|
47
47
|
// notifications, etc., and routinely return empty pages with a stale
|
|
48
48
|
// `has_more` cursor.
|
|
49
49
|
const { chunk, end } = await this.opts.client.fetchRoomMessages({
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
toUpdateBody,
|
|
10
10
|
toPlanBody,
|
|
11
11
|
toErrorBody,
|
|
12
|
+
toAvailableCommandsBody,
|
|
12
13
|
} from './event-encoders.js'
|
|
13
14
|
|
|
14
15
|
describe('toToolCallBody', () => {
|
|
@@ -124,6 +125,27 @@ describe('toPlanBody', () => {
|
|
|
124
125
|
})
|
|
125
126
|
})
|
|
126
127
|
|
|
128
|
+
describe('toAvailableCommandsBody', () => {
|
|
129
|
+
it('encodes available_commands into the body ZNC021 decodes', () => {
|
|
130
|
+
expect(
|
|
131
|
+
toAvailableCommandsBody({
|
|
132
|
+
type: 'available_commands',
|
|
133
|
+
sessionId: 's-1',
|
|
134
|
+
commands: [
|
|
135
|
+
{ name: 'plan', description: 'Switch to plan mode' },
|
|
136
|
+
{ name: 'compact', description: 'Compact the context' },
|
|
137
|
+
],
|
|
138
|
+
}),
|
|
139
|
+
).toEqual({
|
|
140
|
+
session_id: 's-1',
|
|
141
|
+
available_commands: [
|
|
142
|
+
{ name: 'plan', description: 'Switch to plan mode' },
|
|
143
|
+
{ name: 'compact', description: 'Compact the context' },
|
|
144
|
+
],
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
127
149
|
describe('toErrorBody', () => {
|
|
128
150
|
const threadRoot = '$root-event-id'
|
|
129
151
|
|
package/src/event-encoders.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
AvailableCommandsEvent,
|
|
2
3
|
PlanEvent,
|
|
3
4
|
TapEvent,
|
|
4
5
|
ToolCallEvent,
|
|
@@ -66,6 +67,18 @@ export function toPlanBody(evt: PlanEvent): Record<string, unknown> {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
export function toAvailableCommandsBody(
|
|
71
|
+
evt: AvailableCommandsEvent,
|
|
72
|
+
): Record<string, unknown> {
|
|
73
|
+
return {
|
|
74
|
+
session_id: evt.sessionId,
|
|
75
|
+
available_commands: evt.commands.map((c) => ({
|
|
76
|
+
name: c.name,
|
|
77
|
+
description: c.description,
|
|
78
|
+
})),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
69
82
|
const RECOVERY_URLS: Partial<Record<string, string>> = {
|
|
70
83
|
auth_missing: 'https://zooid.dev/docs/guides/run-in-container#authentication-that-carries-over',
|
|
71
84
|
auth_invalid: 'https://zooid.dev/docs/guides/run-in-container#authentication-that-carries-over',
|
|
@@ -287,7 +287,7 @@ describe('MatrixClient', () => {
|
|
|
287
287
|
|
|
288
288
|
it('sends a custom event type when content type is set', async () => {
|
|
289
289
|
const fetch = fakeFetch(async ({ url, init }) => {
|
|
290
|
-
expect(url).toMatch(/\/send\/
|
|
290
|
+
expect(url).toMatch(/\/send\/dev\.zooid\.approval_request\//)
|
|
291
291
|
const body = JSON.parse(init.body as string)
|
|
292
292
|
expect(body.approval_id).toBe('a1')
|
|
293
293
|
return new Response(JSON.stringify({ event_id: '$x' }), { status: 200 })
|
|
@@ -300,7 +300,7 @@ describe('MatrixClient', () => {
|
|
|
300
300
|
await client.sendCustomEvent({
|
|
301
301
|
roomId: '!r:example.com',
|
|
302
302
|
asUserId: '@architect:example.com',
|
|
303
|
-
eventType: '
|
|
303
|
+
eventType: 'dev.zooid.approval_request',
|
|
304
304
|
content: { approval_id: 'a1', description: 'Run: git push' },
|
|
305
305
|
})
|
|
306
306
|
})
|
|
@@ -445,6 +445,39 @@ describe('MatrixClient.createRoom userPowerLevels', () => {
|
|
|
445
445
|
})
|
|
446
446
|
})
|
|
447
447
|
|
|
448
|
+
describe('MatrixClient.leaveRoom', () => {
|
|
449
|
+
it('leaves (rejects) a room as the impersonated user, with a reason', async () => {
|
|
450
|
+
const fetch = fakeFetch(async ({ url, init }) => {
|
|
451
|
+
expect(url).toBe(
|
|
452
|
+
'https://hs.example.com/_matrix/client/v3/rooms/!r%3Aexample.com/leave' +
|
|
453
|
+
'?user_id=%40zooid%3Aexample.com',
|
|
454
|
+
)
|
|
455
|
+
expect(init.method).toBe('POST')
|
|
456
|
+
expect(JSON.parse(init.body as string)).toEqual({ reason: 'no thanks' })
|
|
457
|
+
return new Response('{}', { status: 200 })
|
|
458
|
+
})
|
|
459
|
+
const client = new MatrixClient({
|
|
460
|
+
homeserver: 'https://hs.example.com',
|
|
461
|
+
asToken: 'as-secret',
|
|
462
|
+
fetch: fetch as unknown as typeof globalThis.fetch,
|
|
463
|
+
})
|
|
464
|
+
await client.leaveRoom('!r:example.com', '@zooid:example.com', { reason: 'no thanks' })
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it('sends an empty body when no reason is given', async () => {
|
|
468
|
+
const fetch = fakeFetch(async ({ init }) => {
|
|
469
|
+
expect(JSON.parse(init.body as string)).toEqual({})
|
|
470
|
+
return new Response('{}', { status: 200 })
|
|
471
|
+
})
|
|
472
|
+
const client = new MatrixClient({
|
|
473
|
+
homeserver: 'https://hs.example.com',
|
|
474
|
+
asToken: 'as-secret',
|
|
475
|
+
fetch: fetch as unknown as typeof globalThis.fetch,
|
|
476
|
+
})
|
|
477
|
+
await client.leaveRoom('!r:example.com', '@zooid:example.com')
|
|
478
|
+
})
|
|
479
|
+
})
|
|
480
|
+
|
|
448
481
|
describe('MatrixClient.createRoom restricted', () => {
|
|
449
482
|
it('injects a restricted join rule referencing the space when restrictedToSpaceId is set', async () => {
|
|
450
483
|
const fetch = fakeFetch(async ({ url, init }) => {
|
package/src/matrix-client.ts
CHANGED
|
@@ -222,6 +222,25 @@ export class MatrixClient {
|
|
|
222
222
|
throw new Error(`invite(${opts.targetUserId}) failed: ${r.status}`)
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
async leaveRoom(
|
|
226
|
+
roomId: string,
|
|
227
|
+
asUserId: string,
|
|
228
|
+
opts?: { reason?: string },
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
const url =
|
|
231
|
+
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave` +
|
|
232
|
+
`?user_id=${encodeURIComponent(asUserId)}`
|
|
233
|
+
const r = await this.fetch(url, {
|
|
234
|
+
method: 'POST',
|
|
235
|
+
headers: {
|
|
236
|
+
Authorization: `Bearer ${this.asToken}`,
|
|
237
|
+
'content-type': 'application/json',
|
|
238
|
+
},
|
|
239
|
+
body: JSON.stringify(opts?.reason ? { reason: opts.reason } : {}),
|
|
240
|
+
})
|
|
241
|
+
if (!r.ok) throw new Error(`leaveRoom(${roomId}, ${asUserId}) failed: ${r.status}`)
|
|
242
|
+
}
|
|
243
|
+
|
|
225
244
|
async joinRoom(roomIdOrAlias: string, asUserId: string): Promise<void> {
|
|
226
245
|
const url =
|
|
227
246
|
`${this.homeserver}/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}` +
|
package/src/transport.test.ts
CHANGED
|
@@ -40,6 +40,7 @@ function fakeClient() {
|
|
|
40
40
|
return {
|
|
41
41
|
registerBot: vi.fn(async () => undefined),
|
|
42
42
|
joinRoom: vi.fn(async () => undefined),
|
|
43
|
+
leaveRoom: vi.fn(async () => undefined),
|
|
43
44
|
sendMessage: vi.fn(async () => ({ event_id: '$x' })),
|
|
44
45
|
sendCustomEvent: vi.fn(async () => ({ event_id: '$x' })),
|
|
45
46
|
setTyping: vi.fn(async () => {}),
|
|
@@ -66,6 +67,7 @@ function makeTransport(drain?: { drainQuietMs?: number; drainMaxMs?: number }) {
|
|
|
66
67
|
client: client as never,
|
|
67
68
|
bindings: baseAgents,
|
|
68
69
|
hsToken: 'hs-secret',
|
|
70
|
+
botUserId: '@zooid:example.com',
|
|
69
71
|
// Disable post-turn drain by default so settleTurn (microtasks) suffices.
|
|
70
72
|
// Tests covering trailing-chunk behavior pass an explicit window.
|
|
71
73
|
drainQuietMs: drain?.drainQuietMs ?? 0,
|
|
@@ -314,7 +316,7 @@ describe('matrix transport /transactions', () => {
|
|
|
314
316
|
expect(call.content).not.toHaveProperty('format')
|
|
315
317
|
})
|
|
316
318
|
|
|
317
|
-
it('emits
|
|
319
|
+
it('emits dev.zooid.approval_request when an approval is registered', async () => {
|
|
318
320
|
const { transport, approvals, client } = makeTransport()
|
|
319
321
|
await postTxn(transport.app, {
|
|
320
322
|
events: [
|
|
@@ -341,18 +343,18 @@ describe('matrix transport /transactions', () => {
|
|
|
341
343
|
await new Promise((r) => setImmediate(r))
|
|
342
344
|
expect(client.sendCustomEvent).toHaveBeenCalledWith(
|
|
343
345
|
expect.objectContaining({
|
|
344
|
-
eventType: '
|
|
346
|
+
eventType: 'dev.zooid.approval_request',
|
|
345
347
|
content: expect.objectContaining({ approval_id: 'a1' }),
|
|
346
348
|
}),
|
|
347
349
|
)
|
|
348
350
|
})
|
|
349
351
|
|
|
350
|
-
it('resolves an approval when an
|
|
352
|
+
it('resolves an approval when an dev.zooid.approval_response event arrives', async () => {
|
|
351
353
|
const { transport, approvals } = makeTransport()
|
|
352
354
|
await postTxn(transport.app, {
|
|
353
355
|
events: [
|
|
354
356
|
{
|
|
355
|
-
type: '
|
|
357
|
+
type: 'dev.zooid.approval_response',
|
|
356
358
|
event_id: '$resp',
|
|
357
359
|
room_id: '!r:example.com',
|
|
358
360
|
sender: '@alice:example.com',
|
|
@@ -535,13 +537,13 @@ describe('thread implicit triggers', () => {
|
|
|
535
537
|
})
|
|
536
538
|
})
|
|
537
539
|
|
|
538
|
-
describe('
|
|
540
|
+
describe('dev.zooid.session_reset', () => {
|
|
539
541
|
it('ends the thread-keyed session when sent inside a thread', async () => {
|
|
540
542
|
const { transport, agents } = makeTransport()
|
|
541
543
|
await postTxn(transport.app, {
|
|
542
544
|
events: [
|
|
543
545
|
{
|
|
544
|
-
type: '
|
|
546
|
+
type: 'dev.zooid.session_reset',
|
|
545
547
|
event_id: '$reset',
|
|
546
548
|
room_id: '!r:example.com',
|
|
547
549
|
sender: '@alice:example.com',
|
|
@@ -559,7 +561,7 @@ describe('eco.zoon.session_reset', () => {
|
|
|
559
561
|
await postTxn(transport.app, {
|
|
560
562
|
events: [
|
|
561
563
|
{
|
|
562
|
-
type: '
|
|
564
|
+
type: 'dev.zooid.session_reset',
|
|
563
565
|
event_id: '$reset-room',
|
|
564
566
|
room_id: '!r:example.com',
|
|
565
567
|
sender: '@alice:example.com',
|
|
@@ -602,7 +604,7 @@ describe('eco.zoon.session_reset', () => {
|
|
|
602
604
|
await postTxn(transport.app, {
|
|
603
605
|
events: [
|
|
604
606
|
{
|
|
605
|
-
type: '
|
|
607
|
+
type: 'dev.zooid.session_reset',
|
|
606
608
|
event_id: '$reset',
|
|
607
609
|
room_id: '!r:example.com',
|
|
608
610
|
sender: '@alice:example.com',
|
|
@@ -844,7 +846,7 @@ describe('tool-call and plan event bridging', () => {
|
|
|
844
846
|
return { transport, agents, client, finishPrompt, sessionId: 'sess-$e6' }
|
|
845
847
|
}
|
|
846
848
|
|
|
847
|
-
it('forwards tool_call as
|
|
849
|
+
it('forwards tool_call as dev.zooid.tool_call in-room under the agent bot user', async () => {
|
|
848
850
|
const { agents, client, finishPrompt, sessionId } = await startTurnAndGetSession()
|
|
849
851
|
await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
|
|
850
852
|
type: 'tool_call',
|
|
@@ -859,7 +861,7 @@ describe('tool-call and plan event bridging', () => {
|
|
|
859
861
|
expect.objectContaining({
|
|
860
862
|
roomId: '!r:example.com',
|
|
861
863
|
asUserId: '@architect:example.com',
|
|
862
|
-
eventType: '
|
|
864
|
+
eventType: 'dev.zooid.tool_call',
|
|
863
865
|
content: expect.objectContaining({
|
|
864
866
|
session_id: sessionId,
|
|
865
867
|
tool_call_id: 'tc-1',
|
|
@@ -873,7 +875,7 @@ describe('tool-call and plan event bridging', () => {
|
|
|
873
875
|
await settleTurn()
|
|
874
876
|
})
|
|
875
877
|
|
|
876
|
-
it('forwards tool_call_update as
|
|
878
|
+
it('forwards tool_call_update as dev.zooid.tool_call_update', async () => {
|
|
877
879
|
const { agents, client, finishPrompt, sessionId } = await startTurnAndGetSession()
|
|
878
880
|
await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
|
|
879
881
|
type: 'tool_call_update',
|
|
@@ -883,14 +885,14 @@ describe('tool-call and plan event bridging', () => {
|
|
|
883
885
|
})
|
|
884
886
|
await settleTurn()
|
|
885
887
|
const call = client.sendCustomEvent.mock.calls.find(
|
|
886
|
-
([arg]) => (arg as { eventType: string }).eventType === '
|
|
888
|
+
([arg]) => (arg as { eventType: string }).eventType === 'dev.zooid.tool_call_update',
|
|
887
889
|
)
|
|
888
890
|
expect(call).toBeDefined()
|
|
889
891
|
finishPrompt()
|
|
890
892
|
await settleTurn()
|
|
891
893
|
})
|
|
892
894
|
|
|
893
|
-
it('forwards plan as
|
|
895
|
+
it('forwards plan as dev.zooid.plan', async () => {
|
|
894
896
|
const { agents, client, finishPrompt, sessionId } = await startTurnAndGetSession()
|
|
895
897
|
await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
|
|
896
898
|
type: 'plan',
|
|
@@ -899,13 +901,61 @@ describe('tool-call and plan event bridging', () => {
|
|
|
899
901
|
})
|
|
900
902
|
await settleTurn()
|
|
901
903
|
const call = client.sendCustomEvent.mock.calls.find(
|
|
902
|
-
([arg]) => (arg as { eventType: string }).eventType === '
|
|
904
|
+
([arg]) => (arg as { eventType: string }).eventType === 'dev.zooid.plan',
|
|
903
905
|
)
|
|
904
906
|
expect(call).toBeDefined()
|
|
905
907
|
finishPrompt()
|
|
906
908
|
await settleTurn()
|
|
907
909
|
})
|
|
908
910
|
|
|
911
|
+
it('replays available_commands advertised during ensureSession (before the session ctx exists)', async () => {
|
|
912
|
+
// Regression (ZOD040 ctx race): shims advertise commands during session
|
|
913
|
+
// load/new — i.e. inside ensureSession, BEFORE runTurn registers the ctx.
|
|
914
|
+
// Without buffer-and-replay the event hits onEvent with no ctx and is
|
|
915
|
+
// dropped, so the command palette never fills. available_commands is only
|
|
916
|
+
// ever emitted at session establishment, so this is its ONLY chance.
|
|
917
|
+
const { transport, agents, client, finishPrompt } = makeTransport()
|
|
918
|
+
agents.ensureSession.mockImplementation(async (_name: string, threadId: string) => {
|
|
919
|
+
const sessionId = `sess-${threadId}`
|
|
920
|
+
await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
|
|
921
|
+
type: 'available_commands',
|
|
922
|
+
sessionId,
|
|
923
|
+
commands: [{ name: 'compact', description: 'Compact the context' }],
|
|
924
|
+
})
|
|
925
|
+
return sessionId
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
await postTxn(transport.app, {
|
|
929
|
+
events: [
|
|
930
|
+
{
|
|
931
|
+
type: 'm.room.message',
|
|
932
|
+
event_id: '$e7',
|
|
933
|
+
origin_server_ts: Date.now(),
|
|
934
|
+
room_id: '!r:example.com',
|
|
935
|
+
sender: '@user:example.com',
|
|
936
|
+
content: {
|
|
937
|
+
msgtype: 'm.text',
|
|
938
|
+
body: 'hi',
|
|
939
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
940
|
+
},
|
|
941
|
+
},
|
|
942
|
+
],
|
|
943
|
+
})
|
|
944
|
+
await settleTurn()
|
|
945
|
+
|
|
946
|
+
const call = client.sendCustomEvent.mock.calls.find(
|
|
947
|
+
([arg]) =>
|
|
948
|
+
(arg as { eventType: string }).eventType === 'dev.zooid.available_commands_update',
|
|
949
|
+
)
|
|
950
|
+
expect(call).toBeDefined()
|
|
951
|
+
expect((call![0] as { content: Record<string, unknown> }).content).toMatchObject({
|
|
952
|
+
session_id: 'sess-$e7',
|
|
953
|
+
available_commands: [{ name: 'compact', description: 'Compact the context' }],
|
|
954
|
+
})
|
|
955
|
+
finishPrompt()
|
|
956
|
+
await settleTurn()
|
|
957
|
+
})
|
|
958
|
+
|
|
909
959
|
it('attaches m.relates_to thread when the originating message was in-thread', async () => {
|
|
910
960
|
const { transport, agents, client, finishPrompt } = makeTransport()
|
|
911
961
|
await postTxn(transport.app, {
|
|
@@ -935,7 +985,7 @@ describe('tool-call and plan event bridging', () => {
|
|
|
935
985
|
})
|
|
936
986
|
await settleTurn()
|
|
937
987
|
const call = client.sendCustomEvent.mock.calls.find(
|
|
938
|
-
([arg]) => (arg as { eventType: string }).eventType === '
|
|
988
|
+
([arg]) => (arg as { eventType: string }).eventType === 'dev.zooid.tool_call',
|
|
939
989
|
)
|
|
940
990
|
expect((call?.[0] as { content: Record<string, unknown> }).content['m.relates_to']).toEqual({
|
|
941
991
|
rel_type: 'm.thread',
|
|
@@ -1034,17 +1084,23 @@ describe('agent_message_chunk message-boundary buffering', () => {
|
|
|
1034
1084
|
const sentBody = (client: { sendMessage: { mock: { calls: unknown[][] } } }) =>
|
|
1035
1085
|
(client.sendMessage.mock.calls[0]![0] as { content: { body: string } }).content.body
|
|
1036
1086
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1087
|
+
const sentBodies = (client: { sendMessage: { mock: { calls: unknown[][] } } }) =>
|
|
1088
|
+
client.sendMessage.mock.calls.map(
|
|
1089
|
+
(c) => (c[0] as { content: { body: string } }).content.body,
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
it('flushes a separate Matrix message when messageId changes (opencode run-on)', async () => {
|
|
1093
|
+
// opencode streams "…it." under one message id, then "Filed:" under a NEW
|
|
1094
|
+
// id with no delimiter chunk. Each id is a distinct assistant message, so
|
|
1095
|
+
// each lands as its own Matrix message rather than welding into "it.Filed:"
|
|
1096
|
+
// (or being buffered into a single turn-end blob).
|
|
1041
1097
|
const { agents, client, finishPrompt, sessionId } = await startTurn('$mid1')
|
|
1042
1098
|
await emit(agents, sessionId, 'Let me file it.', 'msg_aaa')
|
|
1043
1099
|
await emit(agents, sessionId, 'Filed: done', 'msg_bbb')
|
|
1044
1100
|
finishPrompt()
|
|
1045
1101
|
await settleTurn()
|
|
1046
|
-
expect(client.sendMessage).toHaveBeenCalledTimes(
|
|
1047
|
-
expect(
|
|
1102
|
+
expect(client.sendMessage).toHaveBeenCalledTimes(2)
|
|
1103
|
+
expect(sentBodies(client)).toEqual(['Let me file it.', 'Filed: done'])
|
|
1048
1104
|
})
|
|
1049
1105
|
|
|
1050
1106
|
it('does NOT break between chunks sharing a messageId (token streaming stays intact)', async () => {
|
|
@@ -1059,14 +1115,15 @@ describe('agent_message_chunk message-boundary buffering', () => {
|
|
|
1059
1115
|
expect(sentBody(client)).toBe('Hello world.')
|
|
1060
1116
|
})
|
|
1061
1117
|
|
|
1062
|
-
it('
|
|
1118
|
+
it('flushes each message of a three-message run separately', async () => {
|
|
1063
1119
|
const { agents, client, finishPrompt, sessionId } = await startTurn('$mid3')
|
|
1064
1120
|
await emit(agents, sessionId, 'one.', 'msg_a')
|
|
1065
1121
|
await emit(agents, sessionId, 'two.', 'msg_b')
|
|
1066
1122
|
await emit(agents, sessionId, 'three.', 'msg_c')
|
|
1067
1123
|
finishPrompt()
|
|
1068
1124
|
await settleTurn()
|
|
1069
|
-
expect(
|
|
1125
|
+
expect(client.sendMessage).toHaveBeenCalledTimes(3)
|
|
1126
|
+
expect(sentBodies(client)).toEqual(['one.', 'two.', 'three.'])
|
|
1070
1127
|
})
|
|
1071
1128
|
|
|
1072
1129
|
it('still breaks on an empty delimiter chunk (agents that signal that way)', async () => {
|
|
@@ -1087,9 +1144,37 @@ describe('agent_message_chunk message-boundary buffering', () => {
|
|
|
1087
1144
|
await settleTurn()
|
|
1088
1145
|
expect(sentBody(client)).toBe('Hello there')
|
|
1089
1146
|
})
|
|
1147
|
+
|
|
1148
|
+
it('flushes buffered text before a following plan event (interleaving)', async () => {
|
|
1149
|
+
// A turn that alternates prose and plan updates: each prose message must
|
|
1150
|
+
// land on the wire before the plan event that follows it, not be deferred
|
|
1151
|
+
// to turn end (the bug — everything buffered, then one blob after N plans).
|
|
1152
|
+
const { agents, client, finishPrompt, sessionId } = await startTurn('$mid6')
|
|
1153
|
+
await emit(agents, sessionId, 'Sure, making a list.', 'msg_a')
|
|
1154
|
+
await (agents.onEvent as (n: string, e: unknown) => unknown)('architect', {
|
|
1155
|
+
type: 'plan',
|
|
1156
|
+
sessionId,
|
|
1157
|
+
entries: [{ content: 'Buy milk', status: 'pending' }],
|
|
1158
|
+
})
|
|
1159
|
+
await emit(agents, sessionId, 'Working through them.', 'msg_b')
|
|
1160
|
+
finishPrompt()
|
|
1161
|
+
await settleTurn()
|
|
1162
|
+
|
|
1163
|
+
expect(sentBodies(client)).toEqual(['Sure, making a list.', 'Working through them.'])
|
|
1164
|
+
const planIdx = client.sendCustomEvent.mock.calls.findIndex(
|
|
1165
|
+
([arg]) => (arg as { eventType: string }).eventType === 'dev.zooid.plan',
|
|
1166
|
+
)
|
|
1167
|
+
expect(planIdx).toBeGreaterThanOrEqual(0)
|
|
1168
|
+
// Order across both mocks: msg1 → plan → msg2.
|
|
1169
|
+
const msg1 = client.sendMessage.mock.invocationCallOrder[0]!
|
|
1170
|
+
const msg2 = client.sendMessage.mock.invocationCallOrder[1]!
|
|
1171
|
+
const plan = client.sendCustomEvent.mock.invocationCallOrder[planIdx]!
|
|
1172
|
+
expect(msg1).toBeLessThan(plan)
|
|
1173
|
+
expect(plan).toBeLessThan(msg2)
|
|
1174
|
+
})
|
|
1090
1175
|
})
|
|
1091
1176
|
|
|
1092
|
-
describe('
|
|
1177
|
+
describe('dev.zooid.interrupt handling', () => {
|
|
1093
1178
|
it('dispatches cancelSession(agent.name, sessionId) for an interrupt that targets a tracked session', async () => {
|
|
1094
1179
|
const { transport, agents, finishPrompt } = makeTransport()
|
|
1095
1180
|
await postTxn(transport.app, {
|
|
@@ -1109,7 +1194,7 @@ describe('eco.zoon.interrupt handling', () => {
|
|
|
1109
1194
|
await postTxn(transport.app, {
|
|
1110
1195
|
events: [
|
|
1111
1196
|
{
|
|
1112
|
-
type: '
|
|
1197
|
+
type: 'dev.zooid.interrupt',
|
|
1113
1198
|
event_id: '$int',
|
|
1114
1199
|
origin_server_ts: Date.now(),
|
|
1115
1200
|
room_id: '!r:example.com',
|
|
@@ -1141,7 +1226,7 @@ describe('eco.zoon.interrupt handling', () => {
|
|
|
1141
1226
|
await postTxn(transport.app, {
|
|
1142
1227
|
events: [
|
|
1143
1228
|
{
|
|
1144
|
-
type: '
|
|
1229
|
+
type: 'dev.zooid.interrupt',
|
|
1145
1230
|
event_id: '$int2',
|
|
1146
1231
|
origin_server_ts: Date.now(),
|
|
1147
1232
|
room_id: '!r:example.com',
|
|
@@ -1160,7 +1245,7 @@ describe('eco.zoon.interrupt handling', () => {
|
|
|
1160
1245
|
await postTxn(transport.app, {
|
|
1161
1246
|
events: [
|
|
1162
1247
|
{
|
|
1163
|
-
type: '
|
|
1248
|
+
type: 'dev.zooid.interrupt',
|
|
1164
1249
|
event_id: '$int3',
|
|
1165
1250
|
origin_server_ts: Date.now(),
|
|
1166
1251
|
room_id: '!r:example.com',
|
|
@@ -1191,7 +1276,7 @@ describe('eco.zoon.interrupt handling', () => {
|
|
|
1191
1276
|
await postTxn(transport.app, {
|
|
1192
1277
|
events: [
|
|
1193
1278
|
{
|
|
1194
|
-
type: '
|
|
1279
|
+
type: 'dev.zooid.interrupt',
|
|
1195
1280
|
event_id: '$intT',
|
|
1196
1281
|
origin_server_ts: Date.now(),
|
|
1197
1282
|
room_id: '!r:example.com',
|
|
@@ -1226,7 +1311,7 @@ describe('eco.zoon.interrupt handling', () => {
|
|
|
1226
1311
|
const interrupt = (id: string) => ({
|
|
1227
1312
|
events: [
|
|
1228
1313
|
{
|
|
1229
|
-
type: '
|
|
1314
|
+
type: 'dev.zooid.interrupt',
|
|
1230
1315
|
event_id: id,
|
|
1231
1316
|
origin_server_ts: Date.now(),
|
|
1232
1317
|
room_id: '!r:example.com',
|
|
@@ -1249,7 +1334,7 @@ describe('eco.zoon.interrupt handling', () => {
|
|
|
1249
1334
|
{
|
|
1250
1335
|
events: [
|
|
1251
1336
|
{
|
|
1252
|
-
type: '
|
|
1337
|
+
type: 'dev.zooid.interrupt',
|
|
1253
1338
|
event_id: '$bad',
|
|
1254
1339
|
origin_server_ts: Date.now(),
|
|
1255
1340
|
room_id: '!r:example.com',
|
|
@@ -1460,7 +1545,7 @@ describe('inbound media', () => {
|
|
|
1460
1545
|
expect((content[0] as { type: string }).type).toBe('resource_link')
|
|
1461
1546
|
})
|
|
1462
1547
|
|
|
1463
|
-
it('emits
|
|
1548
|
+
it('emits dev.zooid.error (code media_failed) when download fails, still runs the turn text-only', async () => {
|
|
1464
1549
|
const media = fakeMedia()
|
|
1465
1550
|
media.download.mockRejectedValueOnce(new Error('download boom'))
|
|
1466
1551
|
const { transport, agents, client } = makeMediaTransport({ media })
|
|
@@ -1479,7 +1564,7 @@ describe('inbound media', () => {
|
|
|
1479
1564
|
|
|
1480
1565
|
expect(client.sendCustomEvent).toHaveBeenCalledWith(
|
|
1481
1566
|
expect.objectContaining({
|
|
1482
|
-
eventType: '
|
|
1567
|
+
eventType: 'dev.zooid.error',
|
|
1483
1568
|
content: expect.objectContaining({ code: 'media_failed' }),
|
|
1484
1569
|
}),
|
|
1485
1570
|
)
|
|
@@ -1554,3 +1639,47 @@ describe('outbound agent images', () => {
|
|
|
1554
1639
|
await settleTurn()
|
|
1555
1640
|
})
|
|
1556
1641
|
})
|
|
1642
|
+
|
|
1643
|
+
describe('ad-hoc bot invite declines', () => {
|
|
1644
|
+
let n = 0
|
|
1645
|
+
const invite = (stateKey: string, sender: string) => ({
|
|
1646
|
+
type: 'm.room.member',
|
|
1647
|
+
event_id: `$inv${++n}`,
|
|
1648
|
+
room_id: '!r:example.com',
|
|
1649
|
+
sender,
|
|
1650
|
+
state_key: stateKey,
|
|
1651
|
+
content: { membership: 'invite' },
|
|
1652
|
+
})
|
|
1653
|
+
|
|
1654
|
+
it('declines an invite to the AS bot from a human, with a reason', async () => {
|
|
1655
|
+
const { transport, client } = makeTransport()
|
|
1656
|
+
await postTxn(transport.app, { events: [invite('@zooid:example.com', '@zongshan:example.com')] })
|
|
1657
|
+
expect(client.leaveRoom).toHaveBeenCalledWith(
|
|
1658
|
+
'!r:example.com',
|
|
1659
|
+
'@zooid:example.com',
|
|
1660
|
+
{ reason: expect.stringContaining('zooid.yaml') },
|
|
1661
|
+
)
|
|
1662
|
+
})
|
|
1663
|
+
|
|
1664
|
+
it('declines an invite to an agent from a human', async () => {
|
|
1665
|
+
const { transport, client } = makeTransport()
|
|
1666
|
+
await postTxn(transport.app, { events: [invite('@architect:example.com', '@zongshan:example.com')] })
|
|
1667
|
+
expect(client.leaveRoom).toHaveBeenCalledWith(
|
|
1668
|
+
'!r:example.com',
|
|
1669
|
+
'@architect:example.com',
|
|
1670
|
+
expect.objectContaining({ reason: expect.any(String) }),
|
|
1671
|
+
)
|
|
1672
|
+
})
|
|
1673
|
+
|
|
1674
|
+
it('does NOT decline a provisioning invite (inviter is our AS bot)', async () => {
|
|
1675
|
+
const { transport, client } = makeTransport()
|
|
1676
|
+
await postTxn(transport.app, { events: [invite('@architect:example.com', '@zooid:example.com')] })
|
|
1677
|
+
expect(client.leaveRoom).not.toHaveBeenCalled()
|
|
1678
|
+
})
|
|
1679
|
+
|
|
1680
|
+
it('ignores an invite to a human (not one of our bots)', async () => {
|
|
1681
|
+
const { transport, client } = makeTransport()
|
|
1682
|
+
await postTxn(transport.app, { events: [invite('@dave:example.com', '@zongshan:example.com')] })
|
|
1683
|
+
expect(client.leaveRoom).not.toHaveBeenCalled()
|
|
1684
|
+
})
|
|
1685
|
+
})
|