@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.
Files changed (45) hide show
  1. package/.claude/rules/accuracy.md +43 -0
  2. package/.claude/rules/bun-apis.md +80 -0
  3. package/.claude/rules/code-review.md +254 -0
  4. package/.claude/rules/git-workflow.md +37 -0
  5. package/.claude/rules/github.md +154 -0
  6. package/.claude/rules/testing.md +172 -0
  7. package/.claude/skills/acp-harness/SKILL.md +310 -0
  8. package/.claude/skills/acp-harness/assets/Dockerfile.acp +25 -0
  9. package/.claude/skills/acp-harness/assets/docker-compose.acp.yml +19 -0
  10. package/.claude/skills/acp-harness/references/downstream.md +288 -0
  11. package/.claude/skills/acp-harness/references/output-formats.md +221 -0
  12. package/.claude-plugin/marketplace.json +15 -0
  13. package/.claude-plugin/plugin.json +16 -0
  14. package/.github/CODEOWNERS +6 -0
  15. package/.github/workflows/ci.yml +63 -0
  16. package/.github/workflows/publish.yml +146 -0
  17. package/.mcp.json +20 -0
  18. package/CLAUDE.md +92 -0
  19. package/Dockerfile.test +23 -0
  20. package/LICENSE +15 -0
  21. package/README.md +94 -0
  22. package/bin/cli.ts +670 -0
  23. package/bin/tests/cli.spec.ts +362 -0
  24. package/biome.json +96 -0
  25. package/bun.lock +513 -0
  26. package/docker-compose.test.yml +21 -0
  27. package/package.json +57 -0
  28. package/scripts/bun-test-wrapper.sh +46 -0
  29. package/src/acp-client.ts +503 -0
  30. package/src/acp-helpers.ts +121 -0
  31. package/src/acp-transport.ts +455 -0
  32. package/src/acp-utils.ts +341 -0
  33. package/src/acp.constants.ts +56 -0
  34. package/src/acp.schemas.ts +161 -0
  35. package/src/acp.ts +27 -0
  36. package/src/acp.types.ts +28 -0
  37. package/src/tests/acp-client.spec.ts +205 -0
  38. package/src/tests/acp-helpers.spec.ts +105 -0
  39. package/src/tests/acp-integration.docker.ts +214 -0
  40. package/src/tests/acp-transport.spec.ts +153 -0
  41. package/src/tests/acp-utils.spec.ts +394 -0
  42. package/src/tests/fixtures/.claude/settings.local.json +8 -0
  43. package/src/tests/fixtures/.claude/skills/greeting/SKILL.md +17 -0
  44. package/src/tests/fixtures/calculator-mcp.ts +215 -0
  45. 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
+ })