@zooid/context-mcp 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/bin.js +44 -4
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-ZY2GPHFX.js → chunk-IZ2JCKE4.js} +173 -14
- package/dist/chunk-IZ2JCKE4.js.map +1 -0
- package/dist/index.js +34 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/bin.ts +63 -9
- package/src/daemon-socket.test.ts +178 -18
- package/src/daemon-socket.ts +139 -12
- package/src/factory.test.ts +10 -0
- package/src/index.ts +3 -2
- package/src/integration.test.ts +99 -15
- package/src/mcp-server.test.ts +225 -21
- package/src/mcp-server.ts +87 -7
- package/src/socket-paths.test.ts +25 -0
- package/src/socket-paths.ts +29 -0
- package/src/spawn-registry.test.ts +14 -1
- package/src/spawn-registry.ts +29 -1
- package/src/types.ts +2 -0
- package/dist/chunk-ZY2GPHFX.js.map +0 -1
package/src/daemon-socket.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import { createServer, createConnection, type Server, type Socket } from 'node:net'
|
|
2
2
|
import { unlink } from 'node:fs/promises'
|
|
3
3
|
import type { SpawnRegistry } from './spawn-registry.js'
|
|
4
|
+
import type { CompleteTaskInput, StartTasksInput } from '@zooid/core'
|
|
5
|
+
import { agentSocketPath } from './socket-paths.js'
|
|
4
6
|
|
|
5
|
-
interface DaemonRequest {
|
|
6
|
-
spawnId
|
|
7
|
+
export interface DaemonRequest {
|
|
8
|
+
spawnId?: string
|
|
9
|
+
acpSessionId?: string
|
|
7
10
|
method:
|
|
8
11
|
| 'getRoomHistory'
|
|
9
12
|
| 'getRecentThreads'
|
|
10
13
|
| 'getThreadHistory'
|
|
11
14
|
| 'getChannelMembers'
|
|
12
|
-
| '
|
|
15
|
+
| 'getRoomInfo'
|
|
16
|
+
| 'getRooms'
|
|
17
|
+
| 'sendMessage'
|
|
18
|
+
| 'startTasks'
|
|
19
|
+
| 'completeTask'
|
|
20
|
+
| 'describeRole'
|
|
13
21
|
params: Record<string, unknown>
|
|
14
22
|
}
|
|
15
23
|
|
|
@@ -27,12 +35,33 @@ export interface DaemonSocketHandle {
|
|
|
27
35
|
close(): Promise<void>
|
|
28
36
|
}
|
|
29
37
|
|
|
38
|
+
export interface AgentSocketsHandle {
|
|
39
|
+
/** Agent → host socket path. A missing path means its listener failed. */
|
|
40
|
+
paths: Record<string, string | undefined>
|
|
41
|
+
close(): Promise<void>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Same wire error for absent and foreign bindings, so sockets are not an ID oracle. */
|
|
45
|
+
const NOT_OWNED = 'binding not owned by caller'
|
|
46
|
+
|
|
30
47
|
export async function startDaemonSocketServer(opts: {
|
|
31
48
|
sockPath: string
|
|
32
49
|
registry: SpawnRegistry
|
|
50
|
+
/** The only agent whose bindings this listener may serve. */
|
|
51
|
+
agentName: string
|
|
33
52
|
}): Promise<DaemonSocketHandle> {
|
|
34
53
|
await unlink(opts.sockPath).catch(() => {})
|
|
54
|
+
// net.Server.close() waits for every open connection and — unlike
|
|
55
|
+
// http.Server since Node 19 — never destroys idle ones, and there is no
|
|
56
|
+
// closeAllConnections() for it. The clients here are context-mcp servers
|
|
57
|
+
// spawned beside each agent, which outlive AcpClient.stop() (it SIGTERMs
|
|
58
|
+
// the agent without waiting, and these are its grandchildren). So without
|
|
59
|
+
// tracking them, close() hangs until they happen to die — which is what
|
|
60
|
+
// made `zooid dev` take ages to stop.
|
|
61
|
+
const open = new Set<Socket>()
|
|
35
62
|
const server: Server = createServer((socket: Socket) => {
|
|
63
|
+
open.add(socket)
|
|
64
|
+
socket.on('close', () => open.delete(socket))
|
|
36
65
|
let buf = ''
|
|
37
66
|
socket.setEncoding('utf8')
|
|
38
67
|
socket.on('data', async (chunk) => {
|
|
@@ -42,7 +71,7 @@ export async function startDaemonSocketServer(opts: {
|
|
|
42
71
|
const line = buf.slice(0, idx)
|
|
43
72
|
buf = buf.slice(idx + 1)
|
|
44
73
|
if (!line) continue
|
|
45
|
-
await handleLine(line, socket, opts.registry)
|
|
74
|
+
await handleLine(line, socket, opts.registry, opts.agentName)
|
|
46
75
|
}
|
|
47
76
|
})
|
|
48
77
|
socket.on('error', () => {})
|
|
@@ -58,33 +87,61 @@ export async function startDaemonSocketServer(opts: {
|
|
|
58
87
|
close: () =>
|
|
59
88
|
new Promise<void>((resolve) => {
|
|
60
89
|
server.close(() => resolve())
|
|
90
|
+
// Shutdown is already tearing the agents down, so there is no
|
|
91
|
+
// in-flight request worth waiting for. Drop the connections that
|
|
92
|
+
// would otherwise keep close() pending forever.
|
|
93
|
+
for (const socket of open) socket.destroy()
|
|
94
|
+
open.clear()
|
|
61
95
|
}),
|
|
62
96
|
}
|
|
63
97
|
}
|
|
64
98
|
|
|
65
|
-
async function handleLine(
|
|
99
|
+
async function handleLine(
|
|
100
|
+
line: string,
|
|
101
|
+
socket: Socket,
|
|
102
|
+
registry: SpawnRegistry,
|
|
103
|
+
callerAgent: string,
|
|
104
|
+
) {
|
|
66
105
|
let req: DaemonRequest
|
|
67
106
|
try {
|
|
68
107
|
req = JSON.parse(line) as DaemonRequest
|
|
69
108
|
} catch {
|
|
70
|
-
socket.write(
|
|
109
|
+
socket.write(
|
|
110
|
+
JSON.stringify({
|
|
111
|
+
ok: false,
|
|
112
|
+
error: 'invalid json',
|
|
113
|
+
} satisfies DaemonError) + '\n',
|
|
114
|
+
)
|
|
71
115
|
return
|
|
72
116
|
}
|
|
73
|
-
const binding =
|
|
117
|
+
const binding = req.spawnId
|
|
118
|
+
? registry.get(req.spawnId)
|
|
119
|
+
: req.acpSessionId
|
|
120
|
+
? registry.getByAcpSession(req.acpSessionId)
|
|
121
|
+
: undefined
|
|
74
122
|
if (!binding) {
|
|
123
|
+
const address = req.acpSessionId ? `session: ${req.acpSessionId}` : `spawn-id: ${req.spawnId ?? ''}`
|
|
75
124
|
process.stderr.write(
|
|
76
|
-
`[context-mcp] daemon: unknown
|
|
125
|
+
`[context-mcp] daemon: unknown ${address} for method=${req.method}\n`,
|
|
77
126
|
)
|
|
78
127
|
socket.write(
|
|
79
128
|
JSON.stringify({
|
|
80
129
|
ok: false,
|
|
81
|
-
error:
|
|
130
|
+
error: NOT_OWNED,
|
|
82
131
|
} satisfies DaemonError) + '\n',
|
|
83
132
|
)
|
|
84
133
|
return
|
|
85
134
|
}
|
|
135
|
+
if (binding.agentName !== callerAgent) {
|
|
136
|
+
process.stderr.write(
|
|
137
|
+
`[context-mcp] daemon: refused ${req.method} — binding owned by ${binding.agentName}, ` +
|
|
138
|
+
`caller is ${callerAgent}\n`,
|
|
139
|
+
)
|
|
140
|
+
socket.write(JSON.stringify({ ok: false, error: NOT_OWNED } satisfies DaemonError) + '\n')
|
|
141
|
+
return
|
|
142
|
+
}
|
|
86
143
|
process.stderr.write(
|
|
87
|
-
`[context-mcp] daemon: ${req.method} spawn=${
|
|
144
|
+
`[context-mcp] daemon: ${req.method} spawn=${binding.spawnId.slice(0, 8)} agent=${binding.agentName}\n`,
|
|
88
145
|
)
|
|
89
146
|
try {
|
|
90
147
|
let result: unknown
|
|
@@ -98,8 +155,41 @@ async function handleLine(line: string, socket: Socket, registry: SpawnRegistry)
|
|
|
98
155
|
result = await binding.provider.getThreadHistory(channelId, threadId, req.params)
|
|
99
156
|
} else if (req.method === 'getChannelMembers') {
|
|
100
157
|
result = await binding.provider.getChannelMembers(channelId)
|
|
101
|
-
} else if (req.method === '
|
|
102
|
-
result = await binding.provider.
|
|
158
|
+
} else if (req.method === 'getRoomInfo') {
|
|
159
|
+
result = await binding.provider.getRoomInfo(channelId)
|
|
160
|
+
} else if (req.method === 'getRooms') {
|
|
161
|
+
result = await binding.provider.getRooms()
|
|
162
|
+
} else if (req.method === 'sendMessage') {
|
|
163
|
+
result = await binding.provider.sendMessage(
|
|
164
|
+
req.params as unknown as Parameters<typeof binding.provider.sendMessage>[0],
|
|
165
|
+
)
|
|
166
|
+
} else if (
|
|
167
|
+
req.method === 'startTasks' ||
|
|
168
|
+
req.method === 'completeTask' ||
|
|
169
|
+
req.method === 'describeRole'
|
|
170
|
+
) {
|
|
171
|
+
const actions = registry.taskActions
|
|
172
|
+
if (!actions) {
|
|
173
|
+
socket.write(
|
|
174
|
+
JSON.stringify({
|
|
175
|
+
ok: false,
|
|
176
|
+
error: 'task actions unavailable',
|
|
177
|
+
} satisfies DaemonError) + '\n',
|
|
178
|
+
)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
const caller = {
|
|
182
|
+
agentName: binding.agentName,
|
|
183
|
+
channelId,
|
|
184
|
+
threadRoot: binding.threadRef.threadId,
|
|
185
|
+
sessionKey: binding.sessionKey ?? binding.threadRef.threadId,
|
|
186
|
+
}
|
|
187
|
+
result =
|
|
188
|
+
req.method === 'startTasks'
|
|
189
|
+
? await actions.startTasks(caller, req.params as unknown as StartTasksInput)
|
|
190
|
+
: req.method === 'completeTask'
|
|
191
|
+
? await actions.completeTask(caller, req.params as unknown as CompleteTaskInput)
|
|
192
|
+
: await actions.describeRole(caller)
|
|
103
193
|
} else {
|
|
104
194
|
socket.write(
|
|
105
195
|
JSON.stringify({
|
|
@@ -120,6 +210,43 @@ async function handleLine(line: string, socket: Socket, registry: SpawnRegistry)
|
|
|
120
210
|
}
|
|
121
211
|
}
|
|
122
212
|
|
|
213
|
+
/** Start independent context listeners; a bind failure disables only that agent. */
|
|
214
|
+
export async function startAgentSocketServers(opts: {
|
|
215
|
+
runDir: string
|
|
216
|
+
registry: SpawnRegistry
|
|
217
|
+
agentNames: string[]
|
|
218
|
+
listen?: (sockPath: string, agentName: string) => Promise<DaemonSocketHandle>
|
|
219
|
+
}): Promise<AgentSocketsHandle> {
|
|
220
|
+
const listen =
|
|
221
|
+
opts.listen ??
|
|
222
|
+
((sockPath: string, agentName: string) =>
|
|
223
|
+
startDaemonSocketServer({ sockPath, registry: opts.registry, agentName }))
|
|
224
|
+
const derived = opts.agentNames.map((agentName) => ({
|
|
225
|
+
agentName,
|
|
226
|
+
sockPath: agentSocketPath({ runDir: opts.runDir, agentName }),
|
|
227
|
+
}))
|
|
228
|
+
const paths: Record<string, string | undefined> = {}
|
|
229
|
+
const handles: Array<{ handle: DaemonSocketHandle; path: string }> = []
|
|
230
|
+
for (const { agentName, sockPath } of derived) {
|
|
231
|
+
try {
|
|
232
|
+
const handle = await listen(sockPath, agentName)
|
|
233
|
+
paths[agentName] = sockPath
|
|
234
|
+
handles.push({ handle, path: sockPath })
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.warn(`[context] socket bind failed for agent=${agentName}; context disabled for it:`, err)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
paths,
|
|
241
|
+
close: async () => {
|
|
242
|
+
for (const { handle, path } of handles) {
|
|
243
|
+
await handle.close()
|
|
244
|
+
await unlink(path).catch(() => {})
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
123
250
|
export async function callDaemon(sockPath: string, req: DaemonRequest): Promise<unknown> {
|
|
124
251
|
return new Promise((resolve, reject) => {
|
|
125
252
|
const socket = createConnection(sockPath)
|
package/src/factory.test.ts
CHANGED
|
@@ -82,6 +82,16 @@ describe('contextContainerMounts', () => {
|
|
|
82
82
|
])
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
it('uses each agent host socket at the same container target', () => {
|
|
86
|
+
const alice = contextContainerMounts({ sockPath: '/data/run/context-alice.sock' })
|
|
87
|
+
const bob = contextContainerMounts({ sockPath: '/data/run/context-bob.sock' })
|
|
88
|
+
const aliceSocket = alice.find((mount) => mount.target === CONTEXT_CONTAINER_SOCK)!
|
|
89
|
+
const bobSocket = bob.find((mount) => mount.target === CONTEXT_CONTAINER_SOCK)!
|
|
90
|
+
expect(aliceSocket.path).not.toBe(bobSocket.path)
|
|
91
|
+
expect(aliceSocket).toMatchObject({ target: CONTEXT_CONTAINER_SOCK, mode: 'rw' })
|
|
92
|
+
expect(bobSocket.target).toBe(CONTEXT_CONTAINER_SOCK)
|
|
93
|
+
})
|
|
94
|
+
|
|
85
95
|
it('defaults the bin dir to the resolved package dist on disk', () => {
|
|
86
96
|
const mounts = contextContainerMounts({ sockPath: '/tmp/x.sock' })
|
|
87
97
|
const binMount = mounts.find((m) => m.target === CONTEXT_CONTAINER_BIN_DIR)!
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { SpawnRegistry } from './spawn-registry.js'
|
|
2
|
-
export { startDaemonSocketServer, callDaemon } from './daemon-socket.js'
|
|
3
|
-
export type { DaemonSocketHandle } from './daemon-socket.js'
|
|
2
|
+
export { startDaemonSocketServer, startAgentSocketServers, callDaemon } from './daemon-socket.js'
|
|
3
|
+
export type { DaemonRequest, DaemonSocketHandle, AgentSocketsHandle } from './daemon-socket.js'
|
|
4
|
+
export { agentSocketPath, SUN_PATH_MAX } from './socket-paths.js'
|
|
4
5
|
export { buildContextMcpServer } from './mcp-server.js'
|
|
5
6
|
export {
|
|
6
7
|
buildContextServerSpec,
|
package/src/integration.test.ts
CHANGED
|
@@ -3,11 +3,12 @@ import { fileURLToPath } from 'node:url'
|
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { randomUUID } from 'node:crypto'
|
|
6
|
-
import { existsSync } from 'node:fs'
|
|
6
|
+
import { existsSync, mkdtempSync } from 'node:fs'
|
|
7
7
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
8
8
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
9
9
|
import { SpawnRegistry } from './spawn-registry.js'
|
|
10
|
-
import { startDaemonSocketServer } from './daemon-socket.js'
|
|
10
|
+
import { callDaemon, startAgentSocketServers, startDaemonSocketServer } from './daemon-socket.js'
|
|
11
|
+
import { agentSocketPath } from './socket-paths.js'
|
|
11
12
|
import type { TransportContextProvider } from '@zooid/core'
|
|
12
13
|
|
|
13
14
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
@@ -25,7 +26,7 @@ function fakeProvider(over: Partial<TransportContextProvider> = {}): TransportCo
|
|
|
25
26
|
getRecentThreads: async () => ({ threads: [], has_more: false }),
|
|
26
27
|
getThreadHistory: async () => ({ messages: [], has_more: false }),
|
|
27
28
|
getChannelMembers: async () => [],
|
|
28
|
-
|
|
29
|
+
getRoomInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
|
|
29
30
|
...over,
|
|
30
31
|
}
|
|
31
32
|
}
|
|
@@ -35,7 +36,13 @@ describe.skipIf(!existsSync(BIN))('zooid-context MCP server (out-of-process)', (
|
|
|
35
36
|
const provider = fakeProvider({
|
|
36
37
|
getRoomHistory: async () => ({
|
|
37
38
|
messages: [
|
|
38
|
-
{
|
|
39
|
+
{
|
|
40
|
+
id: 'e1',
|
|
41
|
+
sender: 'alice',
|
|
42
|
+
text: 'hi',
|
|
43
|
+
timestamp: 'T',
|
|
44
|
+
is_agent: false,
|
|
45
|
+
},
|
|
39
46
|
],
|
|
40
47
|
has_more: false,
|
|
41
48
|
}),
|
|
@@ -47,7 +54,7 @@ describe.skipIf(!existsSync(BIN))('zooid-context MCP server (out-of-process)', (
|
|
|
47
54
|
provider,
|
|
48
55
|
})
|
|
49
56
|
const sockPath = join(tmpdir(), `zooid-it-${randomUUID()}.sock`)
|
|
50
|
-
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
57
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'architect' })
|
|
51
58
|
cleanup.push(() => server.close())
|
|
52
59
|
|
|
53
60
|
const transport = new StdioClientTransport({
|
|
@@ -61,16 +68,24 @@ describe.skipIf(!existsSync(BIN))('zooid-context MCP server (out-of-process)', (
|
|
|
61
68
|
await client.close()
|
|
62
69
|
})
|
|
63
70
|
|
|
71
|
+
// No taskActions registered on this registry, so the daemon-side
|
|
72
|
+
// describeRole query fails, bin.ts's role stays undefined, and neither
|
|
73
|
+
// task tool registers ([[ZOD084]] role-conditional registration).
|
|
64
74
|
const list = await client.listTools()
|
|
65
75
|
expect(list.tools.map((t) => t.name).sort()).toEqual([
|
|
66
|
-
'zooid_get_channel_info',
|
|
67
76
|
'zooid_get_history',
|
|
68
77
|
'zooid_get_members',
|
|
69
78
|
'zooid_get_recent_threads',
|
|
79
|
+
'zooid_get_room_info',
|
|
80
|
+
'zooid_get_rooms',
|
|
70
81
|
'zooid_get_thread_history',
|
|
82
|
+
'zooid_send_message',
|
|
71
83
|
])
|
|
72
84
|
|
|
73
|
-
const result = await client.callTool({
|
|
85
|
+
const result = await client.callTool({
|
|
86
|
+
name: 'zooid_get_history',
|
|
87
|
+
arguments: {},
|
|
88
|
+
})
|
|
74
89
|
const payload = JSON.parse((result.content as Array<{ text: string }>)[0].text)
|
|
75
90
|
expect(payload.messages[0].id).toBe('e1')
|
|
76
91
|
})
|
|
@@ -78,17 +93,41 @@ describe.skipIf(!existsSync(BIN))('zooid-context MCP server (out-of-process)', (
|
|
|
78
93
|
it('two MCP server subprocesses sharing one socket route to their own bindings', async () => {
|
|
79
94
|
const providerA = fakeProvider({
|
|
80
95
|
getRoomHistory: async () => ({
|
|
81
|
-
messages: [
|
|
96
|
+
messages: [
|
|
97
|
+
{
|
|
98
|
+
id: 'A1',
|
|
99
|
+
sender: 'alice',
|
|
100
|
+
text: 'from A',
|
|
101
|
+
timestamp: 'T',
|
|
102
|
+
is_agent: false,
|
|
103
|
+
},
|
|
104
|
+
],
|
|
82
105
|
has_more: false,
|
|
83
106
|
}),
|
|
84
|
-
|
|
107
|
+
getRoomInfo: async () => ({
|
|
108
|
+
id: '!a:hs',
|
|
109
|
+
name: 'room-A',
|
|
110
|
+
transport: 'matrix',
|
|
111
|
+
}),
|
|
85
112
|
})
|
|
86
113
|
const providerB = fakeProvider({
|
|
87
114
|
getRoomHistory: async () => ({
|
|
88
|
-
messages: [
|
|
115
|
+
messages: [
|
|
116
|
+
{
|
|
117
|
+
id: 'B1',
|
|
118
|
+
sender: 'bob',
|
|
119
|
+
text: 'from B',
|
|
120
|
+
timestamp: 'T',
|
|
121
|
+
is_agent: false,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
89
124
|
has_more: false,
|
|
90
125
|
}),
|
|
91
|
-
|
|
126
|
+
getRoomInfo: async () => ({
|
|
127
|
+
id: '!b:hs',
|
|
128
|
+
name: 'room-B',
|
|
129
|
+
transport: 'matrix',
|
|
130
|
+
}),
|
|
92
131
|
})
|
|
93
132
|
const registry = new SpawnRegistry()
|
|
94
133
|
const spawnA = registry.register({
|
|
@@ -97,12 +136,12 @@ describe.skipIf(!existsSync(BIN))('zooid-context MCP server (out-of-process)', (
|
|
|
97
136
|
provider: providerA,
|
|
98
137
|
})
|
|
99
138
|
const spawnB = registry.register({
|
|
100
|
-
agentName: '
|
|
139
|
+
agentName: 'architect',
|
|
101
140
|
threadRef: { channelId: '!b:hs', threadId: '!b:hs' },
|
|
102
141
|
provider: providerB,
|
|
103
142
|
})
|
|
104
143
|
const sockPath = join(tmpdir(), `zooid-it-${randomUUID()}.sock`)
|
|
105
|
-
const server = await startDaemonSocketServer({ sockPath, registry })
|
|
144
|
+
const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'architect' })
|
|
106
145
|
cleanup.push(() => server.close())
|
|
107
146
|
|
|
108
147
|
async function startClient(spawnId: string) {
|
|
@@ -130,9 +169,54 @@ describe.skipIf(!existsSync(BIN))('zooid-context MCP server (out-of-process)', (
|
|
|
130
169
|
expect(payloadA.messages[0].id).toBe('A1')
|
|
131
170
|
expect(payloadB.messages[0].id).toBe('B1')
|
|
132
171
|
|
|
133
|
-
const infoA = await clientA.callTool({
|
|
134
|
-
|
|
172
|
+
const infoA = await clientA.callTool({
|
|
173
|
+
name: 'zooid_get_room_info',
|
|
174
|
+
arguments: {},
|
|
175
|
+
})
|
|
176
|
+
const infoB = await clientB.callTool({
|
|
177
|
+
name: 'zooid_get_room_info',
|
|
178
|
+
arguments: {},
|
|
179
|
+
})
|
|
135
180
|
expect(JSON.parse((infoA.content as Array<{ text: string }>)[0].text).id).toBe('!a:hs')
|
|
136
181
|
expect(JSON.parse((infoB.content as Array<{ text: string }>)[0].text).id).toBe('!b:hs')
|
|
137
182
|
})
|
|
138
183
|
})
|
|
184
|
+
|
|
185
|
+
describe('per-agent sockets (integration)', () => {
|
|
186
|
+
it('isolates two agents sharing a registry', async () => {
|
|
187
|
+
const registry = new SpawnRegistry()
|
|
188
|
+
const aliceSpawn = registry.register({
|
|
189
|
+
agentName: 'alice',
|
|
190
|
+
threadRef: { channelId: '!alice:hs', threadId: 'a' },
|
|
191
|
+
provider: fakeProvider({ getRoomInfo: async () => ({ id: '!alice:hs', name: 'alice', transport: 'matrix' }) }),
|
|
192
|
+
})
|
|
193
|
+
const bobSpawn = registry.register({
|
|
194
|
+
agentName: 'bob',
|
|
195
|
+
threadRef: { channelId: '!bob:hs', threadId: 'b' },
|
|
196
|
+
provider: fakeProvider({ getRoomInfo: async () => ({ id: '!bob:hs', name: 'bob', transport: 'matrix' }) }),
|
|
197
|
+
})
|
|
198
|
+
const runDir = mkdtempSync(join(tmpdir(), 'zooid-run-'))
|
|
199
|
+
const sockets = await startAgentSocketServers({ runDir, registry, agentNames: ['alice', 'bob'] })
|
|
200
|
+
cleanup.push(() => sockets.close())
|
|
201
|
+
expect(sockets.paths.alice).toBe(agentSocketPath({ runDir, agentName: 'alice' }))
|
|
202
|
+
await expect(callDaemon(sockets.paths.alice!, { spawnId: aliceSpawn, method: 'getRoomInfo', params: {} })).resolves.toMatchObject({ id: '!alice:hs' })
|
|
203
|
+
await expect(callDaemon(sockets.paths.bob!, { spawnId: aliceSpawn, method: 'getRoomInfo', params: {} })).rejects.toThrow(/binding not owned by caller/)
|
|
204
|
+
await expect(callDaemon(sockets.paths.bob!, { spawnId: bobSpawn, method: 'getRoomInfo', params: {} })).resolves.toMatchObject({ id: '!bob:hs' })
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('keeps other listeners serving when one bind fails', async () => {
|
|
208
|
+
const registry = new SpawnRegistry()
|
|
209
|
+
const sockets = await startAgentSocketServers({
|
|
210
|
+
runDir: mkdtempSync(join(tmpdir(), 'zooid-run-')),
|
|
211
|
+
registry,
|
|
212
|
+
agentNames: ['alice', 'bob'],
|
|
213
|
+
listen: async (path, name) => {
|
|
214
|
+
if (name === 'bob') throw new Error('EADDRINUSE')
|
|
215
|
+
return startDaemonSocketServer({ sockPath: path, registry, agentName: name })
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
cleanup.push(() => sockets.close())
|
|
219
|
+
expect(sockets.paths.alice).toBeDefined()
|
|
220
|
+
expect(sockets.paths.bob).toBeUndefined()
|
|
221
|
+
})
|
|
222
|
+
})
|