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