@zooid/acp-client 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/package.json +40 -0
- package/src/acp-client.cancel.test.ts +44 -0
- package/src/acp-client.context.test.ts +93 -0
- package/src/acp-client.preset.test.ts +120 -0
- package/src/acp-client.runtime.test.ts +51 -0
- package/src/acp-client.session-load.test.ts +184 -0
- package/src/acp-client.tap.test.ts +36 -0
- package/src/acp-client.ts +361 -0
- package/src/agent-process.test.ts +103 -0
- package/src/agent-process.ts +49 -0
- package/src/event-mapping.test.ts +105 -0
- package/src/event-mapping.ts +69 -0
- package/src/index.ts +25 -0
- package/src/presets.test.ts +102 -0
- package/src/presets.ts +64 -0
- package/src/session-map.test.ts +54 -0
- package/src/session-map.ts +47 -0
- package/src/session-store.test.ts +115 -0
- package/src/session-store.ts +110 -0
- package/src/turn-tracker.test.ts +70 -0
- package/src/turn-tracker.ts +96 -0
- package/src/types.ts +103 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process'
|
|
2
|
+
import { resolve as pathResolve } from 'node:path'
|
|
3
|
+
import { Readable, Writable } from 'node:stream'
|
|
4
|
+
import {
|
|
5
|
+
ClientSideConnection,
|
|
6
|
+
PROTOCOL_VERSION,
|
|
7
|
+
ndJsonStream,
|
|
8
|
+
type Client,
|
|
9
|
+
} from '@agentclientprotocol/sdk'
|
|
10
|
+
import { AgentProcess } from './agent-process.js'
|
|
11
|
+
import { SessionMap } from './session-map.js'
|
|
12
|
+
import { JsonFileSessionStore } from './session-store.js'
|
|
13
|
+
import { resolvePreset } from './presets.js'
|
|
14
|
+
import {
|
|
15
|
+
acpUpdateToAgentEvent,
|
|
16
|
+
approvalDecisionToPermissionResponse,
|
|
17
|
+
} from './event-mapping.js'
|
|
18
|
+
import { TurnTracker, type TapEvent } from './turn-tracker.js'
|
|
19
|
+
import type {
|
|
20
|
+
AgentConfig,
|
|
21
|
+
AgentEvent,
|
|
22
|
+
ApprovalDecision,
|
|
23
|
+
ApprovalRequest,
|
|
24
|
+
PromptInput,
|
|
25
|
+
PromptResult,
|
|
26
|
+
} from './types.js'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Minimal interface for an external process spawner. Mirrors `AcpRuntime`
|
|
30
|
+
* in `@zooid/core` but kept structural here to avoid a back-edge.
|
|
31
|
+
*/
|
|
32
|
+
export interface SpawnRuntime {
|
|
33
|
+
spawn(spec: {
|
|
34
|
+
command: string
|
|
35
|
+
args: string[]
|
|
36
|
+
env?: Record<string, string>
|
|
37
|
+
cwd?: string
|
|
38
|
+
/** Container image. Honoured by container runtimes; ignored by local spawners. */
|
|
39
|
+
image?: string
|
|
40
|
+
}): ChildProcess
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AcpClientOptions {
|
|
44
|
+
agent: AgentConfig
|
|
45
|
+
/**
|
|
46
|
+
* Per-agent state directory (typically `<dataRoot>/agents/<agentId>/`).
|
|
47
|
+
* `sessions.json` is written here so threads survive daemon restarts.
|
|
48
|
+
* When omitted, session continuity across restarts is disabled (a warning
|
|
49
|
+
* is logged once on first ensureSession).
|
|
50
|
+
*/
|
|
51
|
+
agentDataDir?: string
|
|
52
|
+
onEvent: (event: AgentEvent) => void
|
|
53
|
+
onApprovalRequest: (req: ApprovalRequest) => Promise<ApprovalDecision>
|
|
54
|
+
/**
|
|
55
|
+
* If set, the runtime is used to spawn the ACP shim process instead of
|
|
56
|
+
* the built-in `AgentProcess` host-spawn path. Lets the daemon launch
|
|
57
|
+
* the shim inside a container (DockerAcpRuntime) without changing the
|
|
58
|
+
* AcpClient surface.
|
|
59
|
+
*/
|
|
60
|
+
runtime?: SpawnRuntime
|
|
61
|
+
/**
|
|
62
|
+
* Observability tap. Receives the unfiltered ACP protocol stream plus
|
|
63
|
+
* synthetic turn-boundary events (turn_started / turn_completed). Optional;
|
|
64
|
+
* when omitted the client behaves as before.
|
|
65
|
+
*/
|
|
66
|
+
onTap?: (e: TapEvent) => void
|
|
67
|
+
/**
|
|
68
|
+
* Optional per-spawn factory. When set, the returned spec is included in
|
|
69
|
+
* `session/new mcpServers` (and `session/load mcpServers`) so the shim
|
|
70
|
+
* connects to the daemon-side zooid-context MCP server for the session
|
|
71
|
+
* lifetime. Called once per `ensureSession(threadId)`.
|
|
72
|
+
*/
|
|
73
|
+
contextSpawn?: (threadId: string, channelId?: string) => Promise<{
|
|
74
|
+
name: 'zooid-context'
|
|
75
|
+
command: string
|
|
76
|
+
args: string[]
|
|
77
|
+
env: Array<{ name: string; value: string }>
|
|
78
|
+
}>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class AcpClient {
|
|
82
|
+
private process: AgentProcess | null = null
|
|
83
|
+
private runtimeChild: ChildProcess | null = null
|
|
84
|
+
private connection: ClientSideConnection | null = null
|
|
85
|
+
private readonly sessions = new SessionMap()
|
|
86
|
+
private store: JsonFileSessionStore | null = null
|
|
87
|
+
private storeLoaded: Promise<void> | null = null
|
|
88
|
+
private agentCapabilities: { loadSession?: boolean } = {}
|
|
89
|
+
private warnedNoStore = false
|
|
90
|
+
private initialized = false
|
|
91
|
+
private readonly turns: TurnTracker | null
|
|
92
|
+
|
|
93
|
+
constructor(private readonly options: AcpClientOptions) {
|
|
94
|
+
this.turns = options.onTap
|
|
95
|
+
? new TurnTracker({ agentId: options.agent.id, onTap: options.onTap })
|
|
96
|
+
: null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async start(): Promise<void> {
|
|
100
|
+
const { command, args } = this.resolveSpawn()
|
|
101
|
+
let stdout: Readable
|
|
102
|
+
let stdin: Writable
|
|
103
|
+
let stderr: Readable | null = null
|
|
104
|
+
if (this.options.runtime) {
|
|
105
|
+
const child = this.options.runtime.spawn({
|
|
106
|
+
command,
|
|
107
|
+
args,
|
|
108
|
+
env: this.options.agent.env,
|
|
109
|
+
cwd: this.options.agent.cwd,
|
|
110
|
+
image: this.options.agent.image,
|
|
111
|
+
})
|
|
112
|
+
this.runtimeChild = child
|
|
113
|
+
if (!child.stdout || !child.stdin) {
|
|
114
|
+
throw new Error('AcpClient: runtime returned a child without piped stdio')
|
|
115
|
+
}
|
|
116
|
+
stdout = child.stdout
|
|
117
|
+
stdin = child.stdin
|
|
118
|
+
stderr = child.stderr
|
|
119
|
+
} else {
|
|
120
|
+
this.process = new AgentProcess({
|
|
121
|
+
command,
|
|
122
|
+
args,
|
|
123
|
+
env: this.options.agent.env,
|
|
124
|
+
cwd: this.options.agent.cwd,
|
|
125
|
+
})
|
|
126
|
+
this.process.start()
|
|
127
|
+
stdout = this.process.stdout
|
|
128
|
+
stdin = this.process.stdin
|
|
129
|
+
stderr = this.process.stderr
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (stderr) forwardStderr(stderr, this.options.agent.id)
|
|
133
|
+
|
|
134
|
+
const input = Readable.toWeb(stdout) as ReadableStream<Uint8Array>
|
|
135
|
+
const output = Writable.toWeb(stdin) as WritableStream<Uint8Array>
|
|
136
|
+
const stream = ndJsonStream(output, input)
|
|
137
|
+
|
|
138
|
+
this.connection = new ClientSideConnection(() => this.buildClient(), stream)
|
|
139
|
+
|
|
140
|
+
const init = await this.connection.initialize({
|
|
141
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
142
|
+
clientCapabilities: {
|
|
143
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
144
|
+
terminal: false,
|
|
145
|
+
},
|
|
146
|
+
clientInfo: { name: 'zooid', title: 'Zooid', version: '0.0.1' },
|
|
147
|
+
})
|
|
148
|
+
this.agentCapabilities = init.agentCapabilities ?? {}
|
|
149
|
+
this.initialized = true
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async stop(): Promise<void> {
|
|
153
|
+
this.process?.kill()
|
|
154
|
+
this.runtimeChild?.kill('SIGTERM')
|
|
155
|
+
this.process = null
|
|
156
|
+
this.runtimeChild = null
|
|
157
|
+
this.connection = null
|
|
158
|
+
this.initialized = false
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async ensureSession(threadId: string, channelId?: string): Promise<string> {
|
|
162
|
+
if (!this.connection || !this.initialized) {
|
|
163
|
+
throw new Error('AcpClient.start() must be called before ensureSession()')
|
|
164
|
+
}
|
|
165
|
+
await this.ensureStoreLoaded()
|
|
166
|
+
|
|
167
|
+
const key = { threadId, agentId: this.options.agent.id }
|
|
168
|
+
const cached = this.sessions.get(key)
|
|
169
|
+
if (cached) return cached.sessionId
|
|
170
|
+
|
|
171
|
+
const mcpServers = this.options.contextSpawn
|
|
172
|
+
? [await this.options.contextSpawn(threadId, channelId)]
|
|
173
|
+
: []
|
|
174
|
+
process.stderr.write(
|
|
175
|
+
`[acp-client:${this.options.agent.id}] ensureSession(${threadId}) mcpServers=${
|
|
176
|
+
mcpServers.length === 0
|
|
177
|
+
? '[]'
|
|
178
|
+
: JSON.stringify(mcpServers.map((s) => ({ name: s.name, command: s.command, args: s.args })))
|
|
179
|
+
}\n`,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const persisted = this.store?.get(threadId)
|
|
183
|
+
if (persisted && this.agentCapabilities.loadSession) {
|
|
184
|
+
try {
|
|
185
|
+
await this.connection.loadSession({
|
|
186
|
+
sessionId: persisted,
|
|
187
|
+
cwd: pathResolve(this.options.agent.cwd ?? process.cwd()),
|
|
188
|
+
mcpServers,
|
|
189
|
+
})
|
|
190
|
+
this.sessions.set(key, { sessionId: persisted, startedAt: Date.now() })
|
|
191
|
+
return persisted
|
|
192
|
+
} catch (err) {
|
|
193
|
+
console.warn(
|
|
194
|
+
`[acp-client:${this.options.agent.id}] loadSession(${persisted}) failed; ` +
|
|
195
|
+
`falling back to newSession:`,
|
|
196
|
+
err,
|
|
197
|
+
)
|
|
198
|
+
await this.store?.delete(threadId)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const { sessionId } = await this.connection.newSession({
|
|
203
|
+
cwd: pathResolve(this.options.agent.cwd ?? process.cwd()),
|
|
204
|
+
mcpServers,
|
|
205
|
+
})
|
|
206
|
+
this.sessions.set(key, { sessionId, startedAt: Date.now() })
|
|
207
|
+
await this.store?.set(threadId, sessionId)
|
|
208
|
+
return sessionId
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async ensureStoreLoaded(): Promise<void> {
|
|
212
|
+
if (!this.store) {
|
|
213
|
+
if (!this.options.agentDataDir) {
|
|
214
|
+
if (!this.warnedNoStore) {
|
|
215
|
+
console.warn(
|
|
216
|
+
`[acp-client:${this.options.agent.id}] no agentDataDir configured; ` +
|
|
217
|
+
`session continuity across restarts disabled`,
|
|
218
|
+
)
|
|
219
|
+
this.warnedNoStore = true
|
|
220
|
+
}
|
|
221
|
+
this.storeLoaded = Promise.resolve()
|
|
222
|
+
return this.storeLoaded
|
|
223
|
+
}
|
|
224
|
+
this.store = new JsonFileSessionStore({
|
|
225
|
+
agentId: this.options.agent.id,
|
|
226
|
+
dir: this.options.agentDataDir,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
if (!this.storeLoaded) {
|
|
230
|
+
this.storeLoaded = this.store.load().catch((err) => {
|
|
231
|
+
console.warn(`[acp-client:${this.options.agent.id}] store load failed:`, err)
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
await this.storeLoaded
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async flushStore(): Promise<void> {
|
|
238
|
+
if (this.store) await this.store.flush()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async cancel(sessionId: string): Promise<void> {
|
|
242
|
+
if (!this.connection || !this.initialized) return
|
|
243
|
+
await this.connection.cancel({ sessionId })
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Drop the session for the given thread so the next prompt starts fresh.
|
|
248
|
+
* No ACP-side cancellation — callers should ensure no prompt is in flight.
|
|
249
|
+
*/
|
|
250
|
+
endSession(threadId: string): void {
|
|
251
|
+
this.sessions.delete({ threadId, agentId: this.options.agent.id })
|
|
252
|
+
void this.store?.delete(threadId)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async prompt(input: PromptInput): Promise<PromptResult> {
|
|
256
|
+
const sessionId = await this.ensureSession(input.threadId, input.channelId)
|
|
257
|
+
const promptText = stringifyPromptForLog(input.content)
|
|
258
|
+
this.turns?.startTurn({ sessionId, promptText })
|
|
259
|
+
debugLog(this.options.agent.id, 'prompt →', { sessionId, content: input.content })
|
|
260
|
+
try {
|
|
261
|
+
const result = await this.connection!.prompt({
|
|
262
|
+
sessionId,
|
|
263
|
+
prompt: input.content,
|
|
264
|
+
})
|
|
265
|
+
this.turns?.endTurn({ sessionId, stopReason: result.stopReason })
|
|
266
|
+
debugLog(this.options.agent.id, 'prompt ←', { sessionId, stopReason: result.stopReason })
|
|
267
|
+
return { stopReason: result.stopReason }
|
|
268
|
+
} catch (err) {
|
|
269
|
+
this.turns?.endTurn({ sessionId, stopReason: 'error' })
|
|
270
|
+
throw err
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private resolveSpawn(): { command: string; args: string[] } {
|
|
275
|
+
const { preset, command, args, model } = this.options.agent
|
|
276
|
+
if (command) {
|
|
277
|
+
return { command, args: args ?? [] }
|
|
278
|
+
}
|
|
279
|
+
if (preset) {
|
|
280
|
+
return resolvePreset(preset, { model })
|
|
281
|
+
}
|
|
282
|
+
throw new Error('AcpClient: agent must specify either `preset` or `command`')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private buildClient(): Client {
|
|
286
|
+
const agentId = this.options.agent.id
|
|
287
|
+
return {
|
|
288
|
+
sessionUpdate: async (params) => {
|
|
289
|
+
this.turns?.observeUpdate(params.sessionId, params.update)
|
|
290
|
+
debugLog(agentId, 'sessionUpdate', params)
|
|
291
|
+
const event = acpUpdateToAgentEvent(params)
|
|
292
|
+
if (event) this.options.onEvent(event)
|
|
293
|
+
else debugLog(agentId, 'sessionUpdate dropped (unmapped)', params)
|
|
294
|
+
},
|
|
295
|
+
requestPermission: async (params) => {
|
|
296
|
+
debugLog(agentId, 'requestPermission', params)
|
|
297
|
+
const tc = params.toolCall as {
|
|
298
|
+
toolCallId: string
|
|
299
|
+
kind?: string
|
|
300
|
+
title?: string
|
|
301
|
+
rawInput?: unknown
|
|
302
|
+
}
|
|
303
|
+
const decision = await this.options.onApprovalRequest({
|
|
304
|
+
sessionId: params.sessionId,
|
|
305
|
+
toolCallId: tc.toolCallId,
|
|
306
|
+
toolKind: tc.kind,
|
|
307
|
+
toolTitle: tc.title,
|
|
308
|
+
toolInput: tc.rawInput,
|
|
309
|
+
options: params.options.map((o) => ({
|
|
310
|
+
optionId: o.optionId,
|
|
311
|
+
name: o.name,
|
|
312
|
+
kind: o.kind,
|
|
313
|
+
})),
|
|
314
|
+
})
|
|
315
|
+
debugLog(agentId, 'requestPermission ←', decision)
|
|
316
|
+
return approvalDecisionToPermissionResponse(decision)
|
|
317
|
+
},
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function stringifyPromptForLog(content: PromptInput['content']): string {
|
|
323
|
+
try {
|
|
324
|
+
return JSON.stringify(content).slice(0, 4096)
|
|
325
|
+
} catch {
|
|
326
|
+
return '<unstringifiable>'
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function debugLog(agentId: string, label: string, payload?: unknown): void {
|
|
331
|
+
if (payload === undefined) {
|
|
332
|
+
process.stderr.write(`[${agentId}] ${label}\n`)
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
let s: string
|
|
336
|
+
try {
|
|
337
|
+
s = JSON.stringify(payload)
|
|
338
|
+
} catch {
|
|
339
|
+
s = String(payload)
|
|
340
|
+
}
|
|
341
|
+
if (s.length > 2000) s = s.slice(0, 2000) + '…'
|
|
342
|
+
process.stderr.write(`[${agentId}] ${label} ${s}\n`)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function forwardStderr(stream: Readable, agentId: string): void {
|
|
346
|
+
let buf = ''
|
|
347
|
+
const prefix = `[${agentId}] `
|
|
348
|
+
stream.setEncoding('utf8')
|
|
349
|
+
stream.on('data', (chunk: string) => {
|
|
350
|
+
buf += chunk
|
|
351
|
+
let nl: number
|
|
352
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
353
|
+
const line = buf.slice(0, nl)
|
|
354
|
+
buf = buf.slice(nl + 1)
|
|
355
|
+
process.stderr.write(prefix + line + '\n')
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
stream.on('end', () => {
|
|
359
|
+
if (buf.length > 0) process.stderr.write(prefix + buf + '\n')
|
|
360
|
+
})
|
|
361
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { EventEmitter } from 'node:events'
|
|
3
|
+
import { Readable, Writable } from 'node:stream'
|
|
4
|
+
|
|
5
|
+
const spawnMock = vi.fn()
|
|
6
|
+
|
|
7
|
+
vi.mock('node:child_process', () => ({
|
|
8
|
+
spawn: spawnMock,
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
const { AgentProcess } = await import('./agent-process.js')
|
|
12
|
+
|
|
13
|
+
class FakeChild extends EventEmitter {
|
|
14
|
+
stdout = new Readable({ read() {} })
|
|
15
|
+
stdin = new Writable({
|
|
16
|
+
write(_c, _e, cb) {
|
|
17
|
+
cb()
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
stderr = new Readable({ read() {} })
|
|
21
|
+
pid = 12345
|
|
22
|
+
kill = vi.fn(() => true)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('AgentProcess', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
spawnMock.mockReset()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.restoreAllMocks()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('spawns the configured command with args, piped stdio, and env', () => {
|
|
35
|
+
const child = new FakeChild()
|
|
36
|
+
spawnMock.mockReturnValue(child)
|
|
37
|
+
|
|
38
|
+
const proc = new AgentProcess({
|
|
39
|
+
command: 'opencode',
|
|
40
|
+
args: ['acp'],
|
|
41
|
+
env: { OPENAI_API_KEY: 'sk-test' },
|
|
42
|
+
cwd: '/workspace',
|
|
43
|
+
})
|
|
44
|
+
proc.start()
|
|
45
|
+
|
|
46
|
+
expect(spawnMock).toHaveBeenCalledTimes(1)
|
|
47
|
+
const [cmd, args, options] = spawnMock.mock.calls[0]
|
|
48
|
+
expect(cmd).toBe('opencode')
|
|
49
|
+
expect(args).toEqual(['acp'])
|
|
50
|
+
expect(options.stdio).toEqual(['pipe', 'pipe', 'pipe'])
|
|
51
|
+
expect(options.env).toMatchObject({ OPENAI_API_KEY: 'sk-test' })
|
|
52
|
+
expect(options.cwd).toBe('/workspace')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('exposes stdout and stdin streams from the spawned child', () => {
|
|
56
|
+
const child = new FakeChild()
|
|
57
|
+
spawnMock.mockReturnValue(child)
|
|
58
|
+
|
|
59
|
+
const proc = new AgentProcess({ command: 'x', args: [] })
|
|
60
|
+
proc.start()
|
|
61
|
+
|
|
62
|
+
expect(proc.stdout).toBe(child.stdout)
|
|
63
|
+
expect(proc.stdin).toBe(child.stdin)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('emits "exit" with code when the child exits', async () => {
|
|
67
|
+
const child = new FakeChild()
|
|
68
|
+
spawnMock.mockReturnValue(child)
|
|
69
|
+
|
|
70
|
+
const proc = new AgentProcess({ command: 'x', args: [] })
|
|
71
|
+
proc.start()
|
|
72
|
+
|
|
73
|
+
const exitPromise = new Promise<number | null>((resolve) => {
|
|
74
|
+
proc.on('exit', resolve)
|
|
75
|
+
})
|
|
76
|
+
child.emit('exit', 0, null)
|
|
77
|
+
await expect(exitPromise).resolves.toBe(0)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('kill() forwards SIGTERM to the child', () => {
|
|
81
|
+
const child = new FakeChild()
|
|
82
|
+
spawnMock.mockReturnValue(child)
|
|
83
|
+
|
|
84
|
+
const proc = new AgentProcess({ command: 'x', args: [] })
|
|
85
|
+
proc.start()
|
|
86
|
+
proc.kill()
|
|
87
|
+
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('inherits parent env unless inheritEnv is false', () => {
|
|
91
|
+
process.env.FROM_PARENT = 'yes'
|
|
92
|
+
const child = new FakeChild()
|
|
93
|
+
spawnMock.mockReturnValue(child)
|
|
94
|
+
|
|
95
|
+
const proc = new AgentProcess({ command: 'x', args: [], env: { EXTRA: '1' } })
|
|
96
|
+
proc.start()
|
|
97
|
+
|
|
98
|
+
const [, , options] = spawnMock.mock.calls[0]
|
|
99
|
+
expect(options.env.FROM_PARENT).toBe('yes')
|
|
100
|
+
expect(options.env.EXTRA).toBe('1')
|
|
101
|
+
delete process.env.FROM_PARENT
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
2
|
+
import { EventEmitter } from 'node:events'
|
|
3
|
+
import type { Readable, Writable } from 'node:stream'
|
|
4
|
+
|
|
5
|
+
export interface AgentProcessOptions {
|
|
6
|
+
command: string
|
|
7
|
+
args?: string[]
|
|
8
|
+
env?: Record<string, string>
|
|
9
|
+
cwd?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class AgentProcess extends EventEmitter {
|
|
13
|
+
private child: ChildProcess | null = null
|
|
14
|
+
|
|
15
|
+
constructor(private readonly options: AgentProcessOptions) {
|
|
16
|
+
super()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
start(): void {
|
|
20
|
+
if (this.child) return
|
|
21
|
+
const env = { ...process.env, ...(this.options.env ?? {}) }
|
|
22
|
+
this.child = spawn(this.options.command, this.options.args ?? [], {
|
|
23
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
24
|
+
env,
|
|
25
|
+
cwd: this.options.cwd,
|
|
26
|
+
})
|
|
27
|
+
this.child.on('exit', (code, signal) => this.emit('exit', code, signal))
|
|
28
|
+
this.child.on('error', (err) => this.emit('error', err))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
kill(signal: NodeJS.Signals = 'SIGTERM'): void {
|
|
32
|
+
this.child?.kill(signal)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get stdout(): Readable {
|
|
36
|
+
if (!this.child?.stdout) throw new Error('agent process not started')
|
|
37
|
+
return this.child.stdout
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get stdin(): Writable {
|
|
41
|
+
if (!this.child?.stdin) throw new Error('agent process not started')
|
|
42
|
+
return this.child.stdin
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get stderr(): Readable {
|
|
46
|
+
if (!this.child?.stderr) throw new Error('agent process not started')
|
|
47
|
+
return this.child.stderr
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
acpUpdateToAgentEvent,
|
|
4
|
+
approvalDecisionToPermissionResponse,
|
|
5
|
+
} from './event-mapping.js'
|
|
6
|
+
import type { AgentEvent } from './types.js'
|
|
7
|
+
|
|
8
|
+
describe('acpUpdateToAgentEvent', () => {
|
|
9
|
+
it('maps agent_message_chunk to agent_message_chunk event', () => {
|
|
10
|
+
const event = acpUpdateToAgentEvent({
|
|
11
|
+
sessionId: 's-1',
|
|
12
|
+
update: {
|
|
13
|
+
sessionUpdate: 'agent_message_chunk',
|
|
14
|
+
content: { type: 'text', text: 'hello' },
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
expect(event).toEqual<AgentEvent>({
|
|
18
|
+
type: 'agent_message_chunk',
|
|
19
|
+
sessionId: 's-1',
|
|
20
|
+
content: { type: 'text', text: 'hello' },
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('maps tool_call to tool_call event with id, title, kind, status', () => {
|
|
25
|
+
const event = acpUpdateToAgentEvent({
|
|
26
|
+
sessionId: 's-1',
|
|
27
|
+
update: {
|
|
28
|
+
sessionUpdate: 'tool_call',
|
|
29
|
+
toolCallId: 'tc-7',
|
|
30
|
+
title: 'Reading auth.ts',
|
|
31
|
+
kind: 'read',
|
|
32
|
+
status: 'pending',
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
expect(event).toEqual<AgentEvent>({
|
|
36
|
+
type: 'tool_call',
|
|
37
|
+
sessionId: 's-1',
|
|
38
|
+
toolCallId: 'tc-7',
|
|
39
|
+
title: 'Reading auth.ts',
|
|
40
|
+
kind: 'read',
|
|
41
|
+
status: 'pending',
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('maps tool_call_update with diff content', () => {
|
|
46
|
+
const event = acpUpdateToAgentEvent({
|
|
47
|
+
sessionId: 's-1',
|
|
48
|
+
update: {
|
|
49
|
+
sessionUpdate: 'tool_call_update',
|
|
50
|
+
toolCallId: 'tc-7',
|
|
51
|
+
status: 'completed',
|
|
52
|
+
content: [{ type: 'diff', path: '/abs/auth.ts', oldText: 'a', newText: 'b' }],
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
expect(event).toEqual<AgentEvent>({
|
|
56
|
+
type: 'tool_call_update',
|
|
57
|
+
sessionId: 's-1',
|
|
58
|
+
toolCallId: 'tc-7',
|
|
59
|
+
status: 'completed',
|
|
60
|
+
kind: undefined,
|
|
61
|
+
content: [{ type: 'diff', path: '/abs/auth.ts', oldText: 'a', newText: 'b' }],
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('maps plan update', () => {
|
|
66
|
+
const event = acpUpdateToAgentEvent({
|
|
67
|
+
sessionId: 's-1',
|
|
68
|
+
update: {
|
|
69
|
+
sessionUpdate: 'plan',
|
|
70
|
+
entries: [{ content: 'Step 1', priority: 'high', status: 'pending' }],
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
expect(event?.type).toBe('plan')
|
|
74
|
+
if (event?.type === 'plan') {
|
|
75
|
+
expect(event.entries).toHaveLength(1)
|
|
76
|
+
expect(event.entries[0].content).toBe('Step 1')
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('returns null for unknown update variants (forward-compat)', () => {
|
|
81
|
+
const event = acpUpdateToAgentEvent({
|
|
82
|
+
sessionId: 's-1',
|
|
83
|
+
// @ts-expect-error — exercising unknown variant
|
|
84
|
+
update: { sessionUpdate: 'something_new', foo: 'bar' },
|
|
85
|
+
})
|
|
86
|
+
expect(event).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('approvalDecisionToPermissionResponse', () => {
|
|
91
|
+
it('maps an allow decision to a selected outcome with the chosen optionId', () => {
|
|
92
|
+
const res = approvalDecisionToPermissionResponse({
|
|
93
|
+
decision: 'allow',
|
|
94
|
+
optionId: 'allow-once',
|
|
95
|
+
})
|
|
96
|
+
expect(res).toEqual({
|
|
97
|
+
outcome: { outcome: 'selected', optionId: 'allow-once' },
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('maps a cancel decision to a cancelled outcome', () => {
|
|
102
|
+
const res = approvalDecisionToPermissionResponse({ decision: 'cancel' })
|
|
103
|
+
expect(res).toEqual({ outcome: { outcome: 'cancelled' } })
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RequestPermissionResponse,
|
|
3
|
+
SessionNotification,
|
|
4
|
+
ToolCallContent,
|
|
5
|
+
ToolCallStatus,
|
|
6
|
+
ToolKind,
|
|
7
|
+
} from '@agentclientprotocol/sdk'
|
|
8
|
+
import type { AgentEvent, ApprovalDecision } from './types.js'
|
|
9
|
+
|
|
10
|
+
export function acpUpdateToAgentEvent(
|
|
11
|
+
notif: SessionNotification,
|
|
12
|
+
): AgentEvent | null {
|
|
13
|
+
const { sessionId, update } = notif
|
|
14
|
+
switch (update.sessionUpdate) {
|
|
15
|
+
case 'agent_message_chunk':
|
|
16
|
+
return {
|
|
17
|
+
type: 'agent_message_chunk',
|
|
18
|
+
sessionId,
|
|
19
|
+
content: update.content,
|
|
20
|
+
}
|
|
21
|
+
case 'tool_call':
|
|
22
|
+
return {
|
|
23
|
+
type: 'tool_call',
|
|
24
|
+
sessionId,
|
|
25
|
+
toolCallId: update.toolCallId,
|
|
26
|
+
title: update.title,
|
|
27
|
+
kind: update.kind,
|
|
28
|
+
status: update.status,
|
|
29
|
+
rawInput: update.rawInput,
|
|
30
|
+
locations: mapLocations(update.locations),
|
|
31
|
+
}
|
|
32
|
+
case 'tool_call_update':
|
|
33
|
+
return {
|
|
34
|
+
type: 'tool_call_update',
|
|
35
|
+
sessionId,
|
|
36
|
+
toolCallId: update.toolCallId,
|
|
37
|
+
status: nullToUndef<ToolCallStatus>(update.status),
|
|
38
|
+
kind: nullToUndef<ToolKind>(update.kind),
|
|
39
|
+
content: nullToUndef<ToolCallContent[]>(update.content),
|
|
40
|
+
rawInput: update.rawInput,
|
|
41
|
+
rawOutput: update.rawOutput,
|
|
42
|
+
locations: mapLocations(update.locations),
|
|
43
|
+
}
|
|
44
|
+
case 'plan':
|
|
45
|
+
return { type: 'plan', sessionId, entries: update.entries }
|
|
46
|
+
default:
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function approvalDecisionToPermissionResponse(
|
|
52
|
+
decision: ApprovalDecision,
|
|
53
|
+
): RequestPermissionResponse {
|
|
54
|
+
if (decision.decision === 'cancel') {
|
|
55
|
+
return { outcome: { outcome: 'cancelled' } }
|
|
56
|
+
}
|
|
57
|
+
return { outcome: { outcome: 'selected', optionId: decision.optionId } }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nullToUndef<T>(v: T | null | undefined): T | undefined {
|
|
61
|
+
return v ?? undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mapLocations(
|
|
65
|
+
locs: Array<{ path: string; line?: number | null }> | null | undefined,
|
|
66
|
+
): { path: string; line?: number }[] | undefined {
|
|
67
|
+
if (!locs || locs.length === 0) return undefined
|
|
68
|
+
return locs.map((l) => ({ path: l.path, line: l.line ?? undefined }))
|
|
69
|
+
}
|