@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
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@plaited/acp-harness",
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"description": "CLI tool for capturing agent trajectories from ACP-compatible agents",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"engines": {
|
|
7
|
+
"bun": ">= v1.2.9"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/plaited/acp-harness.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/plaited/acp-harness/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/plaited/acp-harness/tree/main#readme",
|
|
17
|
+
"bin": {
|
|
18
|
+
"acp-harness": "./bin/cli.ts"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"check": "bun run check:biome && bun run check:types && bun run check:package",
|
|
26
|
+
"check:biome": "biome check",
|
|
27
|
+
"check:package": "format-package --check",
|
|
28
|
+
"check:types": "tsc --noEmit",
|
|
29
|
+
"check:write": "biome check --write && format-package --write",
|
|
30
|
+
"prepare": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath .hooks || true",
|
|
31
|
+
"test": "bun test ./src/ ./bin/ ./.claude",
|
|
32
|
+
"test:docker": "docker compose -f docker-compose.test.yml run --rm acp-test"
|
|
33
|
+
},
|
|
34
|
+
"lint-staged": {
|
|
35
|
+
"*.{js,cjs,jsx,tsx,ts}": [
|
|
36
|
+
"bunx biome check --write --files-ignore-unknown"
|
|
37
|
+
],
|
|
38
|
+
"package.json": [
|
|
39
|
+
"format-package -w"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@agentclientprotocol/sdk": "^0.13.0",
|
|
44
|
+
"zod": "^4.3.5"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"bun": ">=1.2.9"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@biomejs/biome": "2.3.11",
|
|
51
|
+
"@types/bun": "1.3.6",
|
|
52
|
+
"@zed-industries/claude-code-acp": "0.13.0",
|
|
53
|
+
"format-package": "7.0.0",
|
|
54
|
+
"lint-staged": "16.2.7",
|
|
55
|
+
"typescript": "5.9.3"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Wrapper for bun test that handles Bun's post-test cleanup crash
|
|
3
|
+
# See: https://github.com/oven-sh/bun/issues/23643
|
|
4
|
+
#
|
|
5
|
+
# Bun 1.3.x has a known bug where the test runner crashes during cleanup
|
|
6
|
+
# after all tests complete successfully. This wrapper catches that crash
|
|
7
|
+
# (exit code 133 = SIGTRAP) and exits cleanly if tests actually passed.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# ./bun-test-wrapper.sh [args...] # Run bun test with provided args
|
|
11
|
+
# ./bun-test-wrapper.sh # (No args) Find and run all *.docker.ts files
|
|
12
|
+
|
|
13
|
+
# Determine test files to run
|
|
14
|
+
if [ $# -eq 0 ]; then
|
|
15
|
+
# No arguments: find all *.docker.ts files for Docker integration tests
|
|
16
|
+
docker_tests=$(find ./src ./bin -name "*.docker.ts" -type f 2>/dev/null)
|
|
17
|
+
if [ -z "$docker_tests" ]; then
|
|
18
|
+
echo "No *.docker.ts files found in ./src or ./bin"
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
echo "Found Docker integration tests:"
|
|
22
|
+
echo "$docker_tests" | sed 's/^/ /'
|
|
23
|
+
echo ""
|
|
24
|
+
# Convert newlines to arguments
|
|
25
|
+
set -- $docker_tests
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# Create temp file for output
|
|
29
|
+
tmpfile=$(mktemp)
|
|
30
|
+
trap "rm -f $tmpfile" EXIT
|
|
31
|
+
|
|
32
|
+
# Run tests with output to both terminal and file
|
|
33
|
+
bun test "$@" 2>&1 | tee "$tmpfile"
|
|
34
|
+
exit_code=${PIPESTATUS[0]}
|
|
35
|
+
|
|
36
|
+
# Check if tests passed (look for "X pass" and "0 fail" in output)
|
|
37
|
+
if grep -q " pass" "$tmpfile" && grep -q "0 fail" "$tmpfile"; then
|
|
38
|
+
# Tests passed - exit 0 even if Bun crashed during cleanup
|
|
39
|
+
if [ $exit_code -eq 133 ]; then
|
|
40
|
+
echo ""
|
|
41
|
+
echo "Note: Bun crashed during cleanup (known bug), but all tests passed."
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
exit $exit_code
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless ACP client for programmatic agent interaction.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* This client enables automated evaluation of ACP-compatible agents like
|
|
6
|
+
* Claude Code, Droid, Gemini CLI, and others. It provides:
|
|
7
|
+
*
|
|
8
|
+
* - **Subprocess management**: Spawn and control agent processes
|
|
9
|
+
* - **Session handling**: Create and manage conversation sessions
|
|
10
|
+
* - **Streaming prompts**: AsyncGenerator for real-time updates
|
|
11
|
+
* - **Sync prompts**: Simple request/response for basic evals
|
|
12
|
+
* - **Auto-permissions**: Automatically approves all permissions for headless use
|
|
13
|
+
*
|
|
14
|
+
* Designed for testing and evaluation, not for user-facing applications.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
AgentCapabilities,
|
|
19
|
+
CancelNotification,
|
|
20
|
+
ClientCapabilities,
|
|
21
|
+
ContentBlock,
|
|
22
|
+
Implementation,
|
|
23
|
+
InitializeRequest,
|
|
24
|
+
InitializeResponse,
|
|
25
|
+
McpServer,
|
|
26
|
+
PromptRequest,
|
|
27
|
+
PromptResponse,
|
|
28
|
+
RequestPermissionRequest,
|
|
29
|
+
RequestPermissionResponse,
|
|
30
|
+
SessionNotification,
|
|
31
|
+
} from '@agentclientprotocol/sdk'
|
|
32
|
+
import { version } from '../package.json' with { type: 'json' }
|
|
33
|
+
import { ACP_METHODS, ACP_PROTOCOL_VERSION, DEFAULT_ACP_CLIENT_NAME } from './acp.constants.ts'
|
|
34
|
+
import { RequestPermissionRequestSchema, SessionNotificationSchema } from './acp.schemas.ts'
|
|
35
|
+
import type { Session } from './acp.types.ts'
|
|
36
|
+
import { createACPTransport } from './acp-transport.ts'
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Types
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
/** Configuration for the ACP client */
|
|
42
|
+
export type ACPClientConfig = {
|
|
43
|
+
/** Command to spawn agent (e.g., ['claude', 'code'] or ['droid']) */
|
|
44
|
+
command: string[]
|
|
45
|
+
/** Working directory for agent process */
|
|
46
|
+
cwd?: string
|
|
47
|
+
/** Environment variables for agent process */
|
|
48
|
+
env?: Record<string, string>
|
|
49
|
+
/** Client info for initialization */
|
|
50
|
+
clientInfo?: Implementation
|
|
51
|
+
/** Client capabilities to advertise */
|
|
52
|
+
capabilities?: ClientCapabilities
|
|
53
|
+
/** Timeout for operations in milliseconds (default: 30000) */
|
|
54
|
+
timeout?: number
|
|
55
|
+
/**
|
|
56
|
+
* Polling interval for streaming updates in milliseconds (default: 50).
|
|
57
|
+
* Lower values provide more responsive updates but increase CPU usage.
|
|
58
|
+
* Consider increasing for testing to reduce timing-related flakiness.
|
|
59
|
+
*/
|
|
60
|
+
pollingInterval?: number
|
|
61
|
+
/**
|
|
62
|
+
* Permission handler for agent requests.
|
|
63
|
+
* Default: auto-approve all permissions (headless mode)
|
|
64
|
+
*/
|
|
65
|
+
onPermissionRequest?: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Session update emitted during prompt streaming */
|
|
69
|
+
export type SessionUpdate = {
|
|
70
|
+
type: 'update'
|
|
71
|
+
params: SessionNotification
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Prompt completion emitted when prompt finishes */
|
|
75
|
+
export type PromptComplete = {
|
|
76
|
+
type: 'complete'
|
|
77
|
+
result: PromptResponse
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Events emitted during prompt streaming */
|
|
81
|
+
export type PromptEvent = SessionUpdate | PromptComplete
|
|
82
|
+
|
|
83
|
+
/** Error thrown by ACP client operations */
|
|
84
|
+
export class ACPClientError extends Error {
|
|
85
|
+
constructor(
|
|
86
|
+
message: string,
|
|
87
|
+
public readonly code?: string,
|
|
88
|
+
) {
|
|
89
|
+
super(message)
|
|
90
|
+
this.name = 'ACPClientError'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// Client Implementation
|
|
96
|
+
// ============================================================================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Creates a headless ACP client for agent evaluation.
|
|
100
|
+
*
|
|
101
|
+
* @param config - Client configuration including command, cwd, and permission handling
|
|
102
|
+
* @returns Client object with lifecycle, session, and prompt methods
|
|
103
|
+
*
|
|
104
|
+
* @remarks
|
|
105
|
+
* The client manages:
|
|
106
|
+
* - Agent subprocess lifecycle (connect/disconnect)
|
|
107
|
+
* - Protocol initialization and capability negotiation
|
|
108
|
+
* - Session creation and management
|
|
109
|
+
* - Prompt streaming with real-time updates
|
|
110
|
+
* - Automatic permission approval for headless evaluation
|
|
111
|
+
*
|
|
112
|
+
* See module-level documentation in `src/acp.ts` for usage guidance.
|
|
113
|
+
* See client tests for usage patterns.
|
|
114
|
+
*/
|
|
115
|
+
export const createACPClient = (config: ACPClientConfig) => {
|
|
116
|
+
const {
|
|
117
|
+
command,
|
|
118
|
+
cwd,
|
|
119
|
+
env,
|
|
120
|
+
clientInfo = { name: DEFAULT_ACP_CLIENT_NAME, version },
|
|
121
|
+
capabilities = {},
|
|
122
|
+
timeout = 30000,
|
|
123
|
+
pollingInterval = 50,
|
|
124
|
+
onPermissionRequest,
|
|
125
|
+
} = config
|
|
126
|
+
|
|
127
|
+
let transport: ReturnType<typeof createACPTransport> | undefined
|
|
128
|
+
let agentCapabilities: AgentCapabilities | undefined
|
|
129
|
+
let initializeResult: InitializeResponse | undefined
|
|
130
|
+
|
|
131
|
+
// Track active prompt sessions for update routing
|
|
132
|
+
const activePrompts = new Map<
|
|
133
|
+
string,
|
|
134
|
+
{
|
|
135
|
+
updates: SessionNotification[]
|
|
136
|
+
resolve: (result: PromptResponse) => void
|
|
137
|
+
reject: (error: Error) => void
|
|
138
|
+
}
|
|
139
|
+
>()
|
|
140
|
+
|
|
141
|
+
// --------------------------------------------------------------------------
|
|
142
|
+
// Permission Handling
|
|
143
|
+
// --------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Default permission handler: auto-approve all requests.
|
|
147
|
+
* For headless evaluation in trusted environments.
|
|
148
|
+
*
|
|
149
|
+
* @remarks
|
|
150
|
+
* Validates params with Zod before processing.
|
|
151
|
+
* Prioritizes `allow_always` for faster headless evaluation with fewer
|
|
152
|
+
* permission round-trips. Cancels if validation fails or no allow option
|
|
153
|
+
* is available.
|
|
154
|
+
*/
|
|
155
|
+
const autoApprovePermission = async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
|
|
156
|
+
const result = RequestPermissionRequestSchema.safeParse(params)
|
|
157
|
+
if (!result.success) {
|
|
158
|
+
return { outcome: { outcome: 'cancelled' } }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const { options } = result.data
|
|
162
|
+
|
|
163
|
+
// Priority: allow_always (fewer round-trips) > allow_once
|
|
164
|
+
const allowAlways = options.find((opt) => opt.kind === 'allow_always')
|
|
165
|
+
if (allowAlways) {
|
|
166
|
+
return { outcome: { outcome: 'selected', optionId: allowAlways.optionId } }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const allowOnce = options.find((opt) => opt.kind === 'allow_once')
|
|
170
|
+
if (allowOnce) {
|
|
171
|
+
return { outcome: { outcome: 'selected', optionId: allowOnce.optionId } }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// No allow option available - cancel
|
|
175
|
+
return { outcome: { outcome: 'cancelled' } }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const handlePermissionRequest = onPermissionRequest ?? autoApprovePermission
|
|
179
|
+
|
|
180
|
+
// --------------------------------------------------------------------------
|
|
181
|
+
// Transport Callbacks
|
|
182
|
+
// --------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
const onNotification = (method: string, params: unknown) => {
|
|
185
|
+
if (method === ACP_METHODS.UPDATE) {
|
|
186
|
+
const updateParams = SessionNotificationSchema.parse(params)
|
|
187
|
+
const activePrompt = activePrompts.get(updateParams.sessionId)
|
|
188
|
+
if (activePrompt) {
|
|
189
|
+
activePrompt.updates.push(updateParams)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const onRequest = async (method: string, params: unknown): Promise<unknown> => {
|
|
195
|
+
if (method === ACP_METHODS.REQUEST_PERMISSION) {
|
|
196
|
+
return handlePermissionRequest(RequestPermissionRequestSchema.parse(params))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
throw new ACPClientError(`Unknown request method: ${method}`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --------------------------------------------------------------------------
|
|
203
|
+
// Lifecycle Methods
|
|
204
|
+
// --------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Connects to the agent by spawning the subprocess and initializing the protocol.
|
|
208
|
+
*
|
|
209
|
+
* @returns Initialize result with agent capabilities
|
|
210
|
+
* @throws {ACPClientError} If already connected or connection fails
|
|
211
|
+
*/
|
|
212
|
+
const connect = async (): Promise<InitializeResponse> => {
|
|
213
|
+
if (transport?.isConnected()) {
|
|
214
|
+
throw new ACPClientError('Already connected')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
transport = createACPTransport({
|
|
218
|
+
command,
|
|
219
|
+
cwd,
|
|
220
|
+
env,
|
|
221
|
+
timeout,
|
|
222
|
+
onNotification,
|
|
223
|
+
onRequest,
|
|
224
|
+
onError: (error) => {
|
|
225
|
+
console.error('[ACP Client Error]:', error.message)
|
|
226
|
+
},
|
|
227
|
+
onClose: (code) => {
|
|
228
|
+
// Reject all active prompts on unexpected close
|
|
229
|
+
for (const [sessionId, prompt] of activePrompts) {
|
|
230
|
+
prompt.reject(new ACPClientError(`Agent process exited with code ${code}`))
|
|
231
|
+
activePrompts.delete(sessionId)
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
await transport.start()
|
|
237
|
+
|
|
238
|
+
// Initialize protocol
|
|
239
|
+
const initParams: InitializeRequest = {
|
|
240
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
241
|
+
clientInfo,
|
|
242
|
+
clientCapabilities: capabilities,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
initializeResult = await transport.request<InitializeResponse>(ACP_METHODS.INITIALIZE, initParams)
|
|
246
|
+
|
|
247
|
+
agentCapabilities = initializeResult?.agentCapabilities
|
|
248
|
+
|
|
249
|
+
return initializeResult
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Disconnects from the agent, closing the subprocess.
|
|
254
|
+
*
|
|
255
|
+
* @param graceful - If true, sends shutdown request first (default: true)
|
|
256
|
+
*/
|
|
257
|
+
const disconnect = async (graceful = true): Promise<void> => {
|
|
258
|
+
if (!transport) return
|
|
259
|
+
|
|
260
|
+
// Cancel all active prompts
|
|
261
|
+
for (const [sessionId, prompt] of activePrompts) {
|
|
262
|
+
prompt.reject(new ACPClientError('Client disconnected'))
|
|
263
|
+
activePrompts.delete(sessionId)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
await transport.close(graceful)
|
|
267
|
+
transport = undefined
|
|
268
|
+
agentCapabilities = undefined
|
|
269
|
+
initializeResult = undefined
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --------------------------------------------------------------------------
|
|
273
|
+
// Session Methods
|
|
274
|
+
// --------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Creates a new conversation session.
|
|
278
|
+
*
|
|
279
|
+
* @param params - Session parameters with working directory and optional MCP servers
|
|
280
|
+
* @returns The created session
|
|
281
|
+
* @throws {ACPClientError} If not connected
|
|
282
|
+
*/
|
|
283
|
+
const createSession = async (params: { cwd: string; mcpServers?: McpServer[] }): Promise<Session> => {
|
|
284
|
+
if (!transport?.isConnected()) {
|
|
285
|
+
throw new ACPClientError('Not connected')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const response = await transport.request<{ sessionId: string }>(ACP_METHODS.CREATE_SESSION, {
|
|
289
|
+
cwd: params.cwd,
|
|
290
|
+
mcpServers: params.mcpServers ?? [],
|
|
291
|
+
})
|
|
292
|
+
return { id: response.sessionId }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Sets the model for a session.
|
|
297
|
+
*
|
|
298
|
+
* @experimental This is an unstable ACP feature and may change.
|
|
299
|
+
* @param sessionId - The session ID to set the model for
|
|
300
|
+
* @param modelId - The model ID (e.g., 'claude-3-5-haiku-20241022', 'claude-sonnet-4-20250514')
|
|
301
|
+
* @throws {ACPClientError} If not connected
|
|
302
|
+
*/
|
|
303
|
+
const setModel = async (sessionId: string, modelId: string): Promise<void> => {
|
|
304
|
+
if (!transport?.isConnected()) {
|
|
305
|
+
throw new ACPClientError('Not connected')
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
await transport.request(ACP_METHODS.SET_MODEL, { sessionId, modelId })
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --------------------------------------------------------------------------
|
|
312
|
+
// Prompt Methods
|
|
313
|
+
// --------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Sends a prompt and streams updates as they arrive.
|
|
317
|
+
*
|
|
318
|
+
* @param sessionId - The session ID to send the prompt to
|
|
319
|
+
* @param content - Content blocks for the prompt
|
|
320
|
+
* @yields Session updates and final completion
|
|
321
|
+
* @throws {ACPClientError} If not connected
|
|
322
|
+
*
|
|
323
|
+
* @remarks
|
|
324
|
+
* Use this for evaluation scenarios where you need access to
|
|
325
|
+
* intermediate updates (tool calls, plan changes, etc).
|
|
326
|
+
*/
|
|
327
|
+
async function* prompt(sessionId: string, content: ContentBlock[]): AsyncGenerator<PromptEvent> {
|
|
328
|
+
if (!transport?.isConnected()) {
|
|
329
|
+
throw new ACPClientError('Not connected')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const { promise, resolve, reject } = Promise.withResolvers<PromptResponse>()
|
|
333
|
+
const updates: SessionNotification[] = []
|
|
334
|
+
const promptState = {
|
|
335
|
+
updates,
|
|
336
|
+
resolve,
|
|
337
|
+
reject,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
activePrompts.set(sessionId, promptState)
|
|
341
|
+
|
|
342
|
+
// Send prompt request
|
|
343
|
+
const promptParams: PromptRequest = {
|
|
344
|
+
sessionId,
|
|
345
|
+
prompt: content,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Start the prompt request (don't await - we'll poll for updates)
|
|
349
|
+
const promptPromise = transport
|
|
350
|
+
.request<PromptResponse>(ACP_METHODS.PROMPT, promptParams)
|
|
351
|
+
.then(resolve)
|
|
352
|
+
.catch(reject)
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
// Poll for updates until prompt completes
|
|
356
|
+
let lastYieldedIndex = 0
|
|
357
|
+
|
|
358
|
+
while (true) {
|
|
359
|
+
// Yield any new updates
|
|
360
|
+
while (lastYieldedIndex < promptState.updates.length) {
|
|
361
|
+
const update = promptState.updates[lastYieldedIndex]
|
|
362
|
+
if (update) {
|
|
363
|
+
yield { type: 'update', params: update }
|
|
364
|
+
}
|
|
365
|
+
lastYieldedIndex++
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Check if prompt completed
|
|
369
|
+
const raceResult = await Promise.race([
|
|
370
|
+
promise.then((result) => ({ done: true as const, result })),
|
|
371
|
+
new Promise<{ done: false }>((res) => setTimeout(() => res({ done: false }), pollingInterval)),
|
|
372
|
+
])
|
|
373
|
+
|
|
374
|
+
if (raceResult.done) {
|
|
375
|
+
// Yield any remaining updates
|
|
376
|
+
while (lastYieldedIndex < promptState.updates.length) {
|
|
377
|
+
const update = promptState.updates[lastYieldedIndex]
|
|
378
|
+
if (update) {
|
|
379
|
+
yield { type: 'update', params: update }
|
|
380
|
+
}
|
|
381
|
+
lastYieldedIndex++
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Yield completion
|
|
385
|
+
yield {
|
|
386
|
+
type: 'complete',
|
|
387
|
+
result: raceResult.result,
|
|
388
|
+
}
|
|
389
|
+
break
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
await promptPromise
|
|
394
|
+
} finally {
|
|
395
|
+
activePrompts.delete(sessionId)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Sends a prompt and waits for the final result.
|
|
401
|
+
*
|
|
402
|
+
* @param sessionId - The session ID to send the prompt to
|
|
403
|
+
* @param content - Content blocks for the prompt
|
|
404
|
+
* @returns The prompt result with all accumulated updates
|
|
405
|
+
* @throws {ACPClientError} If not connected
|
|
406
|
+
*
|
|
407
|
+
* @remarks
|
|
408
|
+
* Use this for simple evaluation scenarios where you only need
|
|
409
|
+
* the final result. All intermediate updates are collected but
|
|
410
|
+
* returned together at the end.
|
|
411
|
+
*/
|
|
412
|
+
const promptSync = async (
|
|
413
|
+
sessionId: string,
|
|
414
|
+
content: ContentBlock[],
|
|
415
|
+
): Promise<{
|
|
416
|
+
result: PromptResponse
|
|
417
|
+
updates: SessionNotification[]
|
|
418
|
+
}> => {
|
|
419
|
+
const updates: SessionNotification[] = []
|
|
420
|
+
let result: PromptResponse | undefined
|
|
421
|
+
|
|
422
|
+
for await (const event of prompt(sessionId, content)) {
|
|
423
|
+
if (event.type === 'update') {
|
|
424
|
+
updates.push(event.params)
|
|
425
|
+
} else if (event.type === 'complete') {
|
|
426
|
+
result = event.result
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (!result) {
|
|
431
|
+
throw new ACPClientError('Prompt completed without result')
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return { result, updates }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Cancels an ongoing prompt.
|
|
439
|
+
*
|
|
440
|
+
* @param sessionId - The session ID to cancel
|
|
441
|
+
* @throws {ACPClientError} If not connected
|
|
442
|
+
*/
|
|
443
|
+
const cancelPrompt = async (sessionId: string): Promise<void> => {
|
|
444
|
+
if (!transport?.isConnected()) {
|
|
445
|
+
throw new ACPClientError('Not connected')
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const cancelParams: CancelNotification = { sessionId }
|
|
449
|
+
await transport.notify(ACP_METHODS.CANCEL, cancelParams)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --------------------------------------------------------------------------
|
|
453
|
+
// State Methods
|
|
454
|
+
// --------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Gets the agent capabilities negotiated during initialization.
|
|
458
|
+
*
|
|
459
|
+
* @returns Agent capabilities or undefined if not connected
|
|
460
|
+
*/
|
|
461
|
+
const getCapabilities = (): AgentCapabilities | undefined => {
|
|
462
|
+
return agentCapabilities
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Gets the full initialization result.
|
|
467
|
+
*
|
|
468
|
+
* @returns Initialize result or undefined if not connected
|
|
469
|
+
*/
|
|
470
|
+
const getInitializeResult = (): InitializeResponse | undefined => {
|
|
471
|
+
return initializeResult
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Checks if the client is connected to an agent.
|
|
476
|
+
*/
|
|
477
|
+
const isConnected = (): boolean => {
|
|
478
|
+
return transport?.isConnected() ?? false
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
// Lifecycle
|
|
483
|
+
connect,
|
|
484
|
+
disconnect,
|
|
485
|
+
|
|
486
|
+
// Sessions
|
|
487
|
+
createSession,
|
|
488
|
+
setModel,
|
|
489
|
+
|
|
490
|
+
// Prompts
|
|
491
|
+
prompt,
|
|
492
|
+
promptSync,
|
|
493
|
+
cancelPrompt,
|
|
494
|
+
|
|
495
|
+
// State
|
|
496
|
+
getCapabilities,
|
|
497
|
+
getInitializeResult,
|
|
498
|
+
isConnected,
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Client instance type */
|
|
503
|
+
export type ACPClient = ReturnType<typeof createACPClient>
|