@zooid/transport-matrix 0.12.0 → 0.14.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 +164 -5
- package/dist/index.js +946 -101
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/context-provider.test.ts +178 -3
- package/src/context-provider.ts +62 -5
- package/src/event-encoders.test.ts +69 -1
- package/src/event-encoders.ts +41 -1
- package/src/index.ts +23 -3
- package/src/invocation-registry.test.ts +22 -0
- package/src/invocation-registry.ts +32 -0
- package/src/matrix-client.ts +58 -34
- package/src/router.test.ts +129 -10
- package/src/router.ts +78 -2
- package/src/task-completion.test.ts +23 -0
- package/src/task-completion.ts +37 -0
- package/src/task-dispatch.test.ts +53 -0
- package/src/task-dispatch.ts +68 -0
- package/src/task-envelope.test.ts +27 -0
- package/src/task-registry.test.ts +50 -0
- package/src/task-registry.ts +152 -0
- package/src/transport.test.ts +520 -111
- package/src/transport.ts +760 -116
package/src/transport.test.ts
CHANGED
|
@@ -91,6 +91,29 @@ async function postTxn(
|
|
|
91
91
|
})
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// A real agent turn ends with dev.zooid.turn.end, and that is what releases a
|
|
95
|
+
// held return. Tests that inject agent replies must carry the boundary too.
|
|
96
|
+
let turnEndCounter = 0
|
|
97
|
+
function postTurnEnd(
|
|
98
|
+
app: ReturnType<typeof makeTransport>['transport']['app'],
|
|
99
|
+
o: { agent: string; root: string },
|
|
100
|
+
) {
|
|
101
|
+
return postTxn(app, {
|
|
102
|
+
events: [
|
|
103
|
+
{
|
|
104
|
+
type: 'dev.zooid.turn.end',
|
|
105
|
+
event_id: `$turn-end-${++turnEndCounter}`,
|
|
106
|
+
room_id: '!r:example.com',
|
|
107
|
+
sender: `@${o.agent}:example.com`,
|
|
108
|
+
content: {
|
|
109
|
+
agent_id: o.agent,
|
|
110
|
+
'm.relates_to': { rel_type: 'm.thread', event_id: o.root },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
94
117
|
// runTurn is fire-and-forget; let any pending microtasks drain before
|
|
95
118
|
// asserting on side-effects driven by the agent prompt.
|
|
96
119
|
async function settleTurn(): Promise<void> {
|
|
@@ -139,14 +162,22 @@ describe('matrix transport /transactions', () => {
|
|
|
139
162
|
expect(res.status).toBe(200)
|
|
140
163
|
await settleTurn()
|
|
141
164
|
// Agent-promotion: sessionKey is the inbound event_id, NOT the room.
|
|
142
|
-
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
165
|
+
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
166
|
+
'architect',
|
|
167
|
+
'$root',
|
|
168
|
+
'!r:example.com',
|
|
169
|
+
'$root',
|
|
170
|
+
)
|
|
143
171
|
// The reply threads against the user's message.
|
|
144
172
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
145
173
|
expect.objectContaining({
|
|
146
174
|
roomId: '!r:example.com',
|
|
147
175
|
asUserId: '@architect:example.com',
|
|
148
176
|
threadRoot: '$root',
|
|
149
|
-
content: expect.objectContaining({
|
|
177
|
+
content: expect.objectContaining({
|
|
178
|
+
msgtype: 'm.notice',
|
|
179
|
+
body: 'hello back',
|
|
180
|
+
}),
|
|
150
181
|
}),
|
|
151
182
|
)
|
|
152
183
|
})
|
|
@@ -156,7 +187,10 @@ describe('matrix transport /transactions', () => {
|
|
|
156
187
|
// response for a normal turn; opencode flushes a trailing chunk just after
|
|
157
188
|
// the stopReason. The post-turn drain must wait for it instead of sending
|
|
158
189
|
// the truncated buffer.
|
|
159
|
-
const { transport, agents, client } = makeTransport({
|
|
190
|
+
const { transport, agents, client } = makeTransport({
|
|
191
|
+
drainQuietMs: 20,
|
|
192
|
+
drainMaxMs: 500,
|
|
193
|
+
})
|
|
160
194
|
const events = [
|
|
161
195
|
{
|
|
162
196
|
type: 'm.room.message',
|
|
@@ -229,7 +263,12 @@ describe('matrix transport /transactions', () => {
|
|
|
229
263
|
})
|
|
230
264
|
await postTxn(transport.app, { events })
|
|
231
265
|
await settleTurn()
|
|
232
|
-
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
266
|
+
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
267
|
+
'architect',
|
|
268
|
+
'$root',
|
|
269
|
+
'!r:example.com',
|
|
270
|
+
'$root',
|
|
271
|
+
)
|
|
233
272
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
234
273
|
expect.objectContaining({ threadRoot: '$root' }),
|
|
235
274
|
)
|
|
@@ -273,7 +312,7 @@ describe('matrix transport /transactions', () => {
|
|
|
273
312
|
formatted_body?: string
|
|
274
313
|
}
|
|
275
314
|
}
|
|
276
|
-
expect(call.content.msgtype).toBe('m.
|
|
315
|
+
expect(call.content.msgtype).toBe('m.notice')
|
|
277
316
|
expect(call.content.body).toBe('**bold** _italic_\n\n```ts\nconst x = 1\n```')
|
|
278
317
|
expect(call.content.format).toBe('org.matrix.custom.html')
|
|
279
318
|
expect(typeof call.content.formatted_body).toBe('string')
|
|
@@ -316,6 +355,39 @@ describe('matrix transport /transactions', () => {
|
|
|
316
355
|
expect(call.content).not.toHaveProperty('format')
|
|
317
356
|
})
|
|
318
357
|
|
|
358
|
+
it('sends agent prose as m.notice so .m.rule.suppress_notices silences the chunk storm server-side', async () => {
|
|
359
|
+
const { transport, agents, client } = makeTransport()
|
|
360
|
+
agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
|
|
361
|
+
agents.onEvent('architect', {
|
|
362
|
+
type: 'agent_message_chunk',
|
|
363
|
+
sessionId: 'sess-' + p.threadId,
|
|
364
|
+
content: { type: 'text', text: 'hello' },
|
|
365
|
+
})
|
|
366
|
+
return { stopReason: 'end_turn' as const }
|
|
367
|
+
})
|
|
368
|
+
await postTxn(transport.app, {
|
|
369
|
+
events: [
|
|
370
|
+
{
|
|
371
|
+
type: 'm.room.message',
|
|
372
|
+
event_id: '$root',
|
|
373
|
+
room_id: '!r:example.com',
|
|
374
|
+
sender: '@alice:example.com',
|
|
375
|
+
content: {
|
|
376
|
+
msgtype: 'm.text',
|
|
377
|
+
body: 'hi',
|
|
378
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
})
|
|
383
|
+
await settleTurn()
|
|
384
|
+
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
385
|
+
expect.objectContaining({
|
|
386
|
+
content: expect.objectContaining({ msgtype: 'm.notice' }),
|
|
387
|
+
}),
|
|
388
|
+
)
|
|
389
|
+
})
|
|
390
|
+
|
|
319
391
|
it('emits dev.zooid.approval_request when an approval is registered', async () => {
|
|
320
392
|
const { transport, approvals, client } = makeTransport()
|
|
321
393
|
await postTxn(transport.app, {
|
|
@@ -336,7 +408,7 @@ describe('matrix transport /transactions', () => {
|
|
|
336
408
|
await settleTurn()
|
|
337
409
|
approvals.emit('registered', {
|
|
338
410
|
approvalId: 'a1',
|
|
339
|
-
sessionId: 'sess-$root',
|
|
411
|
+
sessionId: 'sess-$root', // session is keyed on the promoted thread root
|
|
340
412
|
toolCallId: 't1',
|
|
341
413
|
options: [{ optionId: 'allow_once', name: 'Allow', kind: 'allow_once' }],
|
|
342
414
|
})
|
|
@@ -367,17 +439,109 @@ describe('matrix transport /transactions', () => {
|
|
|
367
439
|
},
|
|
368
440
|
],
|
|
369
441
|
})
|
|
370
|
-
expect(approvals.resolve).toHaveBeenCalledWith(
|
|
371
|
-
'
|
|
372
|
-
'
|
|
373
|
-
|
|
442
|
+
expect(approvals.resolve).toHaveBeenCalledWith('sess-$root', 'a1', {
|
|
443
|
+
decision: 'allow',
|
|
444
|
+
optionId: 'allow_once',
|
|
445
|
+
})
|
|
446
|
+
})
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
describe('dev.zooid.turn.end', () => {
|
|
450
|
+
function topLevelMention() {
|
|
451
|
+
return {
|
|
452
|
+
type: 'm.room.message',
|
|
453
|
+
event_id: '$root',
|
|
454
|
+
room_id: '!r:example.com',
|
|
455
|
+
sender: '@alice:example.com',
|
|
456
|
+
content: {
|
|
457
|
+
msgtype: 'm.text',
|
|
458
|
+
body: 'hi',
|
|
459
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
460
|
+
},
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
it('is sent once per turn, after the final flush has settled', async () => {
|
|
465
|
+
const { transport, agents, client } = makeTransport()
|
|
466
|
+
agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
|
|
467
|
+
agents.onEvent('architect', {
|
|
468
|
+
type: 'agent_message_chunk',
|
|
469
|
+
sessionId: 'sess-' + p.threadId,
|
|
470
|
+
content: { type: 'text', text: 'hello back' },
|
|
471
|
+
})
|
|
472
|
+
return { stopReason: 'end_turn' as const }
|
|
473
|
+
})
|
|
474
|
+
await postTxn(transport.app, { events: [topLevelMention()] })
|
|
475
|
+
await settleTurn()
|
|
476
|
+
|
|
477
|
+
const sendMessageOrder = client.sendMessage.mock.invocationCallOrder[0]!
|
|
478
|
+
const turnEndCall = client.sendCustomEvent.mock.calls.find(
|
|
479
|
+
(c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
|
|
480
|
+
)
|
|
481
|
+
expect(turnEndCall).toBeDefined()
|
|
482
|
+
const turnEndIdx = client.sendCustomEvent.mock.calls.indexOf(turnEndCall!)
|
|
483
|
+
const turnEndOrder = client.sendCustomEvent.mock.invocationCallOrder[turnEndIdx]!
|
|
484
|
+
// Ordering matters: a turn.end that lands before the prose would notify
|
|
485
|
+
// the user to look at a room that has nothing in it yet.
|
|
486
|
+
expect(turnEndOrder).toBeGreaterThan(sendMessageOrder)
|
|
487
|
+
expect(turnEndCall![0]).toMatchObject({
|
|
488
|
+
roomId: '!r:example.com',
|
|
489
|
+
asUserId: '@architect:example.com',
|
|
490
|
+
content: expect.objectContaining({ produced_output: true }),
|
|
491
|
+
})
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('reports produced_output: false for a silent turn', async () => {
|
|
495
|
+
const { transport, agents, client } = makeTransport()
|
|
496
|
+
// The agent emits no chunks at all — this is exactly ZOD076's case.
|
|
497
|
+
agents.prompt.mockImplementation(async () => ({
|
|
498
|
+
stopReason: 'end_turn' as const,
|
|
499
|
+
}))
|
|
500
|
+
await postTxn(transport.app, { events: [topLevelMention()] })
|
|
501
|
+
await settleTurn()
|
|
502
|
+
|
|
503
|
+
const turnEndCall = client.sendCustomEvent.mock.calls.find(
|
|
504
|
+
(c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
|
|
374
505
|
)
|
|
506
|
+
expect(turnEndCall![0]).toMatchObject({
|
|
507
|
+
content: expect.objectContaining({ produced_output: false }),
|
|
508
|
+
})
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it('is still sent when the turn throws, so the room never hangs on a spinner', async () => {
|
|
512
|
+
const { transport, agents, client } = makeTransport()
|
|
513
|
+
agents.prompt.mockImplementation(async () => {
|
|
514
|
+
throw new Error('boom')
|
|
515
|
+
})
|
|
516
|
+
await postTxn(transport.app, { events: [topLevelMention()] })
|
|
517
|
+
await settleTurn()
|
|
518
|
+
|
|
519
|
+
const turnEndCall = client.sendCustomEvent.mock.calls.find(
|
|
520
|
+
(c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
|
|
521
|
+
)
|
|
522
|
+
expect(turnEndCall).toBeDefined()
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('threads to the turn root', async () => {
|
|
526
|
+
const { transport, agents, client } = makeTransport()
|
|
527
|
+
agents.prompt.mockImplementation(async () => ({
|
|
528
|
+
stopReason: 'end_turn' as const,
|
|
529
|
+
}))
|
|
530
|
+
await postTxn(transport.app, { events: [topLevelMention()] })
|
|
531
|
+
await settleTurn()
|
|
532
|
+
|
|
533
|
+
const turnEndCall = client.sendCustomEvent.mock.calls.find(
|
|
534
|
+
(c) => (c[0] as { eventType: string }).eventType === 'dev.zooid.turn.end',
|
|
535
|
+
)
|
|
536
|
+
expect(
|
|
537
|
+
(turnEndCall![0] as { content: { 'm.relates_to': unknown } }).content['m.relates_to'],
|
|
538
|
+
).toEqual({ rel_type: 'm.thread', event_id: '$root' })
|
|
375
539
|
})
|
|
376
540
|
})
|
|
377
541
|
|
|
378
542
|
describe('thread implicit triggers', () => {
|
|
379
543
|
it('triggers the most-recent-posting agent for a bare reply in a thread', async () => {
|
|
380
|
-
const { transport, agents } = makeTransport()
|
|
544
|
+
const { transport, agents, finishPrompt } = makeTransport()
|
|
381
545
|
// Turn 1: user @mentions architect at top level. Agent-promotion makes
|
|
382
546
|
// $root the thread root and architect a thread participant.
|
|
383
547
|
agents.prompt.mockImplementation(async (_name: string, p: { threadId: string }) => {
|
|
@@ -425,13 +589,16 @@ describe('thread implicit triggers', () => {
|
|
|
425
589
|
await settleTurn()
|
|
426
590
|
|
|
427
591
|
// architect should be triggered implicitly because they posted in the thread.
|
|
428
|
-
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
592
|
+
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
593
|
+
'architect',
|
|
594
|
+
'$root',
|
|
595
|
+
'!r:example.com',
|
|
596
|
+
'$root',
|
|
597
|
+
)
|
|
429
598
|
})
|
|
430
599
|
|
|
431
600
|
it("inherits the thread root's @mentions when no agent has posted yet", async () => {
|
|
432
|
-
const { transport, agents } = makeTransport()
|
|
433
|
-
// Have prompt() never resolve, so no agent reply has landed when turn 2 arrives.
|
|
434
|
-
agents.prompt.mockImplementation(() => new Promise(() => {}))
|
|
601
|
+
const { transport, agents, finishPrompt } = makeTransport()
|
|
435
602
|
await postTxn(transport.app, {
|
|
436
603
|
events: [
|
|
437
604
|
{
|
|
@@ -467,8 +634,17 @@ describe('thread implicit triggers', () => {
|
|
|
467
634
|
],
|
|
468
635
|
})
|
|
469
636
|
await settleTurn()
|
|
470
|
-
//
|
|
471
|
-
expect(agents.ensureSession).
|
|
637
|
+
// Per-session FIFO holds a fast follow-up until the original turn ends.
|
|
638
|
+
expect(agents.ensureSession).not.toHaveBeenCalled()
|
|
639
|
+
finishPrompt()
|
|
640
|
+
await settleTurn()
|
|
641
|
+
// It then inherits the root's @mention of architect.
|
|
642
|
+
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
643
|
+
'architect',
|
|
644
|
+
'$root',
|
|
645
|
+
'!r:example.com',
|
|
646
|
+
'$root',
|
|
647
|
+
)
|
|
472
648
|
})
|
|
473
649
|
|
|
474
650
|
it('does not trigger any agent for a bare top-level message', async () => {
|
|
@@ -533,7 +709,12 @@ describe('thread implicit triggers', () => {
|
|
|
533
709
|
],
|
|
534
710
|
})
|
|
535
711
|
await settleTurn()
|
|
536
|
-
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
712
|
+
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
713
|
+
'architect',
|
|
714
|
+
'$root',
|
|
715
|
+
'!r:example.com',
|
|
716
|
+
'$root',
|
|
717
|
+
)
|
|
537
718
|
})
|
|
538
719
|
})
|
|
539
720
|
|
|
@@ -608,7 +789,9 @@ describe('dev.zooid.session_reset', () => {
|
|
|
608
789
|
event_id: '$reset',
|
|
609
790
|
room_id: '!r:example.com',
|
|
610
791
|
sender: '@alice:example.com',
|
|
611
|
-
content: {
|
|
792
|
+
content: {
|
|
793
|
+
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
|
|
794
|
+
},
|
|
612
795
|
},
|
|
613
796
|
],
|
|
614
797
|
})
|
|
@@ -634,7 +817,12 @@ describe('dev.zooid.session_reset', () => {
|
|
|
634
817
|
],
|
|
635
818
|
})
|
|
636
819
|
await settleTurn()
|
|
637
|
-
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
820
|
+
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
821
|
+
'architect',
|
|
822
|
+
'$root',
|
|
823
|
+
'!r:example.com',
|
|
824
|
+
'$root',
|
|
825
|
+
)
|
|
638
826
|
})
|
|
639
827
|
})
|
|
640
828
|
|
|
@@ -727,10 +915,10 @@ describe('typing indicator lifecycle', () => {
|
|
|
727
915
|
room_id: '!r:example.com',
|
|
728
916
|
sender: '@user:example.com',
|
|
729
917
|
content: {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
918
|
+
msgtype: 'm.text',
|
|
919
|
+
body: 'hi',
|
|
920
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
921
|
+
},
|
|
734
922
|
},
|
|
735
923
|
],
|
|
736
924
|
})
|
|
@@ -788,7 +976,9 @@ describe('presence lifecycle', () => {
|
|
|
788
976
|
expect(seenUnavailable).toBe(true)
|
|
789
977
|
finishPrompt()
|
|
790
978
|
await settleTurn()
|
|
791
|
-
const last = client.setPresence.mock.calls.at(-1)?.[0] as {
|
|
979
|
+
const last = client.setPresence.mock.calls.at(-1)?.[0] as {
|
|
980
|
+
presence: string
|
|
981
|
+
}
|
|
792
982
|
expect(last.presence).toBe('online')
|
|
793
983
|
})
|
|
794
984
|
|
|
@@ -944,8 +1134,7 @@ describe('tool-call and plan event bridging', () => {
|
|
|
944
1134
|
await settleTurn()
|
|
945
1135
|
|
|
946
1136
|
const call = client.sendCustomEvent.mock.calls.find(
|
|
947
|
-
([arg]) =>
|
|
948
|
-
(arg as { eventType: string }).eventType === 'dev.zooid.available_commands_update',
|
|
1137
|
+
([arg]) => (arg as { eventType: string }).eventType === 'dev.zooid.available_commands_update',
|
|
949
1138
|
)
|
|
950
1139
|
expect(call).toBeDefined()
|
|
951
1140
|
expect((call![0] as { content: Record<string, unknown> }).content).toMatchObject({
|
|
@@ -1085,9 +1274,7 @@ describe('agent_message_chunk message-boundary buffering', () => {
|
|
|
1085
1274
|
(client.sendMessage.mock.calls[0]![0] as { content: { body: string } }).content.body
|
|
1086
1275
|
|
|
1087
1276
|
const sentBodies = (client: { sendMessage: { mock: { calls: unknown[][] } } }) =>
|
|
1088
|
-
client.sendMessage.mock.calls.map(
|
|
1089
|
-
(c) => (c[0] as { content: { body: string } }).content.body,
|
|
1090
|
-
)
|
|
1277
|
+
client.sendMessage.mock.calls.map((c) => (c[0] as { content: { body: string } }).content.body)
|
|
1091
1278
|
|
|
1092
1279
|
it('flushes a separate Matrix message when messageId changes (opencode run-on)', async () => {
|
|
1093
1280
|
// opencode streams "…it." under one message id, then "Filed:" under a NEW
|
|
@@ -1185,7 +1372,11 @@ describe('dev.zooid.interrupt handling', () => {
|
|
|
1185
1372
|
origin_server_ts: Date.now(),
|
|
1186
1373
|
room_id: '!r:example.com',
|
|
1187
1374
|
sender: '@user:example.com',
|
|
1188
|
-
content: {
|
|
1375
|
+
content: {
|
|
1376
|
+
msgtype: 'm.text',
|
|
1377
|
+
body: 'hi',
|
|
1378
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
1379
|
+
},
|
|
1189
1380
|
},
|
|
1190
1381
|
],
|
|
1191
1382
|
})
|
|
@@ -1218,7 +1409,11 @@ describe('dev.zooid.interrupt handling', () => {
|
|
|
1218
1409
|
origin_server_ts: Date.now(),
|
|
1219
1410
|
room_id: '!r:example.com',
|
|
1220
1411
|
sender: '@user:example.com',
|
|
1221
|
-
content: {
|
|
1412
|
+
content: {
|
|
1413
|
+
msgtype: 'm.text',
|
|
1414
|
+
body: 'hi',
|
|
1415
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
1416
|
+
},
|
|
1222
1417
|
},
|
|
1223
1418
|
],
|
|
1224
1419
|
})
|
|
@@ -1267,7 +1462,11 @@ describe('dev.zooid.interrupt handling', () => {
|
|
|
1267
1462
|
origin_server_ts: Date.now(),
|
|
1268
1463
|
room_id: '!r:example.com',
|
|
1269
1464
|
sender: '@user:example.com',
|
|
1270
|
-
content: {
|
|
1465
|
+
content: {
|
|
1466
|
+
msgtype: 'm.text',
|
|
1467
|
+
body: 'hi',
|
|
1468
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
1469
|
+
},
|
|
1271
1470
|
},
|
|
1272
1471
|
],
|
|
1273
1472
|
})
|
|
@@ -1303,7 +1502,11 @@ describe('dev.zooid.interrupt handling', () => {
|
|
|
1303
1502
|
origin_server_ts: Date.now(),
|
|
1304
1503
|
room_id: '!r:example.com',
|
|
1305
1504
|
sender: '@user:example.com',
|
|
1306
|
-
content: {
|
|
1505
|
+
content: {
|
|
1506
|
+
msgtype: 'm.text',
|
|
1507
|
+
body: 'hi',
|
|
1508
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
1509
|
+
},
|
|
1307
1510
|
},
|
|
1308
1511
|
],
|
|
1309
1512
|
})
|
|
@@ -1363,14 +1566,19 @@ describe('full loop integration', () => {
|
|
|
1363
1566
|
|
|
1364
1567
|
// Turn 1: top-level mention.
|
|
1365
1568
|
await postTxn(transport.app, {
|
|
1366
|
-
events: [
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1569
|
+
events: [
|
|
1570
|
+
{
|
|
1571
|
+
type: 'm.room.message',
|
|
1572
|
+
event_id: '$root',
|
|
1573
|
+
room_id: '!r:example.com',
|
|
1574
|
+
sender: '@alice:example.com',
|
|
1575
|
+
content: {
|
|
1576
|
+
msgtype: 'm.text',
|
|
1577
|
+
body: 'hi @architect',
|
|
1578
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
1579
|
+
},
|
|
1372
1580
|
},
|
|
1373
|
-
|
|
1581
|
+
],
|
|
1374
1582
|
})
|
|
1375
1583
|
await settleTurn()
|
|
1376
1584
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
@@ -1381,17 +1589,27 @@ describe('full loop integration', () => {
|
|
|
1381
1589
|
agents.ensureSession.mockClear()
|
|
1382
1590
|
client.sendMessage.mockClear()
|
|
1383
1591
|
await postTxn(transport.app, {
|
|
1384
|
-
events: [
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1592
|
+
events: [
|
|
1593
|
+
{
|
|
1594
|
+
type: 'm.room.message',
|
|
1595
|
+
event_id: '$bare',
|
|
1596
|
+
room_id: '!r:example.com',
|
|
1597
|
+
sender: '@alice:example.com',
|
|
1598
|
+
content: {
|
|
1599
|
+
msgtype: 'm.text',
|
|
1600
|
+
body: 'follow up',
|
|
1601
|
+
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
|
|
1602
|
+
},
|
|
1390
1603
|
},
|
|
1391
|
-
|
|
1604
|
+
],
|
|
1392
1605
|
})
|
|
1393
1606
|
await settleTurn()
|
|
1394
|
-
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
1607
|
+
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
1608
|
+
'architect',
|
|
1609
|
+
'$root',
|
|
1610
|
+
'!r:example.com',
|
|
1611
|
+
'$root',
|
|
1612
|
+
)
|
|
1395
1613
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
1396
1614
|
expect.objectContaining({ threadRoot: '$root' }),
|
|
1397
1615
|
)
|
|
@@ -1406,7 +1624,10 @@ const TINY_PNG = Buffer.from(TINY_PNG_B64, 'base64')
|
|
|
1406
1624
|
|
|
1407
1625
|
function fakeMedia() {
|
|
1408
1626
|
return {
|
|
1409
|
-
download: vi.fn(async () => ({
|
|
1627
|
+
download: vi.fn(async () => ({
|
|
1628
|
+
data: new Uint8Array(TINY_PNG),
|
|
1629
|
+
contentType: 'image/png',
|
|
1630
|
+
})),
|
|
1410
1631
|
upload: vi.fn(async () => ({ content_uri: 'mxc://localhost/up1' })),
|
|
1411
1632
|
}
|
|
1412
1633
|
}
|
|
@@ -1417,10 +1638,12 @@ const workspaceBinding = {
|
|
|
1417
1638
|
agentWorkspacePath: '/workspace',
|
|
1418
1639
|
}
|
|
1419
1640
|
|
|
1420
|
-
function makeMediaTransport(
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1641
|
+
function makeMediaTransport(
|
|
1642
|
+
opts: {
|
|
1643
|
+
media?: ReturnType<typeof fakeMedia>
|
|
1644
|
+
writeAttachmentFn?: unknown
|
|
1645
|
+
} = {},
|
|
1646
|
+
) {
|
|
1424
1647
|
const { reg, finishPrompt } = fakeRegistry()
|
|
1425
1648
|
const approvals = fakeApprovals()
|
|
1426
1649
|
const client = fakeClient()
|
|
@@ -1437,13 +1660,15 @@ function makeMediaTransport(opts: {
|
|
|
1437
1660
|
return { transport, agents: reg, client, finishPrompt }
|
|
1438
1661
|
}
|
|
1439
1662
|
|
|
1440
|
-
function imageEvent(
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1663
|
+
function imageEvent(
|
|
1664
|
+
over: {
|
|
1665
|
+
size?: number
|
|
1666
|
+
mimetype?: string
|
|
1667
|
+
msgtype?: string
|
|
1668
|
+
body?: string
|
|
1669
|
+
eventId?: string
|
|
1670
|
+
} = {},
|
|
1671
|
+
) {
|
|
1447
1672
|
return {
|
|
1448
1673
|
type: 'm.room.message',
|
|
1449
1674
|
event_id: over.eventId ?? '$media1',
|
|
@@ -1496,7 +1721,11 @@ describe('inbound media', () => {
|
|
|
1496
1721
|
|
|
1497
1722
|
expect(agents.prompt).toHaveBeenCalledOnce()
|
|
1498
1723
|
const content = (agents.prompt.mock.calls[0][1] as { content: unknown[] }).content
|
|
1499
|
-
expect(content[0]).toMatchObject({
|
|
1724
|
+
expect(content[0]).toMatchObject({
|
|
1725
|
+
type: 'image',
|
|
1726
|
+
data: TINY_PNG_B64,
|
|
1727
|
+
mimeType: 'image/png',
|
|
1728
|
+
})
|
|
1500
1729
|
expect((content[1] as { type: string; text: string }).type).toBe('text')
|
|
1501
1730
|
expect(media.download).toHaveBeenCalledOnce()
|
|
1502
1731
|
})
|
|
@@ -1507,7 +1736,10 @@ describe('inbound media', () => {
|
|
|
1507
1736
|
hostPath: '/tmp/ws/.zooid/attachments/media1/dog.png',
|
|
1508
1737
|
agentPath: '/workspace/.zooid/attachments/media1/dog.png',
|
|
1509
1738
|
}))
|
|
1510
|
-
const { transport, agents } = makeMediaTransport({
|
|
1739
|
+
const { transport, agents } = makeMediaTransport({
|
|
1740
|
+
media,
|
|
1741
|
+
writeAttachmentFn,
|
|
1742
|
+
})
|
|
1511
1743
|
|
|
1512
1744
|
agents.prompt.mockResolvedValue({ stopReason: 'end_turn' as const })
|
|
1513
1745
|
await postTxn(transport.app, { events: [imageEvent({ size: 600_000 })] }) // > MAX_INLINE_IMAGE_BYTES
|
|
@@ -1531,11 +1763,20 @@ describe('inbound media', () => {
|
|
|
1531
1763
|
hostPath: '/tmp/ws/.zooid/attachments/media1/report.pdf',
|
|
1532
1764
|
agentPath: '/workspace/.zooid/attachments/media1/report.pdf',
|
|
1533
1765
|
}))
|
|
1534
|
-
const { transport, agents } = makeMediaTransport({
|
|
1766
|
+
const { transport, agents } = makeMediaTransport({
|
|
1767
|
+
media,
|
|
1768
|
+
writeAttachmentFn,
|
|
1769
|
+
})
|
|
1535
1770
|
|
|
1536
1771
|
agents.prompt.mockResolvedValue({ stopReason: 'end_turn' as const })
|
|
1537
1772
|
await postTxn(transport.app, {
|
|
1538
|
-
events: [
|
|
1773
|
+
events: [
|
|
1774
|
+
imageEvent({
|
|
1775
|
+
msgtype: 'm.file',
|
|
1776
|
+
body: 'report.pdf',
|
|
1777
|
+
mimetype: 'application/pdf',
|
|
1778
|
+
}),
|
|
1779
|
+
],
|
|
1539
1780
|
})
|
|
1540
1781
|
await postTxn(transport.app, { events: [mentionMsg('read it')] })
|
|
1541
1782
|
await settleTurn()
|
|
@@ -1601,7 +1842,10 @@ describe('outbound agent images', () => {
|
|
|
1601
1842
|
await new Promise((r) => setTimeout(r, 10))
|
|
1602
1843
|
|
|
1603
1844
|
expect(media.upload).toHaveBeenCalledWith(
|
|
1604
|
-
expect.objectContaining({
|
|
1845
|
+
expect.objectContaining({
|
|
1846
|
+
contentType: 'image/png',
|
|
1847
|
+
asUserId: '@architect:example.com',
|
|
1848
|
+
}),
|
|
1605
1849
|
)
|
|
1606
1850
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
1607
1851
|
expect.objectContaining({
|
|
@@ -1609,7 +1853,10 @@ describe('outbound agent images', () => {
|
|
|
1609
1853
|
content: expect.objectContaining({
|
|
1610
1854
|
msgtype: 'm.image',
|
|
1611
1855
|
url: 'mxc://localhost/up1',
|
|
1612
|
-
info: expect.objectContaining({
|
|
1856
|
+
info: expect.objectContaining({
|
|
1857
|
+
mimetype: 'image/png',
|
|
1858
|
+
size: TINY_PNG.length,
|
|
1859
|
+
}),
|
|
1613
1860
|
}),
|
|
1614
1861
|
}),
|
|
1615
1862
|
)
|
|
@@ -1653,17 +1900,19 @@ describe('ad-hoc bot invite declines', () => {
|
|
|
1653
1900
|
|
|
1654
1901
|
it('declines an invite to the AS bot from a human, with a reason', async () => {
|
|
1655
1902
|
const { transport, client } = makeTransport()
|
|
1656
|
-
await postTxn(transport.app, {
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
)
|
|
1903
|
+
await postTxn(transport.app, {
|
|
1904
|
+
events: [invite('@zooid:example.com', '@zongshan:example.com')],
|
|
1905
|
+
})
|
|
1906
|
+
expect(client.leaveRoom).toHaveBeenCalledWith('!r:example.com', '@zooid:example.com', {
|
|
1907
|
+
reason: expect.stringContaining('zooid.yaml'),
|
|
1908
|
+
})
|
|
1662
1909
|
})
|
|
1663
1910
|
|
|
1664
1911
|
it('declines an invite to an agent from a human', async () => {
|
|
1665
1912
|
const { transport, client } = makeTransport()
|
|
1666
|
-
await postTxn(transport.app, {
|
|
1913
|
+
await postTxn(transport.app, {
|
|
1914
|
+
events: [invite('@architect:example.com', '@zongshan:example.com')],
|
|
1915
|
+
})
|
|
1667
1916
|
expect(client.leaveRoom).toHaveBeenCalledWith(
|
|
1668
1917
|
'!r:example.com',
|
|
1669
1918
|
'@architect:example.com',
|
|
@@ -1673,13 +1922,17 @@ describe('ad-hoc bot invite declines', () => {
|
|
|
1673
1922
|
|
|
1674
1923
|
it('does NOT decline a provisioning invite (inviter is our AS bot)', async () => {
|
|
1675
1924
|
const { transport, client } = makeTransport()
|
|
1676
|
-
await postTxn(transport.app, {
|
|
1925
|
+
await postTxn(transport.app, {
|
|
1926
|
+
events: [invite('@architect:example.com', '@zooid:example.com')],
|
|
1927
|
+
})
|
|
1677
1928
|
expect(client.leaveRoom).not.toHaveBeenCalled()
|
|
1678
1929
|
})
|
|
1679
1930
|
|
|
1680
1931
|
it('ignores an invite to a human (not one of our bots)', async () => {
|
|
1681
1932
|
const { transport, client } = makeTransport()
|
|
1682
|
-
await postTxn(transport.app, {
|
|
1933
|
+
await postTxn(transport.app, {
|
|
1934
|
+
events: [invite('@dave:example.com', '@zongshan:example.com')],
|
|
1935
|
+
})
|
|
1683
1936
|
expect(client.leaveRoom).not.toHaveBeenCalled()
|
|
1684
1937
|
})
|
|
1685
1938
|
})
|
|
@@ -1705,7 +1958,9 @@ describe('directional agent-to-agent handoffs', () => {
|
|
|
1705
1958
|
const approvals = fakeApprovals()
|
|
1706
1959
|
const client = fakeClient()
|
|
1707
1960
|
// Resolve prompts immediately so runTurn completes and `participants` append.
|
|
1708
|
-
reg.prompt.mockImplementation(async () => ({
|
|
1961
|
+
reg.prompt.mockImplementation(async () => ({
|
|
1962
|
+
stopReason: 'end_turn' as const,
|
|
1963
|
+
}))
|
|
1709
1964
|
const transport = createMatrixTransport({
|
|
1710
1965
|
agents: reg as never,
|
|
1711
1966
|
approvals: approvals as never,
|
|
@@ -1729,7 +1984,13 @@ describe('directional agent-to-agent handoffs', () => {
|
|
|
1729
1984
|
if (o.root) content['m.relates_to'] = { rel_type: 'm.thread', event_id: o.root }
|
|
1730
1985
|
return postTxn(transport.app, {
|
|
1731
1986
|
events: [
|
|
1732
|
-
{
|
|
1987
|
+
{
|
|
1988
|
+
type: 'm.room.message',
|
|
1989
|
+
event_id: o.id,
|
|
1990
|
+
room_id: '!r:example.com',
|
|
1991
|
+
sender: o.sender,
|
|
1992
|
+
content,
|
|
1993
|
+
},
|
|
1733
1994
|
],
|
|
1734
1995
|
})
|
|
1735
1996
|
}
|
|
@@ -1738,19 +1999,33 @@ describe('directional agent-to-agent handoffs', () => {
|
|
|
1738
1999
|
const { transport, agents } = makePairTransport()
|
|
1739
2000
|
|
|
1740
2001
|
// 1. Human @mentions parent (top-level) → thread root is $root.
|
|
1741
|
-
await post(transport, {
|
|
2002
|
+
await post(transport, {
|
|
2003
|
+
id: '$root',
|
|
2004
|
+
sender: '@alice:example.com',
|
|
2005
|
+
mentions: ['@parent:example.com'],
|
|
2006
|
+
})
|
|
1742
2007
|
await settleTurn()
|
|
1743
2008
|
|
|
1744
2009
|
// 2. Parent replies in-thread @mentioning sub → sub is called; caller[sub]=parent.
|
|
1745
2010
|
// [[ZOD071]]: this call opens a fresh handoff arc for sub, keyed by $p1.
|
|
1746
|
-
await post(transport, {
|
|
2011
|
+
await post(transport, {
|
|
2012
|
+
id: '$p1',
|
|
2013
|
+
sender: '@parent:example.com',
|
|
2014
|
+
root: '$root',
|
|
2015
|
+
mentions: ['@sub:example.com'],
|
|
2016
|
+
})
|
|
1747
2017
|
await settleTurn()
|
|
1748
2018
|
expect(agents.ensureSession).toHaveBeenCalledWith('sub', '$root|$p1', '!r:example.com', '$root')
|
|
1749
2019
|
|
|
1750
2020
|
agents.ensureSession.mockClear()
|
|
1751
2021
|
|
|
1752
2022
|
// 3. Sub replies bare → bubbles UP to its caller (parent), nobody else.
|
|
1753
|
-
await post(transport, {
|
|
2023
|
+
await post(transport, {
|
|
2024
|
+
id: '$s1',
|
|
2025
|
+
sender: '@sub:example.com',
|
|
2026
|
+
root: '$root',
|
|
2027
|
+
})
|
|
2028
|
+
await postTurnEnd(transport.app, { agent: 'sub', root: '$root' })
|
|
1754
2029
|
await settleTurn()
|
|
1755
2030
|
expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com', '$root')
|
|
1756
2031
|
expect(agents.ensureSession.mock.calls.map((c) => c[0])).not.toContain('sub')
|
|
@@ -1759,9 +2034,61 @@ describe('directional agent-to-agent handoffs', () => {
|
|
|
1759
2034
|
|
|
1760
2035
|
// 4. Parent replies bare (the "ack") → parent has no caller → routes to NOBODY.
|
|
1761
2036
|
// This is the loop guard: sub is NOT re-triggered.
|
|
1762
|
-
await post(transport, {
|
|
2037
|
+
await post(transport, {
|
|
2038
|
+
id: '$p2',
|
|
2039
|
+
sender: '@parent:example.com',
|
|
2040
|
+
root: '$root',
|
|
2041
|
+
})
|
|
2042
|
+
await settleTurn()
|
|
2043
|
+
expect(agents.ensureSession).not.toHaveBeenCalled()
|
|
2044
|
+
})
|
|
2045
|
+
|
|
2046
|
+
it('coalesces every message in a callee turn into one return, including an explicit @caller', async () => {
|
|
2047
|
+
const { transport, agents } = makePairTransport()
|
|
2048
|
+
|
|
2049
|
+
await post(transport, {
|
|
2050
|
+
id: '$root',
|
|
2051
|
+
sender: '@alice:example.com',
|
|
2052
|
+
mentions: ['@parent:example.com'],
|
|
2053
|
+
})
|
|
2054
|
+
await settleTurn()
|
|
2055
|
+
await post(transport, {
|
|
2056
|
+
id: '$p1',
|
|
2057
|
+
sender: '@parent:example.com',
|
|
2058
|
+
root: '$root',
|
|
2059
|
+
mentions: ['@sub:example.com'],
|
|
2060
|
+
})
|
|
2061
|
+
await settleTurn()
|
|
2062
|
+
agents.ensureSession.mockClear()
|
|
2063
|
+
agents.prompt.mockClear()
|
|
2064
|
+
|
|
2065
|
+
// Tool boundaries can flush several assistant messages in one turn. The
|
|
2066
|
+
// last one may explicitly address the caller; none may wake it early.
|
|
2067
|
+
await post(transport, { id: '$s1', sender: '@sub:example.com', root: '$root' })
|
|
2068
|
+
await post(transport, {
|
|
2069
|
+
id: '$s2',
|
|
2070
|
+
sender: '@sub:example.com',
|
|
2071
|
+
root: '$root',
|
|
2072
|
+
mentions: ['@parent:example.com'],
|
|
2073
|
+
})
|
|
1763
2074
|
await settleTurn()
|
|
1764
2075
|
expect(agents.ensureSession).not.toHaveBeenCalled()
|
|
2076
|
+
|
|
2077
|
+
await postTurnEnd(transport.app, { agent: 'sub', root: '$root' })
|
|
2078
|
+
await settleTurn()
|
|
2079
|
+
expect(agents.ensureSession).toHaveBeenCalledTimes(1)
|
|
2080
|
+
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
2081
|
+
'parent',
|
|
2082
|
+
'$root',
|
|
2083
|
+
'!r:example.com',
|
|
2084
|
+
'$root',
|
|
2085
|
+
)
|
|
2086
|
+
expect(agents.prompt).toHaveBeenCalledWith(
|
|
2087
|
+
'parent',
|
|
2088
|
+
expect.objectContaining({
|
|
2089
|
+
content: [expect.objectContaining({ type: 'text', text: 'x\n\nx' })],
|
|
2090
|
+
}),
|
|
2091
|
+
)
|
|
1765
2092
|
})
|
|
1766
2093
|
|
|
1767
2094
|
it('rebuildThreadState reconstructs caller[] from the timeline after a restart', async () => {
|
|
@@ -1780,7 +2107,11 @@ describe('directional agent-to-agent handoffs', () => {
|
|
|
1780
2107
|
sender: '@parent:example.com',
|
|
1781
2108
|
content: { 'm.mentions': { user_ids: ['@sub:example.com'] } },
|
|
1782
2109
|
},
|
|
1783
|
-
{
|
|
2110
|
+
{
|
|
2111
|
+
type: 'm.room.message',
|
|
2112
|
+
sender: '@sub:example.com',
|
|
2113
|
+
content: { body: 'ack' },
|
|
2114
|
+
},
|
|
1784
2115
|
],
|
|
1785
2116
|
})),
|
|
1786
2117
|
}
|
|
@@ -1850,7 +2181,6 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
|
|
|
1850
2181
|
],
|
|
1851
2182
|
})
|
|
1852
2183
|
}
|
|
1853
|
-
|
|
1854
2184
|
const agentsCalled = (reg: ReturnType<typeof fakeRegistry>['reg']) =>
|
|
1855
2185
|
reg.ensureSession.mock.calls.map((c) => c[0] as string)
|
|
1856
2186
|
|
|
@@ -1865,12 +2195,7 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
|
|
|
1865
2195
|
mentions: ['@parent:example.com'],
|
|
1866
2196
|
})
|
|
1867
2197
|
await settleTurn()
|
|
1868
|
-
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
1869
|
-
'parent',
|
|
1870
|
-
'$root',
|
|
1871
|
-
'!r:example.com',
|
|
1872
|
-
'$root',
|
|
1873
|
-
)
|
|
2198
|
+
expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com', '$root')
|
|
1874
2199
|
|
|
1875
2200
|
// 2. Parent calls BOTH subs in ONE message ($p1). Each sub gets a fresh
|
|
1876
2201
|
// arc session keyed by the same call event id — the composed key
|
|
@@ -1906,14 +2231,14 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
|
|
|
1906
2231
|
// thread-level session; the sibling is untouched.
|
|
1907
2232
|
gates.get('rocksteady')!()
|
|
1908
2233
|
await settleTurn()
|
|
1909
|
-
await post(transport, {
|
|
2234
|
+
await post(transport, {
|
|
2235
|
+
id: '$r1',
|
|
2236
|
+
sender: '@rocksteady:example.com',
|
|
2237
|
+
root: '$root',
|
|
2238
|
+
})
|
|
2239
|
+
await postTurnEnd(transport.app, { agent: 'rocksteady', root: '$root' })
|
|
1910
2240
|
await settleTurn()
|
|
1911
|
-
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
1912
|
-
'parent',
|
|
1913
|
-
'$root',
|
|
1914
|
-
'!r:example.com',
|
|
1915
|
-
'$root',
|
|
1916
|
-
)
|
|
2241
|
+
expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com', '$root')
|
|
1917
2242
|
expect(agentsCalled(agents)).not.toContain('bebop')
|
|
1918
2243
|
|
|
1919
2244
|
agents.ensureSession.mockClear()
|
|
@@ -1923,14 +2248,14 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
|
|
|
1923
2248
|
// SAME thread-level session (it aggregates the two results).
|
|
1924
2249
|
gates.get('bebop')!()
|
|
1925
2250
|
await settleTurn()
|
|
1926
|
-
await post(transport, {
|
|
2251
|
+
await post(transport, {
|
|
2252
|
+
id: '$b1',
|
|
2253
|
+
sender: '@bebop:example.com',
|
|
2254
|
+
root: '$root',
|
|
2255
|
+
})
|
|
2256
|
+
await postTurnEnd(transport.app, { agent: 'bebop', root: '$root' })
|
|
1927
2257
|
await settleTurn()
|
|
1928
|
-
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
1929
|
-
'parent',
|
|
1930
|
-
'$root',
|
|
1931
|
-
'!r:example.com',
|
|
1932
|
-
'$root',
|
|
1933
|
-
)
|
|
2258
|
+
expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com', '$root')
|
|
1934
2259
|
expect(agentsCalled(agents)).not.toContain('rocksteady')
|
|
1935
2260
|
})
|
|
1936
2261
|
|
|
@@ -1954,7 +2279,12 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
|
|
|
1954
2279
|
await settleTurn()
|
|
1955
2280
|
gates.get('bebop')!()
|
|
1956
2281
|
await settleTurn()
|
|
1957
|
-
await post(transport, {
|
|
2282
|
+
await post(transport, {
|
|
2283
|
+
id: '$b1',
|
|
2284
|
+
sender: '@bebop:example.com',
|
|
2285
|
+
root: '$root',
|
|
2286
|
+
})
|
|
2287
|
+
await postTurnEnd(transport.app, { agent: 'bebop', root: '$root' })
|
|
1958
2288
|
await settleTurn()
|
|
1959
2289
|
|
|
1960
2290
|
// Second delegation: arc $p2 — a NEW session key, not a resume.
|
|
@@ -1997,7 +2327,11 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
|
|
|
1997
2327
|
|
|
1998
2328
|
// Human "why did you do X?" — routes to bebop (last poster) and lands in
|
|
1999
2329
|
// the session that DID the work, not a cold thread-level one.
|
|
2000
|
-
await post(transport, {
|
|
2330
|
+
await post(transport, {
|
|
2331
|
+
id: '$h1',
|
|
2332
|
+
sender: '@alice:example.com',
|
|
2333
|
+
root: '$root',
|
|
2334
|
+
})
|
|
2001
2335
|
await settleTurn()
|
|
2002
2336
|
expect(agents.ensureSession).toHaveBeenCalledWith(
|
|
2003
2337
|
'bebop',
|
|
@@ -2034,7 +2368,9 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
|
|
|
2034
2368
|
event_id: '$reset',
|
|
2035
2369
|
room_id: '!r:example.com',
|
|
2036
2370
|
sender: '@alice:example.com',
|
|
2037
|
-
content: {
|
|
2371
|
+
content: {
|
|
2372
|
+
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
|
|
2373
|
+
},
|
|
2038
2374
|
},
|
|
2039
2375
|
],
|
|
2040
2376
|
})
|
|
@@ -2075,7 +2411,9 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
|
|
|
2075
2411
|
event_id: '$reset',
|
|
2076
2412
|
room_id: '!r:example.com',
|
|
2077
2413
|
sender: '@alice:example.com',
|
|
2078
|
-
content: {
|
|
2414
|
+
content: {
|
|
2415
|
+
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
|
|
2416
|
+
},
|
|
2079
2417
|
},
|
|
2080
2418
|
],
|
|
2081
2419
|
})
|
|
@@ -2100,10 +2438,17 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
|
|
|
2100
2438
|
event_id: '$p1',
|
|
2101
2439
|
sender: '@parent:example.com',
|
|
2102
2440
|
content: {
|
|
2103
|
-
'm.mentions': {
|
|
2441
|
+
'm.mentions': {
|
|
2442
|
+
user_ids: ['@bebop:example.com', '@rocksteady:example.com'],
|
|
2443
|
+
},
|
|
2104
2444
|
},
|
|
2105
2445
|
},
|
|
2106
|
-
{
|
|
2446
|
+
{
|
|
2447
|
+
type: 'm.room.message',
|
|
2448
|
+
event_id: '$b1',
|
|
2449
|
+
sender: '@bebop:example.com',
|
|
2450
|
+
content: { body: 'done' },
|
|
2451
|
+
},
|
|
2107
2452
|
{
|
|
2108
2453
|
type: 'm.room.message',
|
|
2109
2454
|
event_id: '$p2',
|
|
@@ -2114,7 +2459,71 @@ describe('per-handoff session isolation ([[ZOD071]])', () => {
|
|
|
2114
2459
|
})),
|
|
2115
2460
|
}
|
|
2116
2461
|
const state = await rebuildThreadState(client as never, '!r:example.com', '$root', trio)
|
|
2117
|
-
expect(state.handoffs).toEqual({
|
|
2462
|
+
expect(state.handoffs).toEqual({
|
|
2463
|
+
bebop: ['$p1', '$p2'],
|
|
2464
|
+
rocksteady: ['$p1'],
|
|
2465
|
+
})
|
|
2118
2466
|
expect(state.callers).toEqual({ bebop: 'parent', rocksteady: 'parent' })
|
|
2119
2467
|
})
|
|
2120
2468
|
})
|
|
2469
|
+
|
|
2470
|
+
describe('taskActions.describeRole', () => {
|
|
2471
|
+
function makeTaskTransport() {
|
|
2472
|
+
const { reg, finishPrompt } = fakeRegistry()
|
|
2473
|
+
const approvals = fakeApprovals()
|
|
2474
|
+
const client = fakeClient()
|
|
2475
|
+
const bindings = [
|
|
2476
|
+
...baseAgents,
|
|
2477
|
+
{
|
|
2478
|
+
name: 'worker',
|
|
2479
|
+
userId: '@worker:example.com',
|
|
2480
|
+
rooms: [{ alias: '!r:example.com' }],
|
|
2481
|
+
trigger: 'mention' as const,
|
|
2482
|
+
},
|
|
2483
|
+
]
|
|
2484
|
+
const transport = createMatrixTransport({
|
|
2485
|
+
agents: reg as never,
|
|
2486
|
+
approvals: approvals as never,
|
|
2487
|
+
client: client as never,
|
|
2488
|
+
bindings,
|
|
2489
|
+
hsToken: 'hs-secret',
|
|
2490
|
+
botUserId: '@zooid:example.com',
|
|
2491
|
+
drainQuietMs: 0,
|
|
2492
|
+
})
|
|
2493
|
+
return { transport, finishPrompt }
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
it('describeRole reports assignee and depth for a task thread', async () => {
|
|
2497
|
+
const { transport } = makeTaskTransport()
|
|
2498
|
+
const started = await transport.taskActions.startTasks(
|
|
2499
|
+
{
|
|
2500
|
+
agentName: 'architect',
|
|
2501
|
+
channelId: '!r:example.com',
|
|
2502
|
+
threadRoot: '$parent',
|
|
2503
|
+
sessionKey: '$parent',
|
|
2504
|
+
},
|
|
2505
|
+
{ tasks: [{ agent: 'worker', prompt: 'do the thing' }] },
|
|
2506
|
+
)
|
|
2507
|
+
const result = started.results[0]!
|
|
2508
|
+
expect(result.status).toBe('started')
|
|
2509
|
+
const threadId = (result as { thread_id: string }).thread_id
|
|
2510
|
+
const role = await transport.taskActions.describeRole({
|
|
2511
|
+
agentName: 'worker',
|
|
2512
|
+
channelId: '!r:example.com',
|
|
2513
|
+
threadRoot: threadId,
|
|
2514
|
+
sessionKey: threadId,
|
|
2515
|
+
})
|
|
2516
|
+
expect(role).toEqual({ is_task_assignee: true, can_start_task_threads: false })
|
|
2517
|
+
})
|
|
2518
|
+
|
|
2519
|
+
it('describeRole reports a plain mention as neither assignee nor capped', async () => {
|
|
2520
|
+
const { transport } = makeTaskTransport()
|
|
2521
|
+
const role = await transport.taskActions.describeRole({
|
|
2522
|
+
agentName: 'architect',
|
|
2523
|
+
channelId: '!r:example.com',
|
|
2524
|
+
threadRoot: '$other',
|
|
2525
|
+
sessionKey: '$other',
|
|
2526
|
+
})
|
|
2527
|
+
expect(role).toEqual({ is_task_assignee: false, can_start_task_threads: true })
|
|
2528
|
+
})
|
|
2529
|
+
})
|