@catheadowl/dsh-eval 0.2.0 → 0.3.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.
@@ -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
- * A user message from a source matching `sourceMatcher` contains `substring`.
344
- * Source matcher: string/RegExp against `source.plugin`, or a predicate over
345
- * the full `source`. This is how a case asserts plugin steer — a `user/message`
346
- * with a plugin source separately from the task prompt (`kind: 'user'`).
347
- */
348
- export function userMessageTextIncludes(sourceMatcher, substring) {
349
- return {
350
- describe: `user message from ${describeSource(sourceMatcher)} includes: '${substring}'`,
351
- check(trace) {
352
- const messages = trace.userMessages.filter(message => sourceMatches(sourceMatcher, message.source))
353
- if (messages.length === 0) {
354
- return { ok: false, message: `expected a user message from ${describeSource(sourceMatcher)}; the run produced none` }
355
- }
356
- const hit = messages.some(message => message.text.includes(substring))
357
- return hit
358
- ? { ok: true, message: '' }
359
- : {
360
- ok: false,
361
- message: `no ${describeSource(sourceMatcher)} user message includes '${substring}'; `
362
- + `texts seen: [${messages.map(message => JSON.stringify(truncate(message.text))).join(', ')}]`,
363
- }
364
- },
365
- }
366
- }
367
-
368
- /**
369
- * No user message from a source matching `sourceMatcher` contains `substring`.
370
- * Passes vacuously when no such message exists — pair it with
371
- * `userMessageTextIncludes` to also prove the message arrived. This is the
372
- * "not steered on someone else's file" half of an isolation assertion.
373
- */
374
- export function userMessageTextExcludes(sourceMatcher, substring) {
375
- return {
376
- describe: `user message from ${describeSource(sourceMatcher)} excludes: '${substring}'`,
377
- check(trace) {
378
- const messages = trace.userMessages.filter(message => sourceMatches(sourceMatcher, message.source))
379
- const hit = messages.find(message => message.text.includes(substring))
380
- return hit === undefined
381
- ? { ok: true, message: '' }
382
- : {
383
- ok: false,
384
- message: `a ${describeSource(sourceMatcher)} user message includes '${substring}': `
385
- + `${JSON.stringify(truncate(hit.text))}`,
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
+ }