@catheadowl/dsh-eval 0.1.0 → 0.2.1
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/CHANGELOG.md +57 -0
- package/README.i18n.yaml +6 -0
- package/README.md +59 -55
- package/README.zh.md +129 -0
- package/bin/dsh-eval.mjs +335 -335
- package/bin/dsh-review.mjs +166 -154
- package/docs/README.md +6 -2
- package/docs/cross-turn.md +66 -0
- package/docs/disablerows.md +1 -1
- package/docs/experimental.md +35 -0
- package/docs/host-wiring.md +3 -3
- package/docs/known-issues.md +9 -1
- package/docs/matchers.md +28 -2
- package/docs/review.md +13 -10
- package/docs/rowconfig.md +39 -0
- package/docs/runner-api.md +42 -0
- package/package.json +19 -3
- package/src/adapters/dsh/review.mjs +210 -192
- package/src/assertions.mjs +499 -389
- package/src/cli.mjs +8 -8
- package/src/config.mjs +1 -1
- package/src/discovery.mjs +190 -115
- package/src/driver/multi-turn-driver.mjs +163 -0
- package/src/experiment/review.mjs +118 -118
- package/src/experimental.mjs +39 -0
- package/src/index.mjs +38 -49
- package/src/mock/mock-adapter.mjs +73 -73
- package/src/mock/script.mjs +49 -49
- package/src/overlay.mjs +109 -0
- package/src/report.mjs +1 -1
- package/src/review-report.mjs +14 -1
- package/src/runner.mjs +236 -373
- package/src/sandbox.mjs +262 -0
- package/src/tool-validation.mjs +77 -77
- package/src/trace.mjs +293 -218
- package/src/adapters/dsh/index.mjs +0 -7
package/src/assertions.mjs
CHANGED
|
@@ -1,389 +1,499 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Trace matchers for dsh agent eval. Every factory returns a matcher:
|
|
3
|
-
* `{ describe, check(trace) -> { ok, message } }` — a pure function over an
|
|
4
|
-
* `EvalTrace` (see trace.mjs), so matchers unit-test without any dsh run.
|
|
5
|
-
* Intent tests assert tool SELECTION over final text: model wording varies,
|
|
6
|
-
* tool choice is the contract under test.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/** Render a name matcher for diagnostics. */
|
|
10
|
-
function describeMatcher(matcher) {
|
|
11
|
-
return matcher instanceof RegExp ? String(matcher) : `'${matcher}'`
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Whether a tool name satisfies a matcher (exact string or RegExp). */
|
|
15
|
-
function nameMatches(matcher, name) {
|
|
16
|
-
return matcher instanceof RegExp ? matcher.test(name) : name === matcher
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Whether a message `source` satisfies a source matcher. A string or RegExp
|
|
21
|
-
* matches `source.plugin` (the producer name — e.g. `'gates'` for steer);
|
|
22
|
-
* a function receives the full `source` object (for `kind`-based matching).
|
|
23
|
-
*/
|
|
24
|
-
function sourceMatches(matcher, source) {
|
|
25
|
-
if (typeof matcher === 'function') return matcher(source) === true
|
|
26
|
-
const plugin = source?.plugin
|
|
27
|
-
if (matcher instanceof RegExp) return typeof plugin === 'string' && matcher.test(plugin)
|
|
28
|
-
return plugin === matcher
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Render a source matcher for diagnostics. */
|
|
32
|
-
function describeSource(matcher) {
|
|
33
|
-
if (typeof matcher === 'function') return '<source predicate>'
|
|
34
|
-
return describeMatcher(matcher)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Render the trace's call sequence for failure messages. */
|
|
38
|
-
function callList(trace) {
|
|
39
|
-
const names = trace.toolCalls.map(call => call.name)
|
|
40
|
-
return names.length === 0 ? '(no tool calls)' : `[${names.join(', ')}]`
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Collect the tool results paired with calls matching `matcher`.
|
|
45
|
-
* @returns {{ callIds: Set<string>, results: object[] }}
|
|
46
|
-
* `callIds` is empty when no call satisfies `matcher`;
|
|
47
|
-
* `results` is the subset of `trace.toolResults` paired with those calls.
|
|
48
|
-
*/
|
|
49
|
-
function resultsForMatcher(matcher, trace) {
|
|
50
|
-
const callIds = new Set(
|
|
51
|
-
trace.toolCalls.filter(call => nameMatches(matcher, call.name)).map(call => call.callId),
|
|
52
|
-
)
|
|
53
|
-
const results = callIds.size === 0
|
|
54
|
-
? []
|
|
55
|
-
: trace.toolResults.filter(r => callIds.has(r.callId))
|
|
56
|
-
return { callIds, results }
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Truncate a string for diagnostics. */
|
|
60
|
-
function truncate(text, max = 200) {
|
|
61
|
-
return text.length > max ? `${text.slice(0, max)}…` : text
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** A tool matching `matcher` was called at least once. */
|
|
65
|
-
export function toolCalled(matcher) {
|
|
66
|
-
return {
|
|
67
|
-
describe: `tool called: ${describeMatcher(matcher)}`,
|
|
68
|
-
check(trace) {
|
|
69
|
-
const hit = trace.toolCalls.some(call => nameMatches(matcher, call.name))
|
|
70
|
-
return hit
|
|
71
|
-
? { ok: true, message: '' }
|
|
72
|
-
: { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
|
|
73
|
-
},
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** No tool matching `matcher` was ever called. */
|
|
78
|
-
export function toolNotCalled(matcher) {
|
|
79
|
-
return {
|
|
80
|
-
describe: `tool not called: ${describeMatcher(matcher)}`,
|
|
81
|
-
check(trace) {
|
|
82
|
-
const hit = trace.toolCalls.find(call => nameMatches(matcher, call.name))
|
|
83
|
-
return hit === undefined
|
|
84
|
-
? { ok: true, message: '' }
|
|
85
|
-
: { ok: false, message: `expected no ${describeMatcher(matcher)} call; saw one at seq ${hit.seq}` }
|
|
86
|
-
},
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** The FIRST tool call matches `matcher`. */
|
|
91
|
-
export function firstTool(matcher) {
|
|
92
|
-
return {
|
|
93
|
-
describe: `first tool is: ${describeMatcher(matcher)}`,
|
|
94
|
-
check(trace) {
|
|
95
|
-
const first = trace.toolCalls[0]
|
|
96
|
-
if (first === undefined) {
|
|
97
|
-
return { ok: false, message: `expected first tool ${describeMatcher(matcher)}; the run made no tool calls` }
|
|
98
|
-
}
|
|
99
|
-
return nameMatches(matcher, first.name)
|
|
100
|
-
? { ok: true, message: '' }
|
|
101
|
-
: { ok: false, message: `expected first tool ${describeMatcher(matcher)}; first was '${first.name}'` }
|
|
102
|
-
},
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* The expected names appear as an ORDERED SUBSEQUENCE of the call sequence
|
|
108
|
-
* (other calls may interleave). `names` entries are matchers.
|
|
109
|
-
*/
|
|
110
|
-
export function toolSequence(names) {
|
|
111
|
-
return {
|
|
112
|
-
describe: `tool sequence: ${names.map(describeMatcher).join(' -> ')}`,
|
|
113
|
-
check(trace) {
|
|
114
|
-
let cursor = 0
|
|
115
|
-
for (const call of trace.toolCalls) {
|
|
116
|
-
const expected = names[cursor]
|
|
117
|
-
if (expected !== undefined && nameMatches(expected, call.name)) cursor += 1
|
|
118
|
-
}
|
|
119
|
-
return cursor === names.length
|
|
120
|
-
? { ok: true, message: '' }
|
|
121
|
-
: {
|
|
122
|
-
ok: false,
|
|
123
|
-
message: `expected subsequence ${names.map(describeMatcher).join(' -> ')}; `
|
|
124
|
-
+ `stalled at ${describeMatcher(names[cursor])}; saw ${callList(trace)}`,
|
|
125
|
-
}
|
|
126
|
-
},
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* One call of `matcher` satisfies `match` on its arguments: an object checks
|
|
132
|
-
* a shallow subset of the parsed JSON arguments; a function receives
|
|
133
|
-
* `(parsedArguments, rawArguments)` and returns a boolean.
|
|
134
|
-
*/
|
|
135
|
-
export function toolCallArgs(matcher, match) {
|
|
136
|
-
return {
|
|
137
|
-
describe: `tool ${describeMatcher(matcher)} arguments match`,
|
|
138
|
-
check(trace) {
|
|
139
|
-
const calls = trace.toolCalls.filter(call => nameMatches(matcher, call.name))
|
|
140
|
-
if (calls.length === 0) {
|
|
141
|
-
return { ok: false, message: `expected a ${describeMatcher(matcher)} call to inspect; saw ${callList(trace)}` }
|
|
142
|
-
}
|
|
143
|
-
const satisfied = calls.some(call => {
|
|
144
|
-
if (typeof match === 'function') return match(call.parsedArguments, call.arguments) === true
|
|
145
|
-
const parsed = call.parsedArguments
|
|
146
|
-
if (parsed === null || typeof parsed !== 'object') return false
|
|
147
|
-
return Object.entries(match).every(
|
|
148
|
-
([key, value]) => JSON.stringify(parsed[key]) === JSON.stringify(value),
|
|
149
|
-
)
|
|
150
|
-
})
|
|
151
|
-
return satisfied
|
|
152
|
-
? { ok: true, message: '' }
|
|
153
|
-
: {
|
|
154
|
-
ok: false,
|
|
155
|
-
message: `no ${describeMatcher(matcher)} call matched the argument predicate; `
|
|
156
|
-
+ `arguments seen: ${calls.map(call => call.arguments).join(' | ')}`,
|
|
157
|
-
}
|
|
158
|
-
},
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** The call matching `matcher` has a paired `tool/result` in the trace. */
|
|
163
|
-
export function toolResultFor(matcher) {
|
|
164
|
-
return {
|
|
165
|
-
describe: `tool result present for: ${describeMatcher(matcher)}`,
|
|
166
|
-
check(trace) {
|
|
167
|
-
const { callIds } = resultsForMatcher(matcher, trace)
|
|
168
|
-
if (callIds.size === 0) {
|
|
169
|
-
return { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
|
|
170
|
-
}
|
|
171
|
-
const hit = trace.toolResults.some(result => callIds.has(result.callId))
|
|
172
|
-
return hit
|
|
173
|
-
? { ok: true, message: '' }
|
|
174
|
-
: { ok: false, message: `${describeMatcher(matcher)} was called but no tool/result arrived for it` }
|
|
175
|
-
},
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* A call matching `matcher` produced a tool result with `isError === true`.
|
|
181
|
-
* Fails when the tool was never called, never received a result, or every
|
|
182
|
-
* result was a success.
|
|
183
|
-
*/
|
|
184
|
-
export function toolResultIsError(matcher) {
|
|
185
|
-
return {
|
|
186
|
-
describe: `tool result isError: ${describeMatcher(matcher)}`,
|
|
187
|
-
check(trace) {
|
|
188
|
-
const { callIds, results } = resultsForMatcher(matcher, trace)
|
|
189
|
-
if (callIds.size === 0) {
|
|
190
|
-
return { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
|
|
191
|
-
}
|
|
192
|
-
if (results.length === 0) {
|
|
193
|
-
return { ok: false, message: `${describeMatcher(matcher)} was called but no tool/result arrived for it` }
|
|
194
|
-
}
|
|
195
|
-
const hit = results.some(r => r.isError === true)
|
|
196
|
-
return hit
|
|
197
|
-
? { ok: true, message: '' }
|
|
198
|
-
: {
|
|
199
|
-
ok: false,
|
|
200
|
-
message: `expected ${describeMatcher(matcher)} to produce an error result; `
|
|
201
|
-
+ `saw isError: [${results.map(r => String(r.isError)).join(', ')}]`,
|
|
202
|
-
}
|
|
203
|
-
},
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* A call matching `matcher` produced a tool result with `isError` NOT true
|
|
209
|
-
* (i.e. `false` or `undefined` — treated as success).
|
|
210
|
-
*/
|
|
211
|
-
export function toolResultSucceeded(matcher) {
|
|
212
|
-
return {
|
|
213
|
-
describe: `tool result succeeded: ${describeMatcher(matcher)}`,
|
|
214
|
-
check(trace) {
|
|
215
|
-
const { callIds, results } = resultsForMatcher(matcher, trace)
|
|
216
|
-
if (callIds.size === 0) {
|
|
217
|
-
return { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
|
|
218
|
-
}
|
|
219
|
-
if (results.length === 0) {
|
|
220
|
-
return { ok: false, message: `${describeMatcher(matcher)} was called but no tool/result arrived for it` }
|
|
221
|
-
}
|
|
222
|
-
const hit = results.some(r => r.isError !== true)
|
|
223
|
-
return hit
|
|
224
|
-
? { ok: true, message: '' }
|
|
225
|
-
: {
|
|
226
|
-
ok: false,
|
|
227
|
-
message: `expected ${describeMatcher(matcher)} to produce a success result; `
|
|
228
|
-
+ `all ${results.length} result(s) had isError: true`,
|
|
229
|
-
}
|
|
230
|
-
},
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* A call matching `matcher` produced a tool result whose text contains
|
|
236
|
-
* `substring`. The text is the same projection used by `toolResultFor`
|
|
237
|
-
* (concatenated inner text blocks of the tool-result wrapper).
|
|
238
|
-
*/
|
|
239
|
-
export function toolResultTextIncludes(matcher, substring) {
|
|
240
|
-
return {
|
|
241
|
-
describe: `tool result text includes: ${describeMatcher(matcher)} → '${substring}'`,
|
|
242
|
-
check(trace) {
|
|
243
|
-
const { callIds, results } = resultsForMatcher(matcher, trace)
|
|
244
|
-
if (callIds.size === 0) {
|
|
245
|
-
return { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
|
|
246
|
-
}
|
|
247
|
-
if (results.length === 0) {
|
|
248
|
-
return { ok: false, message: `${describeMatcher(matcher)} was called but no tool/result arrived for it` }
|
|
249
|
-
}
|
|
250
|
-
const hit = results.some(r => r.text.includes(substring))
|
|
251
|
-
return hit
|
|
252
|
-
? { ok: true, message: '' }
|
|
253
|
-
: {
|
|
254
|
-
ok: false,
|
|
255
|
-
message: `no ${describeMatcher(matcher)} result text includes '${substring}'; `
|
|
256
|
-
+ `texts seen: [${results.map(r => JSON.stringify(truncate(r.text))).join(', ')}]`,
|
|
257
|
-
}
|
|
258
|
-
},
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** The final assistant text contains `substring`. */
|
|
263
|
-
export function finalTextIncludes(substring) {
|
|
264
|
-
return {
|
|
265
|
-
describe: `final text includes: '${substring}'`,
|
|
266
|
-
check(trace) {
|
|
267
|
-
const hit = trace.finalText.includes(substring)
|
|
268
|
-
return hit
|
|
269
|
-
? { ok: true, message: '' }
|
|
270
|
-
: { ok: false, message: `final text does not include '${substring}'; final text: ${JSON.stringify(trace.finalText.slice(0, 400))}` }
|
|
271
|
-
},
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Any assembled assistant text contains `substring`. Unlike
|
|
277
|
-
* `finalTextIncludes`, later turns cannot invalidate the assertion — blocking
|
|
278
|
-
* gates that splice feedback after the script ends (turn-close hooks) push
|
|
279
|
-
* their own trailing steps, so a scripted closing line may no longer be the
|
|
280
|
-
* FINAL text even though the script delivered it.
|
|
281
|
-
*/
|
|
282
|
-
export function assistantTextIncludes(substring) {
|
|
283
|
-
return {
|
|
284
|
-
describe: `assistant text includes: '${substring}'`,
|
|
285
|
-
check(trace) {
|
|
286
|
-
const hit = trace.assistantTexts.some(text => text.includes(substring))
|
|
287
|
-
return hit
|
|
288
|
-
? { ok: true, message: '' }
|
|
289
|
-
: { ok: false, message: `no assistant text includes '${substring}'; texts seen: [${trace.assistantTexts.map(t => JSON.stringify(t.slice(0, 120))).join(', ')}]` }
|
|
290
|
-
},
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/** The final assistant text matches `regex`. */
|
|
295
|
-
export function finalTextMatches(regex) {
|
|
296
|
-
return {
|
|
297
|
-
describe: `final text matches: ${String(regex)}`,
|
|
298
|
-
check(trace) {
|
|
299
|
-
const hit = regex.test(trace.finalText)
|
|
300
|
-
return hit
|
|
301
|
-
? { ok: true, message: '' }
|
|
302
|
-
: { ok: false, message: `final text does not match ${String(regex)}; final text: ${JSON.stringify(trace.finalText.slice(0, 400))}` }
|
|
303
|
-
},
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/** The assembled system prompt of a request contains `substring`. */
|
|
308
|
-
export function systemPromptIncludes(substring) {
|
|
309
|
-
return {
|
|
310
|
-
describe: `system prompt includes: '${substring}'`,
|
|
311
|
-
check(trace) {
|
|
312
|
-
const headers = trace.requestHeaders
|
|
313
|
-
if (headers.length === 0) {
|
|
314
|
-
return { ok: false, message: 'expected a request/header event; the run produced none' }
|
|
315
|
-
}
|
|
316
|
-
const hit = headers.some(header => header.system.includes(substring))
|
|
317
|
-
return hit
|
|
318
|
-
? { ok: true, message: '' }
|
|
319
|
-
: { ok: false, message: `no request/header system prompt contains '${substring}' (${headers.length} header(s) seen)` }
|
|
320
|
-
},
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/** A tool named `matcher` is mounted in some request header (not merely called). */
|
|
325
|
-
export function toolMounted(matcher) {
|
|
326
|
-
return {
|
|
327
|
-
describe: `tool mounted: ${describeMatcher(matcher)}`,
|
|
328
|
-
check(trace) {
|
|
329
|
-
const headers = trace.requestHeaders
|
|
330
|
-
if (headers.length === 0) {
|
|
331
|
-
return { ok: false, message: 'expected a request/header event; the run produced none' }
|
|
332
|
-
}
|
|
333
|
-
const names = [...new Set(headers.flatMap(header => header.toolNames))]
|
|
334
|
-
const hit = names.some(name => nameMatches(matcher, name))
|
|
335
|
-
return hit
|
|
336
|
-
? { ok: true, message: '' }
|
|
337
|
-
: { ok: false, message: `expected ${describeMatcher(matcher)} among mounted tools; saw [${names.join(', ')}]` }
|
|
338
|
-
},
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
* the full
|
|
346
|
-
*
|
|
347
|
-
*/
|
|
348
|
-
|
|
349
|
-
return
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Trace matchers for dsh agent eval. Every factory returns a matcher:
|
|
3
|
+
* `{ describe, check(trace) -> { ok, message } }` — a pure function over an
|
|
4
|
+
* `EvalTrace` (see trace.mjs), so matchers unit-test without any dsh run.
|
|
5
|
+
* Intent tests assert tool SELECTION over final text: model wording varies,
|
|
6
|
+
* tool choice is the contract under test.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Render a name matcher for diagnostics. */
|
|
10
|
+
function describeMatcher(matcher) {
|
|
11
|
+
return matcher instanceof RegExp ? String(matcher) : `'${matcher}'`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Whether a tool name satisfies a matcher (exact string or RegExp). */
|
|
15
|
+
function nameMatches(matcher, name) {
|
|
16
|
+
return matcher instanceof RegExp ? matcher.test(name) : name === matcher
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a message `source` satisfies a source matcher. A string or RegExp
|
|
21
|
+
* matches `source.plugin` (the producer name — e.g. `'gates'` for steer);
|
|
22
|
+
* a function receives the full `source` object (for `kind`-based matching).
|
|
23
|
+
*/
|
|
24
|
+
function sourceMatches(matcher, source) {
|
|
25
|
+
if (typeof matcher === 'function') return matcher(source) === true
|
|
26
|
+
const plugin = source?.plugin
|
|
27
|
+
if (matcher instanceof RegExp) return typeof plugin === 'string' && matcher.test(plugin)
|
|
28
|
+
return plugin === matcher
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Render a source matcher for diagnostics. */
|
|
32
|
+
function describeSource(matcher) {
|
|
33
|
+
if (typeof matcher === 'function') return '<source predicate>'
|
|
34
|
+
return describeMatcher(matcher)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Render the trace's call sequence for failure messages. */
|
|
38
|
+
function callList(trace) {
|
|
39
|
+
const names = trace.toolCalls.map(call => call.name)
|
|
40
|
+
return names.length === 0 ? '(no tool calls)' : `[${names.join(', ')}]`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Collect the tool results paired with calls matching `matcher`.
|
|
45
|
+
* @returns {{ callIds: Set<string>, results: object[] }}
|
|
46
|
+
* `callIds` is empty when no call satisfies `matcher`;
|
|
47
|
+
* `results` is the subset of `trace.toolResults` paired with those calls.
|
|
48
|
+
*/
|
|
49
|
+
function resultsForMatcher(matcher, trace) {
|
|
50
|
+
const callIds = new Set(
|
|
51
|
+
trace.toolCalls.filter(call => nameMatches(matcher, call.name)).map(call => call.callId),
|
|
52
|
+
)
|
|
53
|
+
const results = callIds.size === 0
|
|
54
|
+
? []
|
|
55
|
+
: trace.toolResults.filter(r => callIds.has(r.callId))
|
|
56
|
+
return { callIds, results }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Truncate a string for diagnostics. */
|
|
60
|
+
function truncate(text, max = 200) {
|
|
61
|
+
return text.length > max ? `${text.slice(0, max)}…` : text
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A tool matching `matcher` was called at least once. */
|
|
65
|
+
export function toolCalled(matcher) {
|
|
66
|
+
return {
|
|
67
|
+
describe: `tool called: ${describeMatcher(matcher)}`,
|
|
68
|
+
check(trace) {
|
|
69
|
+
const hit = trace.toolCalls.some(call => nameMatches(matcher, call.name))
|
|
70
|
+
return hit
|
|
71
|
+
? { ok: true, message: '' }
|
|
72
|
+
: { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** No tool matching `matcher` was ever called. */
|
|
78
|
+
export function toolNotCalled(matcher) {
|
|
79
|
+
return {
|
|
80
|
+
describe: `tool not called: ${describeMatcher(matcher)}`,
|
|
81
|
+
check(trace) {
|
|
82
|
+
const hit = trace.toolCalls.find(call => nameMatches(matcher, call.name))
|
|
83
|
+
return hit === undefined
|
|
84
|
+
? { ok: true, message: '' }
|
|
85
|
+
: { ok: false, message: `expected no ${describeMatcher(matcher)} call; saw one at seq ${hit.seq}` }
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The FIRST tool call matches `matcher`. */
|
|
91
|
+
export function firstTool(matcher) {
|
|
92
|
+
return {
|
|
93
|
+
describe: `first tool is: ${describeMatcher(matcher)}`,
|
|
94
|
+
check(trace) {
|
|
95
|
+
const first = trace.toolCalls[0]
|
|
96
|
+
if (first === undefined) {
|
|
97
|
+
return { ok: false, message: `expected first tool ${describeMatcher(matcher)}; the run made no tool calls` }
|
|
98
|
+
}
|
|
99
|
+
return nameMatches(matcher, first.name)
|
|
100
|
+
? { ok: true, message: '' }
|
|
101
|
+
: { ok: false, message: `expected first tool ${describeMatcher(matcher)}; first was '${first.name}'` }
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The expected names appear as an ORDERED SUBSEQUENCE of the call sequence
|
|
108
|
+
* (other calls may interleave). `names` entries are matchers.
|
|
109
|
+
*/
|
|
110
|
+
export function toolSequence(names) {
|
|
111
|
+
return {
|
|
112
|
+
describe: `tool sequence: ${names.map(describeMatcher).join(' -> ')}`,
|
|
113
|
+
check(trace) {
|
|
114
|
+
let cursor = 0
|
|
115
|
+
for (const call of trace.toolCalls) {
|
|
116
|
+
const expected = names[cursor]
|
|
117
|
+
if (expected !== undefined && nameMatches(expected, call.name)) cursor += 1
|
|
118
|
+
}
|
|
119
|
+
return cursor === names.length
|
|
120
|
+
? { ok: true, message: '' }
|
|
121
|
+
: {
|
|
122
|
+
ok: false,
|
|
123
|
+
message: `expected subsequence ${names.map(describeMatcher).join(' -> ')}; `
|
|
124
|
+
+ `stalled at ${describeMatcher(names[cursor])}; saw ${callList(trace)}`,
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One call of `matcher` satisfies `match` on its arguments: an object checks
|
|
132
|
+
* a shallow subset of the parsed JSON arguments; a function receives
|
|
133
|
+
* `(parsedArguments, rawArguments)` and returns a boolean.
|
|
134
|
+
*/
|
|
135
|
+
export function toolCallArgs(matcher, match) {
|
|
136
|
+
return {
|
|
137
|
+
describe: `tool ${describeMatcher(matcher)} arguments match`,
|
|
138
|
+
check(trace) {
|
|
139
|
+
const calls = trace.toolCalls.filter(call => nameMatches(matcher, call.name))
|
|
140
|
+
if (calls.length === 0) {
|
|
141
|
+
return { ok: false, message: `expected a ${describeMatcher(matcher)} call to inspect; saw ${callList(trace)}` }
|
|
142
|
+
}
|
|
143
|
+
const satisfied = calls.some(call => {
|
|
144
|
+
if (typeof match === 'function') return match(call.parsedArguments, call.arguments) === true
|
|
145
|
+
const parsed = call.parsedArguments
|
|
146
|
+
if (parsed === null || typeof parsed !== 'object') return false
|
|
147
|
+
return Object.entries(match).every(
|
|
148
|
+
([key, value]) => JSON.stringify(parsed[key]) === JSON.stringify(value),
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
return satisfied
|
|
152
|
+
? { ok: true, message: '' }
|
|
153
|
+
: {
|
|
154
|
+
ok: false,
|
|
155
|
+
message: `no ${describeMatcher(matcher)} call matched the argument predicate; `
|
|
156
|
+
+ `arguments seen: ${calls.map(call => call.arguments).join(' | ')}`,
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The call matching `matcher` has a paired `tool/result` in the trace. */
|
|
163
|
+
export function toolResultFor(matcher) {
|
|
164
|
+
return {
|
|
165
|
+
describe: `tool result present for: ${describeMatcher(matcher)}`,
|
|
166
|
+
check(trace) {
|
|
167
|
+
const { callIds } = resultsForMatcher(matcher, trace)
|
|
168
|
+
if (callIds.size === 0) {
|
|
169
|
+
return { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
|
|
170
|
+
}
|
|
171
|
+
const hit = trace.toolResults.some(result => callIds.has(result.callId))
|
|
172
|
+
return hit
|
|
173
|
+
? { ok: true, message: '' }
|
|
174
|
+
: { ok: false, message: `${describeMatcher(matcher)} was called but no tool/result arrived for it` }
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A call matching `matcher` produced a tool result with `isError === true`.
|
|
181
|
+
* Fails when the tool was never called, never received a result, or every
|
|
182
|
+
* result was a success.
|
|
183
|
+
*/
|
|
184
|
+
export function toolResultIsError(matcher) {
|
|
185
|
+
return {
|
|
186
|
+
describe: `tool result isError: ${describeMatcher(matcher)}`,
|
|
187
|
+
check(trace) {
|
|
188
|
+
const { callIds, results } = resultsForMatcher(matcher, trace)
|
|
189
|
+
if (callIds.size === 0) {
|
|
190
|
+
return { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
|
|
191
|
+
}
|
|
192
|
+
if (results.length === 0) {
|
|
193
|
+
return { ok: false, message: `${describeMatcher(matcher)} was called but no tool/result arrived for it` }
|
|
194
|
+
}
|
|
195
|
+
const hit = results.some(r => r.isError === true)
|
|
196
|
+
return hit
|
|
197
|
+
? { ok: true, message: '' }
|
|
198
|
+
: {
|
|
199
|
+
ok: false,
|
|
200
|
+
message: `expected ${describeMatcher(matcher)} to produce an error result; `
|
|
201
|
+
+ `saw isError: [${results.map(r => String(r.isError)).join(', ')}]`,
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A call matching `matcher` produced a tool result with `isError` NOT true
|
|
209
|
+
* (i.e. `false` or `undefined` — treated as success).
|
|
210
|
+
*/
|
|
211
|
+
export function toolResultSucceeded(matcher) {
|
|
212
|
+
return {
|
|
213
|
+
describe: `tool result succeeded: ${describeMatcher(matcher)}`,
|
|
214
|
+
check(trace) {
|
|
215
|
+
const { callIds, results } = resultsForMatcher(matcher, trace)
|
|
216
|
+
if (callIds.size === 0) {
|
|
217
|
+
return { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
|
|
218
|
+
}
|
|
219
|
+
if (results.length === 0) {
|
|
220
|
+
return { ok: false, message: `${describeMatcher(matcher)} was called but no tool/result arrived for it` }
|
|
221
|
+
}
|
|
222
|
+
const hit = results.some(r => r.isError !== true)
|
|
223
|
+
return hit
|
|
224
|
+
? { ok: true, message: '' }
|
|
225
|
+
: {
|
|
226
|
+
ok: false,
|
|
227
|
+
message: `expected ${describeMatcher(matcher)} to produce a success result; `
|
|
228
|
+
+ `all ${results.length} result(s) had isError: true`,
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A call matching `matcher` produced a tool result whose text contains
|
|
236
|
+
* `substring`. The text is the same projection used by `toolResultFor`
|
|
237
|
+
* (concatenated inner text blocks of the tool-result wrapper).
|
|
238
|
+
*/
|
|
239
|
+
export function toolResultTextIncludes(matcher, substring) {
|
|
240
|
+
return {
|
|
241
|
+
describe: `tool result text includes: ${describeMatcher(matcher)} → '${substring}'`,
|
|
242
|
+
check(trace) {
|
|
243
|
+
const { callIds, results } = resultsForMatcher(matcher, trace)
|
|
244
|
+
if (callIds.size === 0) {
|
|
245
|
+
return { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
|
|
246
|
+
}
|
|
247
|
+
if (results.length === 0) {
|
|
248
|
+
return { ok: false, message: `${describeMatcher(matcher)} was called but no tool/result arrived for it` }
|
|
249
|
+
}
|
|
250
|
+
const hit = results.some(r => r.text.includes(substring))
|
|
251
|
+
return hit
|
|
252
|
+
? { ok: true, message: '' }
|
|
253
|
+
: {
|
|
254
|
+
ok: false,
|
|
255
|
+
message: `no ${describeMatcher(matcher)} result text includes '${substring}'; `
|
|
256
|
+
+ `texts seen: [${results.map(r => JSON.stringify(truncate(r.text))).join(', ')}]`,
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The final assistant text contains `substring`. */
|
|
263
|
+
export function finalTextIncludes(substring) {
|
|
264
|
+
return {
|
|
265
|
+
describe: `final text includes: '${substring}'`,
|
|
266
|
+
check(trace) {
|
|
267
|
+
const hit = trace.finalText.includes(substring)
|
|
268
|
+
return hit
|
|
269
|
+
? { ok: true, message: '' }
|
|
270
|
+
: { ok: false, message: `final text does not include '${substring}'; final text: ${JSON.stringify(trace.finalText.slice(0, 400))}` }
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Any assembled assistant text contains `substring`. Unlike
|
|
277
|
+
* `finalTextIncludes`, later turns cannot invalidate the assertion — blocking
|
|
278
|
+
* gates that splice feedback after the script ends (turn-close hooks) push
|
|
279
|
+
* their own trailing steps, so a scripted closing line may no longer be the
|
|
280
|
+
* FINAL text even though the script delivered it.
|
|
281
|
+
*/
|
|
282
|
+
export function assistantTextIncludes(substring) {
|
|
283
|
+
return {
|
|
284
|
+
describe: `assistant text includes: '${substring}'`,
|
|
285
|
+
check(trace) {
|
|
286
|
+
const hit = trace.assistantTexts.some(text => text.includes(substring))
|
|
287
|
+
return hit
|
|
288
|
+
? { ok: true, message: '' }
|
|
289
|
+
: { ok: false, message: `no assistant text includes '${substring}'; texts seen: [${trace.assistantTexts.map(t => JSON.stringify(t.slice(0, 120))).join(', ')}]` }
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The final assistant text matches `regex`. */
|
|
295
|
+
export function finalTextMatches(regex) {
|
|
296
|
+
return {
|
|
297
|
+
describe: `final text matches: ${String(regex)}`,
|
|
298
|
+
check(trace) {
|
|
299
|
+
const hit = regex.test(trace.finalText)
|
|
300
|
+
return hit
|
|
301
|
+
? { ok: true, message: '' }
|
|
302
|
+
: { ok: false, message: `final text does not match ${String(regex)}; final text: ${JSON.stringify(trace.finalText.slice(0, 400))}` }
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The assembled system prompt of a request contains `substring`. */
|
|
308
|
+
export function systemPromptIncludes(substring) {
|
|
309
|
+
return {
|
|
310
|
+
describe: `system prompt includes: '${substring}'`,
|
|
311
|
+
check(trace) {
|
|
312
|
+
const headers = trace.requestHeaders
|
|
313
|
+
if (headers.length === 0) {
|
|
314
|
+
return { ok: false, message: 'expected a request/header event; the run produced none' }
|
|
315
|
+
}
|
|
316
|
+
const hit = headers.some(header => header.system.includes(substring))
|
|
317
|
+
return hit
|
|
318
|
+
? { ok: true, message: '' }
|
|
319
|
+
: { ok: false, message: `no request/header system prompt contains '${substring}' (${headers.length} header(s) seen)` }
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** A tool named `matcher` is mounted in some request header (not merely called). */
|
|
325
|
+
export function toolMounted(matcher) {
|
|
326
|
+
return {
|
|
327
|
+
describe: `tool mounted: ${describeMatcher(matcher)}`,
|
|
328
|
+
check(trace) {
|
|
329
|
+
const headers = trace.requestHeaders
|
|
330
|
+
if (headers.length === 0) {
|
|
331
|
+
return { ok: false, message: 'expected a request/header event; the run produced none' }
|
|
332
|
+
}
|
|
333
|
+
const names = [...new Set(headers.flatMap(header => header.toolNames))]
|
|
334
|
+
const hit = names.some(name => nameMatches(matcher, name))
|
|
335
|
+
return hit
|
|
336
|
+
? { ok: true, message: '' }
|
|
337
|
+
: { ok: false, message: `expected ${describeMatcher(matcher)} among mounted tools; saw [${names.join(', ')}]` }
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Whether a subagent child record satisfies a label matcher: string/RegExp
|
|
344
|
+
* against the child's durable `label` (`subagent/descriptor`), or a predicate
|
|
345
|
+
* over the full child record (mode/provider/delegationDepth-based matching).
|
|
346
|
+
* A child without a label never satisfies a string/RegExp matcher.
|
|
347
|
+
*/
|
|
348
|
+
function childMatches(matcher, child) {
|
|
349
|
+
if (typeof matcher === 'function') return matcher(child) === true
|
|
350
|
+
if (child.label === undefined) return false
|
|
351
|
+
return matcher instanceof RegExp ? matcher.test(child.label) : child.label === matcher
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Render the label list of a trace's subagent children for diagnostics. */
|
|
355
|
+
function childLabelList(trace) {
|
|
356
|
+
const labels = trace.subagentChildren.map(child => child.label ?? '<unlabeled>')
|
|
357
|
+
return labels.length === 0 ? '(no subagent children)' : `[${labels.join(', ')}]`
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** At least one subagent child was dispatched with a matching label. */
|
|
361
|
+
export function subagentDispatched(matcher) {
|
|
362
|
+
return {
|
|
363
|
+
describe: `subagent dispatched: ${describeMatcher(matcher)}`,
|
|
364
|
+
check(trace) {
|
|
365
|
+
const hit = trace.subagentChildren.some(child => childMatches(matcher, child))
|
|
366
|
+
return hit
|
|
367
|
+
? { ok: true, message: '' }
|
|
368
|
+
: {
|
|
369
|
+
ok: false,
|
|
370
|
+
message: `expected a dispatched subagent matching ${describeMatcher(matcher)}; `
|
|
371
|
+
+ `saw ${childLabelList(trace)}`,
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* A subagent child with a matching label ran to an answer: its own session
|
|
379
|
+
* log holds at least one non-empty assistant text. Dispatch alone (the child
|
|
380
|
+
* log exists but produced nothing) does not satisfy this matcher.
|
|
381
|
+
*/
|
|
382
|
+
export function subagentCompleted(matcher) {
|
|
383
|
+
return {
|
|
384
|
+
describe: `subagent completed: ${describeMatcher(matcher)}`,
|
|
385
|
+
check(trace) {
|
|
386
|
+
const children = trace.subagentChildren.filter(child => childMatches(matcher, child))
|
|
387
|
+
if (children.length === 0) {
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
message: `expected a dispatched subagent matching ${describeMatcher(matcher)}; `
|
|
391
|
+
+ `saw ${childLabelList(trace)}`,
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const hit = children.some(child => child.finalText !== '')
|
|
395
|
+
return hit
|
|
396
|
+
? { ok: true, message: '' }
|
|
397
|
+
: {
|
|
398
|
+
ok: false,
|
|
399
|
+
message: `${describeMatcher(matcher)} was dispatched but produced no assistant text`,
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* A user message from a source matching `sourceMatcher` contains `substring`.
|
|
407
|
+
* Source matcher: string/RegExp against `source.plugin`, or a predicate over
|
|
408
|
+
* the full `source`. This is how a case asserts plugin steer — a `user/message`
|
|
409
|
+
* with a plugin source — separately from the task prompt (`kind: 'user'`).
|
|
410
|
+
*/
|
|
411
|
+
export function userMessageTextIncludes(sourceMatcher, substring) {
|
|
412
|
+
return {
|
|
413
|
+
describe: `user message from ${describeSource(sourceMatcher)} includes: '${substring}'`,
|
|
414
|
+
check(trace) {
|
|
415
|
+
const messages = trace.userMessages.filter(message => sourceMatches(sourceMatcher, message.source))
|
|
416
|
+
if (messages.length === 0) {
|
|
417
|
+
return { ok: false, message: `expected a user message from ${describeSource(sourceMatcher)}; the run produced none` }
|
|
418
|
+
}
|
|
419
|
+
const hit = messages.some(message => message.text.includes(substring))
|
|
420
|
+
return hit
|
|
421
|
+
? { ok: true, message: '' }
|
|
422
|
+
: {
|
|
423
|
+
ok: false,
|
|
424
|
+
message: `no ${describeSource(sourceMatcher)} user message includes '${substring}'; `
|
|
425
|
+
+ `texts seen: [${messages.map(message => JSON.stringify(truncate(message.text))).join(', ')}]`,
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* No user message from a source matching `sourceMatcher` contains `substring`.
|
|
433
|
+
* Passes vacuously when no such message exists — pair it with
|
|
434
|
+
* `userMessageTextIncludes` to also prove the message arrived. This is the
|
|
435
|
+
* "not steered on someone else's file" half of an isolation assertion.
|
|
436
|
+
*/
|
|
437
|
+
export function userMessageTextExcludes(sourceMatcher, substring) {
|
|
438
|
+
return {
|
|
439
|
+
describe: `user message from ${describeSource(sourceMatcher)} excludes: '${substring}'`,
|
|
440
|
+
check(trace) {
|
|
441
|
+
const messages = trace.userMessages.filter(message => sourceMatches(sourceMatcher, message.source))
|
|
442
|
+
const hit = messages.find(message => message.text.includes(substring))
|
|
443
|
+
return hit === undefined
|
|
444
|
+
? { ok: true, message: '' }
|
|
445
|
+
: {
|
|
446
|
+
ok: false,
|
|
447
|
+
message: `a ${describeSource(sourceMatcher)} user message includes '${substring}': `
|
|
448
|
+
+ `${JSON.stringify(truncate(hit.text))}`,
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Exactly `expected` subagent children matching the label matcher were
|
|
456
|
+
* dispatched. The bounded-redispatch assertion ("one dispatch per turn, no
|
|
457
|
+
* more") — pair with cross-turn driving (`followups`), where the count spans
|
|
458
|
+
* every driven turn.
|
|
459
|
+
*/
|
|
460
|
+
export function subagentDispatchCount(matcher, expected) {
|
|
461
|
+
return {
|
|
462
|
+
describe: `subagent dispatch count: ${describeMatcher(matcher)} × ${expected}`,
|
|
463
|
+
check(trace) {
|
|
464
|
+
const count = trace.subagentChildren.filter(child => childMatches(matcher, child)).length
|
|
465
|
+
return count === expected
|
|
466
|
+
? { ok: true, message: '' }
|
|
467
|
+
: {
|
|
468
|
+
ok: false,
|
|
469
|
+
message: `expected ${expected} dispatched subagent(s) matching ${describeMatcher(matcher)}; `
|
|
470
|
+
+ `saw ${count} (${childLabelList(trace)})`,
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Exactly `expected` subagent children matching the label matcher COMPLETED
|
|
478
|
+
* (produced a non-empty assistant text). Unlike `subagentCompleted` (any one
|
|
479
|
+
* suffices), this pins every dispatched child's outcome — "dispatched ⇒
|
|
480
|
+
* observable outcome" for cross-turn cases where a truncated child must fail
|
|
481
|
+
* the case even when its siblings finished.
|
|
482
|
+
*/
|
|
483
|
+
export function subagentCompletedCount(matcher, expected) {
|
|
484
|
+
return {
|
|
485
|
+
describe: `subagent completed count: ${describeMatcher(matcher)} × ${expected}`,
|
|
486
|
+
check(trace) {
|
|
487
|
+
const children = trace.subagentChildren.filter(child => childMatches(matcher, child))
|
|
488
|
+
const completed = children.filter(child => child.finalText !== '')
|
|
489
|
+
return completed.length === expected
|
|
490
|
+
? { ok: true, message: '' }
|
|
491
|
+
: {
|
|
492
|
+
ok: false,
|
|
493
|
+
message: `expected exactly ${expected} completed subagent(s) matching ${describeMatcher(matcher)}; `
|
|
494
|
+
+ `saw ${completed.length} of ${children.length} dispatched `
|
|
495
|
+
+ `(${children.map(child => `${child.label ?? '<unlabeled>'}:${child.finalText !== '' ? 'done' : 'silent'}`).join(', ')})`,
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
}
|
|
499
|
+
}
|