@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.
@@ -11,6 +11,7 @@ export interface SendMessageInput {
11
11
  asUserId: string
12
12
  content: { msgtype: string; body: string; [k: string]: unknown }
13
13
  threadRoot?: string
14
+ txnId?: string
14
15
  }
15
16
 
16
17
  export interface SendCustomEventInput {
@@ -18,6 +19,7 @@ export interface SendCustomEventInput {
18
19
  asUserId: string
19
20
  eventType: string
20
21
  content: Record<string, unknown>
22
+ txnId?: string
21
23
  }
22
24
 
23
25
  export interface SetTypingInput {
@@ -51,7 +53,10 @@ export class MatrixClient {
51
53
  const r = await this.fetch(`${this.homeserver}/_matrix/client/v3/register`, {
52
54
  method: 'POST',
53
55
  headers: { Authorization: `Bearer ${this.asToken}` },
54
- body: JSON.stringify({ type: 'm.login.application_service', username: localpart }),
56
+ body: JSON.stringify({
57
+ type: 'm.login.application_service',
58
+ username: localpart,
59
+ }),
55
60
  })
56
61
  if (r.status === 200) return (await r.json()) as { user_id: string; device_id: string }
57
62
  if (r.status === 400) {
@@ -143,10 +148,7 @@ export class MatrixClient {
143
148
  return j.room_id
144
149
  }
145
150
 
146
- async createRoomRaw(opts: {
147
- asUserId: string
148
- body: Record<string, unknown>
149
- }): Promise<string> {
151
+ async createRoomRaw(opts: { asUserId: string; body: Record<string, unknown> }): Promise<string> {
150
152
  const url = `${this.homeserver}/_matrix/client/v3/createRoom?user_id=${encodeURIComponent(opts.asUserId)}`
151
153
  const r = await this.fetch(url, {
152
154
  method: 'POST',
@@ -190,11 +192,7 @@ export class MatrixClient {
190
192
  * already invited" responses idempotently so bootstrap can run on a
191
193
  * fresh AND a populated homeserver without branching.
192
194
  */
193
- async invite(opts: {
194
- roomId: string
195
- asUserId: string
196
- targetUserId: string
197
- }): Promise<void> {
195
+ async invite(opts: { roomId: string; asUserId: string; targetUserId: string }): Promise<void> {
198
196
  const url =
199
197
  `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/invite` +
200
198
  `?user_id=${encodeURIComponent(opts.asUserId)}`
@@ -229,11 +227,7 @@ export class MatrixClient {
229
227
  throw new Error(`invite(${opts.targetUserId}) failed: ${r.status}`)
230
228
  }
231
229
 
232
- async leaveRoom(
233
- roomId: string,
234
- asUserId: string,
235
- opts?: { reason?: string },
236
- ): Promise<void> {
230
+ async leaveRoom(roomId: string, asUserId: string, opts?: { reason?: string }): Promise<void> {
237
231
  const url =
238
232
  `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave` +
239
233
  `?user_id=${encodeURIComponent(asUserId)}`
@@ -263,13 +257,16 @@ export class MatrixClient {
263
257
  async sendMessage(input: SendMessageInput): Promise<{ event_id: string }> {
264
258
  const content: Record<string, unknown> = { ...input.content }
265
259
  if (input.threadRoot) {
266
- content['m.relates_to'] = { rel_type: 'm.thread', event_id: input.threadRoot }
260
+ content['m.relates_to'] = {
261
+ rel_type: 'm.thread',
262
+ event_id: input.threadRoot,
263
+ }
267
264
  }
268
- return this.sendEvent(input.roomId, input.asUserId, 'm.room.message', content)
265
+ return this.sendEvent(input.roomId, input.asUserId, 'm.room.message', content, input.txnId)
269
266
  }
270
267
 
271
268
  async sendCustomEvent(input: SendCustomEventInput): Promise<{ event_id: string }> {
272
- return this.sendEvent(input.roomId, input.asUserId, input.eventType, input.content)
269
+ return this.sendEvent(input.roomId, input.asUserId, input.eventType, input.content, input.txnId)
273
270
  }
274
271
 
275
272
  async setTyping(input: SetTypingInput): Promise<void> {
@@ -356,9 +353,14 @@ export class MatrixClient {
356
353
  user_id: opts.asUserId,
357
354
  })
358
355
  if (opts.from) params.set('from', opts.from)
356
+ // The 3-segment relations endpoint (rel_type + event_type) scopes both the
357
+ // returned chunk AND the next_batch cursor to m.room.message server-side —
358
+ // without it we'd paginate over every m.thread relation (edits, reactions,
359
+ // redactions) and toMessage()'s client-side filtering would desync from
360
+ // has_more/next_before (zooid-ai/zooid#21).
359
361
  const url =
360
362
  `${this.homeserver}/_matrix/client/v1/rooms/${encodeURIComponent(opts.roomId)}` +
361
- `/relations/${encodeURIComponent(opts.rootEventId)}/m.thread?${params.toString()}`
363
+ `/relations/${encodeURIComponent(opts.rootEventId)}/m.thread/m.room.message?${params.toString()}`
362
364
  const r = await this.fetch(url, {
363
365
  method: 'GET',
364
366
  headers: { Authorization: `Bearer ${this.asToken}` },
@@ -409,7 +411,10 @@ export class MatrixClient {
409
411
  headers: { Authorization: `Bearer ${this.asToken}` },
410
412
  })
411
413
  if (!r.ok) throw new Error(`fetchRoomMessages(${opts.roomId}) failed: ${r.status}`)
412
- return (await r.json()) as { chunk: Array<Record<string, unknown>>; end?: string }
414
+ return (await r.json()) as {
415
+ chunk: Array<Record<string, unknown>>
416
+ end?: string
417
+ }
413
418
  }
414
419
 
415
420
  async getJoinedMembers(
@@ -424,19 +429,24 @@ export class MatrixClient {
424
429
  headers: { Authorization: `Bearer ${this.asToken}` },
425
430
  })
426
431
  if (!r.ok) throw new Error(`getJoinedMembers(${roomId}) failed: ${r.status}`)
427
- return (await r.json()) as { joined: Record<string, { display_name?: string }> }
432
+ return (await r.json()) as {
433
+ joined: Record<string, { display_name?: string }>
434
+ }
428
435
  }
429
436
 
430
- async sync(opts: {
431
- asUserId: string
432
- since?: string | null
433
- timeoutMs?: number
434
- }): Promise<{
437
+ async sync(opts: { asUserId: string; since?: string | null; timeoutMs?: number }): Promise<{
435
438
  next_batch: string
436
439
  rooms: {
437
- join: Record<string, {
438
- timeline: { events: Record<string, unknown>[]; prev_batch?: string; limited?: boolean }
439
- }>
440
+ join: Record<
441
+ string,
442
+ {
443
+ timeline: {
444
+ events: Record<string, unknown>[]
445
+ prev_batch?: string
446
+ limited?: boolean
447
+ }
448
+ }
449
+ >
440
450
  }
441
451
  }> {
442
452
  const params = new URLSearchParams({
@@ -452,9 +462,16 @@ export class MatrixClient {
452
462
  return r.json() as Promise<{
453
463
  next_batch: string
454
464
  rooms: {
455
- join: Record<string, {
456
- timeline: { events: Record<string, unknown>[]; prev_batch?: string; limited?: boolean }
457
- }>
465
+ join: Record<
466
+ string,
467
+ {
468
+ timeline: {
469
+ events: Record<string, unknown>[]
470
+ prev_batch?: string
471
+ limited?: boolean
472
+ }
473
+ }
474
+ >
458
475
  }
459
476
  }>
460
477
  }
@@ -478,8 +495,9 @@ export class MatrixClient {
478
495
  asUserId: string,
479
496
  eventType: string,
480
497
  content: Record<string, unknown>,
498
+ txnId?: string,
481
499
  ): Promise<{ event_id: string }> {
482
- const txn = randomUUID()
500
+ const txn = txnId ?? randomUUID()
483
501
  const url =
484
502
  `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}` +
485
503
  `/send/${eventType}/${txn}?user_id=${encodeURIComponent(asUserId)}`
@@ -488,7 +506,13 @@ export class MatrixClient {
488
506
  headers: { Authorization: `Bearer ${this.asToken}` },
489
507
  body: JSON.stringify(content),
490
508
  })
491
- if (!r.ok) throw new Error(`sendEvent(${eventType}) failed: ${r.status}`)
509
+ if (!r.ok) {
510
+ const err = new Error(`sendEvent(${eventType}) failed: ${r.status}`) as Error & {
511
+ status?: number
512
+ }
513
+ err.status = r.status
514
+ throw err
515
+ }
492
516
  return (await r.json()) as { event_id: string }
493
517
  }
494
518
  }
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { route, isMediaMsgtype, type AgentBinding, type ThreadState } from './router.js'
2
+ import {
3
+ route,
4
+ isMediaMsgtype,
5
+ wouldCycleCallers,
6
+ type AgentBinding,
7
+ type ThreadState,
8
+ } from './router.js'
3
9
 
4
10
  const agents: AgentBinding[] = [
5
11
  {
@@ -17,7 +23,12 @@ const agents: AgentBinding[] = [
17
23
  ]
18
24
 
19
25
  function msg(
20
- overrides: Partial<{ room: string; sender: string; body: string; mentions: string[] }> = {},
26
+ overrides: Partial<{
27
+ room: string
28
+ sender: string
29
+ body: string
30
+ mentions: string[]
31
+ }> = {},
21
32
  ) {
22
33
  return {
23
34
  type: 'm.room.message',
@@ -55,7 +66,10 @@ describe('route', () => {
55
66
 
56
67
  it('skips events whose sender is the matched agent itself', () => {
57
68
  const matches = route(
58
- msg({ sender: '@architect:example.com', mentions: ['@architect:example.com'] }),
69
+ msg({
70
+ sender: '@architect:example.com',
71
+ mentions: ['@architect:example.com'],
72
+ }),
59
73
  agents,
60
74
  )
61
75
  expect(matches).toEqual([])
@@ -71,10 +85,7 @@ describe('route', () => {
71
85
  trigger: 'mention',
72
86
  },
73
87
  ]
74
- const matches = route(
75
- msg({ mentions: ['@architect:example.com', '@qa:example.com'] }),
76
- both,
77
- )
88
+ const matches = route(msg({ mentions: ['@architect:example.com', '@qa:example.com'] }), both)
78
89
  expect(matches.map((m) => m.name).sort()).toEqual(['architect', 'qa'])
79
90
  })
80
91
 
@@ -89,6 +100,86 @@ describe('route', () => {
89
100
  })
90
101
  })
91
102
 
103
+ describe('directed task routing', () => {
104
+ const agents: AgentBinding[] = [
105
+ {
106
+ name: 'supervisor',
107
+ userId: '@supervisor:hs',
108
+ rooms: [{ alias: '!r:hs' }],
109
+ trigger: 'mention',
110
+ },
111
+ {
112
+ name: 'worker',
113
+ userId: '@worker:hs',
114
+ rooms: [{ alias: '!r:hs' }],
115
+ trigger: 'mention',
116
+ },
117
+ {
118
+ name: 'eager',
119
+ userId: '@eager:hs',
120
+ rooms: [{ alias: '!r:hs' }],
121
+ trigger: 'any',
122
+ },
123
+ ]
124
+ const root = {
125
+ type: 'm.room.message',
126
+ room_id: '!r:hs',
127
+ sender: '@supervisor:hs',
128
+ content: {
129
+ msgtype: 'm.notice',
130
+ body: '@worker:hs task',
131
+ 'm.mentions': { user_ids: ['@worker:hs'] },
132
+ },
133
+ }
134
+ it('routes a task root solely to its assignee, including self assignment', () => {
135
+ expect(
136
+ route(root, agents, new Map(), { assignee: 'worker', isRoot: true }).map((x) => x.name),
137
+ ).toEqual(['worker'])
138
+ expect(
139
+ route(root, agents, new Map(), {
140
+ assignee: 'supervisor',
141
+ isRoot: true,
142
+ }).map((x) => x.name),
143
+ ).toEqual(['supervisor'])
144
+ })
145
+ it('keeps trigger:any out of a task thread while allowing human steering and explicit mentions', () => {
146
+ const state = new Map([
147
+ [
148
+ '$task',
149
+ {
150
+ participants: ['worker'],
151
+ rootMentions: ['worker'],
152
+ callers: {},
153
+ handoffs: {},
154
+ },
155
+ ],
156
+ ])
157
+ const human = {
158
+ ...root,
159
+ sender: '@alice:hs',
160
+ content: {
161
+ msgtype: 'm.text',
162
+ body: 'continue',
163
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$task' },
164
+ },
165
+ }
166
+ expect(
167
+ route(human, agents, state, { assignee: 'worker', isRoot: false }).map((x) => x.name),
168
+ ).toEqual(['worker'])
169
+ const mention = {
170
+ ...human,
171
+ sender: '@worker:hs',
172
+ content: {
173
+ ...human.content,
174
+ 'm.mentions': { user_ids: ['@supervisor:hs'] },
175
+ },
176
+ }
177
+ expect(
178
+ route(mention, agents, state, { assignee: 'worker', isRoot: false }).map((x) => x.name),
179
+ ).toEqual(['supervisor'])
180
+ })
181
+ })
182
+
92
183
  describe('media events', () => {
93
184
  it('classifies media msgtypes', () => {
94
185
  for (const t of ['m.image', 'm.file', 'm.video', 'm.audio']) {
@@ -103,7 +194,11 @@ describe('media events', () => {
103
194
  const monitorRoom = msg({ room: '!alerts:example.com', body: 'dog.jpg' })
104
195
  const mediaEvent = {
105
196
  ...monitorRoom,
106
- content: { msgtype: 'm.image', body: 'dog.jpg', url: 'mxc://localhost/abc' },
197
+ content: {
198
+ msgtype: 'm.image',
199
+ body: 'dog.jpg',
200
+ url: 'mxc://localhost/abc',
201
+ },
107
202
  }
108
203
  const matches = route(mediaEvent, agents)
109
204
  expect(matches).toEqual([])
@@ -169,7 +264,10 @@ describe('directional thread continuation (agent-to-agent handoffs)', () => {
169
264
 
170
265
  it('an explicit @mention still re-engages the sub (rule 1 wins)', () => {
171
266
  const matches = route(
172
- threadMsg({ sender: '@parent:example.com', mentions: ['@sub:example.com'] }),
267
+ threadMsg({
268
+ sender: '@parent:example.com',
269
+ mentions: ['@sub:example.com'],
270
+ }),
173
271
  pair,
174
272
  states({ participants: ['parent', 'sub'], callers: { sub: 'parent' } }),
175
273
  )
@@ -178,7 +276,10 @@ describe('directional thread continuation (agent-to-agent handoffs)', () => {
178
276
 
179
277
  it('dedupes: a sub reply that also @mentions its caller triggers the caller once', () => {
180
278
  const matches = route(
181
- threadMsg({ sender: '@sub:example.com', mentions: ['@parent:example.com'] }),
279
+ threadMsg({
280
+ sender: '@sub:example.com',
281
+ mentions: ['@parent:example.com'],
282
+ }),
182
283
  pair,
183
284
  states({ participants: ['parent'], callers: { sub: 'parent' } }),
184
285
  )
@@ -228,6 +329,24 @@ describe('directional thread continuation (agent-to-agent handoffs)', () => {
228
329
  })
229
330
  })
230
331
 
332
+ describe('caller graph cycle guard', () => {
333
+ it('rejects a reverse edge back to an existing caller', () => {
334
+ expect(wouldCycleCallers({ sub: 'parent' }, 'parent', 'sub')).toBe(true)
335
+ })
336
+
337
+ it('rejects a cycle through a deeper ancestor', () => {
338
+ expect(
339
+ wouldCycleCallers({ child: 'parent', grandchild: 'child' }, 'parent', 'grandchild'),
340
+ ).toBe(true)
341
+ })
342
+
343
+ it('allows a new downward or sibling edge', () => {
344
+ const callers = { child: 'parent' }
345
+ expect(wouldCycleCallers(callers, 'grandchild', 'child')).toBe(false)
346
+ expect(wouldCycleCallers(callers, 'sibling', 'parent')).toBe(false)
347
+ })
348
+ })
349
+
231
350
  describe('fan-out: two subs called in one message ([[ZOD071]] acceptance)', () => {
232
351
  const mk = (name: string): AgentBinding => ({
233
352
  name,
package/src/router.ts CHANGED
@@ -50,6 +50,11 @@ export interface ThreadState {
50
50
  handoffs: Record<string, string[]>
51
51
  }
52
52
 
53
+ export interface TaskThreadContext {
54
+ assignee: string
55
+ isRoot: boolean
56
+ }
57
+
53
58
  interface MaybeEvent {
54
59
  type?: string
55
60
  room_id?: string
@@ -71,6 +76,7 @@ export function route(
71
76
  event: MaybeEvent,
72
77
  agents: AgentBinding[],
73
78
  threadStates?: Map<string, ThreadState>,
79
+ task?: TaskThreadContext,
74
80
  ): RouteMatch[] {
75
81
  if (event.type !== 'm.room.message') return []
76
82
  if (!event.content?.msgtype) return []
@@ -81,8 +87,27 @@ export function route(
81
87
  const threadState = threadRoot ? threadStates?.get(threadRoot) : undefined
82
88
 
83
89
  for (const a of agents) {
84
- if (event.sender === a.userId) continue
85
90
  if (!a.rooms.some((r) => r.alias === event.room_id)) continue
91
+ if (task?.isRoot) {
92
+ if (a.name === task.assignee) matches.push(a)
93
+ continue
94
+ }
95
+ if (event.sender === a.userId) continue
96
+ if (task) {
97
+ if (mentions.has(a.userId)) {
98
+ matches.push(a)
99
+ continue
100
+ }
101
+ const senderAgent = agents.find((x) => x.userId === event.sender)
102
+ if (senderAgent) {
103
+ // A delegated task returns at an invocation terminal boundary, never
104
+ // because a callee happened to post progress prose.
105
+ continue
106
+ } else if (a.name === task.assignee) {
107
+ matches.push(a)
108
+ }
109
+ continue
110
+ }
86
111
  if (a.trigger === 'any') {
87
112
  matches.push(a)
88
113
  continue
@@ -100,7 +125,7 @@ export function route(
100
125
  // sender (its caller), never to a callee. Directional continuation
101
126
  // keeps agent↔agent handoffs from looping — the call graph is a tree
102
127
  // rooted at the human, so returns only ever walk up.
103
- if (threadState.callers[senderAgent.name] === a.name) matches.push(a)
128
+ if (isReturnRoute(event, a, agents, threadState)) matches.push(a)
104
129
  } else {
105
130
  // Human (or non-agent) follow-up: continue with the most-recent-posting
106
131
  // agent, or inherit the root mention if no agent has posted yet.
@@ -115,3 +140,54 @@ export function route(
115
140
  }
116
141
  return matches
117
142
  }
143
+
144
+ /**
145
+ * True when routing `event` to `agent` is a *return* — a callee's reply
146
+ * bubbling up to the agent that called it — rather than a fresh call or a
147
+ * human follow-up. A callee may address its existing caller explicitly and it
148
+ * is still a return.
149
+ *
150
+ * The transport defers returns to the sender's turn boundary. An agent turn
151
+ * posts one `m.room.message` per buffered chunk (every tool call forces a
152
+ * flush), so treating each chunk as a return woke the caller once per chunk
153
+ * and the two agents read as re-triggering each other. See [[ZOD039]]
154
+ * § Implicit triggers → Directional continuation.
155
+ */
156
+ export function isReturnRoute(
157
+ event: MaybeEvent,
158
+ agent: AgentBinding,
159
+ agents: AgentBinding[],
160
+ threadState: ThreadState | undefined,
161
+ ): boolean {
162
+ if (!threadState || agent.trigger !== 'mention') return false
163
+ const sender = agents.find((x) => x.userId === event.sender)
164
+ if (!sender || sender.name === agent.name) return false
165
+ // Addressing the existing caller explicitly does not reverse the call edge:
166
+ // it is still the callee returning control. This matters for agents that
167
+ // naturally prefix their final answer with `@caller`; treating that as a new
168
+ // call creates the exact A ↔ B cycle directional continuation prevents.
169
+ return threadState.callers[sender.name] === agent.name
170
+ }
171
+
172
+ /**
173
+ * True when recording `callee`’s caller as `caller` would put a cycle in the
174
+ * call graph — i.e. `callee` is already an ancestor of `caller`. The graph has
175
+ * to stay a tree rooted at the human, because `route` walks it upward on every
176
+ * return; a 2-cycle (A calls B, B @mentions A back) would bounce forever.
177
+ * A mention that would close a cycle is a return, not a call, so it routes but
178
+ * records no edge.
179
+ */
180
+ export function wouldCycleCallers(
181
+ callers: Record<string, string>,
182
+ callee: string,
183
+ caller: string,
184
+ ): boolean {
185
+ const seen = new Set<string>()
186
+ let cursor: string | undefined = caller
187
+ while (cursor !== undefined) {
188
+ if (cursor === callee || seen.has(cursor)) return true
189
+ seen.add(cursor)
190
+ cursor = callers[cursor]
191
+ }
192
+ return false
193
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { evaluateCompletion } from './task-completion.js'
3
+
4
+ const base = { agent: 'worker', threadId: '$root', outstanding: 0, awaitingHuman: 0 } as const
5
+
6
+ describe('evaluateCompletion', () => {
7
+ it('uses a summary only after all work has returned', () => {
8
+ expect(evaluateCompletion({ ...base, stopReason: 'end_turn', summary: 'done', outstanding: 1 }))
9
+ .toEqual({ decision: 'stay_open', reason: 'outstanding_handoff' })
10
+ expect(evaluateCompletion({ ...base, stopReason: 'end_turn', summary: 'done' }))
11
+ .toMatchObject({ decision: 'finish', completion: { status: 'complete', output: { text: 'done' } } })
12
+ })
13
+ it('makes cancellation and limits terminal even with outstanding work', () => {
14
+ expect(evaluateCompletion({ ...base, stopReason: 'cancelled', outstanding: 1 }))
15
+ .toMatchObject({ decision: 'finish', completion: { status: 'cancelled' } })
16
+ expect(evaluateCompletion({ ...base, stopReason: 'max_tokens', outstanding: 1 }))
17
+ .toMatchObject({ decision: 'finish', completion: { status: 'partial', reason: 'max_tokens' } })
18
+ })
19
+ it('does not report an empty successful result', () => {
20
+ expect(evaluateCompletion({ ...base, stopReason: 'end_turn' }))
21
+ .toMatchObject({ decision: 'finish', completion: { status: 'failed', reason: 'no_result' } })
22
+ })
23
+ })
@@ -0,0 +1,37 @@
1
+ import type { ThreadCompletion } from '@zooid/core'
2
+
3
+ /** ACP's stable prompt termination values (kept local to avoid an SDK runtime dep). */
4
+ export type StopReason = 'end_turn' | 'max_tokens' | 'max_turn_requests' | 'refusal' | 'cancelled'
5
+
6
+ export interface CompletionInputs {
7
+ agent: string
8
+ threadId: string
9
+ stopReason?: StopReason
10
+ error?: unknown
11
+ summary?: string
12
+ prose?: string
13
+ outstanding: number
14
+ awaitingHuman: number
15
+ }
16
+ export type CompletionDecision =
17
+ | { decision: 'stay_open'; reason: 'outstanding_handoff' | 'awaiting_human' }
18
+ | { decision: 'finish'; completion: ThreadCompletion }
19
+
20
+ export function evaluateCompletion(input: CompletionInputs): CompletionDecision {
21
+ const prose = input.prose?.trim()
22
+ const output = prose ? { type: 'message' as const, text: prose } : undefined
23
+ const finish = (completion: Omit<ThreadCompletion, 'agent' | 'thread_id'>): CompletionDecision => ({
24
+ decision: 'finish', completion: { agent: input.agent, thread_id: input.threadId, ...completion },
25
+ })
26
+ if (input.error !== undefined)
27
+ return finish({ status: 'failed', error: input.error instanceof Error ? input.error.message : String(input.error), ...(output ? { output } : {}) })
28
+ if (input.stopReason === 'cancelled') return finish({ status: 'cancelled', ...(output ? { output } : {}) })
29
+ if (input.stopReason === 'max_tokens' || input.stopReason === 'max_turn_requests')
30
+ return finish({ status: 'partial', reason: input.stopReason, ...(output ? { output } : {}) })
31
+ if (input.stopReason === 'refusal') return finish({ status: 'failed', reason: 'refusal', ...(output ? { output } : {}) })
32
+ if (input.awaitingHuman > 0) return { decision: 'stay_open', reason: 'awaiting_human' }
33
+ if (input.outstanding > 0) return { decision: 'stay_open', reason: 'outstanding_handoff' }
34
+ if (input.summary) return finish({ status: 'complete', output: { type: 'message', text: input.summary } })
35
+ if (output) return finish({ status: 'complete', output })
36
+ return finish({ status: 'failed', reason: 'no_result', error: 'No result produced' })
37
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildAssignmentContent, checkDelegable, renderCompletionPrompt } from './task-dispatch.js'
3
+ import type { AgentBinding } from './router.js'
4
+ const agents: AgentBinding[] = [
5
+ {
6
+ name: 'supervisor',
7
+ userId: '@supervisor:hs',
8
+ rooms: [{ alias: '!r:hs' }],
9
+ trigger: 'mention',
10
+ },
11
+ {
12
+ name: 'worker',
13
+ userId: '@worker:hs',
14
+ rooms: [{ alias: '!r:hs' }],
15
+ trigger: 'mention',
16
+ },
17
+ ]
18
+ describe('task dispatch', () => {
19
+ it('admits only local room agents', () => {
20
+ expect(checkDelegable('worker', '!r:hs', agents)).toEqual({ ok: true })
21
+ expect(checkDelegable('ghost', '!r:hs', agents)).toMatchObject({
22
+ ok: false,
23
+ reason: expect.stringContaining('unknown_agent'),
24
+ })
25
+ })
26
+ it('makes a visible, unthreaded signed root and renders a return', () => {
27
+ const content = buildAssignmentContent({
28
+ assigneeUserId: '@worker:hs',
29
+ prompt: 'audit',
30
+ start: {
31
+ version: 1,
32
+ assignee: 'worker',
33
+ attempt_id: 'a1',
34
+ parent: { agent: 'supervisor', thread_root: '$p', session_key: '$p' },
35
+ notify: 'caller',
36
+ },
37
+ })
38
+ expect(content).toMatchObject({
39
+ msgtype: 'm.notice',
40
+ body: '@worker:hs audit',
41
+ 'm.mentions': { user_ids: ['@worker:hs'] },
42
+ })
43
+ expect(content['m.relates_to']).toBeUndefined()
44
+ expect(
45
+ renderCompletionPrompt({
46
+ agent: 'worker',
47
+ thread_id: '$task',
48
+ status: 'complete',
49
+ output: { type: 'message', text: 'done' },
50
+ }),
51
+ ).toContain('done')
52
+ })
53
+ })
@@ -0,0 +1,68 @@
1
+ import { THREAD_START_FIELD, type ThreadCompletion, type ThreadStartContent } from '@zooid/core'
2
+ import type { AgentBinding } from './router.js'
3
+ export type Admission = { ok: true } | { ok: false; reason: string }
4
+ export function checkDelegable(
5
+ agentName: string,
6
+ roomId: string,
7
+ bindings: AgentBinding[],
8
+ ): Admission {
9
+ const target = bindings.find((b) => b.name === agentName)
10
+ if (!target)
11
+ return {
12
+ ok: false,
13
+ reason: `unknown_agent: no agent named "${agentName}" is configured here`,
14
+ }
15
+ if (!target.rooms.some((r) => r.alias === roomId))
16
+ return {
17
+ ok: false,
18
+ reason: `not_in_room: "${agentName}" is not a member of this room`,
19
+ }
20
+ return { ok: true }
21
+ }
22
+ export function buildAssignmentContent(input: {
23
+ assigneeUserId: string
24
+ prompt: string
25
+ start: ThreadStartContent
26
+ }): { msgtype: string; body: string; [key: string]: unknown } {
27
+ const escaped = input.prompt.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
28
+ const body = `${input.assigneeUserId} ${input.prompt}`.trim()
29
+ return {
30
+ msgtype: 'm.notice',
31
+ body,
32
+ format: 'org.matrix.custom.html',
33
+ formatted_body: `<a href="https://matrix.to/#/${encodeURIComponent(input.assigneeUserId)}">${input.assigneeUserId}</a> ${escaped}`,
34
+ 'm.mentions': { user_ids: [input.assigneeUserId] },
35
+ [THREAD_START_FIELD]: input.start,
36
+ }
37
+ }
38
+ export function renderCompletionPrompt(c: ThreadCompletion) {
39
+ return [
40
+ `[task result] ${c.agent} — status: ${c.status} (thread ${c.thread_id})`,
41
+ ...(c.reason ? [`reason: ${c.reason}`] : []),
42
+ ...(c.error ? [`error: ${c.error}`] : []),
43
+ ...(c.output?.text ? ['', c.output.text] : []),
44
+ ].join('\n')
45
+ }
46
+ export function renderInvocationReturn(c: ThreadCompletion) {
47
+ return [
48
+ `[handoff result] ${c.agent} — status: ${c.status}`,
49
+ ...(c.reason ? [`reason: ${c.reason}`] : []),
50
+ ...(c.error ? [`error: ${c.error}`] : []),
51
+ ...(c.output?.text ? ['', c.output.text] : []),
52
+ ].join('\n')
53
+ }
54
+ export function renderDelivery(notify: 'caller' | 'none'): string {
55
+ return notify === 'caller'
56
+ ? 'Each result returns to you as a new turn when that task completes. End your turn now — do not read the task thread to wait for it.'
57
+ : 'No result returns to you. The task thread is the result surface; thread_id is for later reference, not something to wait on.'
58
+ }
59
+ export function renderAssigneeEnvelope(input: { parentAgent: string; prompt: string }): string {
60
+ return [
61
+ `[task] from ${input.parentAgent} — you are the assignee of this thread.`,
62
+ 'Call zooid_complete_task with a self-contained summary when you are done;',
63
+ 'ending your turn without one publishes your last message as the result.',
64
+ 'Sibling task threads are refused here — @mention an agent in this thread to hand off.',
65
+ '',
66
+ input.prompt,
67
+ ].join('\n')
68
+ }