@zooid/context-mcp 0.7.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,221 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { SpawnRegistry } from './spawn-registry.js'
6
+ import { startDaemonSocketServer, callDaemon } from './daemon-socket.js'
7
+ import type { TransportContextProvider } from '@zooid/core'
8
+
9
+ function fakeProvider(over: Partial<TransportContextProvider> = {}): TransportContextProvider {
10
+ return {
11
+ getRoomHistory: async () => ({ messages: [], has_more: false }),
12
+ getRecentThreads: async () => ({ threads: [], has_more: false }),
13
+ getThreadHistory: async () => ({ messages: [], has_more: false }),
14
+ getChannelMembers: async () => [],
15
+ getChannelInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
16
+ ...over,
17
+ }
18
+ }
19
+
20
+ const defaultProvider = fakeProvider({
21
+ getRoomHistory: async () => ({
22
+ messages: [
23
+ { id: 'e1', sender: 'alice', text: 'hi', timestamp: '2026-05-11T00:00:00Z', is_agent: false },
24
+ ],
25
+ has_more: false,
26
+ }),
27
+ getChannelMembers: async () => [{ id: '@alice:hs', name: 'alice', is_agent: false }],
28
+ getChannelInfo: async () => ({ id: '!r:hs', name: 'general', transport: 'matrix' }),
29
+ })
30
+
31
+ const cleanup: Array<() => Promise<void>> = []
32
+ afterEach(async () => {
33
+ for (const fn of cleanup) await fn()
34
+ cleanup.length = 0
35
+ })
36
+
37
+ describe('daemon-socket', () => {
38
+ it('routes a getRoomHistory call to the bound provider and returns the payload', async () => {
39
+ const registry = new SpawnRegistry()
40
+ const spawnId = registry.register({
41
+ agentName: 'a',
42
+ threadRef: { channelId: 'c', threadId: 't' },
43
+ provider: defaultProvider,
44
+ })
45
+ const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
46
+ const server = await startDaemonSocketServer({ sockPath, registry })
47
+ cleanup.push(() => server.close())
48
+
49
+ const res = await callDaemon(sockPath, {
50
+ spawnId,
51
+ method: 'getRoomHistory',
52
+ params: { limit: 50 },
53
+ })
54
+
55
+ expect(res).toEqual({
56
+ messages: [
57
+ { id: 'e1', sender: 'alice', text: 'hi', timestamp: '2026-05-11T00:00:00Z', is_agent: false },
58
+ ],
59
+ has_more: false,
60
+ })
61
+ })
62
+
63
+ it('routes getRecentThreads and getThreadHistory with thread metadata', async () => {
64
+ const provider = fakeProvider({
65
+ getRecentThreads: async () => ({
66
+ threads: [
67
+ {
68
+ id: '$root',
69
+ sender: 'alice',
70
+ text: 'kickoff',
71
+ timestamp: 'T',
72
+ is_agent: false,
73
+ reply_count: 2,
74
+ last_activity_at: 'T2',
75
+ },
76
+ ],
77
+ has_more: false,
78
+ }),
79
+ getThreadHistory: async (_c, threadId) => ({
80
+ messages: [
81
+ { id: threadId, sender: 'alice', text: 'root', timestamp: 'T', is_agent: false, thread_id: threadId },
82
+ ],
83
+ has_more: false,
84
+ }),
85
+ })
86
+ const registry = new SpawnRegistry()
87
+ const spawnId = registry.register({
88
+ agentName: 'a',
89
+ threadRef: { channelId: 'c', threadId: 't' },
90
+ provider,
91
+ })
92
+ const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
93
+ const server = await startDaemonSocketServer({ sockPath, registry })
94
+ cleanup.push(() => server.close())
95
+
96
+ const overview = (await callDaemon(sockPath, {
97
+ spawnId,
98
+ method: 'getRecentThreads',
99
+ params: {},
100
+ })) as { threads: Array<{ id: string; reply_count: number }> }
101
+ expect(overview.threads[0]).toMatchObject({ id: '$root', reply_count: 2 })
102
+
103
+ const detail = (await callDaemon(sockPath, {
104
+ spawnId,
105
+ method: 'getThreadHistory',
106
+ params: { threadId: '$root' },
107
+ })) as { messages: Array<{ id: string; thread_id: string }> }
108
+ expect(detail.messages[0]).toMatchObject({ id: '$root', thread_id: '$root' })
109
+ })
110
+
111
+ it('returns an error envelope for unknown spawn-ids', async () => {
112
+ const registry = new SpawnRegistry()
113
+ const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
114
+ const server = await startDaemonSocketServer({ sockPath, registry })
115
+ cleanup.push(() => server.close())
116
+
117
+ await expect(
118
+ callDaemon(sockPath, { spawnId: 'unknown', method: 'getRoomHistory', params: {} }),
119
+ ).rejects.toThrow(/unknown spawn/i)
120
+ })
121
+
122
+ it('routes getChannelMembers and getChannelInfo', async () => {
123
+ const registry = new SpawnRegistry()
124
+ const spawnId = registry.register({
125
+ agentName: 'a',
126
+ threadRef: { channelId: 'c', threadId: 't' },
127
+ provider: defaultProvider,
128
+ })
129
+ const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
130
+ const server = await startDaemonSocketServer({ sockPath, registry })
131
+ cleanup.push(() => server.close())
132
+
133
+ const members = await callDaemon(sockPath, { spawnId, method: 'getChannelMembers', params: {} })
134
+ expect(members).toEqual([{ id: '@alice:hs', name: 'alice', is_agent: false }])
135
+
136
+ const info = await callDaemon(sockPath, { spawnId, method: 'getChannelInfo', params: {} })
137
+ expect(info).toEqual({ id: '!r:hs', name: 'general', transport: 'matrix' })
138
+ })
139
+
140
+ it('serves two different spawns on one shared socket — each routed to its own provider', async () => {
141
+ const providerA = fakeProvider({
142
+ getRoomHistory: async () => ({
143
+ messages: [{ id: 'A1', sender: 'alice', text: 'from A', timestamp: 'T', is_agent: false }],
144
+ has_more: false,
145
+ }),
146
+ getChannelInfo: async () => ({ id: '!a:hs', name: 'room-A', transport: 'matrix' }),
147
+ })
148
+ const providerB = fakeProvider({
149
+ getRoomHistory: async () => ({
150
+ messages: [{ id: 'B1', sender: 'bob', text: 'from B', timestamp: 'T', is_agent: false }],
151
+ has_more: false,
152
+ }),
153
+ getChannelInfo: async () => ({ id: '!b:hs', name: 'room-B', transport: 'matrix' }),
154
+ })
155
+ const registry = new SpawnRegistry()
156
+ const spawnA = registry.register({
157
+ agentName: 'architect',
158
+ threadRef: { channelId: '!a:hs', threadId: '!a:hs' },
159
+ provider: providerA,
160
+ })
161
+ const spawnB = registry.register({
162
+ agentName: 'product-owner',
163
+ threadRef: { channelId: '!b:hs', threadId: '!b:hs' },
164
+ provider: providerB,
165
+ })
166
+ const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
167
+ const server = await startDaemonSocketServer({ sockPath, registry })
168
+ cleanup.push(() => server.close())
169
+
170
+ const [resA, resB] = await Promise.all([
171
+ callDaemon(sockPath, { spawnId: spawnA, method: 'getRoomHistory', params: {} }),
172
+ callDaemon(sockPath, { spawnId: spawnB, method: 'getRoomHistory', params: {} }),
173
+ ])
174
+
175
+ expect((resA as { messages: Array<{ id: string }> }).messages[0].id).toBe('A1')
176
+ expect((resB as { messages: Array<{ id: string }> }).messages[0].id).toBe('B1')
177
+
178
+ const infoB = await callDaemon(sockPath, { spawnId: spawnB, method: 'getChannelInfo', params: {} })
179
+ expect((infoB as { id: string }).id).toBe('!b:hs')
180
+ })
181
+
182
+ it('handles many sequential calls from one client connection without leaking state', async () => {
183
+ const providerA = fakeProvider({
184
+ getRoomHistory: async () => ({
185
+ messages: [{ id: 'A', sender: 'a', text: 'a', timestamp: 'T', is_agent: false }],
186
+ has_more: false,
187
+ }),
188
+ })
189
+ const providerB = fakeProvider({
190
+ getRoomHistory: async () => ({
191
+ messages: [{ id: 'B', sender: 'b', text: 'b', timestamp: 'T', is_agent: false }],
192
+ has_more: false,
193
+ }),
194
+ })
195
+ const registry = new SpawnRegistry()
196
+ const spawnA = registry.register({
197
+ agentName: 'a',
198
+ threadRef: { channelId: 'a', threadId: 'a' },
199
+ provider: providerA,
200
+ })
201
+ const spawnB = registry.register({
202
+ agentName: 'b',
203
+ threadRef: { channelId: 'b', threadId: 'b' },
204
+ provider: providerB,
205
+ })
206
+ const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
207
+ const server = await startDaemonSocketServer({ sockPath, registry })
208
+ cleanup.push(() => server.close())
209
+
210
+ for (let i = 0; i < 20; i++) {
211
+ const spawnId = i % 2 === 0 ? spawnA : spawnB
212
+ const expected = i % 2 === 0 ? 'A' : 'B'
213
+ const res = (await callDaemon(sockPath, {
214
+ spawnId,
215
+ method: 'getRoomHistory',
216
+ params: {},
217
+ })) as { messages: Array<{ id: string }> }
218
+ expect(res.messages[0].id).toBe(expected)
219
+ }
220
+ })
221
+ })
@@ -0,0 +1,148 @@
1
+ import { createServer, createConnection, type Server, type Socket } from 'node:net'
2
+ import { unlink } from 'node:fs/promises'
3
+ import type { SpawnRegistry } from './spawn-registry.js'
4
+
5
+ interface DaemonRequest {
6
+ spawnId: string
7
+ method:
8
+ | 'getRoomHistory'
9
+ | 'getRecentThreads'
10
+ | 'getThreadHistory'
11
+ | 'getChannelMembers'
12
+ | 'getChannelInfo'
13
+ params: Record<string, unknown>
14
+ }
15
+
16
+ interface DaemonResponse {
17
+ ok: true
18
+ result: unknown
19
+ }
20
+
21
+ interface DaemonError {
22
+ ok: false
23
+ error: string
24
+ }
25
+
26
+ export interface DaemonSocketHandle {
27
+ close(): Promise<void>
28
+ }
29
+
30
+ export async function startDaemonSocketServer(opts: {
31
+ sockPath: string
32
+ registry: SpawnRegistry
33
+ }): Promise<DaemonSocketHandle> {
34
+ await unlink(opts.sockPath).catch(() => {})
35
+ const server: Server = createServer((socket: Socket) => {
36
+ let buf = ''
37
+ socket.setEncoding('utf8')
38
+ socket.on('data', async (chunk) => {
39
+ buf += chunk
40
+ let idx: number
41
+ while ((idx = buf.indexOf('\n')) >= 0) {
42
+ const line = buf.slice(0, idx)
43
+ buf = buf.slice(idx + 1)
44
+ if (!line) continue
45
+ await handleLine(line, socket, opts.registry)
46
+ }
47
+ })
48
+ socket.on('error', () => {})
49
+ })
50
+ await new Promise<void>((resolve, reject) => {
51
+ server.once('error', reject)
52
+ server.listen(opts.sockPath, () => {
53
+ server.removeListener('error', reject)
54
+ resolve()
55
+ })
56
+ })
57
+ return {
58
+ close: () =>
59
+ new Promise<void>((resolve) => {
60
+ server.close(() => resolve())
61
+ }),
62
+ }
63
+ }
64
+
65
+ async function handleLine(line: string, socket: Socket, registry: SpawnRegistry) {
66
+ let req: DaemonRequest
67
+ try {
68
+ req = JSON.parse(line) as DaemonRequest
69
+ } catch {
70
+ socket.write(JSON.stringify({ ok: false, error: 'invalid json' } satisfies DaemonError) + '\n')
71
+ return
72
+ }
73
+ const binding = registry.get(req.spawnId)
74
+ if (!binding) {
75
+ process.stderr.write(
76
+ `[context-mcp] daemon: unknown spawn-id ${req.spawnId} for method=${req.method}\n`,
77
+ )
78
+ socket.write(
79
+ JSON.stringify({
80
+ ok: false,
81
+ error: `unknown spawn-id: ${req.spawnId}`,
82
+ } satisfies DaemonError) + '\n',
83
+ )
84
+ return
85
+ }
86
+ process.stderr.write(
87
+ `[context-mcp] daemon: ${req.method} spawn=${req.spawnId.slice(0, 8)} agent=${binding.agentName}\n`,
88
+ )
89
+ try {
90
+ let result: unknown
91
+ const channelId = binding.threadRef.channelId
92
+ if (req.method === 'getRoomHistory') {
93
+ result = await binding.provider.getRoomHistory(channelId, req.params)
94
+ } else if (req.method === 'getRecentThreads') {
95
+ result = await binding.provider.getRecentThreads(channelId, req.params)
96
+ } else if (req.method === 'getThreadHistory') {
97
+ const threadId = String(req.params.threadId ?? '')
98
+ result = await binding.provider.getThreadHistory(channelId, threadId, req.params)
99
+ } else if (req.method === 'getChannelMembers') {
100
+ result = await binding.provider.getChannelMembers(channelId)
101
+ } else if (req.method === 'getChannelInfo') {
102
+ result = await binding.provider.getChannelInfo(channelId)
103
+ } else {
104
+ socket.write(
105
+ JSON.stringify({
106
+ ok: false,
107
+ error: `unknown method: ${(req as { method: string }).method}`,
108
+ } satisfies DaemonError) + '\n',
109
+ )
110
+ return
111
+ }
112
+ socket.write(JSON.stringify({ ok: true, result } satisfies DaemonResponse) + '\n')
113
+ } catch (err) {
114
+ socket.write(
115
+ JSON.stringify({
116
+ ok: false,
117
+ error: String(err instanceof Error ? err.message : err),
118
+ } satisfies DaemonError) + '\n',
119
+ )
120
+ }
121
+ }
122
+
123
+ export async function callDaemon(sockPath: string, req: DaemonRequest): Promise<unknown> {
124
+ return new Promise((resolve, reject) => {
125
+ const socket = createConnection(sockPath)
126
+ let buf = ''
127
+ socket.setEncoding('utf8')
128
+ socket.on('error', reject)
129
+ socket.on('data', (chunk) => {
130
+ buf += chunk
131
+ const idx = buf.indexOf('\n')
132
+ if (idx < 0) return
133
+ const line = buf.slice(0, idx)
134
+ let parsed: DaemonResponse | DaemonError
135
+ try {
136
+ parsed = JSON.parse(line) as DaemonResponse | DaemonError
137
+ } catch (e) {
138
+ socket.end()
139
+ reject(e instanceof Error ? e : new Error(String(e)))
140
+ return
141
+ }
142
+ socket.end()
143
+ if (parsed.ok) resolve(parsed.result)
144
+ else reject(new Error(parsed.error))
145
+ })
146
+ socket.write(JSON.stringify(req) + '\n')
147
+ })
148
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { existsSync } from 'node:fs'
3
+ import { buildContextServerSpec } from './factory.js'
4
+
5
+ describe('buildContextServerSpec', () => {
6
+ it('produces the ACP mcpServers entry the daemon passes to session/new', () => {
7
+ const spec = buildContextServerSpec({
8
+ spawnId: '11111111-1111-4111-8111-111111111111',
9
+ sockPath: '/run/zooid/abc.sock',
10
+ binPath: '/usr/local/lib/zooid/zooid-context-mcp.js',
11
+ })
12
+ expect(spec.name).toBe('zooid-context')
13
+ expect(spec.command).toBe(process.execPath)
14
+ expect(spec.args[0]).toBe('/usr/local/lib/zooid/zooid-context-mcp.js')
15
+ expect(spec.args).toContain('--spawn-id')
16
+ expect(spec.args).toContain('11111111-1111-4111-8111-111111111111')
17
+ expect(spec.env).toEqual([{ name: 'ZOOID_DAEMON_SOCK', value: '/run/zooid/abc.sock' }])
18
+ })
19
+
20
+ it('resolves the default bin via Node module resolution to a real path on disk', () => {
21
+ // Pinned regression: tsup bundles this file into the CLI chunk, so the
22
+ // earlier `import.meta.url`-based default broke at runtime (it resolved
23
+ // to `<cli-dist>/bin.js`, not this package's dist). `createRequire` must
24
+ // walk node_modules and return an actual file path.
25
+ const spec = buildContextServerSpec({
26
+ spawnId: 'sid',
27
+ sockPath: '/tmp/x.sock',
28
+ })
29
+ expect(spec.args[0]).toMatch(/context-mcp[/\\]dist[/\\]bin\.js$/)
30
+ expect(existsSync(spec.args[0])).toBe(true)
31
+ })
32
+ })
package/src/factory.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { createRequire } from 'node:module'
2
+ import type { ZooidContextServerSpec } from './types.js'
3
+
4
+ /**
5
+ * Resolve `dist/bin.js` via Node's runtime module resolver rather than
6
+ * `import.meta.url`. tsup bundles this file into the CLI's own chunk, so
7
+ * `import.meta.url` at runtime points to the CLI bundle — not this
8
+ * package's dist. `createRequire` walks the runtime `node_modules` tree
9
+ * instead and finds the package wherever it actually lives.
10
+ */
11
+ function resolveDefaultBin(): string {
12
+ const req = createRequire(import.meta.url)
13
+ // Resolve via the dedicated `./bin` export → `./dist/bin.js`. Avoids the
14
+ // exports-restriction surprise of importing `./package.json`.
15
+ return req.resolve('@zooid/context-mcp/bin')
16
+ }
17
+
18
+ let cachedDefaultBin: string | null = null
19
+ function getDefaultBin(): string {
20
+ if (!cachedDefaultBin) cachedDefaultBin = resolveDefaultBin()
21
+ return cachedDefaultBin
22
+ }
23
+
24
+ export function buildContextServerSpec(opts: {
25
+ spawnId: string
26
+ sockPath: string
27
+ binPath?: string
28
+ }): ZooidContextServerSpec {
29
+ return {
30
+ name: 'zooid-context',
31
+ command: process.execPath,
32
+ args: [opts.binPath ?? getDefaultBin(), '--spawn-id', opts.spawnId],
33
+ env: [{ name: 'ZOOID_DAEMON_SOCK', value: opts.sockPath }],
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { SpawnRegistry } from './spawn-registry.js'
2
+ export { startDaemonSocketServer, callDaemon } from './daemon-socket.js'
3
+ export type { DaemonSocketHandle } from './daemon-socket.js'
4
+ export { buildContextMcpServer } from './mcp-server.js'
5
+ export { buildContextServerSpec } from './factory.js'
6
+ export type { SpawnBinding, ZooidContextServerSpec } from './types.js'
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { dirname, join } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import { randomUUID } from 'node:crypto'
6
+ import { existsSync } from 'node:fs'
7
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
8
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
9
+ import { SpawnRegistry } from './spawn-registry.js'
10
+ import { startDaemonSocketServer } from './daemon-socket.js'
11
+ import type { TransportContextProvider } from '@zooid/core'
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url))
14
+ const BIN = join(__dirname, '..', 'dist', 'bin.js')
15
+
16
+ const cleanup: Array<() => Promise<void>> = []
17
+ afterEach(async () => {
18
+ for (const fn of cleanup) await fn()
19
+ cleanup.length = 0
20
+ })
21
+
22
+ function fakeProvider(over: Partial<TransportContextProvider> = {}): TransportContextProvider {
23
+ return {
24
+ getRoomHistory: async () => ({ messages: [], has_more: false }),
25
+ getRecentThreads: async () => ({ threads: [], has_more: false }),
26
+ getThreadHistory: async () => ({ messages: [], has_more: false }),
27
+ getChannelMembers: async () => [],
28
+ getChannelInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
29
+ ...over,
30
+ }
31
+ }
32
+
33
+ describe.skipIf(!existsSync(BIN))('zooid-context MCP server (out-of-process)', () => {
34
+ it('end-to-end tools/call → daemon socket → provider → tool result', async () => {
35
+ const provider = fakeProvider({
36
+ getRoomHistory: async () => ({
37
+ messages: [
38
+ { id: 'e1', sender: 'alice', text: 'hi', timestamp: 'T', is_agent: false },
39
+ ],
40
+ has_more: false,
41
+ }),
42
+ })
43
+ const registry = new SpawnRegistry()
44
+ const spawnId = registry.register({
45
+ agentName: 'architect',
46
+ threadRef: { channelId: '!room:hs', threadId: '!room:hs' },
47
+ provider,
48
+ })
49
+ const sockPath = join(tmpdir(), `zooid-it-${randomUUID()}.sock`)
50
+ const server = await startDaemonSocketServer({ sockPath, registry })
51
+ cleanup.push(() => server.close())
52
+
53
+ const transport = new StdioClientTransport({
54
+ command: process.execPath,
55
+ args: [BIN, '--spawn-id', spawnId],
56
+ env: { ...process.env, ZOOID_DAEMON_SOCK: sockPath } as Record<string, string>,
57
+ })
58
+ const client = new Client({ name: 'it', version: '0.0.1' }, { capabilities: {} })
59
+ await client.connect(transport)
60
+ cleanup.push(async () => {
61
+ await client.close()
62
+ })
63
+
64
+ const list = await client.listTools()
65
+ expect(list.tools.map((t) => t.name).sort()).toEqual([
66
+ 'zooid_get_channel_info',
67
+ 'zooid_get_history',
68
+ 'zooid_get_members',
69
+ 'zooid_get_recent_threads',
70
+ 'zooid_get_thread_history',
71
+ ])
72
+
73
+ const result = await client.callTool({ name: 'zooid_get_history', arguments: {} })
74
+ const payload = JSON.parse((result.content as Array<{ text: string }>)[0].text)
75
+ expect(payload.messages[0].id).toBe('e1')
76
+ })
77
+
78
+ it('two MCP server subprocesses sharing one socket route to their own bindings', async () => {
79
+ const providerA = fakeProvider({
80
+ getRoomHistory: async () => ({
81
+ messages: [{ id: 'A1', sender: 'alice', text: 'from A', timestamp: 'T', is_agent: false }],
82
+ has_more: false,
83
+ }),
84
+ getChannelInfo: async () => ({ id: '!a:hs', name: 'room-A', transport: 'matrix' }),
85
+ })
86
+ const providerB = fakeProvider({
87
+ getRoomHistory: async () => ({
88
+ messages: [{ id: 'B1', sender: 'bob', text: 'from B', timestamp: 'T', is_agent: false }],
89
+ has_more: false,
90
+ }),
91
+ getChannelInfo: async () => ({ id: '!b:hs', name: 'room-B', transport: 'matrix' }),
92
+ })
93
+ const registry = new SpawnRegistry()
94
+ const spawnA = registry.register({
95
+ agentName: 'architect',
96
+ threadRef: { channelId: '!a:hs', threadId: '!a:hs' },
97
+ provider: providerA,
98
+ })
99
+ const spawnB = registry.register({
100
+ agentName: 'product-owner',
101
+ threadRef: { channelId: '!b:hs', threadId: '!b:hs' },
102
+ provider: providerB,
103
+ })
104
+ const sockPath = join(tmpdir(), `zooid-it-${randomUUID()}.sock`)
105
+ const server = await startDaemonSocketServer({ sockPath, registry })
106
+ cleanup.push(() => server.close())
107
+
108
+ async function startClient(spawnId: string) {
109
+ const transport = new StdioClientTransport({
110
+ command: process.execPath,
111
+ args: [BIN, '--spawn-id', spawnId],
112
+ env: { ...process.env, ZOOID_DAEMON_SOCK: sockPath } as Record<string, string>,
113
+ })
114
+ const client = new Client({ name: 'it', version: '0.0.1' }, { capabilities: {} })
115
+ await client.connect(transport)
116
+ cleanup.push(async () => {
117
+ await client.close()
118
+ })
119
+ return client
120
+ }
121
+
122
+ const [clientA, clientB] = await Promise.all([startClient(spawnA), startClient(spawnB)])
123
+
124
+ const [resA, resB] = await Promise.all([
125
+ clientA.callTool({ name: 'zooid_get_history', arguments: {} }),
126
+ clientB.callTool({ name: 'zooid_get_history', arguments: {} }),
127
+ ])
128
+ const payloadA = JSON.parse((resA.content as Array<{ text: string }>)[0].text)
129
+ const payloadB = JSON.parse((resB.content as Array<{ text: string }>)[0].text)
130
+ expect(payloadA.messages[0].id).toBe('A1')
131
+ expect(payloadB.messages[0].id).toBe('B1')
132
+
133
+ const infoA = await clientA.callTool({ name: 'zooid_get_channel_info', arguments: {} })
134
+ const infoB = await clientB.callTool({ name: 'zooid_get_channel_info', arguments: {} })
135
+ expect(JSON.parse((infoA.content as Array<{ text: string }>)[0].text).id).toBe('!a:hs')
136
+ expect(JSON.parse((infoB.content as Array<{ text: string }>)[0].text).id).toBe('!b:hs')
137
+ })
138
+ })