@zooid/core 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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +491 -0
- package/dist/index.js +868 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
- package/src/__fixtures__/ec2-workspace.yaml +16 -0
- package/src/__fixtures__/opencode-vertex-gemini.yaml +34 -0
- package/src/__fixtures__/triage-agent.yaml +40 -0
- package/src/__fixtures__/zooid-dev.yaml +53 -0
- package/src/acp-config.test.ts +162 -0
- package/src/acp-registry.test.ts +217 -0
- package/src/acp-registry.ts +235 -0
- package/src/acp-types.test.ts +55 -0
- package/src/acp-types.ts +48 -0
- package/src/approval-correlator.test.ts +129 -0
- package/src/approval-correlator.ts +143 -0
- package/src/config-file.test.ts +35 -0
- package/src/config.test.ts +1317 -0
- package/src/config.ts +712 -0
- package/src/env-interpolation.ts +90 -0
- package/src/example-yaml.test.ts +35 -0
- package/src/index.ts +56 -0
- package/src/transport-context.test.ts +34 -0
- package/src/transport-context.ts +91 -0
- package/src/types.ts +213 -0
- package/src/zooid-config.test.ts +164 -0
- package/src/zooid-helpers.test.ts +54 -0
- package/src/zooid-yaml-sweep.test.ts +389 -0
|
@@ -0,0 +1,1317 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { loadZooidConfig, mergeCliFlags } from './config.js'
|
|
3
|
+
import type { ZooidConfig } from './types.js'
|
|
4
|
+
|
|
5
|
+
const HTTP_TRANSPORT = `
|
|
6
|
+
transports:
|
|
7
|
+
http-local:
|
|
8
|
+
type: http
|
|
9
|
+
port: 8080
|
|
10
|
+
`
|
|
11
|
+
|
|
12
|
+
const QA_AGENTS = `
|
|
13
|
+
agents:
|
|
14
|
+
qa:
|
|
15
|
+
workdir: ./qa
|
|
16
|
+
acp:
|
|
17
|
+
preset: claude
|
|
18
|
+
http:
|
|
19
|
+
transport: http-local
|
|
20
|
+
`
|
|
21
|
+
|
|
22
|
+
const MATRIX_TRANSPORT_FULL = `
|
|
23
|
+
transports:
|
|
24
|
+
matrix:
|
|
25
|
+
type: matrix
|
|
26
|
+
homeserver: http://localhost:8448
|
|
27
|
+
as_token: as-tok
|
|
28
|
+
hs_token: hs-tok
|
|
29
|
+
sender_localpart: zooid
|
|
30
|
+
user_namespace: '@.*:localhost'
|
|
31
|
+
`
|
|
32
|
+
|
|
33
|
+
// Minimal matrix transport relying on every transport-side default (type from
|
|
34
|
+
// key, sender_localpart, user_namespace, tokens). Used by the agent-side
|
|
35
|
+
// tests that need a working matrix transport without restating defaults.
|
|
36
|
+
const MATRIX_TRANSPORT_MIN = `
|
|
37
|
+
transports:
|
|
38
|
+
matrix:
|
|
39
|
+
homeserver: http://localhost:8448
|
|
40
|
+
`
|
|
41
|
+
|
|
42
|
+
function withEnv<T>(vars: Record<string, string | undefined>, fn: () => T): T {
|
|
43
|
+
const prev: Record<string, string | undefined> = {}
|
|
44
|
+
for (const k of Object.keys(vars)) prev[k] = process.env[k]
|
|
45
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
46
|
+
if (v === undefined) delete process.env[k]
|
|
47
|
+
else process.env[k] = v
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return fn()
|
|
51
|
+
} finally {
|
|
52
|
+
for (const [k, v] of Object.entries(prev)) {
|
|
53
|
+
if (v === undefined) delete process.env[k]
|
|
54
|
+
else process.env[k] = v
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('loadZooidConfig', () => {
|
|
60
|
+
it('parses a minimal zooid.yaml', () => {
|
|
61
|
+
const config = loadZooidConfig(`
|
|
62
|
+
runtime: local
|
|
63
|
+
${HTTP_TRANSPORT.trimStart()}${QA_AGENTS}`)
|
|
64
|
+
expect(config).toEqual({
|
|
65
|
+
runtime: 'local',
|
|
66
|
+
transports: {
|
|
67
|
+
'http-local': { type: 'http', port: 8080 },
|
|
68
|
+
},
|
|
69
|
+
agents: {
|
|
70
|
+
qa: {
|
|
71
|
+
name: 'qa',
|
|
72
|
+
workdir: './qa',
|
|
73
|
+
hooks: {},
|
|
74
|
+
acp: { preset: 'claude' },
|
|
75
|
+
approval_timeout_ms: 0,
|
|
76
|
+
http: { transport: 'http-local' },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
hooks: {},
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('parses workforce-wide hooks', () => {
|
|
84
|
+
const config = loadZooidConfig(`
|
|
85
|
+
runtime: local
|
|
86
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
87
|
+
hooks:
|
|
88
|
+
pre_turn: "git pull"
|
|
89
|
+
post_turn: "git push"
|
|
90
|
+
${QA_AGENTS.trimStart()}`)
|
|
91
|
+
expect(config.hooks.pre_turn).toBe('git pull')
|
|
92
|
+
expect(config.hooks.post_turn).toBe('git push')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('http transport defaults port to 8080', () => {
|
|
96
|
+
const config = loadZooidConfig(`
|
|
97
|
+
runtime: local
|
|
98
|
+
transports:
|
|
99
|
+
http-local:
|
|
100
|
+
type: http
|
|
101
|
+
${QA_AGENTS}`)
|
|
102
|
+
expect(config.transports['http-local']).toEqual({ type: 'http', port: 8080 })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('default runtime flips to docker', () => {
|
|
106
|
+
const config = loadZooidConfig(`${HTTP_TRANSPORT}${QA_AGENTS}`)
|
|
107
|
+
expect(config.runtime).toBe('docker')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('parses container.image override at workforce level', () => {
|
|
111
|
+
const config = loadZooidConfig(`
|
|
112
|
+
runtime: docker
|
|
113
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
114
|
+
container:
|
|
115
|
+
image: ghcr.io/zooid-ai/zooid-agent-base:1.2.3
|
|
116
|
+
agents:
|
|
117
|
+
qa:
|
|
118
|
+
workdir: ./qa
|
|
119
|
+
acp:
|
|
120
|
+
preset: claude
|
|
121
|
+
http:
|
|
122
|
+
transport: http-local
|
|
123
|
+
`)
|
|
124
|
+
expect(config.container?.image).toBe('ghcr.io/zooid-ai/zooid-agent-base:1.2.3')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('container is undefined when omitted under runtime: docker', () => {
|
|
128
|
+
const config = loadZooidConfig(`runtime: docker${HTTP_TRANSPORT}${QA_AGENTS}`)
|
|
129
|
+
expect(config.container).toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('rejects http transport with non-integer port', () => {
|
|
133
|
+
expect(() =>
|
|
134
|
+
loadZooidConfig(`
|
|
135
|
+
runtime: local
|
|
136
|
+
transports:
|
|
137
|
+
http-local:
|
|
138
|
+
type: http
|
|
139
|
+
port: "eighty"
|
|
140
|
+
${QA_AGENTS}`),
|
|
141
|
+
).toThrow(/transports\.http-local\.port must be an integer/)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('rejects malformed yaml', () => {
|
|
145
|
+
expect(() => loadZooidConfig(`runtime: local\n bad: indent`)).toThrow()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('rejects top-level transport: (legacy shape)', () => {
|
|
149
|
+
expect(() =>
|
|
150
|
+
loadZooidConfig(`
|
|
151
|
+
transport: http
|
|
152
|
+
runtime: local
|
|
153
|
+
${QA_AGENTS}`),
|
|
154
|
+
).toThrow(/top-level "transport:" is no longer supported/)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('rejects top-level matrix: (legacy shape)', () => {
|
|
158
|
+
expect(() =>
|
|
159
|
+
loadZooidConfig(`
|
|
160
|
+
runtime: local
|
|
161
|
+
matrix:
|
|
162
|
+
homeserver: http://localhost:8448
|
|
163
|
+
${HTTP_TRANSPORT}${QA_AGENTS}`),
|
|
164
|
+
).toThrow(/top-level "matrix:" is no longer supported/)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('loadZooidConfig — agents map', () => {
|
|
169
|
+
it('parses multiple agents with per-agent workdir, hooks, and acp blocks', () => {
|
|
170
|
+
const config = loadZooidConfig(`
|
|
171
|
+
runtime: local
|
|
172
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
173
|
+
agents:
|
|
174
|
+
qa:
|
|
175
|
+
workdir: ./workspaces/qa
|
|
176
|
+
acp:
|
|
177
|
+
preset: claude
|
|
178
|
+
http: { transport: http-local }
|
|
179
|
+
hooks:
|
|
180
|
+
pre_turn: ./hooks/qa-pre.sh
|
|
181
|
+
product:
|
|
182
|
+
workdir: ./workspaces/product
|
|
183
|
+
acp:
|
|
184
|
+
preset: codex
|
|
185
|
+
http: { transport: http-local }
|
|
186
|
+
`)
|
|
187
|
+
expect(Object.keys(config.agents).sort()).toEqual(['product', 'qa'])
|
|
188
|
+
expect(config.agents.qa!.acp).toEqual({ preset: 'claude' })
|
|
189
|
+
expect(config.agents.qa!.hooks.pre_turn).toBe('./hooks/qa-pre.sh')
|
|
190
|
+
expect(config.agents.product!.acp).toEqual({ preset: 'codex' })
|
|
191
|
+
expect(config.agents.product!.hooks).toEqual({})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('merges workforce-wide hooks into each agent, per-agent overrides win', () => {
|
|
195
|
+
const config = loadZooidConfig(`
|
|
196
|
+
runtime: local
|
|
197
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
198
|
+
hooks:
|
|
199
|
+
pre_turn: daemon-pre
|
|
200
|
+
post_turn: daemon-post
|
|
201
|
+
agents:
|
|
202
|
+
qa:
|
|
203
|
+
workdir: ./qa
|
|
204
|
+
acp: { preset: claude }
|
|
205
|
+
http: { transport: http-local }
|
|
206
|
+
hooks:
|
|
207
|
+
pre_turn: qa-pre
|
|
208
|
+
product:
|
|
209
|
+
workdir: ./product
|
|
210
|
+
acp: { preset: codex }
|
|
211
|
+
http: { transport: http-local }
|
|
212
|
+
`)
|
|
213
|
+
expect(config.agents.qa!.hooks).toEqual({
|
|
214
|
+
pre_turn: 'qa-pre',
|
|
215
|
+
post_turn: 'daemon-post',
|
|
216
|
+
})
|
|
217
|
+
expect(config.agents.product!.hooks).toEqual({
|
|
218
|
+
pre_turn: 'daemon-pre',
|
|
219
|
+
post_turn: 'daemon-post',
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('null at agent-level disables a workforce-wide hook', () => {
|
|
224
|
+
const config = loadZooidConfig(`
|
|
225
|
+
runtime: local
|
|
226
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
227
|
+
hooks:
|
|
228
|
+
pre_turn: daemon-pre
|
|
229
|
+
agents:
|
|
230
|
+
qa:
|
|
231
|
+
workdir: ./qa
|
|
232
|
+
acp: { preset: claude }
|
|
233
|
+
http: { transport: http-local }
|
|
234
|
+
hooks:
|
|
235
|
+
pre_turn: ~
|
|
236
|
+
`)
|
|
237
|
+
expect(config.agents.qa!.hooks.pre_turn).toBeUndefined()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('rejects missing agents key', () => {
|
|
241
|
+
expect(() =>
|
|
242
|
+
loadZooidConfig(`runtime: local${HTTP_TRANSPORT}`),
|
|
243
|
+
).toThrow(/agents: is required/i)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('rejects empty agents: map', () => {
|
|
247
|
+
expect(() =>
|
|
248
|
+
loadZooidConfig(`runtime: local${HTTP_TRANSPORT}\nagents: {}`),
|
|
249
|
+
).toThrow(/agents: must have at least one entry/i)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('rejects top-level workdir (flat form removed)', () => {
|
|
253
|
+
expect(() =>
|
|
254
|
+
loadZooidConfig(`
|
|
255
|
+
runtime: local
|
|
256
|
+
workdir: ./
|
|
257
|
+
${HTTP_TRANSPORT}${QA_AGENTS}`),
|
|
258
|
+
).toThrow(/top-level workdir is not supported/i)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('rejects bad agent names', () => {
|
|
262
|
+
expect(() =>
|
|
263
|
+
loadZooidConfig(`
|
|
264
|
+
runtime: local
|
|
265
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
266
|
+
agents:
|
|
267
|
+
Qa:
|
|
268
|
+
workdir: ./qa
|
|
269
|
+
acp: { preset: claude }
|
|
270
|
+
http: { transport: http-local }
|
|
271
|
+
`),
|
|
272
|
+
).toThrow(/agents\.Qa: name must match/i)
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
describe('loadZooidConfig — per-agent container block', () => {
|
|
277
|
+
it('parses per-agent container.image', () => {
|
|
278
|
+
const config = loadZooidConfig(`
|
|
279
|
+
runtime: docker
|
|
280
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
281
|
+
agents:
|
|
282
|
+
qa:
|
|
283
|
+
workdir: ./qa
|
|
284
|
+
acp: { preset: claude }
|
|
285
|
+
http: { transport: http-local }
|
|
286
|
+
ship:
|
|
287
|
+
workdir: ./ship
|
|
288
|
+
acp: { preset: codex }
|
|
289
|
+
http: { transport: http-local }
|
|
290
|
+
container:
|
|
291
|
+
image: ghcr.io/zooid-ai/zooid-agent-base:custom
|
|
292
|
+
`)
|
|
293
|
+
expect(config.agents.qa!.container?.image).toBeUndefined()
|
|
294
|
+
expect(config.agents.ship!.container?.image).toBe(
|
|
295
|
+
'ghcr.io/zooid-ai/zooid-agent-base:custom',
|
|
296
|
+
)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('rejects agents.*.container when runtime is local', () => {
|
|
300
|
+
expect(() =>
|
|
301
|
+
loadZooidConfig(`
|
|
302
|
+
runtime: local
|
|
303
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
304
|
+
agents:
|
|
305
|
+
qa:
|
|
306
|
+
workdir: ./qa
|
|
307
|
+
acp: { preset: claude }
|
|
308
|
+
http: { transport: http-local }
|
|
309
|
+
container:
|
|
310
|
+
image: x
|
|
311
|
+
`),
|
|
312
|
+
).toThrow(/container.*only valid when runtime is 'docker' or 'podman'/i)
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
describe('mergeCliFlags', () => {
|
|
317
|
+
function baseConfig(overrides: Partial<ZooidConfig> = {}): ZooidConfig {
|
|
318
|
+
return {
|
|
319
|
+
runtime: 'local',
|
|
320
|
+
transports: { 'http-local': { type: 'http', port: 8080 } },
|
|
321
|
+
agents: {
|
|
322
|
+
qa: {
|
|
323
|
+
name: 'qa',
|
|
324
|
+
workdir: './qa',
|
|
325
|
+
hooks: {},
|
|
326
|
+
acp: { preset: 'claude' },
|
|
327
|
+
approval_timeout_ms: 0,
|
|
328
|
+
http: { transport: 'http-local' },
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
hooks: {},
|
|
332
|
+
...overrides,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
it('absent CLI flags leave YAML values intact', () => {
|
|
337
|
+
const merged = mergeCliFlags(baseConfig(), {})
|
|
338
|
+
expect(merged.runtime).toBe('local')
|
|
339
|
+
expect(merged.transports['http-local']).toEqual({ type: 'http', port: 8080 })
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('preserves agents map through merge', () => {
|
|
343
|
+
const merged = mergeCliFlags(baseConfig(), { runtime: 'docker' })
|
|
344
|
+
expect(merged.agents.qa!.workdir).toBe('./qa')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('accepts --runtime docker from CLI flags (no auto-default image)', () => {
|
|
348
|
+
const merged = mergeCliFlags(baseConfig(), { runtime: 'docker' })
|
|
349
|
+
expect(merged.runtime).toBe('docker')
|
|
350
|
+
expect(merged.container).toBeUndefined()
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('CLI --image overrides container.image', () => {
|
|
354
|
+
const dockerBase = baseConfig({
|
|
355
|
+
runtime: 'docker',
|
|
356
|
+
container: { image: 'ghcr.io/zooid-ai/zooid-agent-base:1.0.0' },
|
|
357
|
+
})
|
|
358
|
+
const merged = mergeCliFlags(dockerBase, { image: 'custom:2.0' })
|
|
359
|
+
expect(merged.container?.image).toBe('custom:2.0')
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('rejects unknown --runtime values from CLI flags', () => {
|
|
363
|
+
expect(() => mergeCliFlags(baseConfig(), { runtime: 'firecracker' })).toThrow(
|
|
364
|
+
/runtime must be "local", "docker", or "podman"/,
|
|
365
|
+
)
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
const MATRIX_TRANSPORT = `
|
|
370
|
+
transports:
|
|
371
|
+
matrix-local:
|
|
372
|
+
type: matrix
|
|
373
|
+
homeserver: http://localhost:8448
|
|
374
|
+
as_token: as-secret
|
|
375
|
+
hs_token: hs-secret
|
|
376
|
+
sender_localpart: zooid
|
|
377
|
+
user_namespace: '@.*:localhost'
|
|
378
|
+
`
|
|
379
|
+
|
|
380
|
+
describe('loadZooidConfig (matrix transport)', () => {
|
|
381
|
+
it('parses a matrix transport with a matrix: binding block', () => {
|
|
382
|
+
const config = loadZooidConfig(`
|
|
383
|
+
runtime: local
|
|
384
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
385
|
+
agents:
|
|
386
|
+
architect:
|
|
387
|
+
workdir: ./architect
|
|
388
|
+
acp:
|
|
389
|
+
preset: claude
|
|
390
|
+
matrix:
|
|
391
|
+
transport: matrix-local
|
|
392
|
+
user_id: '@architect:localhost'
|
|
393
|
+
rooms:
|
|
394
|
+
- '!r1:localhost'
|
|
395
|
+
trigger: mention
|
|
396
|
+
`)
|
|
397
|
+
const t = config.transports['matrix-local']!
|
|
398
|
+
expect(t.type).toBe('matrix')
|
|
399
|
+
if (t.type === 'matrix') {
|
|
400
|
+
expect(t.homeserver).toBe('http://localhost:8448')
|
|
401
|
+
expect(t.user_namespace).toBe('@.*:localhost')
|
|
402
|
+
}
|
|
403
|
+
expect(config.agents.architect!.matrix?.user_id).toBe('@architect:localhost')
|
|
404
|
+
expect(config.agents.architect!.matrix?.rooms).toEqual(['!r1:localhost'])
|
|
405
|
+
expect(config.agents.architect!.matrix?.trigger).toBe('mention')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('defaults trigger to "mention" when omitted', () => {
|
|
409
|
+
const config = loadZooidConfig(`
|
|
410
|
+
runtime: local
|
|
411
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
412
|
+
agents:
|
|
413
|
+
architect:
|
|
414
|
+
workdir: ./architect
|
|
415
|
+
acp:
|
|
416
|
+
preset: claude
|
|
417
|
+
matrix:
|
|
418
|
+
transport: matrix-local
|
|
419
|
+
user_id: '@architect:localhost'
|
|
420
|
+
rooms:
|
|
421
|
+
- '!r1:localhost'
|
|
422
|
+
`)
|
|
423
|
+
expect(config.agents.architect!.matrix?.trigger).toBe('mention')
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('rejects matrix block referencing http transport', () => {
|
|
427
|
+
expect(() =>
|
|
428
|
+
loadZooidConfig(`
|
|
429
|
+
runtime: local
|
|
430
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
431
|
+
agents:
|
|
432
|
+
qa:
|
|
433
|
+
workdir: ./qa
|
|
434
|
+
acp: { preset: claude }
|
|
435
|
+
matrix:
|
|
436
|
+
transport: http-local
|
|
437
|
+
user_id: '@qa:localhost'
|
|
438
|
+
rooms: ['!r:localhost']
|
|
439
|
+
`),
|
|
440
|
+
).toThrow(/matrix.*references transport.*type: http/i)
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('rejects bad matrix.user_id format', () => {
|
|
444
|
+
expect(() =>
|
|
445
|
+
loadZooidConfig(`
|
|
446
|
+
runtime: local
|
|
447
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
448
|
+
agents:
|
|
449
|
+
architect:
|
|
450
|
+
workdir: ./architect
|
|
451
|
+
acp:
|
|
452
|
+
preset: claude
|
|
453
|
+
matrix:
|
|
454
|
+
transport: matrix-local
|
|
455
|
+
user_id: 'not-a-matrix-id'
|
|
456
|
+
rooms:
|
|
457
|
+
- '!r1:localhost'
|
|
458
|
+
`),
|
|
459
|
+
).toThrow(/matrix\.user_id must look like @localpart:server/)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('accepts an optional display_name on a matrix binding', () => {
|
|
463
|
+
const config = loadZooidConfig(`
|
|
464
|
+
runtime: local
|
|
465
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
466
|
+
agents:
|
|
467
|
+
docs:
|
|
468
|
+
workdir: ./docs
|
|
469
|
+
acp: { preset: claude }
|
|
470
|
+
matrix:
|
|
471
|
+
transport: matrix-local
|
|
472
|
+
user_id: '@docs:localhost'
|
|
473
|
+
display_name: 'Docs Agent'
|
|
474
|
+
rooms:
|
|
475
|
+
- '!r1:localhost'
|
|
476
|
+
`)
|
|
477
|
+
expect(config.agents.docs!.matrix?.display_name).toBe('Docs Agent')
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('trims surrounding whitespace from display_name', () => {
|
|
481
|
+
const config = loadZooidConfig(`
|
|
482
|
+
runtime: local
|
|
483
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
484
|
+
agents:
|
|
485
|
+
docs:
|
|
486
|
+
workdir: ./docs
|
|
487
|
+
acp: { preset: claude }
|
|
488
|
+
matrix:
|
|
489
|
+
transport: matrix-local
|
|
490
|
+
user_id: '@docs:localhost'
|
|
491
|
+
display_name: ' Docs Agent '
|
|
492
|
+
rooms:
|
|
493
|
+
- '!r1:localhost'
|
|
494
|
+
`)
|
|
495
|
+
expect(config.agents.docs!.matrix?.display_name).toBe('Docs Agent')
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('rejects empty display_name (after trim)', () => {
|
|
499
|
+
expect(() =>
|
|
500
|
+
loadZooidConfig(`
|
|
501
|
+
runtime: local
|
|
502
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
503
|
+
agents:
|
|
504
|
+
docs:
|
|
505
|
+
workdir: ./docs
|
|
506
|
+
acp: { preset: claude }
|
|
507
|
+
matrix:
|
|
508
|
+
transport: matrix-local
|
|
509
|
+
user_id: '@docs:localhost'
|
|
510
|
+
display_name: ' '
|
|
511
|
+
rooms:
|
|
512
|
+
- '!r1:localhost'
|
|
513
|
+
`),
|
|
514
|
+
).toThrow(/display_name.*non-empty/i)
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
it('rejects display_name longer than 256 characters', () => {
|
|
518
|
+
const long = 'a'.repeat(257)
|
|
519
|
+
expect(() =>
|
|
520
|
+
loadZooidConfig(`
|
|
521
|
+
runtime: local
|
|
522
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
523
|
+
agents:
|
|
524
|
+
docs:
|
|
525
|
+
workdir: ./docs
|
|
526
|
+
acp: { preset: claude }
|
|
527
|
+
matrix:
|
|
528
|
+
transport: matrix-local
|
|
529
|
+
user_id: '@docs:localhost'
|
|
530
|
+
display_name: '${long}'
|
|
531
|
+
rooms:
|
|
532
|
+
- '!r1:localhost'
|
|
533
|
+
`),
|
|
534
|
+
).toThrow(/display_name.*256/)
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('rejects non-string display_name', () => {
|
|
538
|
+
expect(() =>
|
|
539
|
+
loadZooidConfig(`
|
|
540
|
+
runtime: local
|
|
541
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
542
|
+
agents:
|
|
543
|
+
docs:
|
|
544
|
+
workdir: ./docs
|
|
545
|
+
acp: { preset: claude }
|
|
546
|
+
matrix:
|
|
547
|
+
transport: matrix-local
|
|
548
|
+
user_id: '@docs:localhost'
|
|
549
|
+
display_name: 42
|
|
550
|
+
rooms:
|
|
551
|
+
- '!r1:localhost'
|
|
552
|
+
`),
|
|
553
|
+
).toThrow(/display_name.*string/i)
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('omits display_name from the parsed binding when absent', () => {
|
|
557
|
+
const config = loadZooidConfig(`
|
|
558
|
+
runtime: local
|
|
559
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
560
|
+
agents:
|
|
561
|
+
docs:
|
|
562
|
+
workdir: ./docs
|
|
563
|
+
acp: { preset: claude }
|
|
564
|
+
matrix:
|
|
565
|
+
transport: matrix-local
|
|
566
|
+
user_id: '@docs:localhost'
|
|
567
|
+
rooms:
|
|
568
|
+
- '!r1:localhost'
|
|
569
|
+
`)
|
|
570
|
+
expect(config.agents.docs!.matrix).toBeDefined()
|
|
571
|
+
expect('display_name' in (config.agents.docs!.matrix as object)).toBe(false)
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it('accepts an optional acp.model string', () => {
|
|
575
|
+
const config = loadZooidConfig(`
|
|
576
|
+
runtime: local
|
|
577
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
578
|
+
agents:
|
|
579
|
+
docs:
|
|
580
|
+
acp:
|
|
581
|
+
preset: claude
|
|
582
|
+
model: claude-sonnet-4-6
|
|
583
|
+
matrix:
|
|
584
|
+
rooms: ['#docs']
|
|
585
|
+
`)
|
|
586
|
+
expect(config.agents.docs!.acp).toEqual({
|
|
587
|
+
preset: 'claude',
|
|
588
|
+
model: 'claude-sonnet-4-6',
|
|
589
|
+
})
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('rejects acp.model when not a non-empty string', () => {
|
|
593
|
+
expect(() =>
|
|
594
|
+
loadZooidConfig(`
|
|
595
|
+
runtime: local
|
|
596
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
597
|
+
agents:
|
|
598
|
+
docs:
|
|
599
|
+
acp: { preset: claude, model: '' }
|
|
600
|
+
matrix: { rooms: ['#docs'] }
|
|
601
|
+
`),
|
|
602
|
+
).toThrow(/acp\.model.*non-empty string/i)
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('omits model from the parsed acp block when absent', () => {
|
|
606
|
+
const config = loadZooidConfig(`
|
|
607
|
+
runtime: local
|
|
608
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
609
|
+
agents:
|
|
610
|
+
docs:
|
|
611
|
+
acp: { preset: claude }
|
|
612
|
+
matrix: { rooms: ['#docs'] }
|
|
613
|
+
`)
|
|
614
|
+
expect('model' in (config.agents.docs!.acp as object)).toBe(false)
|
|
615
|
+
})
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
describe('loadZooidConfig (matrix transport — implicit server)', () => {
|
|
619
|
+
it('expands @localpart user_id to fully-qualified mxid using server_name', () => {
|
|
620
|
+
const config = loadZooidConfig(`
|
|
621
|
+
runtime: local
|
|
622
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
623
|
+
agents:
|
|
624
|
+
docs:
|
|
625
|
+
workdir: ./docs
|
|
626
|
+
acp: { preset: claude }
|
|
627
|
+
matrix:
|
|
628
|
+
transport: matrix-local
|
|
629
|
+
user_id: '@docs'
|
|
630
|
+
rooms:
|
|
631
|
+
- '#welcome:localhost'
|
|
632
|
+
`)
|
|
633
|
+
expect(config.agents.docs!.matrix?.user_id).toBe('@docs:localhost')
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
it('expands #alias rooms to fully-qualified aliases', () => {
|
|
637
|
+
const config = loadZooidConfig(`
|
|
638
|
+
runtime: local
|
|
639
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
640
|
+
agents:
|
|
641
|
+
docs:
|
|
642
|
+
workdir: ./docs
|
|
643
|
+
acp: { preset: claude }
|
|
644
|
+
matrix:
|
|
645
|
+
transport: matrix-local
|
|
646
|
+
user_id: '@docs:localhost'
|
|
647
|
+
rooms:
|
|
648
|
+
- '#welcome'
|
|
649
|
+
- '#docs'
|
|
650
|
+
`)
|
|
651
|
+
expect(config.agents.docs!.matrix?.rooms).toEqual([
|
|
652
|
+
'#welcome:localhost',
|
|
653
|
+
'#docs:localhost',
|
|
654
|
+
])
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
it('expands !roomId rooms to fully-qualified room IDs', () => {
|
|
658
|
+
const config = loadZooidConfig(`
|
|
659
|
+
runtime: local
|
|
660
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
661
|
+
agents:
|
|
662
|
+
docs:
|
|
663
|
+
workdir: ./docs
|
|
664
|
+
acp: { preset: claude }
|
|
665
|
+
matrix:
|
|
666
|
+
transport: matrix-local
|
|
667
|
+
user_id: '@docs:localhost'
|
|
668
|
+
rooms:
|
|
669
|
+
- '!abc'
|
|
670
|
+
`)
|
|
671
|
+
expect(config.agents.docs!.matrix?.rooms).toEqual(['!abc:localhost'])
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
it('leaves fully-qualified user_id and rooms untouched (mixed forms in one binding)', () => {
|
|
675
|
+
const config = loadZooidConfig(`
|
|
676
|
+
runtime: local
|
|
677
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
678
|
+
agents:
|
|
679
|
+
docs:
|
|
680
|
+
workdir: ./docs
|
|
681
|
+
acp: { preset: claude }
|
|
682
|
+
matrix:
|
|
683
|
+
transport: matrix-local
|
|
684
|
+
user_id: '@docs:localhost'
|
|
685
|
+
rooms:
|
|
686
|
+
- '#welcome'
|
|
687
|
+
- '#docs:localhost'
|
|
688
|
+
- '!r1:localhost'
|
|
689
|
+
`)
|
|
690
|
+
expect(config.agents.docs!.matrix?.user_id).toBe('@docs:localhost')
|
|
691
|
+
expect(config.agents.docs!.matrix?.rooms).toEqual([
|
|
692
|
+
'#welcome:localhost',
|
|
693
|
+
'#docs:localhost',
|
|
694
|
+
'!r1:localhost',
|
|
695
|
+
])
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
it('rejects an invalid normalized user_id (catches bad localpart chars)', () => {
|
|
699
|
+
expect(() =>
|
|
700
|
+
loadZooidConfig(`
|
|
701
|
+
runtime: local
|
|
702
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
703
|
+
agents:
|
|
704
|
+
docs:
|
|
705
|
+
workdir: ./docs
|
|
706
|
+
acp: { preset: claude }
|
|
707
|
+
matrix:
|
|
708
|
+
transport: matrix-local
|
|
709
|
+
user_id: '@INVALID name'
|
|
710
|
+
rooms:
|
|
711
|
+
- '#welcome'
|
|
712
|
+
`),
|
|
713
|
+
).toThrow(/user_id must look like @localpart:server/)
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
it("rejects rooms[] entries that lack a leading # or !", () => {
|
|
717
|
+
expect(() =>
|
|
718
|
+
loadZooidConfig(`
|
|
719
|
+
runtime: local
|
|
720
|
+
${MATRIX_TRANSPORT.trimStart()}
|
|
721
|
+
agents:
|
|
722
|
+
docs:
|
|
723
|
+
workdir: ./docs
|
|
724
|
+
acp: { preset: claude }
|
|
725
|
+
matrix:
|
|
726
|
+
transport: matrix-local
|
|
727
|
+
user_id: '@docs:localhost'
|
|
728
|
+
rooms:
|
|
729
|
+
- 'welcome'
|
|
730
|
+
`),
|
|
731
|
+
).toThrow(/rooms\[\] must start with '#' or '!'/)
|
|
732
|
+
})
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
describe('parseAgents workdir default', () => {
|
|
736
|
+
it('defaults workdir to ./agents/<name> when omitted', () => {
|
|
737
|
+
const cfg = loadZooidConfig(`
|
|
738
|
+
runtime: local
|
|
739
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
740
|
+
agents:
|
|
741
|
+
qa:
|
|
742
|
+
acp: { preset: claude }
|
|
743
|
+
http:
|
|
744
|
+
transport: http-local
|
|
745
|
+
`)
|
|
746
|
+
expect(cfg.agents.qa.workdir).toBe('./agents/qa')
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
it('explicit workdir wins over the default', () => {
|
|
750
|
+
const cfg = loadZooidConfig(`
|
|
751
|
+
runtime: local
|
|
752
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
753
|
+
agents:
|
|
754
|
+
qa:
|
|
755
|
+
workdir: ./custom/qa-dir
|
|
756
|
+
acp: { preset: claude }
|
|
757
|
+
http:
|
|
758
|
+
transport: http-local
|
|
759
|
+
`)
|
|
760
|
+
expect(cfg.agents.qa.workdir).toBe('./custom/qa-dir')
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
it('rejects empty-string workdir explicitly set', () => {
|
|
764
|
+
expect(() =>
|
|
765
|
+
loadZooidConfig(`
|
|
766
|
+
runtime: local
|
|
767
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
768
|
+
agents:
|
|
769
|
+
qa:
|
|
770
|
+
workdir: ''
|
|
771
|
+
acp: { preset: claude }
|
|
772
|
+
http:
|
|
773
|
+
transport: http-local
|
|
774
|
+
`),
|
|
775
|
+
).toThrow(/agents\.qa\.workdir must be a non-empty string/)
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
it('rejects null workdir explicitly set', () => {
|
|
779
|
+
expect(() =>
|
|
780
|
+
loadZooidConfig(`
|
|
781
|
+
runtime: local
|
|
782
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
783
|
+
agents:
|
|
784
|
+
qa:
|
|
785
|
+
workdir: null
|
|
786
|
+
acp: { preset: claude }
|
|
787
|
+
http:
|
|
788
|
+
transport: http-local
|
|
789
|
+
`),
|
|
790
|
+
).toThrow(/agents\.qa\.workdir must be a non-empty string/)
|
|
791
|
+
})
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
describe('agent transport: inference', () => {
|
|
795
|
+
it('infers transport when exactly one matrix transport exists', () => {
|
|
796
|
+
const cfg = loadZooidConfig(`
|
|
797
|
+
runtime: local
|
|
798
|
+
${MATRIX_TRANSPORT_FULL.trimStart()}
|
|
799
|
+
agents:
|
|
800
|
+
docs:
|
|
801
|
+
workdir: ./agents/docs
|
|
802
|
+
acp: { preset: claude }
|
|
803
|
+
matrix:
|
|
804
|
+
user_id: '@docs'
|
|
805
|
+
rooms: ['#docs']
|
|
806
|
+
`)
|
|
807
|
+
expect(cfg.agents.docs.matrix?.transport).toBe('matrix')
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
it('infers transport when exactly one http transport exists', () => {
|
|
811
|
+
const cfg = loadZooidConfig(`
|
|
812
|
+
runtime: local
|
|
813
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
814
|
+
agents:
|
|
815
|
+
qa:
|
|
816
|
+
workdir: ./qa
|
|
817
|
+
acp: { preset: claude }
|
|
818
|
+
http: {}
|
|
819
|
+
`)
|
|
820
|
+
expect(cfg.agents.qa.http?.transport).toBe('http-local')
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
it('explicit transport wins over the inferred one', () => {
|
|
824
|
+
const cfg = loadZooidConfig(`
|
|
825
|
+
runtime: local
|
|
826
|
+
transports:
|
|
827
|
+
primary:
|
|
828
|
+
type: matrix
|
|
829
|
+
homeserver: http://localhost:8448
|
|
830
|
+
as_token: a
|
|
831
|
+
hs_token: h
|
|
832
|
+
sender_localpart: zooid
|
|
833
|
+
user_namespace: '@.*:localhost'
|
|
834
|
+
secondary:
|
|
835
|
+
type: matrix
|
|
836
|
+
homeserver: http://localhost:8449
|
|
837
|
+
as_token: a2
|
|
838
|
+
hs_token: h2
|
|
839
|
+
sender_localpart: zooid
|
|
840
|
+
user_namespace: '@.*:other'
|
|
841
|
+
agents:
|
|
842
|
+
docs:
|
|
843
|
+
workdir: ./agents/docs
|
|
844
|
+
acp: { preset: claude }
|
|
845
|
+
matrix:
|
|
846
|
+
transport: secondary
|
|
847
|
+
user_id: '@docs'
|
|
848
|
+
rooms: ['#docs']
|
|
849
|
+
`)
|
|
850
|
+
expect(cfg.agents.docs.matrix?.transport).toBe('secondary')
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
it('errors clearly when transport: omitted and multiple of the kind exist', () => {
|
|
854
|
+
expect(() =>
|
|
855
|
+
loadZooidConfig(`
|
|
856
|
+
runtime: local
|
|
857
|
+
transports:
|
|
858
|
+
primary:
|
|
859
|
+
type: matrix
|
|
860
|
+
homeserver: http://localhost:8448
|
|
861
|
+
as_token: a
|
|
862
|
+
hs_token: h
|
|
863
|
+
sender_localpart: zooid
|
|
864
|
+
user_namespace: '@.*:localhost'
|
|
865
|
+
secondary:
|
|
866
|
+
type: matrix
|
|
867
|
+
homeserver: http://localhost:8449
|
|
868
|
+
as_token: a2
|
|
869
|
+
hs_token: h2
|
|
870
|
+
sender_localpart: zooid
|
|
871
|
+
user_namespace: '@.*:other'
|
|
872
|
+
agents:
|
|
873
|
+
docs:
|
|
874
|
+
workdir: ./agents/docs
|
|
875
|
+
acp: { preset: claude }
|
|
876
|
+
matrix:
|
|
877
|
+
user_id: '@docs'
|
|
878
|
+
rooms: ['#docs']
|
|
879
|
+
`),
|
|
880
|
+
).toThrow(/agents\.docs\.matrix\.transport is required.*primary.*secondary/s)
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
it('errors when no transport of the kind exists at all', () => {
|
|
884
|
+
expect(() =>
|
|
885
|
+
loadZooidConfig(`
|
|
886
|
+
runtime: local
|
|
887
|
+
${HTTP_TRANSPORT.trimStart()}
|
|
888
|
+
agents:
|
|
889
|
+
docs:
|
|
890
|
+
workdir: ./agents/docs
|
|
891
|
+
acp: { preset: claude }
|
|
892
|
+
matrix:
|
|
893
|
+
user_id: '@docs'
|
|
894
|
+
rooms: ['#docs']
|
|
895
|
+
`),
|
|
896
|
+
).toThrow(/agents\.docs\.matrix: no transport of type matrix declared/)
|
|
897
|
+
})
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
describe('agent user_id inference', () => {
|
|
901
|
+
it('infers user_id as @<name>:<server_name> when omitted', () => {
|
|
902
|
+
const cfg = loadZooidConfig(`
|
|
903
|
+
runtime: local
|
|
904
|
+
${MATRIX_TRANSPORT_FULL.trimStart()}
|
|
905
|
+
agents:
|
|
906
|
+
docs:
|
|
907
|
+
workdir: ./agents/docs
|
|
908
|
+
acp: { preset: claude }
|
|
909
|
+
matrix:
|
|
910
|
+
rooms: ['#docs']
|
|
911
|
+
`)
|
|
912
|
+
expect(cfg.agents.docs.matrix?.user_id).toBe('@docs:localhost')
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
it('short-form explicit user_id (@docs-bot) overrides and gets server appended', () => {
|
|
916
|
+
const cfg = loadZooidConfig(`
|
|
917
|
+
runtime: local
|
|
918
|
+
${MATRIX_TRANSPORT_FULL.trimStart()}
|
|
919
|
+
agents:
|
|
920
|
+
docs:
|
|
921
|
+
workdir: ./agents/docs
|
|
922
|
+
acp: { preset: claude }
|
|
923
|
+
matrix:
|
|
924
|
+
user_id: '@docs-bot'
|
|
925
|
+
rooms: ['#docs']
|
|
926
|
+
`)
|
|
927
|
+
expect(cfg.agents.docs.matrix?.user_id).toBe('@docs-bot:localhost')
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
it('fully-qualified explicit user_id overrides untouched', () => {
|
|
931
|
+
const cfg = loadZooidConfig(`
|
|
932
|
+
runtime: local
|
|
933
|
+
${MATRIX_TRANSPORT_FULL.trimStart()}
|
|
934
|
+
agents:
|
|
935
|
+
docs:
|
|
936
|
+
workdir: ./agents/docs
|
|
937
|
+
acp: { preset: claude }
|
|
938
|
+
matrix:
|
|
939
|
+
user_id: '@docs-bot:other.example'
|
|
940
|
+
rooms: ['#docs']
|
|
941
|
+
`)
|
|
942
|
+
expect(cfg.agents.docs.matrix?.user_id).toBe('@docs-bot:other.example')
|
|
943
|
+
})
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
describe('transport type inference from key', () => {
|
|
947
|
+
it('infers type: matrix when key is "matrix" and type is omitted', () => {
|
|
948
|
+
withEnv(
|
|
949
|
+
{ MATRIX_AS_TOKEN: 'as-tok', MATRIX_HS_TOKEN: 'hs-tok' },
|
|
950
|
+
() => {
|
|
951
|
+
const cfg = loadZooidConfig(`
|
|
952
|
+
runtime: local
|
|
953
|
+
${MATRIX_TRANSPORT_MIN.trimStart()}
|
|
954
|
+
agents:
|
|
955
|
+
docs:
|
|
956
|
+
workdir: ./agents/docs
|
|
957
|
+
acp: { preset: claude }
|
|
958
|
+
matrix:
|
|
959
|
+
rooms: ['#docs']
|
|
960
|
+
`)
|
|
961
|
+
expect(cfg.transports.matrix.type).toBe('matrix')
|
|
962
|
+
},
|
|
963
|
+
)
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
it('infers type: http when key is "http" and type is omitted', () => {
|
|
967
|
+
const cfg = loadZooidConfig(`
|
|
968
|
+
runtime: local
|
|
969
|
+
transports:
|
|
970
|
+
http:
|
|
971
|
+
port: 8081
|
|
972
|
+
agents:
|
|
973
|
+
qa:
|
|
974
|
+
workdir: ./qa
|
|
975
|
+
acp: { preset: claude }
|
|
976
|
+
http: {}
|
|
977
|
+
`)
|
|
978
|
+
expect(cfg.transports.http).toEqual({ type: 'http', port: 8081 })
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
it('errors when key is unknown and type is omitted', () => {
|
|
982
|
+
expect(() =>
|
|
983
|
+
loadZooidConfig(`
|
|
984
|
+
runtime: local
|
|
985
|
+
transports:
|
|
986
|
+
primary:
|
|
987
|
+
homeserver: http://localhost:8448
|
|
988
|
+
${QA_AGENTS}`),
|
|
989
|
+
).toThrow(/transports\.primary\.type must be "matrix" or "http"/)
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
it('explicit type wins (operator names the transport "prod-matrix")', () => {
|
|
993
|
+
const cfg = loadZooidConfig(`
|
|
994
|
+
runtime: local
|
|
995
|
+
transports:
|
|
996
|
+
prod-matrix:
|
|
997
|
+
type: matrix
|
|
998
|
+
homeserver: http://localhost:8448
|
|
999
|
+
as_token: a
|
|
1000
|
+
hs_token: h
|
|
1001
|
+
sender_localpart: zooid
|
|
1002
|
+
user_namespace: '@.*:localhost'
|
|
1003
|
+
agents:
|
|
1004
|
+
docs:
|
|
1005
|
+
workdir: ./agents/docs
|
|
1006
|
+
acp: { preset: claude }
|
|
1007
|
+
matrix:
|
|
1008
|
+
transport: prod-matrix
|
|
1009
|
+
rooms: ['#docs']
|
|
1010
|
+
`)
|
|
1011
|
+
expect(cfg.transports['prod-matrix'].type).toBe('matrix')
|
|
1012
|
+
})
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
describe('matrix sender_localpart default', () => {
|
|
1016
|
+
it("defaults sender_localpart to 'zooid' when omitted", () => {
|
|
1017
|
+
withEnv(
|
|
1018
|
+
{ MATRIX_AS_TOKEN: 'as-tok', MATRIX_HS_TOKEN: 'hs-tok' },
|
|
1019
|
+
() => {
|
|
1020
|
+
const cfg = loadZooidConfig(`
|
|
1021
|
+
runtime: local
|
|
1022
|
+
${MATRIX_TRANSPORT_MIN.trimStart()}
|
|
1023
|
+
agents:
|
|
1024
|
+
docs:
|
|
1025
|
+
workdir: ./agents/docs
|
|
1026
|
+
acp: { preset: claude }
|
|
1027
|
+
matrix:
|
|
1028
|
+
rooms: ['#docs']
|
|
1029
|
+
`)
|
|
1030
|
+
const mt = cfg.transports.matrix
|
|
1031
|
+
if (mt.type !== 'matrix') throw new Error('not matrix')
|
|
1032
|
+
expect(mt.sender_localpart).toBe('zooid')
|
|
1033
|
+
},
|
|
1034
|
+
)
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
it('explicit sender_localpart wins', () => {
|
|
1038
|
+
withEnv(
|
|
1039
|
+
{ MATRIX_AS_TOKEN: 'as-tok', MATRIX_HS_TOKEN: 'hs-tok' },
|
|
1040
|
+
() => {
|
|
1041
|
+
const cfg = loadZooidConfig(`
|
|
1042
|
+
runtime: local
|
|
1043
|
+
transports:
|
|
1044
|
+
matrix:
|
|
1045
|
+
homeserver: http://localhost:8448
|
|
1046
|
+
sender_localpart: appservice
|
|
1047
|
+
agents:
|
|
1048
|
+
docs:
|
|
1049
|
+
workdir: ./agents/docs
|
|
1050
|
+
acp: { preset: claude }
|
|
1051
|
+
matrix:
|
|
1052
|
+
rooms: ['#docs']
|
|
1053
|
+
`)
|
|
1054
|
+
const mt = cfg.transports.matrix
|
|
1055
|
+
if (mt.type !== 'matrix') throw new Error('not matrix')
|
|
1056
|
+
expect(mt.sender_localpart).toBe('appservice')
|
|
1057
|
+
},
|
|
1058
|
+
)
|
|
1059
|
+
})
|
|
1060
|
+
|
|
1061
|
+
it('rejects explicit empty-string sender_localpart', () => {
|
|
1062
|
+
expect(() =>
|
|
1063
|
+
loadZooidConfig(`
|
|
1064
|
+
runtime: local
|
|
1065
|
+
transports:
|
|
1066
|
+
matrix:
|
|
1067
|
+
homeserver: http://localhost:8448
|
|
1068
|
+
sender_localpart: ''
|
|
1069
|
+
${QA_AGENTS}`),
|
|
1070
|
+
).toThrow(/transports\.matrix\.sender_localpart must be a non-empty string/)
|
|
1071
|
+
})
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
describe('matrix user_namespace derivation', () => {
|
|
1075
|
+
it('derives user_namespace from homeserver hostname', () => {
|
|
1076
|
+
withEnv(
|
|
1077
|
+
{ MATRIX_AS_TOKEN: 'as-tok', MATRIX_HS_TOKEN: 'hs-tok' },
|
|
1078
|
+
() => {
|
|
1079
|
+
const cfg = loadZooidConfig(`
|
|
1080
|
+
runtime: local
|
|
1081
|
+
transports:
|
|
1082
|
+
matrix:
|
|
1083
|
+
homeserver: http://localhost:8448
|
|
1084
|
+
agents:
|
|
1085
|
+
docs:
|
|
1086
|
+
workdir: ./agents/docs
|
|
1087
|
+
acp: { preset: claude }
|
|
1088
|
+
matrix:
|
|
1089
|
+
rooms: ['#docs']
|
|
1090
|
+
`)
|
|
1091
|
+
const mt = cfg.transports.matrix
|
|
1092
|
+
if (mt.type !== 'matrix') throw new Error('not matrix')
|
|
1093
|
+
expect(mt.user_namespace).toBe('@.*:localhost')
|
|
1094
|
+
},
|
|
1095
|
+
)
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
it('derives user_namespace from a non-localhost homeserver', () => {
|
|
1099
|
+
withEnv(
|
|
1100
|
+
{ MATRIX_AS_TOKEN: 'as-tok', MATRIX_HS_TOKEN: 'hs-tok' },
|
|
1101
|
+
() => {
|
|
1102
|
+
const cfg = loadZooidConfig(`
|
|
1103
|
+
runtime: local
|
|
1104
|
+
transports:
|
|
1105
|
+
matrix:
|
|
1106
|
+
homeserver: https://home.zoon.eco
|
|
1107
|
+
agents:
|
|
1108
|
+
docs:
|
|
1109
|
+
workdir: ./agents/docs
|
|
1110
|
+
acp: { preset: claude }
|
|
1111
|
+
matrix:
|
|
1112
|
+
rooms: ['#docs']
|
|
1113
|
+
`)
|
|
1114
|
+
const mt = cfg.transports.matrix
|
|
1115
|
+
if (mt.type !== 'matrix') throw new Error('not matrix')
|
|
1116
|
+
expect(mt.user_namespace).toBe('@.*:home.zoon.eco')
|
|
1117
|
+
},
|
|
1118
|
+
)
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
it('explicit user_namespace wins', () => {
|
|
1122
|
+
withEnv(
|
|
1123
|
+
{ MATRIX_AS_TOKEN: 'as-tok', MATRIX_HS_TOKEN: 'hs-tok' },
|
|
1124
|
+
() => {
|
|
1125
|
+
const cfg = loadZooidConfig(`
|
|
1126
|
+
runtime: local
|
|
1127
|
+
transports:
|
|
1128
|
+
matrix:
|
|
1129
|
+
homeserver: http://localhost:8448
|
|
1130
|
+
user_namespace: '@docs-.*:localhost'
|
|
1131
|
+
agents:
|
|
1132
|
+
docs:
|
|
1133
|
+
workdir: ./agents/docs
|
|
1134
|
+
acp: { preset: claude }
|
|
1135
|
+
matrix:
|
|
1136
|
+
rooms: ['#docs']
|
|
1137
|
+
`)
|
|
1138
|
+
const mt = cfg.transports.matrix
|
|
1139
|
+
if (mt.type !== 'matrix') throw new Error('not matrix')
|
|
1140
|
+
expect(mt.user_namespace).toBe('@docs-.*:localhost')
|
|
1141
|
+
},
|
|
1142
|
+
)
|
|
1143
|
+
})
|
|
1144
|
+
})
|
|
1145
|
+
|
|
1146
|
+
describe('matrix token env-var defaults', () => {
|
|
1147
|
+
it('fills as_token / hs_token from MATRIX_AS_TOKEN / MATRIX_HS_TOKEN when one matrix transport', () => {
|
|
1148
|
+
withEnv(
|
|
1149
|
+
{ MATRIX_AS_TOKEN: 'as-from-env', MATRIX_HS_TOKEN: 'hs-from-env' },
|
|
1150
|
+
() => {
|
|
1151
|
+
const cfg = loadZooidConfig(`
|
|
1152
|
+
runtime: local
|
|
1153
|
+
${MATRIX_TRANSPORT_MIN.trimStart()}
|
|
1154
|
+
agents:
|
|
1155
|
+
docs:
|
|
1156
|
+
workdir: ./agents/docs
|
|
1157
|
+
acp: { preset: claude }
|
|
1158
|
+
matrix:
|
|
1159
|
+
rooms: ['#docs']
|
|
1160
|
+
`)
|
|
1161
|
+
const mt = cfg.transports.matrix
|
|
1162
|
+
if (mt.type !== 'matrix') throw new Error('not matrix')
|
|
1163
|
+
expect(mt.as_token).toBe('as-from-env')
|
|
1164
|
+
expect(mt.hs_token).toBe('hs-from-env')
|
|
1165
|
+
},
|
|
1166
|
+
)
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
it('explicit as_token / hs_token wins over env-var defaults', () => {
|
|
1170
|
+
withEnv(
|
|
1171
|
+
{ MATRIX_AS_TOKEN: 'env-as', MATRIX_HS_TOKEN: 'env-hs' },
|
|
1172
|
+
() => {
|
|
1173
|
+
const cfg = loadZooidConfig(`
|
|
1174
|
+
runtime: local
|
|
1175
|
+
transports:
|
|
1176
|
+
matrix:
|
|
1177
|
+
homeserver: http://localhost:8448
|
|
1178
|
+
as_token: explicit-as
|
|
1179
|
+
hs_token: explicit-hs
|
|
1180
|
+
agents:
|
|
1181
|
+
docs:
|
|
1182
|
+
workdir: ./agents/docs
|
|
1183
|
+
acp: { preset: claude }
|
|
1184
|
+
matrix:
|
|
1185
|
+
rooms: ['#docs']
|
|
1186
|
+
`)
|
|
1187
|
+
const mt = cfg.transports.matrix
|
|
1188
|
+
if (mt.type !== 'matrix') throw new Error('not matrix')
|
|
1189
|
+
expect(mt.as_token).toBe('explicit-as')
|
|
1190
|
+
expect(mt.hs_token).toBe('explicit-hs')
|
|
1191
|
+
},
|
|
1192
|
+
)
|
|
1193
|
+
})
|
|
1194
|
+
|
|
1195
|
+
it('clear error when env var is unset and token was inferred', () => {
|
|
1196
|
+
withEnv({ MATRIX_AS_TOKEN: undefined, MATRIX_HS_TOKEN: undefined }, () => {
|
|
1197
|
+
expect(() =>
|
|
1198
|
+
loadZooidConfig(`
|
|
1199
|
+
runtime: local
|
|
1200
|
+
${MATRIX_TRANSPORT_MIN.trimStart()}
|
|
1201
|
+
agents:
|
|
1202
|
+
docs:
|
|
1203
|
+
workdir: ./agents/docs
|
|
1204
|
+
acp: { preset: claude }
|
|
1205
|
+
matrix:
|
|
1206
|
+
rooms: ['#docs']
|
|
1207
|
+
`),
|
|
1208
|
+
).toThrow(/MATRIX_AS_TOKEN/)
|
|
1209
|
+
})
|
|
1210
|
+
})
|
|
1211
|
+
|
|
1212
|
+
it('errors when multiple matrix transports and tokens are omitted', () => {
|
|
1213
|
+
withEnv(
|
|
1214
|
+
{ MATRIX_AS_TOKEN: 'as', MATRIX_HS_TOKEN: 'hs' },
|
|
1215
|
+
() => {
|
|
1216
|
+
expect(() =>
|
|
1217
|
+
loadZooidConfig(`
|
|
1218
|
+
runtime: local
|
|
1219
|
+
transports:
|
|
1220
|
+
matrix:
|
|
1221
|
+
homeserver: http://localhost:8448
|
|
1222
|
+
matrix-staging:
|
|
1223
|
+
type: matrix
|
|
1224
|
+
homeserver: http://localhost:8449
|
|
1225
|
+
agents:
|
|
1226
|
+
docs:
|
|
1227
|
+
workdir: ./agents/docs
|
|
1228
|
+
acp: { preset: claude }
|
|
1229
|
+
matrix:
|
|
1230
|
+
transport: matrix
|
|
1231
|
+
rooms: ['#docs']
|
|
1232
|
+
`),
|
|
1233
|
+
).toThrow(/explicitly.*more than one matrix transport/s)
|
|
1234
|
+
},
|
|
1235
|
+
)
|
|
1236
|
+
})
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
describe('canonical zooid.yaml example (§3.6)', () => {
|
|
1240
|
+
it('parses the minimal localhost-dev workforce', () => {
|
|
1241
|
+
withEnv(
|
|
1242
|
+
{ MATRIX_AS_TOKEN: 'as-tok', MATRIX_HS_TOKEN: 'hs-tok' },
|
|
1243
|
+
() => {
|
|
1244
|
+
const cfg = loadZooidConfig(`
|
|
1245
|
+
runtime: local
|
|
1246
|
+
|
|
1247
|
+
transports:
|
|
1248
|
+
matrix:
|
|
1249
|
+
homeserver: http://localhost:8448
|
|
1250
|
+
|
|
1251
|
+
agents:
|
|
1252
|
+
echo:
|
|
1253
|
+
acp:
|
|
1254
|
+
command: node
|
|
1255
|
+
args: ['--import', 'tsx', './echo-agent.ts']
|
|
1256
|
+
matrix:
|
|
1257
|
+
rooms: ['#welcome']
|
|
1258
|
+
|
|
1259
|
+
docs:
|
|
1260
|
+
acp: { preset: opencode }
|
|
1261
|
+
matrix:
|
|
1262
|
+
display_name: 'Docs Agent'
|
|
1263
|
+
rooms: ['#docs']
|
|
1264
|
+
|
|
1265
|
+
ux-consultant:
|
|
1266
|
+
acp: { preset: opencode }
|
|
1267
|
+
matrix:
|
|
1268
|
+
rooms: ['#ux-consultant']
|
|
1269
|
+
`)
|
|
1270
|
+
const mt = cfg.transports.matrix
|
|
1271
|
+
if (mt.type !== 'matrix') throw new Error('not matrix')
|
|
1272
|
+
expect(mt).toMatchObject({
|
|
1273
|
+
type: 'matrix',
|
|
1274
|
+
homeserver: 'http://localhost:8448',
|
|
1275
|
+
as_token: 'as-tok',
|
|
1276
|
+
hs_token: 'hs-tok',
|
|
1277
|
+
sender_localpart: 'zooid',
|
|
1278
|
+
user_namespace: '@.*:localhost',
|
|
1279
|
+
})
|
|
1280
|
+
expect(cfg.agents.echo.workdir).toBe('./agents/echo')
|
|
1281
|
+
expect(cfg.agents.echo.matrix?.transport).toBe('matrix')
|
|
1282
|
+
expect(cfg.agents.echo.matrix?.user_id).toBe('@echo:localhost')
|
|
1283
|
+
expect(cfg.agents.echo.matrix?.rooms).toEqual(['#welcome:localhost'])
|
|
1284
|
+
|
|
1285
|
+
expect(cfg.agents.docs.workdir).toBe('./agents/docs')
|
|
1286
|
+
expect(cfg.agents.docs.matrix?.user_id).toBe('@docs:localhost')
|
|
1287
|
+
expect(cfg.agents.docs.matrix?.display_name).toBe('Docs Agent')
|
|
1288
|
+
|
|
1289
|
+
expect(cfg.agents['ux-consultant'].workdir).toBe('./agents/ux-consultant')
|
|
1290
|
+
expect(cfg.agents['ux-consultant'].matrix?.user_id).toBe(
|
|
1291
|
+
'@ux-consultant:localhost',
|
|
1292
|
+
)
|
|
1293
|
+
},
|
|
1294
|
+
)
|
|
1295
|
+
})
|
|
1296
|
+
})
|
|
1297
|
+
|
|
1298
|
+
describe('backwards compat: long-form yaml continues to parse', () => {
|
|
1299
|
+
it('parses a fully-explicit zooid.yaml unchanged', () => {
|
|
1300
|
+
const cfg = loadZooidConfig(`
|
|
1301
|
+
runtime: local
|
|
1302
|
+
${MATRIX_TRANSPORT_FULL.trimStart()}
|
|
1303
|
+
agents:
|
|
1304
|
+
docs:
|
|
1305
|
+
workdir: ./agents/docs
|
|
1306
|
+
acp: { preset: opencode }
|
|
1307
|
+
matrix:
|
|
1308
|
+
transport: matrix
|
|
1309
|
+
user_id: '@docs-agent'
|
|
1310
|
+
rooms: ['#docs']
|
|
1311
|
+
trigger: mention
|
|
1312
|
+
`)
|
|
1313
|
+
expect(cfg.agents.docs.workdir).toBe('./agents/docs')
|
|
1314
|
+
expect(cfg.agents.docs.matrix?.transport).toBe('matrix')
|
|
1315
|
+
expect(cfg.agents.docs.matrix?.user_id).toBe('@docs-agent:localhost')
|
|
1316
|
+
})
|
|
1317
|
+
})
|