@plaited/acp-harness 0.3.2 → 0.4.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/README.md +53 -31
- package/bin/cli.ts +15 -0
- package/package.json +5 -7
- package/src/acp-client.ts +7 -4
- package/src/adapter-check.ts +0 -1
- package/src/adapter-scaffold.ts +16 -15
- package/src/calibrate.ts +28 -8
- package/src/capture.ts +114 -33
- package/src/grader-loader.ts +3 -3
- package/src/harness.ts +4 -0
- package/src/headless-cli.ts +433 -0
- package/src/headless-history-builder.ts +141 -0
- package/src/headless-output-parser.ts +251 -0
- package/src/headless-session-manager.ts +389 -0
- package/src/headless.schemas.ts +241 -0
- package/src/headless.ts +71 -0
- package/src/headless.types.ts +19 -0
- package/src/integration_tests/acp-claude.spec.ts +170 -0
- package/src/integration_tests/acp-gemini.spec.ts +174 -0
- package/src/schemas.ts +88 -36
- package/src/summarize.ts +4 -8
- package/src/tests/acp-client.spec.ts +1 -1
- package/src/tests/capture-cli.spec.ts +188 -0
- package/src/tests/capture-helpers.spec.ts +229 -67
- package/src/tests/constants.spec.ts +121 -0
- package/src/tests/fixtures/grader-exec.py +3 -3
- package/src/tests/fixtures/grader-module.ts +2 -2
- package/src/tests/grader-loader.spec.ts +5 -5
- package/src/tests/headless.spec.ts +460 -0
- package/src/tests/schemas-cli.spec.ts +142 -0
- package/src/tests/schemas.spec.ts +657 -0
- package/src/tests/summarize-helpers.spec.ts +3 -3
- package/src/tests/trials-cli.spec.ts +145 -0
- package/src/trials.ts +6 -19
- package/src/validate-refs.ts +1 -1
- package/src/tests/acp-integration.docker.ts +0 -214
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schemas for headless ACP adapter configuration.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* These schemas define how to interact with ANY headless CLI agent via a
|
|
6
|
+
* schema-driven approach. No hardcoded agent-specific logic - the schema
|
|
7
|
+
* defines everything: command, flags, output parsing rules.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Output Event Mapping Schema
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Schema for matching CLI output to ACP update types.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* Uses JSONPath-like patterns to match events in CLI JSON output
|
|
23
|
+
* and map them to ACP session update types.
|
|
24
|
+
*/
|
|
25
|
+
export const OutputEventMatchSchema = z.object({
|
|
26
|
+
/** JSONPath to match event type in CLI output (e.g., "$.type") */
|
|
27
|
+
path: z.string(),
|
|
28
|
+
/** Value to match at the path (e.g., "tool_use") */
|
|
29
|
+
value: z.string(),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/** Output event match type */
|
|
33
|
+
export type OutputEventMatch = z.infer<typeof OutputEventMatchSchema>
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Schema for extracting content from matched events.
|
|
37
|
+
*
|
|
38
|
+
* @remarks
|
|
39
|
+
* Paths can be:
|
|
40
|
+
* - JSONPath expressions (e.g., "$.message.text")
|
|
41
|
+
* - Literal strings in single quotes (e.g., "'pending'")
|
|
42
|
+
*/
|
|
43
|
+
export const OutputEventExtractSchema = z.object({
|
|
44
|
+
/** JSONPath to extract main content */
|
|
45
|
+
content: z.string().optional(),
|
|
46
|
+
/** JSONPath to extract title (for tool calls) */
|
|
47
|
+
title: z.string().optional(),
|
|
48
|
+
/** JSONPath to extract status (or literal like "'pending'") */
|
|
49
|
+
status: z.string().optional(),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/** Output event extract type */
|
|
53
|
+
export type OutputEventExtract = z.infer<typeof OutputEventExtractSchema>
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Schema for mapping CLI output events to ACP update types.
|
|
57
|
+
*
|
|
58
|
+
* @remarks
|
|
59
|
+
* Each mapping specifies:
|
|
60
|
+
* 1. How to match events (match.path + match.value)
|
|
61
|
+
* 2. What ACP update type to emit (emitAs)
|
|
62
|
+
* 3. What content to extract (extract)
|
|
63
|
+
*/
|
|
64
|
+
export const OutputEventMappingSchema = z.object({
|
|
65
|
+
/** Matching criteria for CLI output */
|
|
66
|
+
match: OutputEventMatchSchema,
|
|
67
|
+
/** ACP session update type to emit */
|
|
68
|
+
emitAs: z.enum(['thought', 'tool_call', 'message', 'plan']),
|
|
69
|
+
/** Content extraction configuration */
|
|
70
|
+
extract: OutputEventExtractSchema.optional(),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
/** Output event mapping type */
|
|
74
|
+
export type OutputEventMapping = z.infer<typeof OutputEventMappingSchema>
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// Prompt Configuration Schema
|
|
78
|
+
// ============================================================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Schema for how to pass prompts to the CLI.
|
|
82
|
+
*/
|
|
83
|
+
export const PromptConfigSchema = z.object({
|
|
84
|
+
/** Flag to pass prompt (e.g., "-p", "--prompt"). Omit for stdin. */
|
|
85
|
+
flag: z.string().optional(),
|
|
86
|
+
/** Format for stdin input in stream mode */
|
|
87
|
+
stdinFormat: z.enum(['text', 'json']).optional(),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/** Prompt configuration type */
|
|
91
|
+
export type PromptConfig = z.infer<typeof PromptConfigSchema>
|
|
92
|
+
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// Output Configuration Schema
|
|
95
|
+
// ============================================================================
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Schema for output format configuration.
|
|
99
|
+
*/
|
|
100
|
+
export const OutputConfigSchema = z.object({
|
|
101
|
+
/** Flag for output format (e.g., "--output-format") */
|
|
102
|
+
flag: z.string(),
|
|
103
|
+
/** Value for output format (e.g., "stream-json") */
|
|
104
|
+
value: z.string(),
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
/** Output configuration type */
|
|
108
|
+
export type OutputConfig = z.infer<typeof OutputConfigSchema>
|
|
109
|
+
|
|
110
|
+
// ============================================================================
|
|
111
|
+
// Resume Configuration Schema
|
|
112
|
+
// ============================================================================
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Schema for session resume support (stream mode).
|
|
116
|
+
*/
|
|
117
|
+
export const ResumeConfigSchema = z.object({
|
|
118
|
+
/** Flag to resume session (e.g., "--resume") */
|
|
119
|
+
flag: z.string(),
|
|
120
|
+
/** JSONPath to extract session ID from output */
|
|
121
|
+
sessionIdPath: z.string(),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
/** Resume configuration type */
|
|
125
|
+
export type ResumeConfig = z.infer<typeof ResumeConfigSchema>
|
|
126
|
+
|
|
127
|
+
// ============================================================================
|
|
128
|
+
// Result Configuration Schema
|
|
129
|
+
// ============================================================================
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Schema for final result extraction.
|
|
133
|
+
*/
|
|
134
|
+
export const ResultConfigSchema = z.object({
|
|
135
|
+
/** JSONPath to match result type (e.g., "$.type") */
|
|
136
|
+
matchPath: z.string(),
|
|
137
|
+
/** Value indicating final result (e.g., "result") */
|
|
138
|
+
matchValue: z.string(),
|
|
139
|
+
/** JSONPath to extract result content */
|
|
140
|
+
contentPath: z.string(),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
/** Result configuration type */
|
|
144
|
+
export type ResultConfig = z.infer<typeof ResultConfigSchema>
|
|
145
|
+
|
|
146
|
+
// ============================================================================
|
|
147
|
+
// Main Adapter Schema
|
|
148
|
+
// ============================================================================
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Schema for headless ACP adapter configuration.
|
|
152
|
+
*
|
|
153
|
+
* @remarks
|
|
154
|
+
* This schema defines everything needed to interact with a headless CLI agent:
|
|
155
|
+
* - Command and flags to spawn
|
|
156
|
+
* - How to pass prompts
|
|
157
|
+
* - How to parse output
|
|
158
|
+
* - Session handling mode
|
|
159
|
+
*
|
|
160
|
+
* Example (Claude):
|
|
161
|
+
* ```json
|
|
162
|
+
* {
|
|
163
|
+
* "version": 1,
|
|
164
|
+
* "name": "claude-headless",
|
|
165
|
+
* "command": ["claude"],
|
|
166
|
+
* "sessionMode": "stream",
|
|
167
|
+
* "prompt": { "flag": "-p" },
|
|
168
|
+
* "output": { "flag": "--output-format", "value": "stream-json" },
|
|
169
|
+
* "outputEvents": [...]
|
|
170
|
+
* }
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
export const HeadlessAdapterSchema = z.object({
|
|
174
|
+
/** Schema version for forward compatibility */
|
|
175
|
+
version: z.literal(1),
|
|
176
|
+
|
|
177
|
+
/** Human-readable adapter name */
|
|
178
|
+
name: z.string(),
|
|
179
|
+
|
|
180
|
+
/** Base command to spawn (e.g., ["claude"], ["gemini"]) */
|
|
181
|
+
command: z.array(z.string()),
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Session mode determines how multi-turn conversations work:
|
|
185
|
+
* - 'stream': Keep process alive, multi-turn via stdin
|
|
186
|
+
* - 'iterative': New process per turn, accumulate context in prompt
|
|
187
|
+
*/
|
|
188
|
+
sessionMode: z.enum(['stream', 'iterative']),
|
|
189
|
+
|
|
190
|
+
/** How to pass the prompt */
|
|
191
|
+
prompt: PromptConfigSchema,
|
|
192
|
+
|
|
193
|
+
/** Output format configuration */
|
|
194
|
+
output: OutputConfigSchema,
|
|
195
|
+
|
|
196
|
+
/** Flags for auto-approval in headless mode (e.g., ["--allowedTools", "*"]) */
|
|
197
|
+
autoApprove: z.array(z.string()).optional(),
|
|
198
|
+
|
|
199
|
+
/** Session resume support (stream mode only) */
|
|
200
|
+
resume: ResumeConfigSchema.optional(),
|
|
201
|
+
|
|
202
|
+
/** Working directory flag (if CLI needs explicit --cwd) */
|
|
203
|
+
cwdFlag: z.string().optional(),
|
|
204
|
+
|
|
205
|
+
/** Output event mappings - how to parse CLI output into ACP updates */
|
|
206
|
+
outputEvents: z.array(OutputEventMappingSchema),
|
|
207
|
+
|
|
208
|
+
/** Final result extraction configuration */
|
|
209
|
+
result: ResultConfigSchema,
|
|
210
|
+
|
|
211
|
+
/** Template for formatting conversation history (iterative mode only) */
|
|
212
|
+
historyTemplate: z.string().optional(),
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
/** Headless adapter configuration type */
|
|
216
|
+
export type HeadlessAdapterConfig = z.infer<typeof HeadlessAdapterSchema>
|
|
217
|
+
|
|
218
|
+
// ============================================================================
|
|
219
|
+
// Validation Helpers
|
|
220
|
+
// ============================================================================
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Validates and parses a headless adapter configuration.
|
|
224
|
+
*
|
|
225
|
+
* @param config - Raw configuration object (e.g., from JSON file)
|
|
226
|
+
* @returns Validated HeadlessAdapterConfig
|
|
227
|
+
* @throws ZodError if validation fails
|
|
228
|
+
*/
|
|
229
|
+
export const parseHeadlessConfig = (config: unknown): HeadlessAdapterConfig => {
|
|
230
|
+
return HeadlessAdapterSchema.parse(config)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Safely validates a headless adapter configuration.
|
|
235
|
+
*
|
|
236
|
+
* @param config - Raw configuration object
|
|
237
|
+
* @returns Result with success/failure and data or error
|
|
238
|
+
*/
|
|
239
|
+
export const safeParseHeadlessConfig = (config: unknown) => {
|
|
240
|
+
return HeadlessAdapterSchema.safeParse(config)
|
|
241
|
+
}
|
package/src/headless.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless ACP adapter factory - schema-driven adapter for any CLI agent.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Re-exports public API from the headless module. The headless adapter enables
|
|
6
|
+
* capturing trajectories from ANY headless CLI agent by defining a schema
|
|
7
|
+
* that describes how to interact with the CLI.
|
|
8
|
+
*
|
|
9
|
+
* **CLI Usage:**
|
|
10
|
+
* ```bash
|
|
11
|
+
* acp-harness headless --schema ./my-agent.json
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* **Programmatic Usage:**
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import { parseHeadlessConfig, createSessionManager } from '@plaited/acp-harness/headless'
|
|
17
|
+
*
|
|
18
|
+
* const schema = parseHeadlessConfig(jsonConfig)
|
|
19
|
+
* const sessions = createSessionManager({ schema })
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @packageDocumentation
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// Schema definitions and parsing
|
|
26
|
+
export {
|
|
27
|
+
HeadlessAdapterSchema,
|
|
28
|
+
OutputConfigSchema,
|
|
29
|
+
OutputEventExtractSchema,
|
|
30
|
+
OutputEventMappingSchema,
|
|
31
|
+
OutputEventMatchSchema,
|
|
32
|
+
PromptConfigSchema,
|
|
33
|
+
parseHeadlessConfig,
|
|
34
|
+
ResultConfigSchema,
|
|
35
|
+
ResumeConfigSchema,
|
|
36
|
+
safeParseHeadlessConfig,
|
|
37
|
+
} from './headless.schemas.ts'
|
|
38
|
+
// Types
|
|
39
|
+
export type {
|
|
40
|
+
HeadlessAdapterConfig,
|
|
41
|
+
OutputConfig,
|
|
42
|
+
OutputEventExtract,
|
|
43
|
+
OutputEventMapping,
|
|
44
|
+
OutputEventMatch,
|
|
45
|
+
PromptConfig,
|
|
46
|
+
ResultConfig,
|
|
47
|
+
ResumeConfig,
|
|
48
|
+
} from './headless.types.ts'
|
|
49
|
+
// CLI entry point
|
|
50
|
+
export { headless } from './headless-cli.ts'
|
|
51
|
+
export type { HistoryBuilder, HistoryBuilderConfig, HistoryTurn } from './headless-history-builder.ts'
|
|
52
|
+
// History builder
|
|
53
|
+
export { createHistoryBuilder } from './headless-history-builder.ts'
|
|
54
|
+
export type {
|
|
55
|
+
OutputParser,
|
|
56
|
+
ParsedResult,
|
|
57
|
+
ParsedUpdate,
|
|
58
|
+
ResultParseResult,
|
|
59
|
+
SessionUpdateType,
|
|
60
|
+
} from './headless-output-parser.ts'
|
|
61
|
+
// Output parser
|
|
62
|
+
export { createOutputParser, jsonPath, jsonPathString } from './headless-output-parser.ts'
|
|
63
|
+
export type {
|
|
64
|
+
PromptResult,
|
|
65
|
+
Session,
|
|
66
|
+
SessionManager,
|
|
67
|
+
SessionManagerConfig,
|
|
68
|
+
UpdateCallback,
|
|
69
|
+
} from './headless-session-manager.ts'
|
|
70
|
+
// Session manager
|
|
71
|
+
export { createSessionManager } from './headless-session-manager.ts'
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type exports for headless ACP adapter.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Re-exports all types from the schemas module for external consumers.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type {
|
|
11
|
+
HeadlessAdapterConfig,
|
|
12
|
+
OutputConfig,
|
|
13
|
+
OutputEventExtract,
|
|
14
|
+
OutputEventMapping,
|
|
15
|
+
OutputEventMatch,
|
|
16
|
+
PromptConfig,
|
|
17
|
+
ResultConfig,
|
|
18
|
+
ResumeConfig,
|
|
19
|
+
} from './headless.schemas.ts'
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless Adapter integration Tests - Claude Code
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* These tests verify the headless ACP adapter works correctly with Claude Code
|
|
6
|
+
* using the schema-driven approach from `.claude/skills/acp-adapters/schemas/`.
|
|
7
|
+
*
|
|
8
|
+
* Run locally with API key:
|
|
9
|
+
* ```bash
|
|
10
|
+
* ANTHROPIC_API_KEY=sk-... bun test ./src/tests/acp-claude.spec.ts
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Prerequisites:
|
|
14
|
+
* 1. Claude CLI installed (`bunx @anthropic-ai/claude-code`)
|
|
15
|
+
* 2. API key: `ANTHROPIC_API_KEY` environment variable
|
|
16
|
+
*
|
|
17
|
+
* These tests make real API calls and consume credits.
|
|
18
|
+
*
|
|
19
|
+
* MCP servers are auto-discovered from project root via:
|
|
20
|
+
* - `.mcp.json` - MCP server configuration
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from 'bun:test'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import { type ACPClient, createACPClient } from '../acp-client.ts'
|
|
26
|
+
import { createPrompt, summarizeResponse } from '../acp-helpers.ts'
|
|
27
|
+
|
|
28
|
+
// Long timeout for real agent interactions (2 minutes)
|
|
29
|
+
setDefaultTimeout(120000)
|
|
30
|
+
|
|
31
|
+
// Use project root as cwd - agents discover MCP servers from config files
|
|
32
|
+
const PROJECT_ROOT = process.cwd()
|
|
33
|
+
|
|
34
|
+
// Schema path for Claude headless adapter
|
|
35
|
+
const SCHEMA_PATH = join(PROJECT_ROOT, '.claude/skills/acp-adapters/schemas/claude-headless.json')
|
|
36
|
+
|
|
37
|
+
// Get API key from environment
|
|
38
|
+
const API_KEY = process.env.ANTHROPIC_API_KEY ?? ''
|
|
39
|
+
|
|
40
|
+
// Skip all tests if no API key is available
|
|
41
|
+
const describeWithApiKey = API_KEY ? describe : describe.skip
|
|
42
|
+
|
|
43
|
+
describeWithApiKey('Headless Adapter Integration - Claude', () => {
|
|
44
|
+
let client: ACPClient
|
|
45
|
+
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
// Use headless adapter with Claude schema
|
|
48
|
+
client = createACPClient({
|
|
49
|
+
command: ['bun', 'src/headless-cli.ts', '--', '--schema', SCHEMA_PATH],
|
|
50
|
+
timeout: 120000, // 2 min timeout for initialization
|
|
51
|
+
env: {
|
|
52
|
+
ANTHROPIC_API_KEY: API_KEY,
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
await client.connect()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
afterAll(async () => {
|
|
60
|
+
await client?.disconnect()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('connects and initializes via headless adapter', () => {
|
|
64
|
+
expect(client.isConnected()).toBe(true)
|
|
65
|
+
|
|
66
|
+
const initResult = client.getInitializeResult()
|
|
67
|
+
expect(initResult).toBeDefined()
|
|
68
|
+
expect(initResult?.protocolVersion).toBeDefined()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('reports agent capabilities', () => {
|
|
72
|
+
const capabilities = client.getCapabilities()
|
|
73
|
+
expect(capabilities).toBeDefined()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('creates session with project cwd', async () => {
|
|
77
|
+
// Session uses project root - agent discovers MCP servers from .mcp.json
|
|
78
|
+
const session = await client.createSession({
|
|
79
|
+
cwd: PROJECT_ROOT,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
expect(session).toBeDefined()
|
|
83
|
+
expect(session.id).toBeDefined()
|
|
84
|
+
expect(typeof session.id).toBe('string')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('sends prompt and receives response', async () => {
|
|
88
|
+
const session = await client.createSession({
|
|
89
|
+
cwd: PROJECT_ROOT,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// Simple prompt that doesn't require tools
|
|
93
|
+
const { result, updates } = await client.promptSync(
|
|
94
|
+
session.id,
|
|
95
|
+
createPrompt('What is 2 + 2? Reply with just the number.'),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
expect(result).toBeDefined()
|
|
99
|
+
expect(updates).toBeInstanceOf(Array)
|
|
100
|
+
|
|
101
|
+
// Summarize and verify response structure
|
|
102
|
+
const summary = summarizeResponse(updates)
|
|
103
|
+
expect(summary.text).toBeDefined()
|
|
104
|
+
expect(summary.text.length).toBeGreaterThan(0)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('streaming prompt yields updates', async () => {
|
|
108
|
+
const session = await client.createSession({
|
|
109
|
+
cwd: PROJECT_ROOT,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const events: string[] = []
|
|
113
|
+
|
|
114
|
+
for await (const event of client.prompt(session.id, createPrompt('Say "hello" and nothing else.'))) {
|
|
115
|
+
events.push(event.type)
|
|
116
|
+
if (event.type === 'complete') {
|
|
117
|
+
expect(event.result).toBeDefined()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
expect(events).toContain('complete')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('uses MCP server from project config', async () => {
|
|
125
|
+
// This test verifies that Claude discovers MCP servers from .mcp.json
|
|
126
|
+
// The bun-docs MCP server is configured at project root
|
|
127
|
+
const session = await client.createSession({
|
|
128
|
+
cwd: PROJECT_ROOT,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// Query the bun-docs MCP server (configured in .mcp.json)
|
|
132
|
+
const { updates } = await client.promptSync(
|
|
133
|
+
session.id,
|
|
134
|
+
createPrompt(
|
|
135
|
+
'Use the bun-docs MCP server to search for information about Bun.serve(). ' +
|
|
136
|
+
'What are the key options for creating an HTTP server with Bun?',
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const summary = summarizeResponse(updates)
|
|
141
|
+
|
|
142
|
+
// Response should contain Bun server-related information
|
|
143
|
+
expect(summary.text.length).toBeGreaterThan(0)
|
|
144
|
+
// Should mention server/HTTP-related concepts from Bun docs
|
|
145
|
+
expect(summary.text.toLowerCase()).toMatch(/serve|server|http|port|fetch|handler/)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('multi-turn conversation maintains context', async () => {
|
|
149
|
+
// Multi-turn: multiple prompts to same session via headless adapter
|
|
150
|
+
const session = await client.createSession({
|
|
151
|
+
cwd: PROJECT_ROOT,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// Turn 1: Establish context
|
|
155
|
+
const { updates: turn1Updates } = await client.promptSync(
|
|
156
|
+
session.id,
|
|
157
|
+
createPrompt('Remember this number: 42. Just confirm you have it.'),
|
|
158
|
+
)
|
|
159
|
+
const turn1Summary = summarizeResponse(turn1Updates)
|
|
160
|
+
expect(turn1Summary.text).toMatch(/42|forty.?two|remember/i)
|
|
161
|
+
|
|
162
|
+
// Turn 2: Reference previous context
|
|
163
|
+
const { updates: turn2Updates } = await client.promptSync(
|
|
164
|
+
session.id,
|
|
165
|
+
createPrompt('What number did I ask you to remember? Reply with just the number.'),
|
|
166
|
+
)
|
|
167
|
+
const turn2Summary = summarizeResponse(turn2Updates)
|
|
168
|
+
expect(turn2Summary.text).toMatch(/42/)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless Adapter integration Tests - Gemini CLI
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* These tests verify the headless ACP adapter works correctly with Gemini CLI
|
|
6
|
+
* using the schema-driven approach from `.claude/skills/acp-adapters/schemas/`.
|
|
7
|
+
*
|
|
8
|
+
* Run locally with API key:
|
|
9
|
+
* ```bash
|
|
10
|
+
* GEMINI_API_KEY=... bun test ./src/tests/acp-gemini.spec.ts
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Prerequisites:
|
|
14
|
+
* 1. Gemini CLI installed (`npm install -g @anthropic-ai/gemini-cli`)
|
|
15
|
+
* 2. API key: `GEMINI_API_KEY` environment variable
|
|
16
|
+
*
|
|
17
|
+
* These tests make real API calls and consume credits.
|
|
18
|
+
*
|
|
19
|
+
* MCP servers are auto-discovered from project root via:
|
|
20
|
+
* - `.gemini/settings.json` - Gemini MCP server configuration
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from 'bun:test'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import { type ACPClient, createACPClient } from '../acp-client.ts'
|
|
26
|
+
import { createPrompt, summarizeResponse } from '../acp-helpers.ts'
|
|
27
|
+
|
|
28
|
+
// Long timeout for real agent interactions (2 minutes)
|
|
29
|
+
setDefaultTimeout(120000)
|
|
30
|
+
|
|
31
|
+
// Use project root as cwd - agents discover MCP servers from config files
|
|
32
|
+
const PROJECT_ROOT = process.cwd()
|
|
33
|
+
|
|
34
|
+
// Schema path for Gemini headless adapter
|
|
35
|
+
const SCHEMA_PATH = join(PROJECT_ROOT, '.claude/skills/acp-adapters/schemas/gemini-headless.json')
|
|
36
|
+
|
|
37
|
+
// Gemini CLI accepts GEMINI_API_KEY
|
|
38
|
+
// Use either one if available
|
|
39
|
+
const GEMINI_API_KEY = process.env.GEMINI_API_KEY ?? ''
|
|
40
|
+
|
|
41
|
+
// Skip all tests if no API key is available
|
|
42
|
+
const describeWithApiKey = GEMINI_API_KEY ? describe : describe.skip
|
|
43
|
+
|
|
44
|
+
describeWithApiKey('Headless Adapter Integration - Gemini', () => {
|
|
45
|
+
let client: ACPClient
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
// Use headless adapter with Gemini schema
|
|
49
|
+
// Pass both API key variants - Gemini CLI should pick up whichever it prefers
|
|
50
|
+
client = createACPClient({
|
|
51
|
+
command: ['bun', 'src/headless-cli.ts', '--', '--schema', SCHEMA_PATH],
|
|
52
|
+
timeout: 120000, // 2 min timeout for initialization
|
|
53
|
+
env: {
|
|
54
|
+
GEMINI_API_KEY,
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
await client.connect()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
afterAll(async () => {
|
|
62
|
+
await client?.disconnect()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('connects and initializes via headless adapter', () => {
|
|
66
|
+
expect(client.isConnected()).toBe(true)
|
|
67
|
+
|
|
68
|
+
const initResult = client.getInitializeResult()
|
|
69
|
+
expect(initResult).toBeDefined()
|
|
70
|
+
expect(initResult?.protocolVersion).toBeDefined()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('reports agent capabilities', () => {
|
|
74
|
+
const capabilities = client.getCapabilities()
|
|
75
|
+
expect(capabilities).toBeDefined()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('creates session with project cwd', async () => {
|
|
79
|
+
// Session uses project root - agent discovers MCP servers from .gemini/settings.json
|
|
80
|
+
const session = await client.createSession({
|
|
81
|
+
cwd: PROJECT_ROOT,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
expect(session).toBeDefined()
|
|
85
|
+
expect(session.id).toBeDefined()
|
|
86
|
+
expect(typeof session.id).toBe('string')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('sends prompt and receives response', async () => {
|
|
90
|
+
const session = await client.createSession({
|
|
91
|
+
cwd: PROJECT_ROOT,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Simple prompt that doesn't require tools
|
|
95
|
+
const { result, updates } = await client.promptSync(
|
|
96
|
+
session.id,
|
|
97
|
+
createPrompt('What is 2 + 2? Reply with just the number.'),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
expect(result).toBeDefined()
|
|
101
|
+
expect(updates).toBeInstanceOf(Array)
|
|
102
|
+
|
|
103
|
+
// Summarize and verify response structure
|
|
104
|
+
const summary = summarizeResponse(updates)
|
|
105
|
+
expect(summary.text).toBeDefined()
|
|
106
|
+
expect(summary.text.length).toBeGreaterThan(0)
|
|
107
|
+
// Should contain "4" somewhere in the response
|
|
108
|
+
expect(summary.text).toMatch(/4/)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('streaming prompt yields updates', async () => {
|
|
112
|
+
const session = await client.createSession({
|
|
113
|
+
cwd: PROJECT_ROOT,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const events: string[] = []
|
|
117
|
+
|
|
118
|
+
for await (const event of client.prompt(session.id, createPrompt('Say "hello" and nothing else.'))) {
|
|
119
|
+
events.push(event.type)
|
|
120
|
+
if (event.type === 'complete') {
|
|
121
|
+
expect(event.result).toBeDefined()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
expect(events).toContain('complete')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('uses MCP server from project config', async () => {
|
|
129
|
+
// This test verifies that Gemini discovers MCP servers from .gemini/settings.json
|
|
130
|
+
// The agent-client-protocol MCP server is configured at project root
|
|
131
|
+
const session = await client.createSession({
|
|
132
|
+
cwd: PROJECT_ROOT,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Query the agent-client-protocol MCP server (configured in .gemini/settings.json)
|
|
136
|
+
const { updates } = await client.promptSync(
|
|
137
|
+
session.id,
|
|
138
|
+
createPrompt(
|
|
139
|
+
'Use the agent-client-protocol MCP server to search for information about ACP. ' +
|
|
140
|
+
'What is the Agent Client Protocol and what problem does it solve?',
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const summary = summarizeResponse(updates)
|
|
145
|
+
|
|
146
|
+
// Response should contain ACP-related information
|
|
147
|
+
expect(summary.text.length).toBeGreaterThan(0)
|
|
148
|
+
// Should mention protocol/agent-related concepts
|
|
149
|
+
expect(summary.text.toLowerCase()).toMatch(/agent|protocol|client|json-rpc|stdio/)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('multi-turn conversation maintains context (iterative mode)', async () => {
|
|
153
|
+
// Multi-turn via headless adapter in iterative mode (history accumulation)
|
|
154
|
+
const session = await client.createSession({
|
|
155
|
+
cwd: PROJECT_ROOT,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Turn 1: Establish context
|
|
159
|
+
const { updates: turn1Updates } = await client.promptSync(
|
|
160
|
+
session.id,
|
|
161
|
+
createPrompt('Remember this number: 42. Just confirm you have it.'),
|
|
162
|
+
)
|
|
163
|
+
const turn1Summary = summarizeResponse(turn1Updates)
|
|
164
|
+
expect(turn1Summary.text).toMatch(/42|forty.?two|remember/i)
|
|
165
|
+
|
|
166
|
+
// Turn 2: Reference previous context
|
|
167
|
+
const { updates: turn2Updates } = await client.promptSync(
|
|
168
|
+
session.id,
|
|
169
|
+
createPrompt('What number did I ask you to remember? Reply with just the number.'),
|
|
170
|
+
)
|
|
171
|
+
const turn2Summary = summarizeResponse(turn2Updates)
|
|
172
|
+
expect(turn2Summary.text).toMatch(/42/)
|
|
173
|
+
})
|
|
174
|
+
})
|