@plaited/acp-harness 0.2.5
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/.claude/rules/accuracy.md +43 -0
- package/.claude/rules/bun-apis.md +80 -0
- package/.claude/rules/code-review.md +254 -0
- package/.claude/rules/git-workflow.md +37 -0
- package/.claude/rules/github.md +154 -0
- package/.claude/rules/testing.md +172 -0
- package/.claude/skills/acp-harness/SKILL.md +310 -0
- package/.claude/skills/acp-harness/assets/Dockerfile.acp +25 -0
- package/.claude/skills/acp-harness/assets/docker-compose.acp.yml +19 -0
- package/.claude/skills/acp-harness/references/downstream.md +288 -0
- package/.claude/skills/acp-harness/references/output-formats.md +221 -0
- package/.claude-plugin/marketplace.json +15 -0
- package/.claude-plugin/plugin.json +16 -0
- package/.github/CODEOWNERS +6 -0
- package/.github/workflows/ci.yml +63 -0
- package/.github/workflows/publish.yml +146 -0
- package/.mcp.json +20 -0
- package/CLAUDE.md +92 -0
- package/Dockerfile.test +23 -0
- package/LICENSE +15 -0
- package/README.md +94 -0
- package/bin/cli.ts +670 -0
- package/bin/tests/cli.spec.ts +362 -0
- package/biome.json +96 -0
- package/bun.lock +513 -0
- package/docker-compose.test.yml +21 -0
- package/package.json +57 -0
- package/scripts/bun-test-wrapper.sh +46 -0
- package/src/acp-client.ts +503 -0
- package/src/acp-helpers.ts +121 -0
- package/src/acp-transport.ts +455 -0
- package/src/acp-utils.ts +341 -0
- package/src/acp.constants.ts +56 -0
- package/src/acp.schemas.ts +161 -0
- package/src/acp.ts +27 -0
- package/src/acp.types.ts +28 -0
- package/src/tests/acp-client.spec.ts +205 -0
- package/src/tests/acp-helpers.spec.ts +105 -0
- package/src/tests/acp-integration.docker.ts +214 -0
- package/src/tests/acp-transport.spec.ts +153 -0
- package/src/tests/acp-utils.spec.ts +394 -0
- package/src/tests/fixtures/.claude/settings.local.json +8 -0
- package/src/tests/fixtures/.claude/skills/greeting/SKILL.md +17 -0
- package/src/tests/fixtures/calculator-mcp.ts +215 -0
- package/tsconfig.json +32 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { ACPClientError, createACPClient } from '../acp-client.ts'
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// ACPClientError Tests
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
describe('ACPClientError', () => {
|
|
9
|
+
test('creates error with message only', () => {
|
|
10
|
+
const error = new ACPClientError('Connection failed')
|
|
11
|
+
expect(error.message).toBe('Connection failed')
|
|
12
|
+
expect(error.name).toBe('ACPClientError')
|
|
13
|
+
expect(error.code).toBeUndefined()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('creates error with code', () => {
|
|
17
|
+
const error = new ACPClientError('Not connected', 'NOT_CONNECTED')
|
|
18
|
+
expect(error.code).toBe('NOT_CONNECTED')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('is instance of Error', () => {
|
|
22
|
+
const error = new ACPClientError('Test')
|
|
23
|
+
expect(error instanceof Error).toBe(true)
|
|
24
|
+
expect(error instanceof ACPClientError).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Client Factory Tests
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
describe('createACPClient', () => {
|
|
33
|
+
test('creates client with minimal config', () => {
|
|
34
|
+
const client = createACPClient({
|
|
35
|
+
command: ['echo', 'test'],
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(client).toBeDefined()
|
|
39
|
+
expect(typeof client.connect).toBe('function')
|
|
40
|
+
expect(typeof client.disconnect).toBe('function')
|
|
41
|
+
expect(typeof client.createSession).toBe('function')
|
|
42
|
+
expect(typeof client.prompt).toBe('function')
|
|
43
|
+
expect(typeof client.promptSync).toBe('function')
|
|
44
|
+
expect(typeof client.cancelPrompt).toBe('function')
|
|
45
|
+
expect(typeof client.getCapabilities).toBe('function')
|
|
46
|
+
expect(typeof client.getInitializeResult).toBe('function')
|
|
47
|
+
expect(typeof client.isConnected).toBe('function')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('creates client with full config', () => {
|
|
51
|
+
const client = createACPClient({
|
|
52
|
+
command: ['claude', 'code'],
|
|
53
|
+
cwd: '/tmp',
|
|
54
|
+
env: { TEST: 'value' },
|
|
55
|
+
clientInfo: { name: 'test-client', version: '1.0.0' },
|
|
56
|
+
capabilities: { fs: { readTextFile: true } },
|
|
57
|
+
timeout: 60000,
|
|
58
|
+
onPermissionRequest: async () => ({ outcome: { outcome: 'cancelled' } }),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
expect(client).toBeDefined()
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// State Methods (before connection)
|
|
67
|
+
// ============================================================================
|
|
68
|
+
|
|
69
|
+
describe('Client state before connection', () => {
|
|
70
|
+
test('isConnected returns false before connect', () => {
|
|
71
|
+
const client = createACPClient({
|
|
72
|
+
command: ['echo', 'test'],
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
expect(client.isConnected()).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('getCapabilities returns undefined before connect', () => {
|
|
79
|
+
const client = createACPClient({
|
|
80
|
+
command: ['echo', 'test'],
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
expect(client.getCapabilities()).toBeUndefined()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('getInitializeResult returns undefined before connect', () => {
|
|
87
|
+
const client = createACPClient({
|
|
88
|
+
command: ['echo', 'test'],
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(client.getInitializeResult()).toBeUndefined()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// Operations Before Connection
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
describe('Operations before connection', () => {
|
|
100
|
+
test('createSession throws when not connected', async () => {
|
|
101
|
+
const client = createACPClient({
|
|
102
|
+
command: ['echo', 'test'],
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
await expect(client.createSession({ cwd: '/tmp', mcpServers: [] })).rejects.toThrow('Not connected')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('promptSync throws when not connected', async () => {
|
|
109
|
+
const client = createACPClient({
|
|
110
|
+
command: ['echo', 'test'],
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
await expect(client.promptSync('session-1', [{ type: 'text', text: 'Hello' }])).rejects.toThrow('Not connected')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('cancelPrompt throws when not connected', async () => {
|
|
117
|
+
const client = createACPClient({
|
|
118
|
+
command: ['echo', 'test'],
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
await expect(client.cancelPrompt('session-1')).rejects.toThrow('Not connected')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('prompt generator throws when not connected', async () => {
|
|
125
|
+
const client = createACPClient({
|
|
126
|
+
command: ['echo', 'test'],
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const generator = client.prompt('session-1', [{ type: 'text', text: 'Hello' }])
|
|
130
|
+
|
|
131
|
+
await expect(generator.next()).rejects.toThrow('Not connected')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// ============================================================================
|
|
136
|
+
// Disconnect Safety
|
|
137
|
+
// ============================================================================
|
|
138
|
+
|
|
139
|
+
describe('Disconnect safety', () => {
|
|
140
|
+
test('disconnect is safe when not connected', async () => {
|
|
141
|
+
const client = createACPClient({
|
|
142
|
+
command: ['echo', 'test'],
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// Should not throw
|
|
146
|
+
await client.disconnect()
|
|
147
|
+
expect(client.isConnected()).toBe(false)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('disconnect with graceful=false is safe when not connected', async () => {
|
|
151
|
+
const client = createACPClient({
|
|
152
|
+
command: ['echo', 'test'],
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// Should not throw
|
|
156
|
+
await client.disconnect(false)
|
|
157
|
+
expect(client.isConnected()).toBe(false)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// ============================================================================
|
|
162
|
+
// Integration Tests with Mock Process
|
|
163
|
+
// ============================================================================
|
|
164
|
+
|
|
165
|
+
describe('Client with mock process', () => {
|
|
166
|
+
test('connect starts transport', async () => {
|
|
167
|
+
const client = createACPClient({
|
|
168
|
+
command: ['cat'], // cat echoes back input
|
|
169
|
+
timeout: 1000,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Start connection - cat won't respond with proper JSON-RPC
|
|
173
|
+
// so this will timeout, but it tests the transport startup
|
|
174
|
+
try {
|
|
175
|
+
await client.connect()
|
|
176
|
+
} catch {
|
|
177
|
+
// Expected - cat doesn't speak JSON-RPC
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Cleanup
|
|
181
|
+
await client.disconnect(false)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('connect throws when already connected', async () => {
|
|
185
|
+
const client = createACPClient({
|
|
186
|
+
command: ['cat'],
|
|
187
|
+
timeout: 500,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// Start first connection
|
|
191
|
+
const connectPromise = client.connect()
|
|
192
|
+
|
|
193
|
+
// Immediately try second connection (before first completes)
|
|
194
|
+
// This should throw because transport is started
|
|
195
|
+
await expect(client.connect()).rejects.toThrow('Already connected')
|
|
196
|
+
|
|
197
|
+
// Cleanup - wait for first connect to timeout then disconnect
|
|
198
|
+
try {
|
|
199
|
+
await connectPromise
|
|
200
|
+
} catch {
|
|
201
|
+
// Expected timeout
|
|
202
|
+
}
|
|
203
|
+
await client.disconnect(false)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
|
3
|
+
import { createPrompt, createPromptWithFiles, createPromptWithImage, summarizeResponse } from '../acp-helpers.ts'
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Prompt Building Utilities
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
describe('createPrompt', () => {
|
|
10
|
+
test('creates single text block prompt', () => {
|
|
11
|
+
const prompt = createPrompt('Hello agent')
|
|
12
|
+
expect(prompt).toHaveLength(1)
|
|
13
|
+
expect(prompt[0]).toEqual({ type: 'text', text: 'Hello agent' })
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('createPromptWithFiles', () => {
|
|
18
|
+
test('creates prompt with file context', () => {
|
|
19
|
+
const prompt = createPromptWithFiles('Analyze this', [
|
|
20
|
+
{ path: '/src/main.ts', content: 'const x = 1;' },
|
|
21
|
+
{ path: '/src/utils.ts', content: 'export const y = 2;' },
|
|
22
|
+
])
|
|
23
|
+
expect(prompt).toHaveLength(3)
|
|
24
|
+
expect(prompt[0]).toEqual({ type: 'text', text: 'Analyze this' })
|
|
25
|
+
expect(prompt[1]?.type).toBe('resource')
|
|
26
|
+
expect(prompt[2]?.type).toBe('resource')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('createPromptWithImage', () => {
|
|
31
|
+
test('creates prompt with image', () => {
|
|
32
|
+
const prompt = createPromptWithImage({ text: 'Describe this', imageData: 'base64img', mimeType: 'image/png' })
|
|
33
|
+
expect(prompt).toHaveLength(2)
|
|
34
|
+
expect(prompt[0]).toEqual({ type: 'text', text: 'Describe this' })
|
|
35
|
+
expect(prompt[1]).toEqual({
|
|
36
|
+
type: 'image',
|
|
37
|
+
data: 'base64img',
|
|
38
|
+
mimeType: 'image/png',
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Response Analysis
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
describe('summarizeResponse', () => {
|
|
48
|
+
test('creates comprehensive summary', () => {
|
|
49
|
+
const notifications: SessionNotification[] = [
|
|
50
|
+
{
|
|
51
|
+
sessionId: 's1',
|
|
52
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Processing...' } },
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
sessionId: 's1',
|
|
56
|
+
update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read', status: 'in_progress' },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
sessionId: 's1',
|
|
60
|
+
update: {
|
|
61
|
+
sessionUpdate: 'plan',
|
|
62
|
+
entries: [{ content: 'Step 1', status: 'in_progress', priority: 'high' }],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
sessionId: 's1',
|
|
67
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Done!' } },
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
sessionId: 's1',
|
|
71
|
+
update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read', status: 'completed' },
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
sessionId: 's1',
|
|
75
|
+
update: {
|
|
76
|
+
sessionUpdate: 'plan',
|
|
77
|
+
entries: [{ content: 'Step 1', status: 'completed', priority: 'high' }],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
const summary = summarizeResponse(notifications)
|
|
83
|
+
|
|
84
|
+
expect(summary.text).toBe('Processing...Done!')
|
|
85
|
+
expect(summary.toolCallCount).toBe(1)
|
|
86
|
+
expect(summary.completedToolCalls).toHaveLength(1)
|
|
87
|
+
expect(summary.failedToolCalls).toHaveLength(0)
|
|
88
|
+
expect(summary.plan).toHaveLength(1)
|
|
89
|
+
expect(summary.planProgress).toBe(100)
|
|
90
|
+
expect(summary.hasErrors).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('detects errors in summary', () => {
|
|
94
|
+
const notifications: SessionNotification[] = [
|
|
95
|
+
{
|
|
96
|
+
sessionId: 's1',
|
|
97
|
+
update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read', status: 'failed' },
|
|
98
|
+
},
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
const summary = summarizeResponse(notifications)
|
|
102
|
+
expect(summary.hasErrors).toBe(true)
|
|
103
|
+
expect(summary.failedToolCalls).toHaveLength(1)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP Client Integration Tests
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* These tests verify the ACP client works against real Claude Code
|
|
6
|
+
* via the `claude-code-acp` adapter.
|
|
7
|
+
*
|
|
8
|
+
* **Run in Docker only** for consistent environment:
|
|
9
|
+
* ```bash
|
|
10
|
+
* ANTHROPIC_API_KEY=sk-... bun run test:acp
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Prerequisites:
|
|
14
|
+
* 1. Docker installed
|
|
15
|
+
* 2. API key: `ANTHROPIC_API_KEY` environment variable
|
|
16
|
+
*
|
|
17
|
+
* These tests make real API calls and consume credits.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from 'bun:test'
|
|
21
|
+
import { type ACPClient, createACPClient } from '../acp-client.ts'
|
|
22
|
+
import { createPrompt, summarizeResponse } from '../acp-helpers.ts'
|
|
23
|
+
|
|
24
|
+
// Long timeout for real agent interactions (2 minutes)
|
|
25
|
+
setDefaultTimeout(120000)
|
|
26
|
+
|
|
27
|
+
// Fixtures directory with .claude/skills and .mcp.json
|
|
28
|
+
const FIXTURES_DIR = `${import.meta.dir}/fixtures`
|
|
29
|
+
|
|
30
|
+
// Use haiku for all tests to reduce costs
|
|
31
|
+
const TEST_MODEL = 'claude-haiku-4-5-20251001'
|
|
32
|
+
|
|
33
|
+
describe('ACP Client Integration', () => {
|
|
34
|
+
let client: ACPClient
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
// cc-acp adapter expects ANTHROPIC_API_KEY
|
|
38
|
+
client = createACPClient({
|
|
39
|
+
command: ['bunx', 'claude-code-acp'],
|
|
40
|
+
timeout: 120000, // 2 min timeout for initialization
|
|
41
|
+
env: {
|
|
42
|
+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? '',
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
await client.connect()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterAll(async () => {
|
|
50
|
+
await client?.disconnect()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('connects and initializes', () => {
|
|
54
|
+
expect(client.isConnected()).toBe(true)
|
|
55
|
+
|
|
56
|
+
const initResult = client.getInitializeResult()
|
|
57
|
+
expect(initResult).toBeDefined()
|
|
58
|
+
expect(initResult?.protocolVersion).toBeDefined()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('reports agent capabilities', () => {
|
|
62
|
+
const capabilities = client.getCapabilities()
|
|
63
|
+
expect(capabilities).toBeDefined()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('creates session', async () => {
|
|
67
|
+
const session = await client.createSession({
|
|
68
|
+
cwd: FIXTURES_DIR,
|
|
69
|
+
mcpServers: [],
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
expect(session).toBeDefined()
|
|
73
|
+
expect(session.id).toBeDefined()
|
|
74
|
+
expect(typeof session.id).toBe('string')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('sends prompt and receives response', async () => {
|
|
78
|
+
const session = await client.createSession({
|
|
79
|
+
cwd: FIXTURES_DIR,
|
|
80
|
+
mcpServers: [],
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// Use haiku for faster/cheaper test runs
|
|
84
|
+
await client.setModel(session.id, TEST_MODEL)
|
|
85
|
+
|
|
86
|
+
// Simple prompt that doesn't require tools
|
|
87
|
+
const { result, updates } = await client.promptSync(
|
|
88
|
+
session.id,
|
|
89
|
+
createPrompt('What is 2 + 2? Reply with just the number.'),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(result).toBeDefined()
|
|
93
|
+
expect(updates).toBeInstanceOf(Array)
|
|
94
|
+
|
|
95
|
+
// Summarize and verify response structure
|
|
96
|
+
const summary = summarizeResponse(updates)
|
|
97
|
+
expect(summary.text).toBeDefined()
|
|
98
|
+
expect(summary.text.length).toBeGreaterThan(0)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('streaming prompt yields updates', async () => {
|
|
102
|
+
const session = await client.createSession({
|
|
103
|
+
cwd: FIXTURES_DIR,
|
|
104
|
+
mcpServers: [],
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Use haiku for faster/cheaper test runs
|
|
108
|
+
await client.setModel(session.id, TEST_MODEL)
|
|
109
|
+
|
|
110
|
+
const events: string[] = []
|
|
111
|
+
|
|
112
|
+
for await (const event of client.prompt(session.id, createPrompt('Say "hello" and nothing else.'))) {
|
|
113
|
+
events.push(event.type)
|
|
114
|
+
if (event.type === 'complete') {
|
|
115
|
+
expect(event.result).toBeDefined()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
expect(events).toContain('complete')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('handles tool usage prompt', async () => {
|
|
123
|
+
const session = await client.createSession({
|
|
124
|
+
cwd: FIXTURES_DIR,
|
|
125
|
+
mcpServers: [],
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Use haiku for faster/cheaper test runs
|
|
129
|
+
await client.setModel(session.id, TEST_MODEL)
|
|
130
|
+
|
|
131
|
+
// Prompt that should trigger tool usage - reading a specific file
|
|
132
|
+
const { updates } = await client.promptSync(
|
|
133
|
+
session.id,
|
|
134
|
+
createPrompt('Use the Read tool to read calculator-mcp.ts and tell me what tools the MCP server provides.'),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const summary = summarizeResponse(updates)
|
|
138
|
+
|
|
139
|
+
// Verify response mentions calculator tools
|
|
140
|
+
expect(summary.text.length).toBeGreaterThan(0)
|
|
141
|
+
// Response should mention the calculator tools (add, subtract, etc.)
|
|
142
|
+
expect(summary.text.toLowerCase()).toMatch(/add|subtract|multiply|divide|calculator/)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('uses skill from cwd', async () => {
|
|
146
|
+
const session = await client.createSession({
|
|
147
|
+
cwd: FIXTURES_DIR,
|
|
148
|
+
mcpServers: [],
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// Use haiku for faster/cheaper test runs
|
|
152
|
+
await client.setModel(session.id, TEST_MODEL)
|
|
153
|
+
|
|
154
|
+
// Ask Claude to use the greeting skill
|
|
155
|
+
const { updates } = await client.promptSync(session.id, createPrompt('Please greet me using the greeting skill.'))
|
|
156
|
+
|
|
157
|
+
const summary = summarizeResponse(updates)
|
|
158
|
+
|
|
159
|
+
// The greeting skill instructs Claude to include specific phrases
|
|
160
|
+
expect(summary.text.length).toBeGreaterThan(0)
|
|
161
|
+
expect(summary.text.toLowerCase()).toMatch(/hello|greet|welcome/)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('uses MCP server tools', async () => {
|
|
165
|
+
// Path to calculator MCP server fixture (must be absolute per ACP spec)
|
|
166
|
+
const calculatorPath = `${FIXTURES_DIR}/calculator-mcp.ts`
|
|
167
|
+
const bunPath = Bun.which('bun') ?? 'bun'
|
|
168
|
+
|
|
169
|
+
// Retry helper for flaky MCP server startup
|
|
170
|
+
const maxRetries = 3
|
|
171
|
+
let lastError: Error | undefined
|
|
172
|
+
|
|
173
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
174
|
+
const session = await client.createSession({
|
|
175
|
+
cwd: FIXTURES_DIR,
|
|
176
|
+
mcpServers: [
|
|
177
|
+
{
|
|
178
|
+
name: 'calculator',
|
|
179
|
+
command: bunPath,
|
|
180
|
+
args: [calculatorPath],
|
|
181
|
+
env: [],
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// Set model to haiku for faster/cheaper test runs
|
|
187
|
+
await client.setModel(session.id, TEST_MODEL)
|
|
188
|
+
|
|
189
|
+
// Ask Claude to use the calculator MCP server
|
|
190
|
+
const { updates } = await client.promptSync(
|
|
191
|
+
session.id,
|
|
192
|
+
createPrompt('Use the calculator MCP server add tool to compute 15 + 27. Reply with just the number.'),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
const summary = summarizeResponse(updates)
|
|
196
|
+
|
|
197
|
+
// Check if we got 42 in the response
|
|
198
|
+
if (summary.text.match(/42/)) {
|
|
199
|
+
expect(summary.text.length).toBeGreaterThan(0)
|
|
200
|
+
expect(summary.text).toMatch(/42/)
|
|
201
|
+
return // Success!
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// MCP server might not have been ready, retry
|
|
205
|
+
lastError = new Error(`Attempt ${attempt}: Response did not contain 42. Got: ${summary.text.slice(0, 100)}...`)
|
|
206
|
+
if (attempt < maxRetries) {
|
|
207
|
+
console.log(`MCP test attempt ${attempt} failed, retrying...`)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// All retries exhausted
|
|
212
|
+
throw lastError ?? new Error('MCP test failed after all retries')
|
|
213
|
+
})
|
|
214
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { createACPTransport } from '../acp-transport.ts'
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Transport Creation Tests (without spawning)
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
describe('createACPTransport', () => {
|
|
9
|
+
test('throws on empty command', async () => {
|
|
10
|
+
const transport = createACPTransport({
|
|
11
|
+
command: [],
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
await expect(transport.start()).rejects.toThrow('Command array is empty')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('isConnected returns false before start', async () => {
|
|
18
|
+
const transport = createACPTransport({
|
|
19
|
+
command: ['echo', 'test'],
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
expect(transport.isConnected()).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('request throws when not connected', async () => {
|
|
26
|
+
const transport = createACPTransport({
|
|
27
|
+
command: ['echo', 'test'],
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
await expect(transport.request('test/method')).rejects.toThrow('Transport is not connected')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('notify throws when not connected', async () => {
|
|
34
|
+
const transport = createACPTransport({
|
|
35
|
+
command: ['echo', 'test'],
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
await expect(transport.notify('test/notification')).rejects.toThrow('Transport is not connected')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('close is safe when not started', async () => {
|
|
42
|
+
const transport = createACPTransport({
|
|
43
|
+
command: ['echo', 'test'],
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Should not throw
|
|
47
|
+
await transport.close()
|
|
48
|
+
expect(transport.isConnected()).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Mock Subprocess Integration Tests
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
describe('Transport with mock subprocess', () => {
|
|
57
|
+
test('starts transport with valid command', async () => {
|
|
58
|
+
const transport = createACPTransport({
|
|
59
|
+
command: ['cat'], // cat echoes back input
|
|
60
|
+
timeout: 1000,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
await transport.start()
|
|
64
|
+
expect(transport.isConnected()).toBe(true)
|
|
65
|
+
|
|
66
|
+
// Close immediately since cat doesn't speak JSON-RPC
|
|
67
|
+
await transport.close(false)
|
|
68
|
+
expect(transport.isConnected()).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('throws on duplicate start', async () => {
|
|
72
|
+
const transport = createACPTransport({
|
|
73
|
+
command: ['cat'],
|
|
74
|
+
timeout: 1000,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
await transport.start()
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await expect(transport.start()).rejects.toThrow('Transport already started')
|
|
81
|
+
} finally {
|
|
82
|
+
await transport.close(false)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('handles process exit', async () => {
|
|
87
|
+
const { createACPTransport } = await import('../acp-transport.ts')
|
|
88
|
+
|
|
89
|
+
let closeCalled = false
|
|
90
|
+
let closeCode: number | null = null
|
|
91
|
+
|
|
92
|
+
const transport = createACPTransport({
|
|
93
|
+
command: ['true'], // exits immediately with code 0
|
|
94
|
+
timeout: 1000,
|
|
95
|
+
onClose: (code) => {
|
|
96
|
+
closeCalled = true
|
|
97
|
+
closeCode = code
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
await transport.start()
|
|
102
|
+
|
|
103
|
+
// Wait for process to exit
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
105
|
+
|
|
106
|
+
expect(closeCalled).toBe(true)
|
|
107
|
+
expect(closeCode === 0).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('handles invalid command', async () => {
|
|
111
|
+
const transport = createACPTransport({
|
|
112
|
+
command: ['nonexistent-command-that-does-not-exist-12345'],
|
|
113
|
+
timeout: 1000,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// Bun.spawn may throw or exit with error depending on the command
|
|
117
|
+
try {
|
|
118
|
+
await transport.start()
|
|
119
|
+
// If it doesn't throw, wait for process exit
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
121
|
+
} catch {
|
|
122
|
+
// Expected - command not found
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// ============================================================================
|
|
128
|
+
// Error Handling Tests
|
|
129
|
+
// ============================================================================
|
|
130
|
+
|
|
131
|
+
describe('Transport error handling', () => {
|
|
132
|
+
test('request times out when no response received', async () => {
|
|
133
|
+
// TODO(human): Implement timeout test
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('close rejects pending requests', async () => {
|
|
137
|
+
const transport = createACPTransport({
|
|
138
|
+
command: ['cat'],
|
|
139
|
+
timeout: 5000,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
await transport.start()
|
|
143
|
+
|
|
144
|
+
// Start a request that will never complete (cat doesn't speak JSON-RPC)
|
|
145
|
+
const requestPromise = transport.request('test/method')
|
|
146
|
+
|
|
147
|
+
// Close transport while request is pending
|
|
148
|
+
await transport.close(false)
|
|
149
|
+
|
|
150
|
+
// Request should be rejected with "Transport closed"
|
|
151
|
+
await expect(requestPromise).rejects.toThrow('Transport closed')
|
|
152
|
+
})
|
|
153
|
+
})
|