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