@plaited/acp-harness 0.4.0 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plaited/acp-harness",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "CLI tool for capturing agent trajectories from ACP-compatible agents",
5
5
  "license": "ISC",
6
6
  "engines": {
@@ -53,12 +53,13 @@ export type ResultParseResult = ParsedResult | NotResult
53
53
  * - `$.field` - Root field access
54
54
  * - `$.nested.field` - Nested field access
55
55
  * - `$.array[0]` - Array index access
56
+ * - `$.array[*]` - Array wildcard (returns all items)
56
57
  * - `$.array[0].field` - Combined array and field access
57
58
  * - `'literal'` - Literal string values (single quotes)
58
59
  *
59
60
  * @param obj - Object to extract from
60
61
  * @param path - JSONPath expression
61
- * @returns Extracted value or undefined
62
+ * @returns Extracted value, array of values (for wildcard), or undefined
62
63
  */
63
64
  export const jsonPath = (obj: unknown, path: string): unknown => {
64
65
  // Handle literal strings (e.g., "'pending'")
@@ -73,13 +74,25 @@ export const jsonPath = (obj: unknown, path: string): unknown => {
73
74
 
74
75
  // Parse path into segments, handling both dot notation and array indices
75
76
  // e.g., "message.content[0].text" -> ["message", "content", 0, "text"]
76
- const segments: (string | number)[] = []
77
+ // e.g., "message.content[*].type" -> ["message", "content", "*", "type"]
78
+ const segments: (string | number | '*')[] = []
77
79
  const pathBody = path.slice(2) // Remove "$."
78
80
 
79
81
  // Split by dots first, then handle array indices within each part
80
82
  for (const part of pathBody.split('.')) {
81
83
  if (!part) continue
82
84
 
85
+ // Check for array wildcard: "content[*]"
86
+ const wildcardMatch = part.match(/^([^[]*)\[\*\]$/)
87
+ if (wildcardMatch) {
88
+ const propName = wildcardMatch[1]
89
+ if (propName) {
90
+ segments.push(propName)
91
+ }
92
+ segments.push('*')
93
+ continue
94
+ }
95
+
83
96
  // Check for array index: "content[0]" or just "[0]"
84
97
  const arrayMatch = part.match(/^([^[]*)\[(\d+)\]$/)
85
98
  if (arrayMatch) {
@@ -103,7 +116,13 @@ export const jsonPath = (obj: unknown, path: string): unknown => {
103
116
  return undefined
104
117
  }
105
118
 
106
- if (typeof segment === 'number') {
119
+ if (segment === '*') {
120
+ // Array wildcard - return array as-is for further processing
121
+ if (!Array.isArray(current)) {
122
+ return undefined
123
+ }
124
+ return current
125
+ } else if (typeof segment === 'number') {
107
126
  // Array index access
108
127
  if (!Array.isArray(current)) {
109
128
  return undefined
@@ -159,9 +178,9 @@ export const createOutputParser = (config: HeadlessAdapterConfig) => {
159
178
  * Parses a single JSON line from CLI output.
160
179
  *
161
180
  * @param line - JSON string from CLI stdout
162
- * @returns Parsed update or null if no mapping matches
181
+ * @returns Parsed update, array of updates (for wildcard matches), or null if no mapping matches
163
182
  */
164
- const parseLine = (line: string): ParsedUpdate | null => {
183
+ const parseLine = (line: string): ParsedUpdate | ParsedUpdate[] | null => {
165
184
  let event: unknown
166
185
  try {
167
186
  event = JSON.parse(line)
@@ -173,13 +192,40 @@ export const createOutputParser = (config: HeadlessAdapterConfig) => {
173
192
  // Try each mapping until one matches
174
193
  for (const mapping of outputEvents) {
175
194
  const matchValue = jsonPath(event, mapping.match.path)
176
- // Support wildcard "*" to match any non-null value
177
- if (mapping.match.value === '*') {
178
- if (matchValue !== undefined && matchValue !== null) {
195
+
196
+ // Handle array results from wildcard paths (e.g., $.message.content[*])
197
+ if (Array.isArray(matchValue)) {
198
+ const updates: ParsedUpdate[] = []
199
+ for (const item of matchValue) {
200
+ // Check if this array item matches the expected value
201
+ if (mapping.match.value === '*') {
202
+ // Wildcard: match any non-null item
203
+ if (item !== undefined && item !== null) {
204
+ updates.push(createUpdate(item, mapping))
205
+ }
206
+ } else if (typeof item === 'object' && item !== null && 'type' in item) {
207
+ // For objects with 'type' property, check nested match
208
+ const itemType = (item as Record<string, unknown>).type
209
+ if (itemType === mapping.match.value) {
210
+ updates.push(createUpdate(item, mapping))
211
+ }
212
+ } else if (item === mapping.match.value) {
213
+ // For primitives, direct match
214
+ updates.push(createUpdate(item, mapping))
215
+ }
216
+ }
217
+ if (updates.length > 0) {
218
+ return updates
219
+ }
220
+ } else {
221
+ // Single value matching (original behavior)
222
+ if (mapping.match.value === '*') {
223
+ if (matchValue !== undefined && matchValue !== null) {
224
+ return createUpdate(event, mapping)
225
+ }
226
+ } else if (matchValue === mapping.match.value) {
179
227
  return createUpdate(event, mapping)
180
228
  }
181
- } else if (matchValue === mapping.match.value) {
182
- return createUpdate(event, mapping)
183
229
  }
184
230
  }
185
231
 
@@ -153,24 +153,37 @@ export const createSessionManager = (config: SessionManagerConfig) => {
153
153
  // Build command for first turn or if no process exists
154
154
  if (!session.process || session.process.killed) {
155
155
  const args = buildCommand(session, promptText)
156
- // First turn: prompt is in command line, use 'ignore' for stdin
157
- // Some CLIs (like Claude) hang when stdin is piped but not written to
156
+
157
+ // Choose stdin mode based on schema configuration
158
+ const stdinMode = schema.prompt.stdin ? 'pipe' : 'ignore'
159
+
158
160
  session.process = Bun.spawn(args, {
159
161
  cwd: session.cwd,
160
- stdin: 'ignore',
162
+ stdin: stdinMode,
161
163
  stdout: 'pipe',
162
164
  stderr: 'inherit',
163
165
  })
166
+
167
+ // If using stdin, write the prompt
168
+ if (schema.prompt.stdin && session.process) {
169
+ writePromptToStdin(session.process, promptText)
170
+ }
164
171
  } else {
165
172
  // Subsequent turns: spawn new process with resume flag
166
- // (stdin-based multi-turn not currently supported)
167
173
  const args = buildCommand(session, promptText)
174
+ const stdinMode = schema.prompt.stdin ? 'pipe' : 'ignore'
175
+
168
176
  session.process = Bun.spawn(args, {
169
177
  cwd: session.cwd,
170
- stdin: 'ignore',
178
+ stdin: stdinMode,
171
179
  stdout: 'pipe',
172
180
  stderr: 'inherit',
173
181
  })
182
+
183
+ // If using stdin, write the prompt
184
+ if (schema.prompt.stdin && session.process) {
185
+ writePromptToStdin(session.process, promptText)
186
+ }
174
187
  }
175
188
 
176
189
  return collectOutput(session, outputParser, onUpdate, timeout)
@@ -188,15 +201,21 @@ export const createSessionManager = (config: SessionManagerConfig) => {
188
201
  const fullPrompt = session.history?.buildPrompt(promptText) ?? promptText
189
202
 
190
203
  // Build and spawn command
191
- // Use 'ignore' for stdin - prompt is passed via command line flag
192
204
  const args = buildCommand(session, fullPrompt)
205
+ const stdinMode = schema.prompt.stdin ? 'pipe' : 'ignore'
206
+
193
207
  session.process = Bun.spawn(args, {
194
208
  cwd: session.cwd,
195
- stdin: 'ignore',
209
+ stdin: stdinMode,
196
210
  stdout: 'pipe',
197
211
  stderr: 'inherit',
198
212
  })
199
213
 
214
+ // If using stdin, write the prompt
215
+ if (schema.prompt.stdin && session.process) {
216
+ writePromptToStdin(session.process, fullPrompt)
217
+ }
218
+
200
219
  const result = await collectOutput(session, outputParser, onUpdate, timeout)
201
220
 
202
221
  // Store in history for next turn
@@ -232,12 +251,14 @@ export const createSessionManager = (config: SessionManagerConfig) => {
232
251
  args.push(schema.resume.flag, session.cliSessionId)
233
252
  }
234
253
 
235
- // Add prompt flag and text
236
- if (schema.prompt.flag) {
237
- args.push(schema.prompt.flag, promptText)
238
- } else {
239
- // Positional argument (no flag)
240
- args.push(promptText)
254
+ // Add prompt flag and text (skip if using stdin)
255
+ if (!schema.prompt.stdin) {
256
+ if (schema.prompt.flag) {
257
+ args.push(schema.prompt.flag, promptText)
258
+ } else {
259
+ // Positional argument (no flag)
260
+ args.push(promptText)
261
+ }
241
262
  }
242
263
 
243
264
  return args
@@ -299,6 +320,32 @@ const generateSessionId = (): string => {
299
320
  return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
300
321
  }
301
322
 
323
+ /**
324
+ * Writes a prompt to a process stdin stream.
325
+ *
326
+ * @remarks
327
+ * Uses Bun's FileSink API to write text to the process stdin.
328
+ * The FileSink type provides `write()` and `flush()` methods for
329
+ * efficient stream writing without async overhead.
330
+ *
331
+ * Type guard ensures stdin is a FileSink (not a file descriptor number)
332
+ * before attempting to write. This handles Bun's subprocess stdin types:
333
+ * - `'pipe'` → FileSink with write/flush methods
334
+ * - `'ignore'` → null (not writable)
335
+ * - number → file descriptor (not a FileSink)
336
+ *
337
+ * @param process - Subprocess with stdin stream
338
+ * @param prompt - Prompt text to write
339
+ *
340
+ * @internal
341
+ */
342
+ const writePromptToStdin = (process: Subprocess, prompt: string): void => {
343
+ if (process.stdin && typeof process.stdin !== 'number') {
344
+ process.stdin.write(`${prompt}\n`)
345
+ process.stdin.flush()
346
+ }
347
+ }
348
+
302
349
  /**
303
350
  * Collects output from a running process.
304
351
  *
@@ -349,16 +396,21 @@ const collectOutput = async (
349
396
 
350
397
  // Parse as update first (so updates are emitted even for result lines)
351
398
  const update = parser.parseLine(line)
352
- if (update) {
353
- updates.push(update)
354
- onUpdate?.(update)
355
-
356
- // Extract CLI session ID if available
357
- if (!cliSessionId && update.raw && typeof update.raw === 'object') {
358
- const raw = update.raw as Record<string, unknown>
359
- if (typeof raw.session_id === 'string') {
360
- cliSessionId = raw.session_id
361
- session.cliSessionId = cliSessionId
399
+ if (update !== null) {
400
+ // Handle both single updates and arrays of updates (from wildcard matches)
401
+ const updatesToProcess = Array.isArray(update) ? update : [update]
402
+
403
+ for (const singleUpdate of updatesToProcess) {
404
+ updates.push(singleUpdate)
405
+ onUpdate?.(singleUpdate)
406
+
407
+ // Extract CLI session ID if available
408
+ if (!cliSessionId && singleUpdate.raw && typeof singleUpdate.raw === 'object') {
409
+ const raw = singleUpdate.raw as Record<string, unknown>
410
+ if (typeof raw.session_id === 'string') {
411
+ cliSessionId = raw.session_id
412
+ session.cliSessionId = cliSessionId
413
+ }
362
414
  }
363
415
  }
364
416
  }
@@ -79,13 +79,25 @@ export type OutputEventMapping = z.infer<typeof OutputEventMappingSchema>
79
79
 
80
80
  /**
81
81
  * Schema for how to pass prompts to the CLI.
82
+ *
83
+ * @remarks
84
+ * Three modes are supported:
85
+ * 1. **Flag-based**: `flag: "-p"` - Pass prompt via command-line flag
86
+ * 2. **Positional**: `flag: ""` - Pass prompt as positional argument
87
+ * 3. **Stdin**: `stdin: true` - Write prompt to stdin (command should include `-` or equivalent)
82
88
  */
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
+ export const PromptConfigSchema = z
90
+ .object({
91
+ /** Flag to pass prompt (e.g., "-p", "--prompt"). Empty string for positional. */
92
+ flag: z.string().optional(),
93
+ /** Use stdin to pass prompt instead of command args */
94
+ stdin: z.boolean().optional(),
95
+ /** Format for stdin input in stream mode */
96
+ stdinFormat: z.enum(['text', 'json']).optional(),
97
+ })
98
+ .refine((data) => !(data.flag && data.stdin), {
99
+ message: "Cannot specify both 'flag' and 'stdin' modes - use either flag-based or stdin mode, not both",
100
+ })
89
101
 
90
102
  /** Prompt configuration type */
91
103
  export type PromptConfig = z.infer<typeof PromptConfigSchema>
@@ -118,6 +118,59 @@ describe('HeadlessAdapterSchema', () => {
118
118
  })
119
119
  })
120
120
 
121
+ describe('stdin mode configuration', () => {
122
+ test('validates schema with stdin: true', () => {
123
+ const stdinSchema = {
124
+ version: 1,
125
+ name: 'stdin-agent',
126
+ command: ['agent', 'exec', '-'],
127
+ sessionMode: 'stream',
128
+ prompt: { stdin: true },
129
+ output: { flag: '--format', value: 'json' },
130
+ outputEvents: [],
131
+ result: { matchPath: '$.type', matchValue: 'done', contentPath: '$.text' },
132
+ }
133
+ const result = HeadlessAdapterSchema.safeParse(stdinSchema)
134
+ expect(result.success).toBe(true)
135
+ })
136
+
137
+ test('validates schema with stdin: false', () => {
138
+ const stdinSchema = {
139
+ version: 1,
140
+ name: 'stdin-agent',
141
+ command: ['agent'],
142
+ sessionMode: 'stream',
143
+ prompt: { stdin: false, flag: '-p' },
144
+ output: { flag: '--format', value: 'json' },
145
+ outputEvents: [],
146
+ result: { matchPath: '$.type', matchValue: 'done', contentPath: '$.text' },
147
+ }
148
+ const result = HeadlessAdapterSchema.safeParse(stdinSchema)
149
+ expect(result.success).toBe(true)
150
+ })
151
+
152
+ test('validates schema with positional prompt and - in command', () => {
153
+ const stdinSchema = {
154
+ version: 1,
155
+ name: 'codex-like',
156
+ command: ['codex', 'exec', '--json', '-'],
157
+ sessionMode: 'iterative',
158
+ prompt: { stdin: true },
159
+ output: { flag: '', value: '' },
160
+ outputEvents: [
161
+ {
162
+ match: { path: '$.item.type', value: 'agent_message' },
163
+ emitAs: 'message',
164
+ extract: { content: '$.item.text' },
165
+ },
166
+ ],
167
+ result: { matchPath: '$.type', matchValue: 'turn.completed', contentPath: '$.usage.output_tokens' },
168
+ }
169
+ const result = HeadlessAdapterSchema.safeParse(stdinSchema)
170
+ expect(result.success).toBe(true)
171
+ })
172
+ })
173
+
121
174
  describe('invalid schemas', () => {
122
175
  test('rejects missing version', () => {
123
176
  const invalid = { ...validClaudeSchema, version: undefined }
@@ -143,6 +196,22 @@ describe('HeadlessAdapterSchema', () => {
143
196
  expect(result.success).toBe(false)
144
197
  })
145
198
 
199
+ test('rejects both flag and stdin specified', () => {
200
+ const invalid = {
201
+ ...validClaudeSchema,
202
+ prompt: {
203
+ flag: '-p',
204
+ stdin: true,
205
+ },
206
+ }
207
+ const result = HeadlessAdapterSchema.safeParse(invalid)
208
+ expect(result.success).toBe(false)
209
+ // Type assertion after checking success is false
210
+ const error = (result as { success: false; error: { issues: Array<{ message: string }> } }).error
211
+ expect(error.issues.length).toBeGreaterThan(0)
212
+ expect(error.issues[0]!.message).toContain("Cannot specify both 'flag' and 'stdin' modes")
213
+ })
214
+
146
215
  test('rejects invalid emitAs type', () => {
147
216
  const invalid = {
148
217
  ...validClaudeSchema,
@@ -287,17 +356,21 @@ describe('createOutputParser', () => {
287
356
  const line = JSON.stringify({ type: 'assistant', message: { text: 'Hello' } })
288
357
  const result = parser.parseLine(line)
289
358
  expect(result).not.toBeNull()
290
- expect(result?.type).toBe('message')
291
- expect(result?.content).toBe('Hello')
359
+ // Handle both single result and array of results
360
+ const singleResult = Array.isArray(result) ? result[0] : result
361
+ expect(singleResult?.type).toBe('message')
362
+ expect(singleResult?.content).toBe('Hello')
292
363
  })
293
364
 
294
365
  test('maps tool_use type to tool_call', () => {
295
366
  const line = JSON.stringify({ type: 'tool_use', name: 'Read' })
296
367
  const result = parser.parseLine(line)
297
368
  expect(result).not.toBeNull()
298
- expect(result?.type).toBe('tool_call')
299
- expect(result?.title).toBe('Read')
300
- expect(result?.status).toBe('pending')
369
+ // Handle both single result and array of results
370
+ const singleResult = Array.isArray(result) ? result[0] : result
371
+ expect(singleResult?.type).toBe('tool_call')
372
+ expect(singleResult?.title).toBe('Read')
373
+ expect(singleResult?.status).toBe('pending')
301
374
  })
302
375
 
303
376
  test('returns null for unmapped event types', () => {
@@ -320,7 +393,152 @@ describe('createOutputParser', () => {
320
393
  const event = { type: 'assistant', message: { text: 'Hi' } }
321
394
  const line = JSON.stringify(event)
322
395
  const result = parser.parseLine(line)
323
- expect(result?.raw).toEqual(event)
396
+ // Handle both single result and array of results
397
+ const singleResult = Array.isArray(result) ? result[0] : result
398
+ expect(singleResult?.raw).toEqual(event)
399
+ })
400
+ })
401
+
402
+ describe('parseLine with array wildcards', () => {
403
+ const wildcardConfig = parseHeadlessConfig({
404
+ version: 1,
405
+ name: 'wildcard-test',
406
+ command: ['test'],
407
+ sessionMode: 'stream',
408
+ prompt: { flag: '-p' },
409
+ output: { flag: '--output', value: 'json' },
410
+ outputEvents: [
411
+ {
412
+ match: { path: '$.message.content[*].type', value: 'tool_use' },
413
+ emitAs: 'tool_call',
414
+ extract: { title: '$.name', status: "'pending'" },
415
+ },
416
+ {
417
+ match: { path: '$.items[*]', value: '*' },
418
+ emitAs: 'message',
419
+ extract: { content: '$.text' },
420
+ },
421
+ ],
422
+ result: {
423
+ matchPath: '$.type',
424
+ matchValue: 'result',
425
+ contentPath: '$.output',
426
+ },
427
+ })
428
+ const wildcardParser = createOutputParser(wildcardConfig)
429
+
430
+ test('returns array of updates for matching array items', () => {
431
+ const line = JSON.stringify({
432
+ message: {
433
+ content: [
434
+ { type: 'tool_use', name: 'Read', input: {} },
435
+ { type: 'text', value: 'Hello' },
436
+ { type: 'tool_use', name: 'Write', input: {} },
437
+ ],
438
+ },
439
+ })
440
+ const result = wildcardParser.parseLine(line)
441
+ expect(Array.isArray(result)).toBe(true)
442
+ if (Array.isArray(result)) {
443
+ expect(result).toHaveLength(2)
444
+ expect(result[0]!.type).toBe('tool_call')
445
+ expect(result[0]!.title).toBe('Read')
446
+ expect(result[0]!.status).toBe('pending')
447
+ expect(result[1]!.type).toBe('tool_call')
448
+ expect(result[1]!.title).toBe('Write')
449
+ expect(result[1]!.status).toBe('pending')
450
+ }
451
+ })
452
+
453
+ test('handles empty array gracefully', () => {
454
+ const line = JSON.stringify({
455
+ message: { content: [] },
456
+ })
457
+ const result = wildcardParser.parseLine(line)
458
+ expect(result).toBeNull()
459
+ })
460
+
461
+ test('handles non-matching array items', () => {
462
+ const line = JSON.stringify({
463
+ message: {
464
+ content: [
465
+ { type: 'text', value: 'No tool use here' },
466
+ { type: 'image', data: 'base64...' },
467
+ ],
468
+ },
469
+ })
470
+ const result = wildcardParser.parseLine(line)
471
+ expect(result).toBeNull()
472
+ })
473
+
474
+ test('matches wildcard value for all non-null items', () => {
475
+ const line = JSON.stringify({
476
+ items: [{ text: 'Item 1' }, { text: 'Item 2' }, { text: 'Item 3' }],
477
+ })
478
+ const result = wildcardParser.parseLine(line)
479
+ expect(Array.isArray(result)).toBe(true)
480
+ if (Array.isArray(result)) {
481
+ expect(result).toHaveLength(3)
482
+ expect(result[0]!.content).toBe('Item 1')
483
+ expect(result[1]!.content).toBe('Item 2')
484
+ expect(result[2]!.content).toBe('Item 3')
485
+ }
486
+ })
487
+
488
+ test('handles mixed array content with type guards', () => {
489
+ const line = JSON.stringify({
490
+ message: {
491
+ content: [
492
+ { type: 'tool_use', name: 'Valid' },
493
+ 'string-item',
494
+ { no_type_property: true },
495
+ null,
496
+ { type: 'tool_use', name: 'AlsoValid' },
497
+ ],
498
+ },
499
+ })
500
+ const result = wildcardParser.parseLine(line)
501
+ expect(Array.isArray(result)).toBe(true)
502
+ if (Array.isArray(result)) {
503
+ expect(result).toHaveLength(2)
504
+ expect(result[0]!.title).toBe('Valid')
505
+ expect(result[1]!.title).toBe('AlsoValid')
506
+ }
507
+ })
508
+ })
509
+
510
+ describe('jsonPath with array wildcard', () => {
511
+ test('extracts array with [*] wildcard', () => {
512
+ const obj = { items: [{ id: 1 }, { id: 2 }] }
513
+ const result = jsonPath(obj, '$.items[*]')
514
+ expect(Array.isArray(result)).toBe(true)
515
+ if (Array.isArray(result)) {
516
+ expect(result).toHaveLength(2)
517
+ }
518
+ })
519
+
520
+ test('returns undefined for non-array at wildcard position', () => {
521
+ const obj = { items: 'not-an-array' }
522
+ const result = jsonPath(obj, '$.items[*]')
523
+ expect(result).toBeUndefined()
524
+ })
525
+
526
+ test('handles empty array', () => {
527
+ const obj = { items: [] }
528
+ const result = jsonPath(obj, '$.items[*]')
529
+ expect(result).toEqual([])
530
+ })
531
+
532
+ test('handles nested path to array', () => {
533
+ const obj = { message: { content: [1, 2, 3] } }
534
+ const result = jsonPath(obj, '$.message.content[*]')
535
+ expect(result).toEqual([1, 2, 3])
536
+ })
537
+
538
+ test('returns undefined when path before wildcard is invalid', () => {
539
+ const obj = { items: [1, 2, 3] }
540
+ const result = jsonPath(obj, '$.missing[*]')
541
+ expect(result).toBeUndefined()
324
542
  })
325
543
  })
326
544