@zooid/transport-matrix 0.9.0 → 0.10.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.
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { SyncLoop } from './sync-loop.js'
3
+
4
+ const evt = (id: string, roomId: string) => ({
5
+ type: 'm.room.message',
6
+ event_id: id,
7
+ sender: '@me:zoon.eco',
8
+ room_id: roomId,
9
+ content: { msgtype: 'm.text', body: 'hi' },
10
+ })
11
+
12
+ function fakeClient(pages: unknown[]) {
13
+ let i = 0
14
+ return {
15
+ sync: vi.fn(async () => pages[Math.min(i++, pages.length - 1)]),
16
+ }
17
+ }
18
+
19
+ describe('SyncLoop', () => {
20
+ it('dispatches each timeline event once and persists next_batch', async () => {
21
+ const store = new Map<string, string>()
22
+ const dispatched: string[] = []
23
+ const client = fakeClient([
24
+ { next_batch: 's1', rooms: { join: { '!r': { timeline: { events: [evt('a', '!r')] } } } } },
25
+ { next_batch: 's2', rooms: { join: { '!r': { timeline: { events: [evt('b', '!r')] } } } } },
26
+ { next_batch: 's2', rooms: { join: {} } },
27
+ ])
28
+ const loop = new SyncLoop({
29
+ client: client as never,
30
+ asUserId: '@laptop.docs:zoon.eco',
31
+ loadSince: () => store.get('docs') ?? null,
32
+ saveSince: (s) => store.set('docs', s),
33
+ onEvent: (e) => dispatched.push(e.event_id as string),
34
+ })
35
+ await loop.tick()
36
+ await loop.tick()
37
+ expect(dispatched).toEqual(['a', 'b'])
38
+ expect(store.get('docs')).toBe('s2')
39
+ })
40
+
41
+ it('resumes from the persisted since (no reprocessing across restart)', async () => {
42
+ const store = new Map<string, string>([['docs', 's-persisted']])
43
+ const client = fakeClient([{ next_batch: 's3', rooms: { join: {} } }])
44
+ const loop = new SyncLoop({
45
+ client: client as never,
46
+ asUserId: '@laptop.docs:zoon.eco',
47
+ loadSince: () => store.get('docs') ?? null,
48
+ saveSince: (s) => store.set('docs', s),
49
+ onEvent: () => {},
50
+ })
51
+ await loop.tick()
52
+ expect((client.sync as ReturnType<typeof vi.fn>).mock.calls[0][0].since).toBe('s-persisted')
53
+ })
54
+
55
+ it('tolerates an idle sync with no `rooms` key (real homeservers omit it)', async () => {
56
+ const store = new Map<string, string>()
57
+ const dispatched: string[] = []
58
+ // Real Tuwunel returns just { next_batch } on an idle incremental sync.
59
+ const client = fakeClient([{ next_batch: 's-idle' }])
60
+ const loop = new SyncLoop({
61
+ client: client as never,
62
+ asUserId: '@laptop.docs:zoon.eco',
63
+ loadSince: () => store.get('docs') ?? null,
64
+ saveSince: (s) => store.set('docs', s),
65
+ onEvent: (e) => dispatched.push(e.event_id as string),
66
+ })
67
+ await expect(loop.tick()).resolves.toBeUndefined()
68
+ expect(dispatched).toEqual([])
69
+ expect(store.get('docs')).toBe('s-idle') // cursor still advances
70
+ })
71
+
72
+ it('tolerates a joined room with no `timeline` (state/ephemeral-only update)', async () => {
73
+ const store = new Map<string, string>()
74
+ const dispatched: string[] = []
75
+ // A room can appear in rooms.join carrying only state — no timeline key.
76
+ const client = fakeClient([
77
+ { next_batch: 's-state', rooms: { join: { '!r': { state: { events: [] } } } } },
78
+ ])
79
+ const loop = new SyncLoop({
80
+ client: client as never,
81
+ asUserId: '@laptop.docs:zoon.eco',
82
+ loadSince: () => store.get('docs') ?? null,
83
+ saveSince: (s) => store.set('docs', s),
84
+ onEvent: (e) => dispatched.push(e.event_id as string),
85
+ })
86
+ await expect(loop.tick()).resolves.toBeUndefined()
87
+ expect(dispatched).toEqual([])
88
+ expect(store.get('docs')).toBe('s-state')
89
+ })
90
+
91
+ it('run() survives a throwing tick and keeps going (no daemon crash)', async () => {
92
+ let calls = 0
93
+ let loop!: SyncLoop
94
+ const client = {
95
+ sync: vi.fn(async () => {
96
+ calls++
97
+ if (calls === 1) throw new Error('transient /sync 502')
98
+ loop.stop() // recovered on the 2nd tick — end the loop deterministically
99
+ return { next_batch: 's-ok' }
100
+ }),
101
+ }
102
+ loop = new SyncLoop({
103
+ client: client as never,
104
+ asUserId: '@laptop.docs:zoon.eco',
105
+ loadSince: () => null,
106
+ saveSince: () => {},
107
+ onEvent: () => {},
108
+ retryDelayMs: 1,
109
+ })
110
+ await loop.run() // tick 1 throws → backoff → tick 2 recovers → stop → exits
111
+ expect(calls).toBe(2)
112
+ })
113
+ })
@@ -0,0 +1,74 @@
1
+ export interface SyncResponse {
2
+ next_batch: string
3
+ // Real homeservers omit `rooms` (and `rooms.join`) entirely on an idle
4
+ // incremental sync — both are optional.
5
+ rooms?: {
6
+ join?: Record<string, {
7
+ // A joined room may carry only state/ephemeral/account_data on a given
8
+ // sync, with no `timeline` (or a timeline with no `events`).
9
+ timeline?: {
10
+ events?: Record<string, unknown>[]
11
+ prev_batch?: string
12
+ limited?: boolean
13
+ }
14
+ }>
15
+ }
16
+ }
17
+
18
+ export interface SyncClient {
19
+ sync(opts: { asUserId: string; since: string | null; timeoutMs: number }): Promise<SyncResponse>
20
+ }
21
+
22
+ export interface SyncLoopOptions {
23
+ client: SyncClient
24
+ asUserId: string
25
+ loadSince: () => string | null
26
+ saveSince: (since: string) => void
27
+ onEvent: (evt: Record<string, unknown>) => void | Promise<void>
28
+ timeoutMs?: number
29
+ /** Backoff after a failed tick before retrying. Default 5000ms. */
30
+ retryDelayMs?: number
31
+ }
32
+
33
+ export class SyncLoop {
34
+ private readonly opts: SyncLoopOptions
35
+ private running = false
36
+
37
+ constructor(opts: SyncLoopOptions) {
38
+ this.opts = opts
39
+ }
40
+
41
+ async tick(): Promise<void> {
42
+ const since = this.opts.loadSince()
43
+ const res = await this.opts.client.sync({
44
+ asUserId: this.opts.asUserId,
45
+ since,
46
+ timeoutMs: this.opts.timeoutMs ?? 30_000,
47
+ })
48
+ for (const [roomId, roomState] of Object.entries(res.rooms?.join ?? {})) {
49
+ for (const baseEvt of roomState.timeline?.events ?? []) {
50
+ await this.opts.onEvent({ ...(baseEvt as Record<string, unknown>), room_id: roomId })
51
+ }
52
+ }
53
+ this.opts.saveSince(res.next_batch)
54
+ }
55
+
56
+ async run(): Promise<void> {
57
+ this.running = true
58
+ while (this.running) {
59
+ try {
60
+ await this.tick()
61
+ } catch (err) {
62
+ // A transient /sync failure (network blip, sleep/wake, 5xx) must not
63
+ // kill the loop — log and back off, then resume from the same `since`.
64
+ if (!this.running) break
65
+ console.warn(`[sync-loop] ${this.opts.asUserId} tick failed, retrying:`, err)
66
+ await new Promise((r) => setTimeout(r, this.opts.retryDelayMs ?? 5_000))
67
+ }
68
+ }
69
+ }
70
+
71
+ stop(): void {
72
+ this.running = false
73
+ }
74
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi } from 'vitest'
2
2
  import { EventEmitter } from 'node:events'
3
- import { createMatrixTransport } from './transport.js'
3
+ import { createMatrixTransport, rebuildThreadState } from './transport.js'
4
4
 
5
5
  function fakeRegistry() {
6
6
  let resolvePrompt: (() => void) | undefined
@@ -1683,3 +1683,109 @@ describe('ad-hoc bot invite declines', () => {
1683
1683
  expect(client.leaveRoom).not.toHaveBeenCalled()
1684
1684
  })
1685
1685
  })
1686
+
1687
+ describe('directional agent-to-agent handoffs', () => {
1688
+ const parentSub = [
1689
+ {
1690
+ name: 'parent',
1691
+ userId: '@parent:example.com',
1692
+ rooms: [{ alias: '!r:example.com' }],
1693
+ trigger: 'mention' as const,
1694
+ },
1695
+ {
1696
+ name: 'sub',
1697
+ userId: '@sub:example.com',
1698
+ rooms: [{ alias: '!r:example.com' }],
1699
+ trigger: 'mention' as const,
1700
+ },
1701
+ ]
1702
+
1703
+ function makePairTransport() {
1704
+ const { reg } = fakeRegistry()
1705
+ const approvals = fakeApprovals()
1706
+ const client = fakeClient()
1707
+ // Resolve prompts immediately so runTurn completes and `participants` append.
1708
+ reg.prompt.mockImplementation(async () => ({ stopReason: 'end_turn' as const }))
1709
+ const transport = createMatrixTransport({
1710
+ agents: reg as never,
1711
+ approvals: approvals as never,
1712
+ client: client as never,
1713
+ bindings: parentSub,
1714
+ hsToken: 'hs-secret',
1715
+ botUserId: '@zooid:example.com',
1716
+ drainQuietMs: 0,
1717
+ })
1718
+ return { transport, agents: reg, client }
1719
+ }
1720
+
1721
+ // Post one m.room.message. Top-level unless `root` is given (then it's a
1722
+ // thread reply on that root). `mentions` sets m.mentions.user_ids.
1723
+ function post(
1724
+ transport: ReturnType<typeof makePairTransport>['transport'],
1725
+ o: { id: string; sender: string; root?: string; mentions?: string[] },
1726
+ ) {
1727
+ const content: Record<string, unknown> = { msgtype: 'm.text', body: 'x' }
1728
+ if (o.mentions) content['m.mentions'] = { user_ids: o.mentions }
1729
+ if (o.root) content['m.relates_to'] = { rel_type: 'm.thread', event_id: o.root }
1730
+ return postTxn(transport.app, {
1731
+ events: [
1732
+ { type: 'm.room.message', event_id: o.id, room_id: '!r:example.com', sender: o.sender, content },
1733
+ ],
1734
+ })
1735
+ }
1736
+
1737
+ it("a parent's ack does not re-trigger the sub, but the sub's reply notifies the parent", async () => {
1738
+ const { transport, agents } = makePairTransport()
1739
+
1740
+ // 1. Human @mentions parent (top-level) → thread root is $root.
1741
+ await post(transport, { id: '$root', sender: '@alice:example.com', mentions: ['@parent:example.com'] })
1742
+ await settleTurn()
1743
+
1744
+ // 2. Parent replies in-thread @mentioning sub → sub is called; caller[sub]=parent.
1745
+ await post(transport, { id: '$p1', sender: '@parent:example.com', root: '$root', mentions: ['@sub:example.com'] })
1746
+ await settleTurn()
1747
+ expect(agents.ensureSession).toHaveBeenCalledWith('sub', '$root', '!r:example.com')
1748
+
1749
+ agents.ensureSession.mockClear()
1750
+
1751
+ // 3. Sub replies bare → bubbles UP to its caller (parent), nobody else.
1752
+ await post(transport, { id: '$s1', sender: '@sub:example.com', root: '$root' })
1753
+ await settleTurn()
1754
+ expect(agents.ensureSession).toHaveBeenCalledWith('parent', '$root', '!r:example.com')
1755
+ expect(agents.ensureSession).not.toHaveBeenCalledWith('sub', '$root', '!r:example.com')
1756
+
1757
+ agents.ensureSession.mockClear()
1758
+
1759
+ // 4. Parent replies bare (the "ack") → parent has no caller → routes to NOBODY.
1760
+ // This is the loop guard: sub is NOT re-triggered.
1761
+ await post(transport, { id: '$p2', sender: '@parent:example.com', root: '$root' })
1762
+ await settleTurn()
1763
+ expect(agents.ensureSession).not.toHaveBeenCalled()
1764
+ })
1765
+
1766
+ it('rebuildThreadState reconstructs caller[] from the timeline after a restart', async () => {
1767
+ const client = {
1768
+ // root: human @mentions parent (no caller — human sender).
1769
+ fetchEvent: vi.fn(async () => ({
1770
+ type: 'm.room.message',
1771
+ sender: '@alice:example.com',
1772
+ content: { 'm.mentions': { user_ids: ['@parent:example.com'] } },
1773
+ })),
1774
+ // thread: parent @mentions sub (caller[sub]=parent), then sub replies bare.
1775
+ fetchThreadRelations: vi.fn(async () => ({
1776
+ chunk: [
1777
+ {
1778
+ type: 'm.room.message',
1779
+ sender: '@parent:example.com',
1780
+ content: { 'm.mentions': { user_ids: ['@sub:example.com'] } },
1781
+ },
1782
+ { type: 'm.room.message', sender: '@sub:example.com', content: { body: 'ack' } },
1783
+ ],
1784
+ })),
1785
+ }
1786
+ const state = await rebuildThreadState(client as never, '!r:example.com', '$root', parentSub)
1787
+ expect(state.callers).toEqual({ sub: 'parent' })
1788
+ expect(state.rootMentions).toEqual(['parent', 'sub'])
1789
+ expect(state.participants).toEqual(['parent', 'sub'])
1790
+ })
1791
+ })