@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,90 @@
1
+ import dotenvExpand from 'dotenv-expand'
2
+
3
+ /**
4
+ * Matches compose-style env references inside a value string:
5
+ * `${NAME}`, `$NAME`, `${NAME:-default}`, `${NAME-default}`,
6
+ * `${NAME:+alt}`, `${NAME+alt}`.
7
+ *
8
+ * The captured group is always the referenced variable name.
9
+ */
10
+ const REF_RE =
11
+ /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?[-+][^}]*)?\}|\$([A-Za-z_][A-Za-z0-9_]*)/g
12
+
13
+ export class EnvInterpolationError extends Error {}
14
+
15
+ const isDenied = (name: string): boolean =>
16
+ name === 'ZOOID_TOKEN' || name.startsWith('ZOOID_')
17
+
18
+ /**
19
+ * Run compose-style interpolation over a `{ KEY: literal-or-${ref} }` map.
20
+ *
21
+ * - Rejects keys in the `ZOOID_*` namespace.
22
+ * - Rejects any `${ZOOID_*}` reference, including inside composed values
23
+ * (e.g. `"prefix-${ZOOID_INTERNAL}"`).
24
+ * - Otherwise delegates to `dotenv-expand` for the actual substitution,
25
+ * preserving compose semantics (missing → empty string,
26
+ * `${VAR:-default}`, `${VAR-default}`, `$$` escape).
27
+ */
28
+ export function interpolateEnv(
29
+ parsed: Record<string, string>,
30
+ processEnv: NodeJS.ProcessEnv,
31
+ scope: string,
32
+ ): Record<string, string> {
33
+ for (const [key, val] of Object.entries(parsed)) {
34
+ if (isDenied(key)) {
35
+ throw new EnvInterpolationError(
36
+ `${scope}.${key}: keys in the ZOOID_* namespace are not allowed`,
37
+ )
38
+ }
39
+ if (typeof val !== 'string') {
40
+ throw new EnvInterpolationError(
41
+ `${scope}.${key}: env values must be strings (got ${typeof val})`,
42
+ )
43
+ }
44
+ REF_RE.lastIndex = 0
45
+ let m: RegExpExecArray | null
46
+ while ((m = REF_RE.exec(val)) !== null) {
47
+ const ref = (m[1] ?? m[2]) as string
48
+ if (isDenied(ref)) {
49
+ throw new EnvInterpolationError(
50
+ `${scope}.${key}: references to ZOOID_* vars are not allowed (saw \${${ref}})`,
51
+ )
52
+ }
53
+ }
54
+ }
55
+ // Process each value through interpolateString. Per-key processing avoids
56
+ // dotenv-expand's "processEnv shadows parsed" behaviour, which would let the
57
+ // daemon's `LOG_LEVEL=debug` overwrite a literal `LOG_LEVEL: info` declared in
58
+ // zooid.yaml. References still resolve against the full processEnv.
59
+ const out: Record<string, string> = {}
60
+ for (const [key, val] of Object.entries(parsed)) {
61
+ out[key] = interpolateString(val, processEnv)
62
+ }
63
+ return out
64
+ }
65
+
66
+ /**
67
+ * Run compose-style interpolation over a single string value (e.g. a
68
+ * transport's `as_token`). No denylist — transports legitimately reference
69
+ * `ZOOID_*` tokens for the daemon itself (see [ZOD043] §Denylist note).
70
+ */
71
+ export function interpolateString(
72
+ value: string,
73
+ processEnv: NodeJS.ProcessEnv,
74
+ ): string {
75
+ // Fast path: literals with no `$` need no expansion. Skipping the dotenv-expand
76
+ // call also avoids the library's "processEnv shadows parsed" merge behaviour,
77
+ // which can leak unrelated env vars through a sentinel key.
78
+ if (!value.includes('$')) return value
79
+ const sentinel = '__zooid_interp_v1__'
80
+ const env: Record<string, string> = {}
81
+ for (const [k, v] of Object.entries(processEnv)) {
82
+ if (typeof v === 'string') env[k] = v
83
+ }
84
+ delete env[sentinel]
85
+ const result = dotenvExpand.expand({
86
+ parsed: { [sentinel]: value },
87
+ processEnv: env,
88
+ })
89
+ return result.parsed?.[sentinel] ?? ''
90
+ }
@@ -0,0 +1,35 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, it, expect } from 'vitest'
5
+ import { loadZooidConfig } from './config.js'
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url))
8
+
9
+ describe('zooid/examples/zooid-dev/zooid.yaml', () => {
10
+ it('parses with MATRIX_AS_TOKEN / MATRIX_HS_TOKEN set', () => {
11
+ const prev = {
12
+ MATRIX_AS_TOKEN: process.env.MATRIX_AS_TOKEN,
13
+ MATRIX_HS_TOKEN: process.env.MATRIX_HS_TOKEN,
14
+ }
15
+ process.env.MATRIX_AS_TOKEN = 'as-tok'
16
+ process.env.MATRIX_HS_TOKEN = 'hs-tok'
17
+ try {
18
+ const path = join(__dirname, '__fixtures__', 'zooid-dev.yaml')
19
+ const yamlText = readFileSync(path, 'utf8')
20
+ const cfg = loadZooidConfig(yamlText)
21
+ expect(cfg.runtime).toBe('local')
22
+ const mt = cfg.transports.matrix
23
+ if (mt.type !== 'matrix') throw new Error('not matrix')
24
+ expect(mt.sender_localpart).toBe('zooid')
25
+ expect(mt.user_namespace).toBe('@.*:localhost')
26
+ expect(cfg.agents.echo.workdir).toBe('./agents/echo')
27
+ expect(cfg.agents.echo.matrix?.user_id).toBe('@echo:localhost')
28
+ } finally {
29
+ if (prev.MATRIX_AS_TOKEN === undefined) delete process.env.MATRIX_AS_TOKEN
30
+ else process.env.MATRIX_AS_TOKEN = prev.MATRIX_AS_TOKEN
31
+ if (prev.MATRIX_HS_TOKEN === undefined) delete process.env.MATRIX_HS_TOKEN
32
+ else process.env.MATRIX_HS_TOKEN = prev.MATRIX_HS_TOKEN
33
+ }
34
+ })
35
+ })
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ export {
2
+ loadZooidConfig,
3
+ mergeCliFlags,
4
+ findTransport,
5
+ findMatrixTransport,
6
+ findHttpTransport,
7
+ findConfigFile,
8
+ } from './config.js'
9
+ export {
10
+ AcpAgentRegistry,
11
+ resolveAcpAgentSpec,
12
+ } from './acp-registry.js'
13
+ export {
14
+ ApprovalCorrelator,
15
+ type RegisteredApproval,
16
+ type RegisterOptions,
17
+ } from './approval-correlator.js'
18
+ export type {
19
+ AcpRegistry,
20
+ AcpAgentRegistryOptions,
21
+ AcpRegistryEventHandler,
22
+ AcpRegistryApprovalHandler,
23
+ ContextSpawnFactory,
24
+ } from './acp-registry.js'
25
+ export type { TapEvent } from '@zooid/acp-client'
26
+ export type {
27
+ AcpAgentSpec,
28
+ AcpMount,
29
+ AcpRuntime,
30
+ AcpSpawnSpec,
31
+ } from './acp-types.js'
32
+ export type {
33
+ AgentConfig,
34
+ ContainerConfig,
35
+ ZooidContainerConfig,
36
+ MatrixBinding,
37
+ HttpBinding,
38
+ ZooidConfig,
39
+ TransportConfig,
40
+ MatrixTransportConfig,
41
+ HttpTransportConfig,
42
+ CliFlags,
43
+ Transport,
44
+ InboundMessage,
45
+ ThreadRef,
46
+ } from './types.js'
47
+ export type {
48
+ HistoryOptions,
49
+ HistoryPage,
50
+ Message,
51
+ Member,
52
+ ChannelInfo,
53
+ ThreadOverview,
54
+ ThreadOverviewPage,
55
+ TransportContextProvider,
56
+ } from './transport-context.js'
@@ -0,0 +1,34 @@
1
+ import { describe, it, expectTypeOf } from 'vitest'
2
+ import type {
3
+ TransportContextProvider,
4
+ HistoryPage,
5
+ Message,
6
+ Member,
7
+ ChannelInfo,
8
+ ThreadOverviewPage,
9
+ } from './transport-context.js'
10
+
11
+ describe('TransportContextProvider', () => {
12
+ it('declares the navigation methods with the spec shape', () => {
13
+ expectTypeOf<TransportContextProvider['getRoomHistory']>().parameters.toEqualTypeOf<
14
+ [string, { limit?: number; before?: string }]
15
+ >()
16
+ expectTypeOf<TransportContextProvider['getRoomHistory']>().returns.resolves.toEqualTypeOf<HistoryPage>()
17
+ expectTypeOf<TransportContextProvider['getRecentThreads']>().returns.resolves.toEqualTypeOf<ThreadOverviewPage>()
18
+ expectTypeOf<TransportContextProvider['getThreadHistory']>().parameters.toEqualTypeOf<
19
+ [string, string, { limit?: number; before?: string }]
20
+ >()
21
+ expectTypeOf<TransportContextProvider['getThreadHistory']>().returns.resolves.toEqualTypeOf<HistoryPage>()
22
+ expectTypeOf<TransportContextProvider['getChannelMembers']>().returns.resolves.toEqualTypeOf<Member[]>()
23
+ expectTypeOf<TransportContextProvider['getChannelInfo']>().returns.resolves.toEqualTypeOf<ChannelInfo>()
24
+ })
25
+
26
+ it('ChannelInfo.transport is the two-transport MVP union', () => {
27
+ expectTypeOf<ChannelInfo['transport']>().toEqualTypeOf<'http' | 'matrix'>()
28
+ })
29
+
30
+ it('Message and Member carry the is_agent flag', () => {
31
+ expectTypeOf<Message['is_agent']>().toEqualTypeOf<boolean>()
32
+ expectTypeOf<Member['is_agent']>().toEqualTypeOf<boolean>()
33
+ })
34
+ })
@@ -0,0 +1,91 @@
1
+ export interface HistoryOptions {
2
+ /** Max messages to return. Default 50, max 200 (enforced by the MCP server). */
3
+ limit?: number
4
+ /** Pagination cursor — opaque to the agent. Provider-defined shape. */
5
+ before?: string
6
+ }
7
+
8
+ export interface Message {
9
+ id: string
10
+ sender: string
11
+ text: string
12
+ timestamp: string
13
+ is_agent: boolean
14
+ agent_name?: string
15
+ /**
16
+ * Thread root event id when this message belongs to a thread. Absent for
17
+ * top-level messages. Lets agents group messages by thread or drill into
18
+ * a specific thread root.
19
+ */
20
+ thread_id?: string
21
+ }
22
+
23
+ export interface Member {
24
+ id: string
25
+ name: string
26
+ is_agent: boolean
27
+ agent_name?: string
28
+ }
29
+
30
+ export interface ChannelInfo {
31
+ id: string
32
+ name: string
33
+ transport: 'http' | 'matrix'
34
+ }
35
+
36
+ export interface HistoryPage {
37
+ messages: Message[]
38
+ next_before?: string
39
+ has_more: boolean
40
+ }
41
+
42
+ /**
43
+ * A top-level entry in a room — either a standalone message or a thread
44
+ * root. Lets the agent scan a room without thread-reply noise. Drill into
45
+ * a thread with `getThreadHistory(thread_id)` where `thread_id` is this
46
+ * entry's `id`.
47
+ */
48
+ export interface ThreadOverview {
49
+ id: string
50
+ sender: string
51
+ text: string
52
+ timestamp: string
53
+ is_agent: boolean
54
+ agent_name?: string
55
+ /** Number of replies in the thread under this entry. 0 = no replies yet. */
56
+ reply_count: number
57
+ /**
58
+ * ISO 8601 of the latest activity in the thread (latest reply, or this
59
+ * entry's own timestamp when there are no replies). Useful for sorting.
60
+ */
61
+ last_activity_at: string
62
+ }
63
+
64
+ export interface ThreadOverviewPage {
65
+ threads: ThreadOverview[]
66
+ next_before?: string
67
+ has_more: boolean
68
+ }
69
+
70
+ /**
71
+ * Read-only conversation-context surface for a single transport.
72
+ * Implemented per-transport when the transport owns (or fronts) durable
73
+ * conversation context. Transports that don't (e.g. current HTTP) return
74
+ * `null` from `Transport.getContextProvider()`.
75
+ *
76
+ * Lets agents navigate a room the way a human does:
77
+ * - `getRoomHistory` — every message chronologically
78
+ * - `getRecentThreads` — top-level entries only (scan-the-room view)
79
+ * - `getThreadHistory` — drill into one specific thread
80
+ */
81
+ export interface TransportContextProvider {
82
+ getRoomHistory(channelId: string, opts: HistoryOptions): Promise<HistoryPage>
83
+ getRecentThreads(channelId: string, opts: HistoryOptions): Promise<ThreadOverviewPage>
84
+ getThreadHistory(
85
+ channelId: string,
86
+ threadId: string,
87
+ opts: HistoryOptions,
88
+ ): Promise<HistoryPage>
89
+ getChannelMembers(channelId: string): Promise<Member[]>
90
+ getChannelInfo(channelId: string): Promise<ChannelInfo>
91
+ }
package/src/types.ts ADDED
@@ -0,0 +1,213 @@
1
+ import type { AcpAgentSpec } from './acp-types.js'
2
+
3
+ /**
4
+ * One classified line of agent stdout. Currently unused (legacy adapter
5
+ * machinery is gone); kept for forward compatibility with future on-disk
6
+ * stream parsing.
7
+ */
8
+
9
+ /**
10
+ * A Transport handles inbound messages and outbound replies. Implemented in
11
+ * future epics (HTTP, Slack, Zooid). Declared here so the core knows the
12
+ * shape downstream packages will plug in to.
13
+ */
14
+ export interface InboundMessage {
15
+ id: string
16
+ text: string
17
+ sender: string
18
+ thread: ThreadRef
19
+ isFollowUp: boolean
20
+ }
21
+
22
+ export interface ThreadRef {
23
+ channelId: string
24
+ threadId: string
25
+ }
26
+
27
+ export interface Transport {
28
+ listen(channel: string, onMessage: (msg: InboundMessage) => void): void
29
+ reply(thread: ThreadRef, message: string): Promise<void> | void
30
+ }
31
+
32
+ /**
33
+ * Per-agent container configuration. Holds runtime-neutral container
34
+ * concerns — image, env. Rejected at parse time when `runtime: local`.
35
+ */
36
+ export interface ContainerConfig {
37
+ image?: string
38
+ /**
39
+ * Env vars passed to the spawned agent container. Values support
40
+ * compose-style `${VAR}` / `${VAR:-default}` / `${VAR-default}`
41
+ * interpolation, resolved at parse time against the daemon's `process.env`.
42
+ * `ZOOID_*` references and `ZOOID_*` keys are rejected.
43
+ */
44
+ env?: Record<string, string>
45
+ }
46
+
47
+ /**
48
+ * Workforce-level container defaults. Currently `image` only — see
49
+ * [ZOD043] §Non-goals on workforce-level env.
50
+ */
51
+ export interface ZooidContainerConfig {
52
+ image?: string
53
+ }
54
+
55
+ /**
56
+ * Matrix transport binding. Lives under `agents.<name>.matrix:` in
57
+ * zooid.yaml. The block name (`matrix`) is the transport-kind
58
+ * discriminator: `transports[transport].type` must equal `"matrix"`.
59
+ */
60
+ export interface MatrixBinding {
61
+ /**
62
+ * Ref into `ZooidConfig.transports`. Resolved transport must have type: 'matrix'.
63
+ * @default if exactly one matrix transport is declared, that transport's key
64
+ */
65
+ transport: string
66
+ /**
67
+ * Full Matrix user ID for this agent's bot, e.g. `@architect:example.com`.
68
+ * @default `@<agent name>:<server>` — server is derived from the resolved
69
+ * transport's `user_namespace`
70
+ */
71
+ user_id: string
72
+ /**
73
+ * Optional human-readable display name. Written to the agent's Matrix
74
+ * profile on bootstrap. Falls back to the user_id localpart when absent.
75
+ */
76
+ display_name?: string
77
+ /** Room IDs / aliases this agent watches. */
78
+ rooms: string[]
79
+ /**
80
+ * Routing rule. `mention` requires the bot to be tagged; `any` triggers
81
+ * on every message.
82
+ * @default 'mention'
83
+ */
84
+ trigger: 'mention' | 'any'
85
+ }
86
+
87
+ /**
88
+ * HTTP transport binding. Lives under `agents.<name>.http:` in
89
+ * zooid.yaml. Reserved for future HTTP-specific binding fields.
90
+ */
91
+ export interface HttpBinding {
92
+ /** Ref into `ZooidConfig.transports`. Resolved transport must have type: 'http'. */
93
+ transport: string
94
+ }
95
+
96
+ /**
97
+ * Per-agent config inside a multi-agent zooid.yaml. Each agent has its
98
+ * own workspace, hooks, an ACP block describing the shim to spawn, and
99
+ * exactly one transport-kind block (`matrix` or `http`).
100
+ */
101
+ export interface AgentConfig {
102
+ /**
103
+ * Routing name. Always the agent's key in `ZooidConfig.agents` — cannot
104
+ * be overridden, and a `name:` field under an agent is silently ignored.
105
+ * The key must match /^[a-z][a-z0-9-]{0,31}$/.
106
+ */
107
+ name: string
108
+ /**
109
+ * Host directory for the agent's workspace.
110
+ * @default `./agents/<name>`
111
+ */
112
+ workdir: string
113
+ /** Per-agent hooks. Workforce-wide hooks are merged in at load time. */
114
+ hooks: {
115
+ pre_turn?: string
116
+ post_turn?: string
117
+ }
118
+ /** Required: how to launch this agent's ACP shim. */
119
+ acp: AcpAgentSpec
120
+ /**
121
+ * Wall-clock timeout for pending permission requests, in milliseconds.
122
+ * 0 = no timeout. The paused agent's idle cost is negligible, so opt-in
123
+ * is the right shape — set this only if you're running in a
124
+ * scale-to-zero / serverless context where unbounded waits would hold
125
+ * resources.
126
+ * @default 0
127
+ */
128
+ approval_timeout_ms: number
129
+ /** Container config. Rejected at parse time when runtime: local. */
130
+ container?: ContainerConfig
131
+ /** Exactly one of matrix / http is set per agent. */
132
+ matrix?: MatrixBinding
133
+ http?: HttpBinding
134
+ }
135
+
136
+ /**
137
+ * Matrix application-service transport. The CLI binds the AS HTTP listener
138
+ * to `port` (defaults to 8080).
139
+ */
140
+ export interface MatrixTransportConfig {
141
+ type: 'matrix'
142
+ homeserver: string
143
+ /**
144
+ * Application-service token. Sent on every Client-Server API call.
145
+ * @default value of `$MATRIX_AS_TOKEN` in the daemon's env
146
+ */
147
+ as_token: string
148
+ /**
149
+ * Homeserver-to-AS token. The homeserver presents this on push.
150
+ * @default value of `$MATRIX_HS_TOKEN` in the daemon's env
151
+ */
152
+ hs_token: string
153
+ /**
154
+ * Localpart of the AS sender user (the bridge bot).
155
+ * @default 'zooid'
156
+ */
157
+ sender_localpart: string
158
+ /**
159
+ * Regex covering all bot users, e.g. `@.*:example.com`.
160
+ * @default `@.*:<host>` — host derived from `homeserver`
161
+ */
162
+ user_namespace: string
163
+ /**
164
+ * AS HTTP listener port.
165
+ * @default 8080
166
+ */
167
+ port?: number
168
+ /**
169
+ * Workforce space localpart. Resolves to alias `#<space>:<server>`.
170
+ * @default 'dev'
171
+ */
172
+ space?: string
173
+ }
174
+
175
+ /**
176
+ * Plain HTTP API transport.
177
+ */
178
+ export interface HttpTransportConfig {
179
+ type: 'http'
180
+ /**
181
+ * HTTP listener port.
182
+ * @default 8080
183
+ */
184
+ port: number
185
+ }
186
+
187
+ export type TransportConfig = MatrixTransportConfig | HttpTransportConfig
188
+
189
+ /**
190
+ * Parsed zooid.yaml shape. Always multi-agent — `agents:` is required and
191
+ * must have at least one entry. At least one transport must be declared and
192
+ * each agent must reference one by name.
193
+ */
194
+ export interface ZooidConfig {
195
+ runtime: 'local' | 'docker' | 'podman'
196
+ /** Workforce-wide container defaults. Image only — no workforce-level env. Rejected when runtime: local. */
197
+ container?: ZooidContainerConfig
198
+ /** Required. Map of operator-chosen names → transport config. At least one entry. */
199
+ transports: Record<string, TransportConfig>
200
+ /** Required. Must have at least one entry. */
201
+ agents: Record<string, AgentConfig>
202
+ /** Workforce-wide hook defaults. Merged into each agent.hooks at load time. */
203
+ hooks: {
204
+ pre_turn?: string
205
+ post_turn?: string
206
+ }
207
+ }
208
+
209
+ export interface CliFlags {
210
+ runtime?: string
211
+ /** Container image override (shorthand for container.image). */
212
+ image?: string
213
+ }
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { loadZooidConfig } from './config.js'
3
+
4
+ const minimal = `
5
+ runtime: docker
6
+
7
+ transports:
8
+ matrix-local:
9
+ type: matrix
10
+ homeserver: http://localhost:8448
11
+ as_token: as-x
12
+ hs_token: hs-x
13
+ sender_localpart: zooid
14
+ user_namespace: '@.*:localhost'
15
+
16
+ agents:
17
+ assistant:
18
+ workdir: .
19
+ acp: { preset: claude }
20
+ matrix:
21
+ transport: matrix-local
22
+ user_id: '@assistant:localhost'
23
+ rooms: ['!general:localhost']
24
+ trigger: mention
25
+ `
26
+
27
+ describe('loadZooidConfig — new shape', () => {
28
+ it('parses a single-matrix-transport workforce', () => {
29
+ const c = loadZooidConfig(minimal)
30
+ expect(c.transports['matrix-local']).toMatchObject({
31
+ type: 'matrix',
32
+ homeserver: 'http://localhost:8448',
33
+ as_token: 'as-x',
34
+ hs_token: 'hs-x',
35
+ sender_localpart: 'zooid',
36
+ user_namespace: '@.*:localhost',
37
+ })
38
+ expect(c.agents.assistant!.matrix?.transport).toBe('matrix-local')
39
+ expect(c.agents.assistant!.matrix?.user_id).toBe('@assistant:localhost')
40
+ })
41
+
42
+ it('runtime defaults to docker when omitted', () => {
43
+ const yaml = `
44
+ transports:
45
+ http-only:
46
+ type: http
47
+ port: 8080
48
+ agents:
49
+ one:
50
+ workdir: .
51
+ acp: { preset: claude }
52
+ http:
53
+ transport: http-only
54
+ `
55
+ expect(loadZooidConfig(yaml).runtime).toBe('docker')
56
+ })
57
+
58
+ it('errors when transports is empty', () => {
59
+ expect(() =>
60
+ loadZooidConfig(`
61
+ transports: {}
62
+ agents:
63
+ a:
64
+ workdir: .
65
+ acp: { preset: claude }
66
+ http:
67
+ transport: x
68
+ `),
69
+ ).toThrow(/transports.*at least one/i)
70
+ })
71
+
72
+ it("errors when an agent's transport reference doesn't exist", () => {
73
+ expect(() =>
74
+ loadZooidConfig(`
75
+ transports:
76
+ matrix-local:
77
+ type: matrix
78
+ homeserver: http://localhost:8448
79
+ as_token: as-x
80
+ hs_token: hs-x
81
+ sender_localpart: zooid
82
+ user_namespace: '@.*:localhost'
83
+ agents:
84
+ oops:
85
+ workdir: .
86
+ acp: { preset: claude }
87
+ matrix:
88
+ transport: ghost
89
+ user_id: '@oops:localhost'
90
+ rooms: ['!r:localhost']
91
+ `),
92
+ ).toThrow(/agents\.oops\.matrix\.transport.*ghost.*not declared/i)
93
+ })
94
+
95
+ it('errors when a transport has an unknown type', () => {
96
+ expect(() =>
97
+ loadZooidConfig(`
98
+ transports:
99
+ bad:
100
+ type: smtp
101
+ agents:
102
+ a:
103
+ workdir: .
104
+ acp: { preset: claude }
105
+ http:
106
+ transport: bad
107
+ `),
108
+ ).toThrow(/transports\.bad\.type.*matrix.*http/i)
109
+ })
110
+
111
+ it('errors when a matrix-typed transport is referenced from an http: block', () => {
112
+ expect(() =>
113
+ loadZooidConfig(`
114
+ transports:
115
+ m:
116
+ type: matrix
117
+ homeserver: http://localhost:8448
118
+ as_token: t
119
+ hs_token: h
120
+ sender_localpart: z
121
+ user_namespace: '@.*:l'
122
+ agents:
123
+ one:
124
+ workdir: .
125
+ acp: { preset: claude }
126
+ http:
127
+ transport: m
128
+ `),
129
+ ).toThrow(/http.*references transport.*type: matrix/i)
130
+ })
131
+
132
+ it('allows multiple transports of mixed types', () => {
133
+ const yaml = `
134
+ transports:
135
+ matrix-local:
136
+ type: matrix
137
+ homeserver: http://localhost:8448
138
+ as_token: as-x
139
+ hs_token: hs-x
140
+ sender_localpart: zooid
141
+ user_namespace: '@.*:localhost'
142
+ http-direct:
143
+ type: http
144
+ port: 8080
145
+ agents:
146
+ m:
147
+ workdir: .
148
+ acp: { preset: claude }
149
+ matrix:
150
+ transport: matrix-local
151
+ user_id: '@m:localhost'
152
+ rooms: ['!a:localhost']
153
+ h:
154
+ workdir: .
155
+ acp: { preset: claude }
156
+ http:
157
+ transport: http-direct
158
+ `
159
+ const c = loadZooidConfig(yaml)
160
+ expect(Object.keys(c.transports)).toEqual(['matrix-local', 'http-direct'])
161
+ expect(c.agents.m!.matrix?.transport).toBe('matrix-local')
162
+ expect(c.agents.h!.http?.transport).toBe('http-direct')
163
+ })
164
+ })