@zooid/transport-matrix 0.7.4 → 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 +116 -1
- package/dist/index.js +387 -51
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/attachments.test.ts +58 -0
- package/src/attachments.ts +30 -0
- package/src/bot-pool.ts +1 -1
- package/src/context-provider.test.ts +33 -0
- package/src/context-provider.ts +22 -4
- package/src/event-encoders.test.ts +22 -0
- package/src/event-encoders.ts +13 -0
- package/src/index.ts +8 -2
- package/src/matrix-client.test.ts +35 -2
- package/src/matrix-client.ts +19 -0
- package/src/media-client.test.ts +102 -0
- package/src/media-client.ts +69 -0
- package/src/pending-media.test.ts +51 -0
- package/src/pending-media.ts +37 -0
- package/src/router.test.ts +22 -1
- package/src/router.ts +11 -0
- package/src/space-provisioner.test.ts +26 -1
- package/src/space-provisioner.ts +15 -4
- package/src/transport.test.ts +401 -30
- package/src/transport.ts +402 -70
- package/src/workforce-publisher.test.ts +2 -2
- package/src/workforce-publisher.ts +1 -1
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',
|
|
@@ -1312,3 +1397,289 @@ describe('full loop integration', () => {
|
|
|
1312
1397
|
)
|
|
1313
1398
|
})
|
|
1314
1399
|
})
|
|
1400
|
+
|
|
1401
|
+
// ─── Media pipeline tests ────────────────────────────────────────────────────
|
|
1402
|
+
|
|
1403
|
+
const TINY_PNG_B64 =
|
|
1404
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
|
|
1405
|
+
const TINY_PNG = Buffer.from(TINY_PNG_B64, 'base64')
|
|
1406
|
+
|
|
1407
|
+
function fakeMedia() {
|
|
1408
|
+
return {
|
|
1409
|
+
download: vi.fn(async () => ({ data: new Uint8Array(TINY_PNG), contentType: 'image/png' })),
|
|
1410
|
+
upload: vi.fn(async () => ({ content_uri: 'mxc://localhost/up1' })),
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const workspaceBinding = {
|
|
1415
|
+
...baseAgents[0],
|
|
1416
|
+
workspaceDir: '/tmp/ws',
|
|
1417
|
+
agentWorkspacePath: '/workspace',
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function makeMediaTransport(opts: {
|
|
1421
|
+
media?: ReturnType<typeof fakeMedia>
|
|
1422
|
+
writeAttachmentFn?: unknown
|
|
1423
|
+
} = {}) {
|
|
1424
|
+
const { reg, finishPrompt } = fakeRegistry()
|
|
1425
|
+
const approvals = fakeApprovals()
|
|
1426
|
+
const client = fakeClient()
|
|
1427
|
+
const transport = createMatrixTransport({
|
|
1428
|
+
agents: reg as never,
|
|
1429
|
+
approvals: approvals as never,
|
|
1430
|
+
client: client as never,
|
|
1431
|
+
bindings: [workspaceBinding],
|
|
1432
|
+
hsToken: 'hs-secret',
|
|
1433
|
+
drainQuietMs: 0,
|
|
1434
|
+
media: opts.media as never,
|
|
1435
|
+
writeAttachmentFn: opts.writeAttachmentFn as never,
|
|
1436
|
+
})
|
|
1437
|
+
return { transport, agents: reg, client, finishPrompt }
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function imageEvent(over: {
|
|
1441
|
+
size?: number
|
|
1442
|
+
mimetype?: string
|
|
1443
|
+
msgtype?: string
|
|
1444
|
+
body?: string
|
|
1445
|
+
eventId?: string
|
|
1446
|
+
} = {}) {
|
|
1447
|
+
return {
|
|
1448
|
+
type: 'm.room.message',
|
|
1449
|
+
event_id: over.eventId ?? '$media1',
|
|
1450
|
+
room_id: '!r:example.com',
|
|
1451
|
+
sender: '@alice:example.com',
|
|
1452
|
+
content: {
|
|
1453
|
+
msgtype: over.msgtype ?? 'm.image',
|
|
1454
|
+
body: over.body ?? 'dog.png',
|
|
1455
|
+
url: 'mxc://localhost/abc',
|
|
1456
|
+
info: { mimetype: over.mimetype ?? 'image/png', size: over.size ?? 67 },
|
|
1457
|
+
},
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function mentionMsg(body: string, eventId = '$text1') {
|
|
1462
|
+
return {
|
|
1463
|
+
type: 'm.room.message',
|
|
1464
|
+
event_id: eventId,
|
|
1465
|
+
room_id: '!r:example.com',
|
|
1466
|
+
sender: '@alice:example.com',
|
|
1467
|
+
content: {
|
|
1468
|
+
msgtype: 'm.text',
|
|
1469
|
+
body: `@architect ${body}`,
|
|
1470
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
1471
|
+
},
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
describe('inbound media', () => {
|
|
1476
|
+
it('media events do not trigger a turn; m.text from the same sender drains them inline', async () => {
|
|
1477
|
+
const media = fakeMedia()
|
|
1478
|
+
const { transport, agents } = makeMediaTransport({ media })
|
|
1479
|
+
|
|
1480
|
+
// Image event: no turn fired
|
|
1481
|
+
await postTxn(transport.app, { events: [imageEvent()] })
|
|
1482
|
+
await settleTurn()
|
|
1483
|
+
expect(agents.prompt).not.toHaveBeenCalled()
|
|
1484
|
+
|
|
1485
|
+
// m.text mention from same sender: turn fires, image block prepended
|
|
1486
|
+
agents.prompt.mockImplementation(async (_name: string, p: { content: unknown[] }) => {
|
|
1487
|
+
agents.onEvent('architect', {
|
|
1488
|
+
type: 'agent_message_chunk',
|
|
1489
|
+
sessionId: 'sess-$text1',
|
|
1490
|
+
content: { type: 'text', text: 'got it' },
|
|
1491
|
+
})
|
|
1492
|
+
return { stopReason: 'end_turn' as const }
|
|
1493
|
+
})
|
|
1494
|
+
await postTxn(transport.app, { events: [mentionMsg('look at this')] })
|
|
1495
|
+
await settleTurn()
|
|
1496
|
+
|
|
1497
|
+
expect(agents.prompt).toHaveBeenCalledOnce()
|
|
1498
|
+
const content = (agents.prompt.mock.calls[0][1] as { content: unknown[] }).content
|
|
1499
|
+
expect(content[0]).toMatchObject({ type: 'image', data: TINY_PNG_B64, mimeType: 'image/png' })
|
|
1500
|
+
expect((content[1] as { type: string; text: string }).type).toBe('text')
|
|
1501
|
+
expect(media.download).toHaveBeenCalledOnce()
|
|
1502
|
+
})
|
|
1503
|
+
|
|
1504
|
+
it('routes an oversized image to the file path with a resource_link block and prose line', async () => {
|
|
1505
|
+
const media = fakeMedia()
|
|
1506
|
+
const writeAttachmentFn = vi.fn(() => ({
|
|
1507
|
+
hostPath: '/tmp/ws/.zooid/attachments/media1/dog.png',
|
|
1508
|
+
agentPath: '/workspace/.zooid/attachments/media1/dog.png',
|
|
1509
|
+
}))
|
|
1510
|
+
const { transport, agents } = makeMediaTransport({ media, writeAttachmentFn })
|
|
1511
|
+
|
|
1512
|
+
agents.prompt.mockResolvedValue({ stopReason: 'end_turn' as const })
|
|
1513
|
+
await postTxn(transport.app, { events: [imageEvent({ size: 600_000 })] }) // > MAX_INLINE_IMAGE_BYTES
|
|
1514
|
+
await postTxn(transport.app, { events: [mentionMsg('summarize')] })
|
|
1515
|
+
await settleTurn()
|
|
1516
|
+
|
|
1517
|
+
const content = (agents.prompt.mock.calls[0][1] as { content: unknown[] }).content
|
|
1518
|
+
expect(content[0]).toMatchObject({
|
|
1519
|
+
type: 'resource_link',
|
|
1520
|
+
uri: 'file:///workspace/.zooid/attachments/media1/dog.png',
|
|
1521
|
+
name: 'dog.png',
|
|
1522
|
+
})
|
|
1523
|
+
expect((content[1] as { text: string }).text).toContain(
|
|
1524
|
+
'/workspace/.zooid/attachments/media1/dog.png',
|
|
1525
|
+
)
|
|
1526
|
+
})
|
|
1527
|
+
|
|
1528
|
+
it('routes m.file to the workspace regardless of size', async () => {
|
|
1529
|
+
const media = fakeMedia()
|
|
1530
|
+
const writeAttachmentFn = vi.fn(() => ({
|
|
1531
|
+
hostPath: '/tmp/ws/.zooid/attachments/media1/report.pdf',
|
|
1532
|
+
agentPath: '/workspace/.zooid/attachments/media1/report.pdf',
|
|
1533
|
+
}))
|
|
1534
|
+
const { transport, agents } = makeMediaTransport({ media, writeAttachmentFn })
|
|
1535
|
+
|
|
1536
|
+
agents.prompt.mockResolvedValue({ stopReason: 'end_turn' as const })
|
|
1537
|
+
await postTxn(transport.app, {
|
|
1538
|
+
events: [imageEvent({ msgtype: 'm.file', body: 'report.pdf', mimetype: 'application/pdf' })],
|
|
1539
|
+
})
|
|
1540
|
+
await postTxn(transport.app, { events: [mentionMsg('read it')] })
|
|
1541
|
+
await settleTurn()
|
|
1542
|
+
|
|
1543
|
+
expect(writeAttachmentFn).toHaveBeenCalledOnce()
|
|
1544
|
+
const content = (agents.prompt.mock.calls[0][1] as { content: unknown[] }).content
|
|
1545
|
+
expect((content[0] as { type: string }).type).toBe('resource_link')
|
|
1546
|
+
})
|
|
1547
|
+
|
|
1548
|
+
it('emits dev.zooid.error (code media_failed) when download fails, still runs the turn text-only', async () => {
|
|
1549
|
+
const media = fakeMedia()
|
|
1550
|
+
media.download.mockRejectedValueOnce(new Error('download boom'))
|
|
1551
|
+
const { transport, agents, client } = makeMediaTransport({ media })
|
|
1552
|
+
|
|
1553
|
+
agents.prompt.mockImplementation(async () => {
|
|
1554
|
+
agents.onEvent('architect', {
|
|
1555
|
+
type: 'agent_message_chunk',
|
|
1556
|
+
sessionId: 'sess-$text1',
|
|
1557
|
+
content: { type: 'text', text: 'ok' },
|
|
1558
|
+
})
|
|
1559
|
+
return { stopReason: 'end_turn' as const }
|
|
1560
|
+
})
|
|
1561
|
+
await postTxn(transport.app, { events: [imageEvent()] })
|
|
1562
|
+
await postTxn(transport.app, { events: [mentionMsg('look')] })
|
|
1563
|
+
await settleTurn()
|
|
1564
|
+
|
|
1565
|
+
expect(client.sendCustomEvent).toHaveBeenCalledWith(
|
|
1566
|
+
expect.objectContaining({
|
|
1567
|
+
eventType: 'dev.zooid.error',
|
|
1568
|
+
content: expect.objectContaining({ code: 'media_failed' }),
|
|
1569
|
+
}),
|
|
1570
|
+
)
|
|
1571
|
+
const content = (agents.prompt.mock.calls[0][1] as { content: unknown[] }).content
|
|
1572
|
+
expect(content).toHaveLength(1)
|
|
1573
|
+
expect((content[0] as { type: string }).type).toBe('text')
|
|
1574
|
+
})
|
|
1575
|
+
})
|
|
1576
|
+
|
|
1577
|
+
describe('outbound agent images', () => {
|
|
1578
|
+
it('uploads an image chunk and sends a threaded m.image as the agent user', async () => {
|
|
1579
|
+
const media = fakeMedia()
|
|
1580
|
+
const { transport, agents, client } = makeMediaTransport({ media })
|
|
1581
|
+
|
|
1582
|
+
agents.prompt.mockImplementation(async () => {
|
|
1583
|
+
// Emit image block during prompt
|
|
1584
|
+
agents.onEvent('architect', {
|
|
1585
|
+
type: 'agent_message_chunk',
|
|
1586
|
+
sessionId: 'sess-$text1',
|
|
1587
|
+
content: { type: 'image', data: TINY_PNG_B64, mimeType: 'image/png' },
|
|
1588
|
+
})
|
|
1589
|
+
// Also emit text block so the turn isn't empty
|
|
1590
|
+
agents.onEvent('architect', {
|
|
1591
|
+
type: 'agent_message_chunk',
|
|
1592
|
+
sessionId: 'sess-$text1',
|
|
1593
|
+
content: { type: 'text', text: 'here is the image' },
|
|
1594
|
+
})
|
|
1595
|
+
return { stopReason: 'end_turn' as const }
|
|
1596
|
+
})
|
|
1597
|
+
|
|
1598
|
+
await postTxn(transport.app, { events: [mentionMsg('show me an image')] })
|
|
1599
|
+
await settleTurn()
|
|
1600
|
+
// Give async upload/send a moment to settle
|
|
1601
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1602
|
+
|
|
1603
|
+
expect(media.upload).toHaveBeenCalledWith(
|
|
1604
|
+
expect.objectContaining({ contentType: 'image/png', asUserId: '@architect:example.com' }),
|
|
1605
|
+
)
|
|
1606
|
+
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
1607
|
+
expect.objectContaining({
|
|
1608
|
+
asUserId: '@architect:example.com',
|
|
1609
|
+
content: expect.objectContaining({
|
|
1610
|
+
msgtype: 'm.image',
|
|
1611
|
+
url: 'mxc://localhost/up1',
|
|
1612
|
+
info: expect.objectContaining({ mimetype: 'image/png', size: TINY_PNG.length }),
|
|
1613
|
+
}),
|
|
1614
|
+
}),
|
|
1615
|
+
)
|
|
1616
|
+
})
|
|
1617
|
+
|
|
1618
|
+
it('does not throw when an audio block arrives (non-goal — warn and drop)', async () => {
|
|
1619
|
+
const media = fakeMedia()
|
|
1620
|
+
const { transport, agents } = makeMediaTransport({ media })
|
|
1621
|
+
|
|
1622
|
+
agents.prompt.mockImplementation(async () => {
|
|
1623
|
+
agents.onEvent('architect', {
|
|
1624
|
+
type: 'agent_message_chunk',
|
|
1625
|
+
sessionId: 'sess-$text1',
|
|
1626
|
+
content: { type: 'audio', data: 'AAAA', mimeType: 'audio/wav' },
|
|
1627
|
+
})
|
|
1628
|
+
agents.onEvent('architect', {
|
|
1629
|
+
type: 'agent_message_chunk',
|
|
1630
|
+
sessionId: 'sess-$text1',
|
|
1631
|
+
content: { type: 'text', text: 'ok' },
|
|
1632
|
+
})
|
|
1633
|
+
return { stopReason: 'end_turn' as const }
|
|
1634
|
+
})
|
|
1635
|
+
|
|
1636
|
+
await expect(
|
|
1637
|
+
postTxn(transport.app, { events: [mentionMsg('test audio')] }),
|
|
1638
|
+
).resolves.not.toThrow()
|
|
1639
|
+
await settleTurn()
|
|
1640
|
+
})
|
|
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
|
+
})
|