@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 +1 -1
- package/src/headless-output-parser.ts +56 -10
- package/src/headless-session-manager.ts +75 -23
- package/src/headless.schemas.ts +18 -6
- package/src/tests/headless.spec.ts +224 -6
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
157
|
-
//
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
}
|
package/src/headless.schemas.ts
CHANGED
|
@@ -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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
expect(
|
|
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
|
-
|
|
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
|
|