@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zooid contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@zooid/acp-client",
3
+ "version": "0.7.0",
4
+ "description": "Zooid's ACP client: spawn agent subprocesses, drive them via Agent Client Protocol, route events and permission requests through callbacks.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.ts",
12
+ "import": "./src/index.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "dependencies": {
27
+ "@agentclientprotocol/sdk": "^0.21.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.0.0",
31
+ "tsx": "^4.21.0",
32
+ "typescript": "^5.5.0",
33
+ "vitest": "^3.2.0"
34
+ },
35
+ "scripts": {
36
+ "test": "vitest run",
37
+ "test:watch": "vitest",
38
+ "typecheck": "tsc --noEmit"
39
+ }
40
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { AcpClient } from './acp-client.js'
3
+
4
+ describe('AcpClient.cancel', () => {
5
+ it('forwards to the underlying ClientSideConnection.cancel with the sessionId', async () => {
6
+ const fakeCancel = vi.fn(async () => {})
7
+ const fakeConnection = { cancel: fakeCancel }
8
+ const client = new AcpClient({
9
+ agent: { id: 'a', command: 'noop', args: [] },
10
+ onEvent: () => {},
11
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
12
+ })
13
+ // Skip start(); install the connection directly.
14
+ ;(client as unknown as { connection: typeof fakeConnection }).connection = fakeConnection
15
+ ;(client as unknown as { initialized: boolean }).initialized = true
16
+ await client.cancel('sess-abc')
17
+ expect(fakeCancel).toHaveBeenCalledWith({ sessionId: 'sess-abc' })
18
+ })
19
+
20
+ it('is a no-op (does not throw) when never started', async () => {
21
+ const client = new AcpClient({
22
+ agent: { id: 'a', command: 'noop', args: [] },
23
+ onEvent: () => {},
24
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
25
+ })
26
+ await expect(client.cancel('sess-abc')).resolves.toBeUndefined()
27
+ })
28
+
29
+ it('is idempotent — second cancel for the same sessionId is forwarded again without error', async () => {
30
+ const fakeCancel = vi.fn(async () => {})
31
+ const client = new AcpClient({
32
+ agent: { id: 'a', command: 'noop', args: [] },
33
+ onEvent: () => {},
34
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
35
+ })
36
+ ;(client as unknown as { connection: { cancel: typeof fakeCancel } }).connection = {
37
+ cancel: fakeCancel,
38
+ }
39
+ ;(client as unknown as { initialized: boolean }).initialized = true
40
+ await client.cancel('sess-abc')
41
+ await client.cancel('sess-abc')
42
+ expect(fakeCancel).toHaveBeenCalledTimes(2)
43
+ })
44
+ })
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { AcpClient } from './acp-client.js'
3
+
4
+ function injectConnection(client: AcpClient, calls: Array<{ method: string; params: unknown }>) {
5
+ const connection = {
6
+ newSession: vi.fn(async (params: unknown) => {
7
+ calls.push({ method: 'newSession', params })
8
+ return { sessionId: 'sess-1' }
9
+ }),
10
+ loadSession: vi.fn(async (params: unknown) => {
11
+ calls.push({ method: 'loadSession', params })
12
+ return {}
13
+ }),
14
+ }
15
+ // @ts-expect-error — test reaches into private state to skip subprocess spawn
16
+ client.connection = connection
17
+ // @ts-expect-error
18
+ client.initialized = true
19
+ // @ts-expect-error
20
+ client.agentCapabilities = { loadSession: true }
21
+ return connection
22
+ }
23
+
24
+ describe('AcpClient — context MCP wiring', () => {
25
+ it('omits zooid-context from mcpServers when no contextSpawn factory is set', async () => {
26
+ const calls: Array<{ method: string; params: unknown }> = []
27
+ const client = new AcpClient({
28
+ agent: { id: 'architect', command: 'x', args: [] },
29
+ onEvent: () => {},
30
+ onApprovalRequest: async () => ({ decision: 'allow', optionId: 'allow' }),
31
+ })
32
+ injectConnection(client, calls)
33
+ await client.ensureSession('!room:hs')
34
+ const params = calls.find((c) => c.method === 'newSession')!.params as {
35
+ mcpServers: unknown[]
36
+ }
37
+ expect(params.mcpServers).toEqual([])
38
+ })
39
+
40
+ it('includes the zooid-context mcpServer entry when contextSpawn is set', async () => {
41
+ const calls: Array<{ method: string; params: unknown }> = []
42
+ const contextSpawn = vi.fn(async (threadId: string) => ({
43
+ name: 'zooid-context' as const,
44
+ command: 'node',
45
+ args: ['/bin/zooid-context-mcp.js', '--spawn-id', `spawn-${threadId}`],
46
+ env: [{ name: 'ZOOID_DAEMON_SOCK', value: '/run/zooid/test.sock' }],
47
+ }))
48
+ const client = new AcpClient({
49
+ agent: { id: 'architect', command: 'x', args: [] },
50
+ onEvent: () => {},
51
+ onApprovalRequest: async () => ({ decision: 'allow', optionId: 'allow' }),
52
+ contextSpawn,
53
+ })
54
+ injectConnection(client, calls)
55
+ await client.ensureSession('!room:hs')
56
+ expect(contextSpawn).toHaveBeenCalledWith('!room:hs', undefined)
57
+ const params = calls.find((c) => c.method === 'newSession')!.params as {
58
+ mcpServers: Array<{ name: string; args: string[] }>
59
+ }
60
+ expect(params.mcpServers).toHaveLength(1)
61
+ expect(params.mcpServers[0].name).toBe('zooid-context')
62
+ expect(params.mcpServers[0].args).toContain('--spawn-id')
63
+ expect(params.mcpServers[0].args).toContain('spawn-!room:hs')
64
+ })
65
+
66
+ it('includes zooid-context in loadSession mcpServers too', async () => {
67
+ const calls: Array<{ method: string; params: unknown }> = []
68
+ const contextSpawn = vi.fn(async () => ({
69
+ name: 'zooid-context' as const,
70
+ command: 'node',
71
+ args: ['/bin/zooid-context-mcp.js'],
72
+ env: [],
73
+ }))
74
+ const client = new AcpClient({
75
+ agent: { id: 'architect', command: 'x', args: [] },
76
+ agentDataDir: '/tmp/zooid-acp-test',
77
+ onEvent: () => {},
78
+ onApprovalRequest: async () => ({ decision: 'allow', optionId: 'allow' }),
79
+ contextSpawn,
80
+ })
81
+ const conn = injectConnection(client, calls)
82
+ // @ts-expect-error
83
+ client.store = { load: async () => {}, get: () => 'sess-old', set: async () => {}, delete: async () => {} }
84
+ // @ts-expect-error
85
+ client.storeLoaded = Promise.resolve()
86
+ await client.ensureSession('!room:hs')
87
+ expect(conn.loadSession).toHaveBeenCalled()
88
+ const params = calls.find((c) => c.method === 'loadSession')!.params as {
89
+ mcpServers: Array<{ name: string }>
90
+ }
91
+ expect(params.mcpServers.map((s) => s.name)).toEqual(['zooid-context'])
92
+ })
93
+ })
@@ -0,0 +1,120 @@
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
+ vi.mock('node:child_process', () => ({ spawn: spawnMock }))
7
+
8
+ const { AcpClient } = await import('./acp-client.js')
9
+
10
+ class FakeChild extends EventEmitter {
11
+ stdout = new Readable({ read() {} })
12
+ stdin = new Writable({
13
+ write(_c, _e, cb) {
14
+ cb()
15
+ },
16
+ })
17
+ stderr = new Readable({ read() {} })
18
+ pid = 99999
19
+ kill = vi.fn(() => true)
20
+ }
21
+
22
+ describe('AcpClient preset support', () => {
23
+ beforeEach(() => {
24
+ spawnMock.mockReset()
25
+ spawnMock.mockReturnValue(new FakeChild())
26
+ })
27
+
28
+ afterEach(() => {
29
+ vi.restoreAllMocks()
30
+ })
31
+
32
+ it('spawns the preset command/args when agent.preset is set', async () => {
33
+ const client = new AcpClient({
34
+ agent: { id: 'claude-1', preset: 'claude' },
35
+ onEvent: () => {},
36
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
37
+ })
38
+
39
+ void client.start().catch(() => {})
40
+
41
+ expect(spawnMock).toHaveBeenCalledTimes(1)
42
+ const [cmd, args] = spawnMock.mock.calls[0]
43
+ expect(cmd).toBe('npx')
44
+ expect(args).toEqual(['-y', '@agentclientprotocol/claude-agent-acp'])
45
+
46
+ await client.stop()
47
+ })
48
+
49
+ it('still accepts explicit command/args (no preset)', async () => {
50
+ const client = new AcpClient({
51
+ agent: { id: 'opencode-1', command: 'opencode', args: ['acp'] },
52
+ onEvent: () => {},
53
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
54
+ })
55
+ void client.start().catch(() => {})
56
+
57
+ const [cmd, args] = spawnMock.mock.calls[0]
58
+ expect(cmd).toBe('opencode')
59
+ expect(args).toEqual(['acp'])
60
+ await client.stop()
61
+ })
62
+
63
+ it('lets explicit command override a preset', async () => {
64
+ const client = new AcpClient({
65
+ agent: {
66
+ id: 'claude-custom',
67
+ preset: 'claude',
68
+ command: '/usr/local/bin/my-claude-acp',
69
+ args: ['--verbose'],
70
+ },
71
+ onEvent: () => {},
72
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
73
+ })
74
+ void client.start().catch(() => {})
75
+
76
+ const [cmd, args] = spawnMock.mock.calls[0]
77
+ expect(cmd).toBe('/usr/local/bin/my-claude-acp')
78
+ expect(args).toEqual(['--verbose'])
79
+ await client.stop()
80
+ })
81
+
82
+ it('throws if neither preset nor command is provided', async () => {
83
+ const client = new AcpClient({
84
+ // @ts-expect-error — missing both command and preset
85
+ agent: { id: 'broken' },
86
+ onEvent: () => {},
87
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
88
+ })
89
+ await expect(client.start()).rejects.toThrow(/preset.*or.*command/i)
90
+ })
91
+
92
+ it('throws on an unknown preset name', async () => {
93
+ const client = new AcpClient({
94
+ // @ts-expect-error — preset is typed to known names
95
+ agent: { id: 'broken', preset: 'made-up' },
96
+ onEvent: () => {},
97
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
98
+ })
99
+ await expect(client.start()).rejects.toThrow(/unknown ACP preset/i)
100
+ })
101
+
102
+ it('forwards env and cwd alongside a preset', async () => {
103
+ const client = new AcpClient({
104
+ agent: {
105
+ id: 'claude-2',
106
+ preset: 'claude',
107
+ env: { ANTHROPIC_API_KEY: 'sk-test' },
108
+ cwd: '/workspace',
109
+ },
110
+ onEvent: () => {},
111
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
112
+ })
113
+ void client.start().catch(() => {})
114
+
115
+ const [, , options] = spawnMock.mock.calls[0]
116
+ expect(options.env.ANTHROPIC_API_KEY).toBe('sk-test')
117
+ expect(options.cwd).toBe('/workspace')
118
+ await client.stop()
119
+ })
120
+ })
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { EventEmitter } from 'node:events'
3
+ import { Readable, Writable } from 'node:stream'
4
+ import type { ChildProcess } from 'node:child_process'
5
+ import { AcpClient } from './acp-client.js'
6
+
7
+ class FakeChild extends EventEmitter {
8
+ stdout = new Readable({ read() {} })
9
+ stdin = new Writable({ write(_c, _e, cb) { cb() } })
10
+ stderr = new Readable({ read() {} })
11
+ pid = 1
12
+ kill = vi.fn(() => true)
13
+ }
14
+
15
+ describe('AcpClient honors a passed-in runtime', () => {
16
+ it('delegates the spawn to the runtime when one is provided', () => {
17
+ const child = new FakeChild() as unknown as ChildProcess
18
+ const rt = { spawn: vi.fn().mockReturnValue(child) }
19
+ const client = new AcpClient({
20
+ agent: { id: 'x', command: 'foo', args: ['bar'], env: { K: 'v' }, cwd: '/x' },
21
+ onEvent: () => {},
22
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
23
+ runtime: rt,
24
+ })
25
+ void client.start().catch(() => {})
26
+ expect(rt.spawn).toHaveBeenCalledTimes(1)
27
+ expect(rt.spawn).toHaveBeenCalledWith(
28
+ expect.objectContaining({
29
+ command: 'foo',
30
+ args: ['bar'],
31
+ env: { K: 'v' },
32
+ cwd: '/x',
33
+ }),
34
+ )
35
+ })
36
+
37
+ it('resolves a preset before passing to the runtime', () => {
38
+ const child = new FakeChild() as unknown as ChildProcess
39
+ const rt = { spawn: vi.fn().mockReturnValue(child) }
40
+ const client = new AcpClient({
41
+ agent: { id: 'c', preset: 'claude' },
42
+ onEvent: () => {},
43
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
44
+ runtime: rt,
45
+ })
46
+ void client.start().catch(() => {})
47
+ const arg = rt.spawn.mock.calls[0][0]
48
+ expect(arg.command).toBe('npx')
49
+ expect(arg.args).toEqual(['-y', '@agentclientprotocol/claude-agent-acp'])
50
+ })
51
+ })
@@ -0,0 +1,184 @@
1
+ import { mkdtemp } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import { AcpClient } from './acp-client.js'
6
+
7
+ function makeStubbedClient(opts: {
8
+ agentDataDir: string
9
+ agentId: string
10
+ loadSessionCapability: boolean
11
+ newSessionImpl?: () => Promise<{ sessionId: string }>
12
+ loadSessionImpl?: (req: { sessionId: string }) => Promise<unknown>
13
+ }) {
14
+ const newSession = vi.fn(opts.newSessionImpl ?? (async () => ({ sessionId: 'sess_new' })))
15
+ const loadSession = vi.fn(
16
+ opts.loadSessionImpl ?? (async () => ({})),
17
+ )
18
+ const fakeConnection = {
19
+ initialize: vi.fn(async () => ({
20
+ protocolVersion: 1,
21
+ agentCapabilities: { loadSession: opts.loadSessionCapability },
22
+ })),
23
+ newSession,
24
+ loadSession,
25
+ prompt: vi.fn(async () => ({ stopReason: 'end_turn' })),
26
+ cancel: vi.fn(async () => {}),
27
+ }
28
+
29
+ const client = new AcpClient({
30
+ agent: { id: opts.agentId, command: '/bin/true' },
31
+ agentDataDir: opts.agentDataDir,
32
+ onEvent: () => {},
33
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
34
+ })
35
+ ;(client as unknown as { connection: typeof fakeConnection }).connection = fakeConnection
36
+ ;(client as unknown as { initialized: boolean }).initialized = true
37
+ ;(client as unknown as { agentCapabilities: { loadSession?: boolean } }).agentCapabilities = {
38
+ loadSession: opts.loadSessionCapability,
39
+ }
40
+ return { client, fakeConnection, newSession, loadSession }
41
+ }
42
+
43
+ describe('AcpClient.ensureSession resume', () => {
44
+ let agentDataDir: string
45
+ beforeEach(async () => {
46
+ agentDataDir = await mkdtemp(join(tmpdir(), 'acpclient-ensure-'))
47
+ })
48
+ afterEach(async () => {
49
+ await import('node:fs/promises').then((fs) => fs.rm(agentDataDir, { recursive: true, force: true }))
50
+ })
51
+
52
+ it('first call mints via newSession and persists the id', async () => {
53
+ const { client, newSession, loadSession } = makeStubbedClient({
54
+ agentDataDir,
55
+ agentId: 'docs',
56
+ loadSessionCapability: true,
57
+ newSessionImpl: async () => ({ sessionId: 'sess_first' }),
58
+ })
59
+ await (client as unknown as { ensureStoreLoaded: () => Promise<void> }).ensureStoreLoaded()
60
+
61
+ const id = await client.ensureSession('$root1')
62
+ expect(id).toBe('sess_first')
63
+ expect(newSession).toHaveBeenCalledTimes(1)
64
+ expect(loadSession).not.toHaveBeenCalled()
65
+ })
66
+
67
+ it('a fresh AcpClient on the same agent data dir resumes via loadSession when capability is on', async () => {
68
+ {
69
+ const a = makeStubbedClient({
70
+ agentDataDir,
71
+ agentId: 'docs',
72
+ loadSessionCapability: true,
73
+ newSessionImpl: async () => ({ sessionId: 'sess_persist' }),
74
+ })
75
+ await (a.client as unknown as { ensureStoreLoaded: () => Promise<void> }).ensureStoreLoaded()
76
+ await a.client.ensureSession('$root1')
77
+ }
78
+
79
+ const b = makeStubbedClient({
80
+ agentDataDir,
81
+ agentId: 'docs',
82
+ loadSessionCapability: true,
83
+ })
84
+ await (b.client as unknown as { ensureStoreLoaded: () => Promise<void> }).ensureStoreLoaded()
85
+
86
+ const id = await b.client.ensureSession('$root1')
87
+ expect(id).toBe('sess_persist')
88
+ expect(b.loadSession).toHaveBeenCalledWith(
89
+ expect.objectContaining({ sessionId: 'sess_persist' }),
90
+ )
91
+ expect(b.newSession).not.toHaveBeenCalled()
92
+ })
93
+
94
+ it('skips loadSession when the agent does not advertise the capability', async () => {
95
+ {
96
+ const a = makeStubbedClient({
97
+ agentDataDir,
98
+ agentId: 'docs',
99
+ loadSessionCapability: false,
100
+ newSessionImpl: async () => ({ sessionId: 'sess_one' }),
101
+ })
102
+ await (a.client as unknown as { ensureStoreLoaded: () => Promise<void> }).ensureStoreLoaded()
103
+ await a.client.ensureSession('$root1')
104
+ }
105
+
106
+ const b = makeStubbedClient({
107
+ agentDataDir,
108
+ agentId: 'docs',
109
+ loadSessionCapability: false,
110
+ newSessionImpl: async () => ({ sessionId: 'sess_two' }),
111
+ })
112
+ await (b.client as unknown as { ensureStoreLoaded: () => Promise<void> }).ensureStoreLoaded()
113
+
114
+ const id = await b.client.ensureSession('$root1')
115
+ expect(id).toBe('sess_two')
116
+ expect(b.loadSession).not.toHaveBeenCalled()
117
+ expect(b.newSession).toHaveBeenCalledTimes(1)
118
+ })
119
+
120
+ it('falls back to newSession when loadSession rejects, and clears the stored id', async () => {
121
+ {
122
+ const a = makeStubbedClient({
123
+ agentDataDir,
124
+ agentId: 'docs',
125
+ loadSessionCapability: true,
126
+ newSessionImpl: async () => ({ sessionId: 'sess_old' }),
127
+ })
128
+ await (a.client as unknown as { ensureStoreLoaded: () => Promise<void> }).ensureStoreLoaded()
129
+ await a.client.ensureSession('$root1')
130
+ }
131
+
132
+ const b = makeStubbedClient({
133
+ agentDataDir,
134
+ agentId: 'docs',
135
+ loadSessionCapability: true,
136
+ loadSessionImpl: async () => {
137
+ throw new Error('shim does not recognise this id')
138
+ },
139
+ newSessionImpl: async () => ({ sessionId: 'sess_replacement' }),
140
+ })
141
+ await (b.client as unknown as { ensureStoreLoaded: () => Promise<void> }).ensureStoreLoaded()
142
+
143
+ const id = await b.client.ensureSession('$root1')
144
+ expect(id).toBe('sess_replacement')
145
+ expect(b.loadSession).toHaveBeenCalledTimes(1)
146
+ expect(b.newSession).toHaveBeenCalledTimes(1)
147
+
148
+ const c = makeStubbedClient({
149
+ agentDataDir,
150
+ agentId: 'docs',
151
+ loadSessionCapability: true,
152
+ })
153
+ await (c.client as unknown as { ensureStoreLoaded: () => Promise<void> }).ensureStoreLoaded()
154
+ const id3 = await c.client.ensureSession('$root1')
155
+ expect(id3).toBe('sess_replacement')
156
+ expect(c.loadSession).toHaveBeenCalledWith(
157
+ expect.objectContaining({ sessionId: 'sess_replacement' }),
158
+ )
159
+ })
160
+
161
+ it('endSession() also clears the persisted id', async () => {
162
+ const { client } = makeStubbedClient({
163
+ agentDataDir,
164
+ agentId: 'docs',
165
+ loadSessionCapability: true,
166
+ newSessionImpl: async () => ({ sessionId: 'sess_one' }),
167
+ })
168
+ await (client as unknown as { ensureStoreLoaded: () => Promise<void> }).ensureStoreLoaded()
169
+ await client.ensureSession('$root1')
170
+ client.endSession('$root1')
171
+ await (client as unknown as { flushStore: () => Promise<void> }).flushStore()
172
+
173
+ const fresh = makeStubbedClient({
174
+ agentDataDir,
175
+ agentId: 'docs',
176
+ loadSessionCapability: true,
177
+ newSessionImpl: async () => ({ sessionId: 'sess_after_clear' }),
178
+ })
179
+ await (fresh.client as unknown as { ensureStoreLoaded: () => Promise<void> }).ensureStoreLoaded()
180
+ const id = await fresh.client.ensureSession('$root1')
181
+ expect(id).toBe('sess_after_clear')
182
+ expect(fresh.loadSession).not.toHaveBeenCalled()
183
+ })
184
+ })
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { EventEmitter } from 'node:events'
3
+ import { Readable, Writable } from 'node:stream'
4
+ import type { ChildProcess } from 'node:child_process'
5
+ import { AcpClient } from './acp-client.js'
6
+
7
+ class FakeChild extends EventEmitter {
8
+ stdout = new Readable({ read() {} })
9
+ stdin = new Writable({ write(_c, _e, cb) { cb() } })
10
+ stderr = new Readable({ read() {} })
11
+ pid = 1
12
+ kill = vi.fn(() => true)
13
+ }
14
+
15
+ describe('AcpClient onTap', () => {
16
+ it('accepts an optional onTap option without breaking the existing surface', () => {
17
+ const child = new FakeChild() as unknown as ChildProcess
18
+ const rt = { spawn: vi.fn().mockReturnValue(child) }
19
+ expect(() => {
20
+ new AcpClient({
21
+ agent: { id: 'x', command: 'foo', args: [] },
22
+ onEvent: () => {},
23
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
24
+ runtime: rt,
25
+ // onTap is optional
26
+ })
27
+ new AcpClient({
28
+ agent: { id: 'y', command: 'foo', args: [] },
29
+ onEvent: () => {},
30
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
31
+ runtime: rt,
32
+ onTap: () => {},
33
+ })
34
+ }).not.toThrow()
35
+ })
36
+ })