@zooid/transport-matrix 0.11.0 → 0.11.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zooid/transport-matrix",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Matrix Application Service transport for zooid. Routes inbound Matrix messages to ACP agents and posts replies plus approval custom events back to threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,8 +28,8 @@
28
28
  "marked": "^18.0.4",
29
29
  "sanitize-html": "^2.17.4",
30
30
  "yaml": "^2.5.0",
31
- "@zooid/acp-client": "^0.11.0",
32
- "@zooid/core": "^0.11.0"
31
+ "@zooid/acp-client": "^0.11.2",
32
+ "@zooid/core": "^0.11.2"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@hono/node-server": "^1.13.0",
@@ -142,7 +142,9 @@ describe('directional thread continuation (agent-to-agent handoffs)', () => {
142
142
  }
143
143
 
144
144
  function states(s: Partial<ThreadState>): Map<string, ThreadState> {
145
- return new Map([['$root', { participants: [], rootMentions: [], callers: {}, ...s }]])
145
+ return new Map([
146
+ ['$root', { participants: [], rootMentions: [], callers: {}, handoffs: {}, ...s }],
147
+ ])
146
148
  }
147
149
 
148
150
  it("routes a sub's bare reply up to its caller (parent notified)", () => {
@@ -225,3 +227,73 @@ describe('directional thread continuation (agent-to-agent handoffs)', () => {
225
227
  expect(matches).toEqual([])
226
228
  })
227
229
  })
230
+
231
+ describe('fan-out: two subs called in one message ([[ZOD071]] acceptance)', () => {
232
+ const mk = (name: string): AgentBinding => ({
233
+ name,
234
+ userId: `@${name}:example.com`,
235
+ rooms: [{ alias: '!room1:example.com' }],
236
+ trigger: 'mention',
237
+ })
238
+ const parent = mk('parent')
239
+ const bebop = mk('bebop')
240
+ const rocksteady = mk('rocksteady')
241
+ const trio = [parent, bebop, rocksteady]
242
+
243
+ function threadMsg(o: { sender: string; mentions?: string[] }) {
244
+ return {
245
+ type: 'm.room.message',
246
+ room_id: '!room1:example.com',
247
+ sender: o.sender,
248
+ event_id: '$evt',
249
+ content: {
250
+ msgtype: 'm.text',
251
+ body: 'reply',
252
+ 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
253
+ ...(o.mentions ? { 'm.mentions': { user_ids: o.mentions } } : {}),
254
+ },
255
+ }
256
+ }
257
+
258
+ function states(s: Partial<ThreadState>): Map<string, ThreadState> {
259
+ return new Map([
260
+ ['$root', { participants: [], rootMentions: [], callers: {}, handoffs: {}, ...s }],
261
+ ])
262
+ }
263
+
264
+ it('a single message @mentioning both subs triggers both (fan-out)', () => {
265
+ const matches = route(
266
+ threadMsg({
267
+ sender: '@parent:example.com',
268
+ mentions: ['@bebop:example.com', '@rocksteady:example.com'],
269
+ }),
270
+ trio,
271
+ states({ participants: ['parent'] }),
272
+ )
273
+ expect(matches.map((m) => m.name).sort()).toEqual(['bebop', 'rocksteady'])
274
+ })
275
+
276
+ it("bebop's bare return triggers only parent — never its sibling", () => {
277
+ const matches = route(
278
+ threadMsg({ sender: '@bebop:example.com' }),
279
+ trio,
280
+ states({
281
+ participants: ['parent', 'rocksteady', 'bebop'],
282
+ callers: { bebop: 'parent', rocksteady: 'parent' },
283
+ }),
284
+ )
285
+ expect(matches.map((m) => m.name)).toEqual(['parent'])
286
+ })
287
+
288
+ it("rocksteady's bare return likewise routes only up", () => {
289
+ const matches = route(
290
+ threadMsg({ sender: '@rocksteady:example.com' }),
291
+ trio,
292
+ states({
293
+ participants: ['parent', 'bebop', 'rocksteady'],
294
+ callers: { bebop: 'parent', rocksteady: 'parent' },
295
+ }),
296
+ )
297
+ expect(matches.map((m) => m.name)).toEqual(['parent'])
298
+ })
299
+ })
package/src/router.ts CHANGED
@@ -41,6 +41,13 @@ export interface ThreadState {
41
41
  * Implicit triggers → Directional continuation.
42
42
  */
43
43
  callers: Record<string, string>
44
+ /**
45
+ * Handoff arcs per sub-agent: the event ids of the agent→agent @mention
46
+ * messages that called it, in timeline order (last = current arc). Each
47
+ * call opens a fresh ACP session keyed `threadRoot|callEventId`
48
+ * ([[ZOD071]]). Append-only; rebuilt from the timeline after a restart.
49
+ */
50
+ handoffs: Record<string, string[]>
44
51
  }
45
52
 
46
53
  interface MaybeEvent {
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { HANDOFF_KEY_SEP, composeHandoffKey, sessionKeyFor } from './session-keys.js'
3
+ import type { ThreadState } from './router.js'
4
+
5
+ const state = (handoffs: Record<string, string[]>): ThreadState => ({
6
+ participants: [],
7
+ rootMentions: [],
8
+ callers: {},
9
+ handoffs,
10
+ })
11
+
12
+ describe('composeHandoffKey', () => {
13
+ it('joins threadRoot and callEventId with the separator', () => {
14
+ expect(composeHandoffKey('$root', '$call')).toBe(`$root${HANDOFF_KEY_SEP}$call`)
15
+ })
16
+ })
17
+
18
+ describe('sessionKeyFor', () => {
19
+ it('returns the bare threadRoot when there is no thread state', () => {
20
+ expect(sessionKeyFor('bebop', '$root', undefined)).toBe('$root')
21
+ })
22
+
23
+ it('returns the bare threadRoot when the agent has never been called', () => {
24
+ expect(sessionKeyFor('bebop', '$root', state({}))).toBe('$root')
25
+ })
26
+
27
+ it('returns the arc key when the agent has been called', () => {
28
+ expect(sessionKeyFor('bebop', '$root', state({ bebop: ['$c1'] }))).toBe('$root|$c1')
29
+ })
30
+
31
+ it('a re-delegation wins: the LAST call is the current arc (Option A)', () => {
32
+ expect(sessionKeyFor('bebop', '$root', state({ bebop: ['$c1', '$c2'] }))).toBe('$root|$c2')
33
+ })
34
+
35
+ it("another agent's arcs do not leak onto this agent's key", () => {
36
+ // parent called bebop; parent's OWN key stays thread-level.
37
+ expect(sessionKeyFor('parent', '$root', state({ bebop: ['$c1'] }))).toBe('$root')
38
+ })
39
+ })
@@ -0,0 +1,29 @@
1
+ import type { ThreadState } from './router.js'
2
+
3
+ /**
4
+ * Separator between a thread root and a handoff-arc call event id inside a
5
+ * session key. Matrix event IDs ('$' + URL-safe base64; legacy '$hash:domain')
6
+ * never contain '|', so composed keys are unambiguous — though nothing below
7
+ * the transport ever parses one: the key is opaque downstream ([[ZOD071]]).
8
+ */
9
+ export const HANDOFF_KEY_SEP = '|'
10
+
11
+ export function composeHandoffKey(threadRoot: string, callEventId: string): string {
12
+ return `${threadRoot}${HANDOFF_KEY_SEP}${callEventId}`
13
+ }
14
+
15
+ /**
16
+ * The session key for an agent's next turn in a thread: its latest handoff
17
+ * arc when it has been called (agent→agent @mention, [[ZOD071]]), else the
18
+ * thread-level key. Relies on the transaction handler recording the arc
19
+ * BEFORE the turn is dispatched, so a just-called sub resolves to the arc
20
+ * minted by its own triggering event.
21
+ */
22
+ export function sessionKeyFor(
23
+ agentName: string,
24
+ threadRoot: string,
25
+ state: ThreadState | undefined,
26
+ ): string {
27
+ const arc = state?.handoffs[agentName]?.at(-1)
28
+ return arc ? composeHandoffKey(threadRoot, arc) : threadRoot
29
+ }
@@ -139,7 +139,7 @@ describe('matrix transport /transactions', () => {
139
139
  expect(res.status).toBe(200)
140
140
  await settleTurn()
141
141
  // Agent-promotion: sessionKey is the inbound event_id, NOT the room.
142
- expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com')
142
+ expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com', '$root')
143
143
  // The reply threads against the user's message.
144
144
  expect(client.sendMessage).toHaveBeenCalledWith(
145
145
  expect.objectContaining({
@@ -229,7 +229,7 @@ describe('matrix transport /transactions', () => {
229
229
  })
230
230
  await postTxn(transport.app, { events })
231
231
  await settleTurn()
232
- expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com')
232
+ expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com', '$root')
233
233
  expect(client.sendMessage).toHaveBeenCalledWith(
234
234
  expect.objectContaining({ threadRoot: '$root' }),
235
235
  )
@@ -425,7 +425,7 @@ describe('thread implicit triggers', () => {
425
425
  await settleTurn()
426
426
 
427
427
  // architect should be triggered implicitly because they posted in the thread.
428
- expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com')
428
+ expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com', '$root')
429
429
  })
430
430
 
431
431
  it("inherits the thread root's @mentions when no agent has posted yet", async () => {
@@ -468,7 +468,7 @@ describe('thread implicit triggers', () => {
468
468
  })
469
469
  await settleTurn()
470
470
  // Inherits the root's @mention of architect.
471
- expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com')
471
+ expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com', '$root')
472
472
  })
473
473
 
474
474
  it('does not trigger any agent for a bare top-level message', async () => {
@@ -533,7 +533,7 @@ describe('thread implicit triggers', () => {
533
533
  ],
534
534
  })
535
535
  await settleTurn()
536
- expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com')
536
+ expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com', '$root')
537
537
  })
538
538
  })
539
539
 
@@ -634,7 +634,7 @@ describe('dev.zooid.session_reset', () => {
634
634
  ],
635
635
  })
636
636
  await settleTurn()
637
- expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com')
637
+ expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com', '$root')
638
638
  })
639
639
  })
640
640
 
@@ -1391,7 +1391,7 @@ describe('full loop integration', () => {
1391
1391
  }],
1392
1392
  })
1393
1393
  await settleTurn()
1394
- expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com')
1394
+ expect(agents.ensureSession).toHaveBeenCalledWith('architect', '$root', '!r:example.com', '$root')
1395
1395
  expect(client.sendMessage).toHaveBeenCalledWith(
1396
1396
  expect.objectContaining({ threadRoot: '$root' }),
1397
1397
  )
@@ -1742,17 +1742,18 @@ describe('directional agent-to-agent handoffs', () => {
1742
1742
  await settleTurn()
1743
1743
 
1744
1744
  // 2. Parent replies in-thread @mentioning sub → sub is called; caller[sub]=parent.
1745
+ // [[ZOD071]]: this call opens a fresh handoff arc for sub, keyed by $p1.
1745
1746
  await post(transport, { id: '$p1', sender: '@parent:example.com', root: '$root', mentions: ['@sub:example.com'] })
1746
1747
  await settleTurn()
1747
- expect(agents.ensureSession).toHaveBeenCalledWith('sub', '$root', '!r:example.com')
1748
+ expect(agents.ensureSession).toHaveBeenCalledWith('sub', '$root|$p1', '!r:example.com', '$root')
1748
1749
 
1749
1750
  agents.ensureSession.mockClear()
1750
1751
 
1751
1752
  // 3. Sub replies bare → bubbles UP to its caller (parent), nobody else.
1752
1753
  await post(transport, { id: '$s1', sender: '@sub:example.com', root: '$root' })
1753
1754
  await settleTurn()
1754
- expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com')
1755
- expect(agents.ensureSession).not.toHaveBeenCalledWith('sub', '$root', '!r:example.com')
1755
+ expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com', '$root')
1756
+ expect(agents.ensureSession.mock.calls.map((c) => c[0])).not.toContain('sub')
1756
1757
 
1757
1758
  agents.ensureSession.mockClear()
1758
1759
 
@@ -1789,3 +1790,331 @@ describe('directional agent-to-agent handoffs', () => {
1789
1790
  expect(state.participants).toEqual(['parent', 'sub'])
1790
1791
  })
1791
1792
  })
1793
+
1794
+ describe('per-handoff session isolation ([[ZOD071]])', () => {
1795
+ const mkBinding = (name: string) => ({
1796
+ name,
1797
+ userId: `@${name}:example.com`,
1798
+ rooms: [{ alias: '!r:example.com' }],
1799
+ trigger: 'mention' as const,
1800
+ })
1801
+ const trio = [mkBinding('parent'), mkBinding('bebop'), mkBinding('rocksteady')]
1802
+
1803
+ function makeTrioTransport() {
1804
+ const { reg } = fakeRegistry()
1805
+ const approvals = fakeApprovals()
1806
+ const client = fakeClient()
1807
+ // Session ids must be unique per (agent, key) — the shared-prompt default
1808
+ // fake returns `sess-${threadId}`, which would collide for two subs
1809
+ // called by the same event (same composed key, different agent).
1810
+ reg.ensureSession.mockImplementation(
1811
+ async (name: string, threadId: string) => `sess-${name}-${threadId}`,
1812
+ )
1813
+ // Per-agent prompt gates: parent turns resolve immediately; sub turns stay
1814
+ // open until released, so both arcs are provably IN FLIGHT at once.
1815
+ const gates = new Map<string, () => void>()
1816
+ reg.prompt.mockImplementation(async (name: string) => {
1817
+ if (name === 'parent') return { stopReason: 'end_turn' as const }
1818
+ return new Promise<{ stopReason: 'end_turn' }>((res) =>
1819
+ gates.set(name, () => res({ stopReason: 'end_turn' as const })),
1820
+ )
1821
+ })
1822
+ const transport = createMatrixTransport({
1823
+ agents: reg as never,
1824
+ approvals: approvals as never,
1825
+ client: client as never,
1826
+ bindings: trio,
1827
+ hsToken: 'hs-secret',
1828
+ botUserId: '@zooid:example.com',
1829
+ drainQuietMs: 0,
1830
+ })
1831
+ return { transport, agents: reg, client, gates }
1832
+ }
1833
+
1834
+ function post(
1835
+ transport: ReturnType<typeof makeTrioTransport>['transport'],
1836
+ o: { id: string; sender: string; root?: string; mentions?: string[] },
1837
+ ) {
1838
+ const content: Record<string, unknown> = { msgtype: 'm.text', body: 'x' }
1839
+ if (o.mentions) content['m.mentions'] = { user_ids: o.mentions }
1840
+ if (o.root) content['m.relates_to'] = { rel_type: 'm.thread', event_id: o.root }
1841
+ return postTxn(transport.app, {
1842
+ events: [
1843
+ {
1844
+ type: 'm.room.message',
1845
+ event_id: o.id,
1846
+ room_id: '!r:example.com',
1847
+ sender: o.sender,
1848
+ content,
1849
+ },
1850
+ ],
1851
+ })
1852
+ }
1853
+
1854
+ const agentsCalled = (reg: ReturnType<typeof fakeRegistry>['reg']) =>
1855
+ reg.ensureSession.mock.calls.map((c) => c[0] as string)
1856
+
1857
+ it('fans out to two subs on one call event, runs their arcs concurrently with interleaved completion, and returns control to the parent once per sub', async () => {
1858
+ const { transport, agents, gates } = makeTrioTransport()
1859
+
1860
+ // 1. Human @parent (top-level, $root) → parent gets the THREAD-LEVEL
1861
+ // session; the real threadRoot rides along as the context ref.
1862
+ await post(transport, {
1863
+ id: '$root',
1864
+ sender: '@alice:example.com',
1865
+ mentions: ['@parent:example.com'],
1866
+ })
1867
+ await settleTurn()
1868
+ expect(agents.ensureSession).toHaveBeenCalledWith(
1869
+ 'parent',
1870
+ '$root',
1871
+ '!r:example.com',
1872
+ '$root',
1873
+ )
1874
+
1875
+ // 2. Parent calls BOTH subs in ONE message ($p1). Each sub gets a fresh
1876
+ // arc session keyed by the same call event id — the composed key
1877
+ // string is identical; the sessions are distinct via the agent
1878
+ // dimension (per-agent client/store).
1879
+ await post(transport, {
1880
+ id: '$p1',
1881
+ sender: '@parent:example.com',
1882
+ root: '$root',
1883
+ mentions: ['@bebop:example.com', '@rocksteady:example.com'],
1884
+ })
1885
+ await settleTurn()
1886
+ expect(agents.ensureSession).toHaveBeenCalledWith(
1887
+ 'bebop',
1888
+ '$root|$p1',
1889
+ '!r:example.com',
1890
+ '$root',
1891
+ )
1892
+ expect(agents.ensureSession).toHaveBeenCalledWith(
1893
+ 'rocksteady',
1894
+ '$root|$p1',
1895
+ '!r:example.com',
1896
+ '$root',
1897
+ )
1898
+ // Both sub turns are open simultaneously — the arcs are concurrent, not
1899
+ // queued. Their thread events will interleave; that's the point.
1900
+ expect(gates.size).toBe(2)
1901
+
1902
+ agents.ensureSession.mockClear()
1903
+
1904
+ // 3. Rocksteady finishes FIRST (reverse of call order — interleaved).
1905
+ // Its bare return routes ONLY to parent, resuming parent's continuous
1906
+ // thread-level session; the sibling is untouched.
1907
+ gates.get('rocksteady')!()
1908
+ await settleTurn()
1909
+ await post(transport, { id: '$r1', sender: '@rocksteady:example.com', root: '$root' })
1910
+ await settleTurn()
1911
+ expect(agents.ensureSession).toHaveBeenCalledWith(
1912
+ 'parent',
1913
+ '$root',
1914
+ '!r:example.com',
1915
+ '$root',
1916
+ )
1917
+ expect(agentsCalled(agents)).not.toContain('bebop')
1918
+
1919
+ agents.ensureSession.mockClear()
1920
+
1921
+ // 4. Bebop finishes second — same story, sibling untouched. Parent has
1922
+ // now been handed control exactly once per sub, both times in the
1923
+ // SAME thread-level session (it aggregates the two results).
1924
+ gates.get('bebop')!()
1925
+ await settleTurn()
1926
+ await post(transport, { id: '$b1', sender: '@bebop:example.com', root: '$root' })
1927
+ await settleTurn()
1928
+ expect(agents.ensureSession).toHaveBeenCalledWith(
1929
+ 'parent',
1930
+ '$root',
1931
+ '!r:example.com',
1932
+ '$root',
1933
+ )
1934
+ expect(agentsCalled(agents)).not.toContain('rocksteady')
1935
+ })
1936
+
1937
+ it('re-delegating to the same sub opens a FRESH session (Option A: cold re-invoke)', async () => {
1938
+ const { transport, agents, gates } = makeTrioTransport()
1939
+
1940
+ await post(transport, {
1941
+ id: '$root',
1942
+ sender: '@alice:example.com',
1943
+ mentions: ['@parent:example.com'],
1944
+ })
1945
+ await settleTurn()
1946
+
1947
+ // First delegation: arc $p1.
1948
+ await post(transport, {
1949
+ id: '$p1',
1950
+ sender: '@parent:example.com',
1951
+ root: '$root',
1952
+ mentions: ['@bebop:example.com'],
1953
+ })
1954
+ await settleTurn()
1955
+ gates.get('bebop')!()
1956
+ await settleTurn()
1957
+ await post(transport, { id: '$b1', sender: '@bebop:example.com', root: '$root' })
1958
+ await settleTurn()
1959
+
1960
+ // Second delegation: arc $p2 — a NEW session key, not a resume.
1961
+ await post(transport, {
1962
+ id: '$p2',
1963
+ sender: '@parent:example.com',
1964
+ root: '$root',
1965
+ mentions: ['@bebop:example.com'],
1966
+ })
1967
+ await settleTurn()
1968
+ gates.get('bebop')!()
1969
+ await settleTurn()
1970
+
1971
+ const bebopKeys = agents.ensureSession.mock.calls
1972
+ .filter((c) => c[0] === 'bebop')
1973
+ .map((c) => c[1])
1974
+ expect(bebopKeys).toEqual(['$root|$p1', '$root|$p2'])
1975
+ })
1976
+
1977
+ it("a human bare reply to the last-posting sub resumes the sub's current arc", async () => {
1978
+ const { transport, agents, gates } = makeTrioTransport()
1979
+
1980
+ await post(transport, {
1981
+ id: '$root',
1982
+ sender: '@alice:example.com',
1983
+ mentions: ['@parent:example.com'],
1984
+ })
1985
+ await settleTurn()
1986
+ await post(transport, {
1987
+ id: '$p1',
1988
+ sender: '@parent:example.com',
1989
+ root: '$root',
1990
+ mentions: ['@bebop:example.com'],
1991
+ })
1992
+ await settleTurn()
1993
+ gates.get('bebop')!()
1994
+ await settleTurn() // participants: [parent, bebop] — bebop is last poster
1995
+
1996
+ agents.ensureSession.mockClear()
1997
+
1998
+ // Human "why did you do X?" — routes to bebop (last poster) and lands in
1999
+ // the session that DID the work, not a cold thread-level one.
2000
+ await post(transport, { id: '$h1', sender: '@alice:example.com', root: '$root' })
2001
+ await settleTurn()
2002
+ expect(agents.ensureSession).toHaveBeenCalledWith(
2003
+ 'bebop',
2004
+ '$root|$p1',
2005
+ '!r:example.com',
2006
+ '$root',
2007
+ )
2008
+ })
2009
+
2010
+ it('/clear (session_reset) ends the thread-level session AND every handoff arc', async () => {
2011
+ const { transport, agents, gates } = makeTrioTransport()
2012
+
2013
+ await post(transport, {
2014
+ id: '$root',
2015
+ sender: '@alice:example.com',
2016
+ mentions: ['@parent:example.com'],
2017
+ })
2018
+ await settleTurn()
2019
+ await post(transport, {
2020
+ id: '$p1',
2021
+ sender: '@parent:example.com',
2022
+ root: '$root',
2023
+ mentions: ['@bebop:example.com', '@rocksteady:example.com'],
2024
+ })
2025
+ await settleTurn()
2026
+ gates.get('bebop')!()
2027
+ gates.get('rocksteady')!()
2028
+ await settleTurn()
2029
+
2030
+ await postTxn(transport.app, {
2031
+ events: [
2032
+ {
2033
+ type: 'dev.zooid.session_reset',
2034
+ event_id: '$reset',
2035
+ room_id: '!r:example.com',
2036
+ sender: '@alice:example.com',
2037
+ content: { 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' } },
2038
+ },
2039
+ ],
2040
+ })
2041
+ await settleTurn()
2042
+
2043
+ const ended = agents.endSession.mock.calls.map((c) => `${c[0]}:${c[1]}`)
2044
+ expect(ended).toContain('parent:$root')
2045
+ expect(ended).toContain('bebop:$root')
2046
+ expect(ended).toContain('rocksteady:$root')
2047
+ expect(ended).toContain('bebop:$root|$p1')
2048
+ expect(ended).toContain('rocksteady:$root|$p1')
2049
+ })
2050
+
2051
+ it('/clear right after a restart rebuilds thread state so arc sessions are still ended', async () => {
2052
+ const { transport, agents, client } = makeTrioTransport()
2053
+ // No prior inbound events — simulate a daemon restart: threadStates is
2054
+ // empty, but the timeline (via the client fetchers) knows the arc.
2055
+ ;(client as unknown as Record<string, unknown>).fetchEvent = vi.fn(async () => ({
2056
+ type: 'm.room.message',
2057
+ sender: '@alice:example.com',
2058
+ content: { 'm.mentions': { user_ids: ['@parent:example.com'] } },
2059
+ }))
2060
+ ;(client as unknown as Record<string, unknown>).fetchThreadRelations = vi.fn(async () => ({
2061
+ chunk: [
2062
+ {
2063
+ type: 'm.room.message',
2064
+ event_id: '$p1',
2065
+ sender: '@parent:example.com',
2066
+ content: { 'm.mentions': { user_ids: ['@bebop:example.com'] } },
2067
+ },
2068
+ ],
2069
+ }))
2070
+
2071
+ await postTxn(transport.app, {
2072
+ events: [
2073
+ {
2074
+ type: 'dev.zooid.session_reset',
2075
+ event_id: '$reset',
2076
+ room_id: '!r:example.com',
2077
+ sender: '@alice:example.com',
2078
+ content: { 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' } },
2079
+ },
2080
+ ],
2081
+ })
2082
+ await settleTurn()
2083
+
2084
+ const ended = agents.endSession.mock.calls.map((c) => `${c[0]}:${c[1]}`)
2085
+ expect(ended).toContain('bebop:$root')
2086
+ expect(ended).toContain('bebop:$root|$p1')
2087
+ })
2088
+
2089
+ it('rebuildThreadState reconstructs handoff arcs (multi-sub + re-delegation) from the timeline', async () => {
2090
+ const client = {
2091
+ fetchEvent: vi.fn(async () => ({
2092
+ type: 'm.room.message',
2093
+ sender: '@alice:example.com',
2094
+ content: { 'm.mentions': { user_ids: ['@parent:example.com'] } },
2095
+ })),
2096
+ fetchThreadRelations: vi.fn(async () => ({
2097
+ chunk: [
2098
+ {
2099
+ type: 'm.room.message',
2100
+ event_id: '$p1',
2101
+ sender: '@parent:example.com',
2102
+ content: {
2103
+ 'm.mentions': { user_ids: ['@bebop:example.com', '@rocksteady:example.com'] },
2104
+ },
2105
+ },
2106
+ { type: 'm.room.message', event_id: '$b1', sender: '@bebop:example.com', content: { body: 'done' } },
2107
+ {
2108
+ type: 'm.room.message',
2109
+ event_id: '$p2',
2110
+ sender: '@parent:example.com',
2111
+ content: { 'm.mentions': { user_ids: ['@bebop:example.com'] } },
2112
+ },
2113
+ ],
2114
+ })),
2115
+ }
2116
+ const state = await rebuildThreadState(client as never, '!r:example.com', '$root', trio)
2117
+ expect(state.handoffs).toEqual({ bebop: ['$p1', '$p2'], rocksteady: ['$p1'] })
2118
+ expect(state.callers).toEqual({ bebop: 'parent', rocksteady: 'parent' })
2119
+ })
2120
+ })