@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,217 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { EventEmitter } from 'node:events'
3
+ import { Readable, Writable } from 'node:stream'
4
+ import type { AcpRuntime, AcpSpawnSpec } from './acp-types.js'
5
+
6
+ class FakeChild extends EventEmitter {
7
+ stdout = new Readable({ read() {} })
8
+ stdin = new Writable({ write(_c, _e, cb) { cb() } })
9
+ stderr = new Readable({ read() {} })
10
+ pid = 1
11
+ kill = vi.fn(() => true)
12
+ }
13
+
14
+ class StubRuntime implements AcpRuntime {
15
+ spawn = vi.fn((_: AcpSpawnSpec) => new FakeChild() as unknown as ReturnType<AcpRuntime['spawn']>)
16
+ }
17
+
18
+ vi.mock('@zooid/acp-client', async (orig) => {
19
+ const real = (await orig()) as Record<string, unknown>
20
+ return {
21
+ ...real,
22
+ AcpClient: vi.fn().mockImplementation(() => ({
23
+ start: vi.fn().mockResolvedValue(undefined),
24
+ prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }),
25
+ stop: vi.fn().mockResolvedValue(undefined),
26
+ })),
27
+ }
28
+ })
29
+
30
+ const { AcpAgentRegistry } = await import('./acp-registry.js')
31
+
32
+ describe('AcpAgentRegistry', () => {
33
+ let runtime: StubRuntime
34
+ let registry: InstanceType<typeof AcpAgentRegistry>
35
+
36
+ beforeEach(async () => {
37
+ const { AcpClient } = (await import('@zooid/acp-client')) as unknown as {
38
+ AcpClient: ReturnType<typeof vi.fn>
39
+ }
40
+ AcpClient.mockClear()
41
+ runtime = new StubRuntime()
42
+ registry = new AcpAgentRegistry({
43
+ runtime,
44
+ agents: {
45
+ triage: {
46
+ name: 'triage',
47
+ workdir: '.',
48
+ hooks: {},
49
+ acp: { preset: 'claude' },
50
+ approval_timeout_ms: 0,
51
+ },
52
+ builder: {
53
+ name: 'builder',
54
+ workdir: '.',
55
+ hooks: {},
56
+ acp: { command: 'opencode', args: ['acp'] },
57
+ approval_timeout_ms: 0,
58
+ },
59
+ },
60
+ env: { triage: { ANTHROPIC_API_KEY: 'sk-test' } },
61
+ })
62
+ })
63
+
64
+ it('throws on prompt() for an unknown agent', async () => {
65
+ await expect(
66
+ registry.prompt('nope', { threadId: 't', content: [] }),
67
+ ).rejects.toThrow(/unknown agent/i)
68
+ })
69
+
70
+ it('hasAgent reflects config keys', () => {
71
+ expect(registry.hasAgent('triage')).toBe(true)
72
+ expect(registry.hasAgent('nope')).toBe(false)
73
+ })
74
+
75
+ it('lazily starts a client on first prompt', async () => {
76
+ const { AcpClient } = (await import('@zooid/acp-client')) as unknown as {
77
+ AcpClient: ReturnType<typeof vi.fn>
78
+ }
79
+ expect(AcpClient).not.toHaveBeenCalled()
80
+ await registry.prompt('triage', { threadId: 't1', content: [] })
81
+ expect(AcpClient).toHaveBeenCalledTimes(1)
82
+ })
83
+
84
+ it('reuses the same client across prompts (long-lived)', async () => {
85
+ const { AcpClient } = (await import('@zooid/acp-client')) as unknown as {
86
+ AcpClient: ReturnType<typeof vi.fn>
87
+ }
88
+ await registry.prompt('triage', { threadId: 't1', content: [] })
89
+ await registry.prompt('triage', { threadId: 't2', content: [] })
90
+ expect(AcpClient).toHaveBeenCalledTimes(1)
91
+ })
92
+
93
+ it('keeps clients per-agent isolated', async () => {
94
+ const { AcpClient } = (await import('@zooid/acp-client')) as unknown as {
95
+ AcpClient: ReturnType<typeof vi.fn>
96
+ }
97
+ await registry.prompt('triage', { threadId: 't', content: [] })
98
+ await registry.prompt('builder', { threadId: 't', content: [] })
99
+ expect(AcpClient).toHaveBeenCalledTimes(2)
100
+ })
101
+
102
+ it('passes preset → AcpClient via @zooid/acp-client preset registry', async () => {
103
+ const { AcpClient } = (await import('@zooid/acp-client')) as unknown as {
104
+ AcpClient: ReturnType<typeof vi.fn>
105
+ }
106
+ await registry.prompt('triage', { threadId: 't', content: [] })
107
+ const opts = AcpClient.mock.calls[0][0]
108
+ expect(opts.agent.command).toBe('npx')
109
+ expect(opts.agent.args).toEqual(['-y', '@agentclientprotocol/claude-agent-acp'])
110
+ })
111
+
112
+ it('passes explicit command/args through', async () => {
113
+ const { AcpClient } = (await import('@zooid/acp-client')) as unknown as {
114
+ AcpClient: ReturnType<typeof vi.fn>
115
+ }
116
+ await registry.prompt('builder', { threadId: 't', content: [] })
117
+ const opts = AcpClient.mock.calls[0][0]
118
+ expect(opts.agent.command).toBe('opencode')
119
+ expect(opts.agent.args).toEqual(['acp'])
120
+ })
121
+
122
+ it('passes per-agent env into AcpClient.agent.env', async () => {
123
+ const { AcpClient } = (await import('@zooid/acp-client')) as unknown as {
124
+ AcpClient: ReturnType<typeof vi.fn>
125
+ }
126
+ await registry.prompt('triage', { threadId: 't', content: [] })
127
+ const opts = AcpClient.mock.calls[0][0]
128
+ expect(opts.agent.env).toMatchObject({ ANTHROPIC_API_KEY: 'sk-test' })
129
+ })
130
+
131
+ it('threads the AcpRuntime into AcpClient', async () => {
132
+ const { AcpClient } = (await import('@zooid/acp-client')) as unknown as {
133
+ AcpClient: ReturnType<typeof vi.fn>
134
+ }
135
+ await registry.prompt('triage', { threadId: 't', content: [] })
136
+ const opts = AcpClient.mock.calls[0][0]
137
+ expect(opts.runtime).toBe(runtime)
138
+ })
139
+
140
+ it('stopAll() invokes stop on every started client', async () => {
141
+ const { AcpClient } = (await import('@zooid/acp-client')) as unknown as {
142
+ AcpClient: ReturnType<typeof vi.fn>
143
+ }
144
+ await registry.prompt('triage', { threadId: 't', content: [] })
145
+ await registry.stopAll()
146
+ const inst = AcpClient.mock.results[0].value as { stop: ReturnType<typeof vi.fn> }
147
+ expect(inst.stop).toHaveBeenCalled()
148
+ })
149
+ })
150
+
151
+ describe('AcpAgentRegistry.cancelSession', () => {
152
+ it('calls cancel on the underlying AcpClient and ApprovalCorrelator.cancelSession', async () => {
153
+ const correlator = {
154
+ register: vi.fn(),
155
+ resolve: vi.fn(),
156
+ cancelSession: vi.fn(),
157
+ listPending: vi.fn(() => []),
158
+ on: vi.fn(),
159
+ emit: vi.fn(),
160
+ } as unknown as import('./approval-correlator.js').ApprovalCorrelator
161
+ const registry = new AcpAgentRegistry({
162
+ runtime: { spawn: vi.fn() } as never,
163
+ agents: { architect: { acp: { command: 'noop' } } as never },
164
+ approvals: correlator,
165
+ })
166
+ const fakeClient = { cancel: vi.fn(async () => {}), stop: vi.fn(async () => {}) }
167
+ ;(registry as unknown as { clients: Map<string, typeof fakeClient> }).clients.set(
168
+ 'architect',
169
+ fakeClient,
170
+ )
171
+ await registry.cancelSession('architect', 'sess-xyz')
172
+ expect(fakeClient.cancel).toHaveBeenCalledWith('sess-xyz')
173
+ expect((correlator as unknown as { cancelSession: ReturnType<typeof vi.fn> }).cancelSession).toHaveBeenCalledWith(
174
+ 'sess-xyz',
175
+ )
176
+ })
177
+
178
+ it('is a no-op for an unknown agent', async () => {
179
+ const correlator = {
180
+ register: vi.fn(),
181
+ resolve: vi.fn(),
182
+ cancelSession: vi.fn(),
183
+ listPending: vi.fn(() => []),
184
+ on: vi.fn(),
185
+ emit: vi.fn(),
186
+ } as unknown as import('./approval-correlator.js').ApprovalCorrelator
187
+ const registry = new AcpAgentRegistry({
188
+ runtime: { spawn: vi.fn() } as never,
189
+ agents: {},
190
+ approvals: correlator,
191
+ })
192
+ await expect(registry.cancelSession('ghost', 'sess-xyz')).resolves.toBeUndefined()
193
+ expect(
194
+ (correlator as unknown as { cancelSession: ReturnType<typeof vi.fn> }).cancelSession,
195
+ ).not.toHaveBeenCalled()
196
+ })
197
+
198
+ it('cancels pending approvals even when no client has been spawned yet', async () => {
199
+ const correlator = {
200
+ register: vi.fn(),
201
+ resolve: vi.fn(),
202
+ cancelSession: vi.fn(),
203
+ listPending: vi.fn(() => []),
204
+ on: vi.fn(),
205
+ emit: vi.fn(),
206
+ } as unknown as import('./approval-correlator.js').ApprovalCorrelator
207
+ const registry = new AcpAgentRegistry({
208
+ runtime: { spawn: vi.fn() } as never,
209
+ agents: { architect: { acp: { command: 'noop' } } as never },
210
+ approvals: correlator,
211
+ })
212
+ await registry.cancelSession('architect', 'sess-xyz')
213
+ expect(
214
+ (correlator as unknown as { cancelSession: ReturnType<typeof vi.fn> }).cancelSession,
215
+ ).toHaveBeenCalledWith('sess-xyz')
216
+ })
217
+ })
@@ -0,0 +1,235 @@
1
+ import { join } from 'node:path'
2
+ import {
3
+ AcpClient,
4
+ resolvePreset,
5
+ type AgentEvent,
6
+ type ApprovalDecision,
7
+ type ApprovalRequest,
8
+ type PromptInput,
9
+ type PromptResult,
10
+ type TapEvent,
11
+ } from '@zooid/acp-client'
12
+ import type { AcpAgentSpec, AcpRuntime } from './acp-types.js'
13
+ import type { AgentConfig } from './types.js'
14
+ import type {
15
+ ApprovalCorrelator,
16
+ RegisteredApproval,
17
+ } from './approval-correlator.js'
18
+
19
+ export type AcpRegistryEventHandler = (
20
+ agentName: string,
21
+ event: AgentEvent,
22
+ ) => void
23
+ export type AcpRegistryApprovalHandler = (
24
+ agentName: string,
25
+ req: ApprovalRequest,
26
+ ) => Promise<ApprovalDecision>
27
+
28
+ /**
29
+ * Daemon-side surface of the ACP agent fleet. The transport (HTTP) consumes
30
+ * this; the CLI builds it via `buildAcpRegistry`. Long-lived: one
31
+ * `AcpClient` per agent, kept alive across prompts.
32
+ */
33
+ export interface AcpRegistry {
34
+ hasAgent(name: string): boolean
35
+ /** Whether an agent has a transport-context provider attached. */
36
+ hasContextSpawn(name: string): boolean
37
+ /** Per-agent approval timeout from zooid.yaml. 0 means no timeout. */
38
+ getApprovalTimeoutMs(name: string): number
39
+ ensureSession(name: string, threadId: string, channelId?: string): Promise<string>
40
+ /** Drop the in-memory session for (agent, threadId). Next prompt re-creates one. */
41
+ endSession(name: string, threadId: string): void
42
+ prompt(name: string, input: PromptInput): Promise<PromptResult>
43
+ /**
44
+ * Cancel an in-flight prompt for (agent, sessionId). Sends `session/cancel`
45
+ * via the underlying AcpClient and resolves any pending approvals with
46
+ * `decision: 'cancel'`. Idempotent.
47
+ */
48
+ cancelSession(name: string, sessionId: string): Promise<void>
49
+ stopAll(): Promise<void>
50
+ /** Set by the transport. Receives every ACP event from any agent. */
51
+ onEvent: AcpRegistryEventHandler
52
+ /** Set by the transport. Resolves permission requests. */
53
+ onApprovalRequest: AcpRegistryApprovalHandler
54
+ }
55
+
56
+ export interface AcpAgentRegistryOptions {
57
+ runtime: AcpRuntime
58
+ agents: Record<string, AgentConfig>
59
+ /** Per-agent env passed to each `AcpClient`'s spawn spec. */
60
+ env?: Record<string, Record<string, string>>
61
+ /** Per-agent container image. Used by DockerAcpRuntime; ignored by LocalAcpRuntime. */
62
+ image?: Record<string, string | undefined>
63
+ /** Initial event handler (the transport may overwrite at app creation). */
64
+ onEvent?: AcpRegistryEventHandler
65
+ /** Initial approval handler (the transport may overwrite at app creation). */
66
+ onApprovalRequest?: AcpRegistryApprovalHandler
67
+ /**
68
+ * Optional correlator: when set, the registry's default
69
+ * `onApprovalRequest` registers each request on the correlator (with the
70
+ * agent's `approval_timeout_ms`) and returns the registered handle's
71
+ * `decisionPromise`. Transports listen on the correlator's `'registered'`
72
+ * + `'timeout'` events to drive the SSE wire and accept HTTP decisions.
73
+ */
74
+ approvals?: ApprovalCorrelator
75
+ /** Called whenever the correlator-backed handler registers an approval. */
76
+ onApprovalRegistered?: (approval: RegisteredApproval) => void
77
+ /**
78
+ * Optional observability tap. Forwarded to each AcpClient so the
79
+ * unfiltered ACP protocol stream + turn-boundary events are visible to
80
+ * the host (e.g. the dev CLI capturing them to disk).
81
+ */
82
+ onTap?: (agentName: string, event: TapEvent) => void
83
+ /**
84
+ * Root directory under which each agent gets a per-agent state dir
85
+ * (`<agentsDir>/<agentName>/`). Used by the AcpClient session store to
86
+ * persist ACP `sessionId`s across daemon restarts. Optional: when unset,
87
+ * session continuity across restarts is disabled.
88
+ */
89
+ agentsDir?: string
90
+ /**
91
+ * Per-agent factory that returns a `mcpServers[]` entry for the
92
+ * `zooid-context` MCP server. Forwarded to each AcpClient. Agents bound to
93
+ * transports without a context provider (e.g. HTTP) have no entry here.
94
+ */
95
+ contextSpawns?: Record<string, ContextSpawnFactory | undefined>
96
+ }
97
+
98
+ export type ContextSpawnFactory = (
99
+ threadId: string,
100
+ channelId?: string,
101
+ ) => Promise<{
102
+ name: 'zooid-context'
103
+ command: string
104
+ args: string[]
105
+ env: Array<{ name: string; value: string }>
106
+ }>
107
+
108
+ export class AcpAgentRegistry implements AcpRegistry {
109
+ readonly opts: AcpAgentRegistryOptions
110
+ private readonly clients = new Map<string, AcpClient>()
111
+
112
+ onEvent: AcpRegistryEventHandler
113
+ onApprovalRequest: AcpRegistryApprovalHandler
114
+
115
+ constructor(opts: AcpAgentRegistryOptions) {
116
+ this.opts = opts
117
+ this.onEvent = opts.onEvent ?? (() => {})
118
+ if (opts.onApprovalRequest) {
119
+ this.onApprovalRequest = opts.onApprovalRequest
120
+ } else if (opts.approvals) {
121
+ const correlator = opts.approvals
122
+ this.onApprovalRequest = async (name, req) => {
123
+ const cfg = this.opts.agents[name]
124
+ const handle = correlator.register(name, req.sessionId, req, {
125
+ timeoutMs: cfg?.approval_timeout_ms ?? 0,
126
+ })
127
+ this.opts.onApprovalRegistered?.(handle)
128
+ return handle.decisionPromise
129
+ }
130
+ } else {
131
+ this.onApprovalRequest = async () => ({ decision: 'cancel' })
132
+ }
133
+ }
134
+
135
+ hasAgent(name: string): boolean {
136
+ return Object.prototype.hasOwnProperty.call(this.opts.agents, name)
137
+ }
138
+
139
+ hasContextSpawn(name: string): boolean {
140
+ return Boolean(this.opts.contextSpawns?.[name])
141
+ }
142
+
143
+ resolveSpawnEnv(name: string): Record<string, string> {
144
+ return this.opts.env?.[name] ?? {}
145
+ }
146
+
147
+ resolveSpawnImage(name: string): string | undefined {
148
+ return this.opts.image?.[name]
149
+ }
150
+
151
+ getApprovalTimeoutMs(name: string): number {
152
+ return this.opts.agents[name]?.approval_timeout_ms ?? 0
153
+ }
154
+
155
+ async ensureSession(name: string, threadId: string, channelId?: string): Promise<string> {
156
+ if (!this.hasAgent(name)) throw new Error(`unknown agent: ${name}`)
157
+ const client = await this.ensureClient(name)
158
+ return client.ensureSession(threadId, channelId)
159
+ }
160
+
161
+ endSession(name: string, threadId: string): void {
162
+ if (!this.hasAgent(name)) return
163
+ const client = this.clients.get(name)
164
+ client?.endSession(threadId)
165
+ }
166
+
167
+ async cancelSession(name: string, sessionId: string): Promise<void> {
168
+ if (!this.hasAgent(name)) return
169
+ const client = this.clients.get(name)
170
+ // Always nudge the correlator first so any pending approvals resolve with
171
+ // 'cancel' regardless of whether the client is alive or already stopped.
172
+ this.opts.approvals?.cancelSession(sessionId)
173
+ if (!client) return
174
+ try {
175
+ await client.cancel(sessionId)
176
+ } catch (err) {
177
+ // ACP cancel is a notification; failures here are typically transport
178
+ // errors after the agent has already exited.
179
+ console.warn(`[acp:${name}] cancel(${sessionId}) failed:`, err)
180
+ }
181
+ }
182
+
183
+ async prompt(name: string, input: PromptInput): Promise<PromptResult> {
184
+ if (!this.hasAgent(name)) throw new Error(`unknown agent: ${name}`)
185
+ const client = await this.ensureClient(name)
186
+ return client.prompt(input)
187
+ }
188
+
189
+ async stopAll(): Promise<void> {
190
+ await Promise.allSettled(
191
+ [...this.clients.values()].map((c) => c.stop()),
192
+ )
193
+ this.clients.clear()
194
+ }
195
+
196
+ private async ensureClient(name: string): Promise<AcpClient> {
197
+ const existing = this.clients.get(name)
198
+ if (existing) return existing
199
+ const cfg = this.opts.agents[name]
200
+ if (!cfg.acp) throw new Error(`agents.${name}: missing acp block`)
201
+ const spawn = resolveAcpAgentSpec(cfg.acp)
202
+ const client = new AcpClient({
203
+ agent: {
204
+ id: name,
205
+ command: spawn.command,
206
+ args: spawn.args,
207
+ env: this.opts.env?.[name],
208
+ cwd: cfg.workdir,
209
+ image: this.opts.image?.[name],
210
+ },
211
+ agentDataDir: this.opts.agentsDir ? join(this.opts.agentsDir, name) : undefined,
212
+ runtime: this.opts.runtime,
213
+ onEvent: (e) => this.onEvent(name, e),
214
+ onApprovalRequest: (req) => this.onApprovalRequest(name, req),
215
+ onTap: this.opts.onTap ? (e) => this.opts.onTap!(name, e) : undefined,
216
+ contextSpawn: this.opts.contextSpawns?.[name],
217
+ })
218
+ await client.start()
219
+ this.clients.set(name, client)
220
+ return client
221
+ }
222
+ }
223
+
224
+ export function resolveAcpAgentSpec(spec: AcpAgentSpec): {
225
+ command: string
226
+ args: string[]
227
+ } {
228
+ if ('preset' in spec && spec.preset) {
229
+ return resolvePreset(spec.preset, { model: spec.model })
230
+ }
231
+ if ('command' in spec && spec.command) {
232
+ return { command: spec.command, args: spec.args ?? [] }
233
+ }
234
+ throw new Error('AcpAgentSpec: must specify either preset or command')
235
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, it, expectTypeOf } from 'vitest'
2
+ import type { ChildProcess } from 'node:child_process'
3
+ import type {
4
+ AcpAgentSpec,
5
+ AcpMount,
6
+ AcpRuntime,
7
+ AcpSpawnSpec,
8
+ } from './acp-types.js'
9
+
10
+ describe('AcpAgentSpec — preset vs command', () => {
11
+ it('accepts the preset form', () => {
12
+ const a: AcpAgentSpec = { preset: 'claude' }
13
+ expectTypeOf(a).toMatchTypeOf<AcpAgentSpec>()
14
+ })
15
+
16
+ it('accepts the explicit form', () => {
17
+ const a: AcpAgentSpec = { command: 'opencode', args: ['acp'] }
18
+ expectTypeOf(a).toMatchTypeOf<AcpAgentSpec>()
19
+ })
20
+
21
+ it('forbids both at compile time', () => {
22
+ // @ts-expect-error — XOR
23
+ const _: AcpAgentSpec = { preset: 'claude', command: 'overridden' }
24
+ void _
25
+ })
26
+ })
27
+
28
+ describe('AcpRuntime returns a ChildProcess', () => {
29
+ it('typechecks', () => {
30
+ const _impl = (rt: AcpRuntime) => {
31
+ const child: ChildProcess = rt.spawn({ command: 'x', args: [] })
32
+ return child
33
+ }
34
+ void _impl
35
+ })
36
+ })
37
+
38
+ describe('AcpSpawnSpec / AcpMount shape', () => {
39
+ it('mount has path/target/mode', () => {
40
+ const m: AcpMount = { path: '/host', target: '/container', mode: 'ro' }
41
+ expectTypeOf(m).toMatchTypeOf<AcpMount>()
42
+ })
43
+
44
+ it('spawn spec carries command, args, optional env/cwd/image/mounts', () => {
45
+ const s: AcpSpawnSpec = {
46
+ command: 'foo',
47
+ args: ['bar'],
48
+ env: { K: 'v' },
49
+ cwd: '/x',
50
+ image: 'img',
51
+ mounts: [{ path: '/h', target: '/c', mode: 'rw' }],
52
+ }
53
+ expectTypeOf(s).toMatchTypeOf<AcpSpawnSpec>()
54
+ })
55
+ })
@@ -0,0 +1,48 @@
1
+ import type { ChildProcess } from 'node:child_process'
2
+
3
+ /**
4
+ * Per-agent ACP block in zooid.yaml. XOR: either a known preset or an
5
+ * explicit command. The schema parser rejects both/neither.
6
+ *
7
+ * Built-in presets: `claude`, `codex`, `opencode`, `cline`, `kiro`, `gemini`.
8
+ * See `@zooid/acp-client`'s preset registry for the current list.
9
+ */
10
+ export type AcpAgentSpec =
11
+ | { preset: string; model?: string; command?: never; args?: never }
12
+ | { preset?: never; model?: never; command: string; args?: string[] }
13
+
14
+ /**
15
+ * A single bind mount the runtime should set up for the spawned agent
16
+ * process. Only DockerAcpRuntime honors mounts; LocalAcpRuntime ignores them.
17
+ */
18
+ export interface AcpMount {
19
+ /** Host-side source path (absolute). */
20
+ path: string
21
+ /** Container-side target path. */
22
+ target: string
23
+ mode: 'ro' | 'rw'
24
+ }
25
+
26
+ /**
27
+ * What an `AcpRuntime` needs in order to launch the ACP agent process.
28
+ * Unlike the legacy `SpawnConfig`, this is just process-spawn shape — no
29
+ * adapter-specific concepts (no workspaceReadOnly, no sessionStateDir).
30
+ */
31
+ export interface AcpSpawnSpec {
32
+ command: string
33
+ args: string[]
34
+ env?: Record<string, string>
35
+ cwd?: string
36
+ /** Container image. Used by DockerAcpRuntime; ignored by LocalAcpRuntime. */
37
+ image?: string
38
+ /** Bind mounts. Used by DockerAcpRuntime; ignored by LocalAcpRuntime. */
39
+ mounts?: AcpMount[]
40
+ }
41
+
42
+ /**
43
+ * A runtime knows how to spawn the ACP shim process (locally, or inside a
44
+ * container). Returns a ChildProcess whose stdio is wired up for ACP NDJSON.
45
+ */
46
+ export interface AcpRuntime {
47
+ spawn(spec: AcpSpawnSpec): ChildProcess
48
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { ApprovalCorrelator } from './approval-correlator.js'
3
+ import type { ApprovalRequest } from '@zooid/acp-client'
4
+
5
+ const req = (id: string): ApprovalRequest => ({
6
+ sessionId: 's-1',
7
+ toolCallId: id,
8
+ options: [
9
+ { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
10
+ { optionId: 'reject-once', name: 'Reject once', kind: 'reject_once' },
11
+ ],
12
+ })
13
+
14
+ describe('ApprovalCorrelator', () => {
15
+ it('mints a unique approval_id per request', () => {
16
+ const c = new ApprovalCorrelator()
17
+ const a = c.register('agent', 's-1', req('tc-1'))
18
+ const b = c.register('agent', 's-1', req('tc-2'))
19
+ expect(a.approvalId).not.toBe(b.approvalId)
20
+ })
21
+
22
+ it('resolve() with a matching id resolves the request promise', async () => {
23
+ const c = new ApprovalCorrelator()
24
+ const handle = c.register('agent', 's-1', req('tc-1'))
25
+ queueMicrotask(() =>
26
+ c.resolve('s-1', handle.approvalId, { decision: 'allow', optionId: 'allow-once' }),
27
+ )
28
+ await expect(handle.decisionPromise).resolves.toEqual({
29
+ decision: 'allow',
30
+ optionId: 'allow-once',
31
+ })
32
+ })
33
+
34
+ it('resolve() returns false for an unknown approval id', () => {
35
+ const c = new ApprovalCorrelator()
36
+ expect(c.resolve('s-x', 'made-up', { decision: 'cancel' })).toBe(false)
37
+ })
38
+
39
+ it('resolve() returns false when the session id does not match the approval', () => {
40
+ const c = new ApprovalCorrelator()
41
+ const handle = c.register('agent', 's-1', req('tc-1'))
42
+ expect(c.resolve('s-other', handle.approvalId, { decision: 'cancel' })).toBe(false)
43
+ })
44
+
45
+ it('cancelSession() rejects all pending approvals for that session', async () => {
46
+ const c = new ApprovalCorrelator()
47
+ const a = c.register('agent', 's-1', req('tc-1'))
48
+ const b = c.register('agent', 's-1', req('tc-2'))
49
+ const c3 = c.register('agent', 's-2', req('tc-3'))
50
+
51
+ c.cancelSession('s-1')
52
+ await expect(a.decisionPromise).resolves.toEqual({ decision: 'cancel' })
53
+ await expect(b.decisionPromise).resolves.toEqual({ decision: 'cancel' })
54
+ expect(c.size()).toBe(1)
55
+ // s-2 still pending
56
+ const settled = await Promise.race([
57
+ c3.decisionPromise,
58
+ Promise.resolve('still-pending' as const),
59
+ ])
60
+ expect(settled).toBe('still-pending')
61
+ })
62
+
63
+ it('listPending() returns approval requests for a session for SSE replay on reconnect', () => {
64
+ const c = new ApprovalCorrelator()
65
+ const h = c.register('agent', 's-1', req('tc-1'))
66
+ const list = c.listPending('s-1')
67
+ expect(list).toHaveLength(1)
68
+ expect(list[0]).toMatchObject({
69
+ approvalId: h.approvalId,
70
+ sessionId: 's-1',
71
+ toolCallId: 'tc-1',
72
+ })
73
+ })
74
+
75
+ describe('idle timeout', () => {
76
+ beforeEach(() => vi.useFakeTimers())
77
+ afterEach(() => vi.useRealTimers())
78
+
79
+ it('resolves with cancel after the configured wall-clock timeout', async () => {
80
+ const c = new ApprovalCorrelator()
81
+ const handle = c.register('agent', 's-1', req('tc-1'), { timeoutMs: 60_000 })
82
+ vi.advanceTimersByTime(60_001)
83
+ await expect(handle.decisionPromise).resolves.toEqual({ decision: 'cancel' })
84
+ })
85
+
86
+ it('emits a timeout signal so the transport can notify the client', async () => {
87
+ const c = new ApprovalCorrelator()
88
+ const onTimeout = vi.fn()
89
+ c.on('timeout', onTimeout)
90
+ const handle = c.register('agent', 's-1', req('tc-1'), { timeoutMs: 1000 })
91
+ vi.advanceTimersByTime(1001)
92
+ await handle.decisionPromise
93
+ expect(onTimeout).toHaveBeenCalledWith(
94
+ expect.objectContaining({ approvalId: handle.approvalId, sessionId: 's-1' }),
95
+ )
96
+ })
97
+
98
+ it('resolve() before the timeout cancels the timer', async () => {
99
+ const c = new ApprovalCorrelator()
100
+ const handle = c.register('agent', 's-1', req('tc-1'), { timeoutMs: 60_000 })
101
+ c.resolve('s-1', handle.approvalId, { decision: 'allow', optionId: 'allow-once' })
102
+ vi.advanceTimersByTime(60_001)
103
+ await expect(handle.decisionPromise).resolves.toEqual({
104
+ decision: 'allow',
105
+ optionId: 'allow-once',
106
+ })
107
+ })
108
+
109
+ it('cancelSession() before the timeout cancels the timer', async () => {
110
+ const c = new ApprovalCorrelator()
111
+ const handle = c.register('agent', 's-1', req('tc-1'), { timeoutMs: 60_000 })
112
+ c.cancelSession('s-1')
113
+ vi.advanceTimersByTime(60_001)
114
+ await expect(handle.decisionPromise).resolves.toEqual({ decision: 'cancel' })
115
+ expect(c.size()).toBe(0)
116
+ })
117
+
118
+ it('timeoutMs: 0 disables the timeout (waits forever)', async () => {
119
+ const c = new ApprovalCorrelator()
120
+ const handle = c.register('agent', 's-1', req('tc-1'), { timeoutMs: 0 })
121
+ vi.advanceTimersByTime(60 * 60 * 1000)
122
+ const settled = await Promise.race([
123
+ handle.decisionPromise,
124
+ Promise.resolve('still-pending' as const),
125
+ ])
126
+ expect(settled).toBe('still-pending')
127
+ })
128
+ })
129
+ })