@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.
@@ -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
+ })