@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,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
|
+
})
|
package/src/acp-types.ts
ADDED
|
@@ -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
|
+
})
|