@catheadowl/dsh-eval 0.2.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.
@@ -1,118 +1,118 @@
1
- /**
2
- * Model-independent review experiment primitives.
3
- *
4
- * A review experiment separates four concerns:
5
- * - frozen inputs owned by the plugin;
6
- * - live observation/projection of those inputs;
7
- * - the blind prompt shown to an independent reviewer;
8
- * - the hidden human rubric used after the run.
9
- *
10
- * Nothing in this module knows how a model is invoked. Callers provide an
11
- * executor (dsh headless is one adapter) when they want to run the assembled
12
- * task.
13
- */
14
-
15
- const OBSERVATIONS_PLACEHOLDER = '{{EVAL_OBSERVATIONS}}'
16
-
17
- /** Define and validate a human-graded review experiment. */
18
- export function defineReviewExperiment(definition) {
19
- if (definition === null || typeof definition !== 'object') {
20
- throw new TypeError('review experiment must be an object')
21
- }
22
- if (!/^[a-z0-9][a-z0-9._-]*$/i.test(definition.id ?? '')) {
23
- throw new TypeError('review experiment id must be a non-empty path-safe string')
24
- }
25
- if (typeof definition.prompt !== 'string' || definition.prompt.split(OBSERVATIONS_PLACEHOLDER).length !== 2) {
26
- throw new TypeError(`review experiment '${definition.id}': prompt must contain exactly one ${OBSERVATIONS_PLACEHOLDER}`)
27
- }
28
- if (typeof definition.observe !== 'function') {
29
- throw new TypeError(`review experiment '${definition.id}': observe must be a function`)
30
- }
31
- if (!(typeof definition.rubric === 'string' || definition.rubric instanceof URL)) {
32
- throw new TypeError(`review experiment '${definition.id}': rubric must identify the hidden grading standard`)
33
- }
34
- const defaultRuns = definition.defaultRuns ?? 3
35
- if (!Number.isInteger(defaultRuns) || defaultRuns < 1) {
36
- throw new TypeError(`review experiment '${definition.id}': defaultRuns must be a positive integer`)
37
- }
38
- return Object.freeze({
39
- ...definition,
40
- kind: 'review',
41
- defaultRuns,
42
- })
43
- }
44
-
45
- /** Render the standard observation document consumed by a blind reviewer. */
46
- export function renderObservationSections(sections) {
47
- if (!Array.isArray(sections) || sections.length === 0) {
48
- throw new TypeError('review observations must contain at least one section')
49
- }
50
- const lines = []
51
- for (const section of sections) {
52
- if (typeof section?.heading !== 'string' || section.heading.length === 0) {
53
- throw new TypeError('every observation section needs a heading')
54
- }
55
- lines.push(`## ${section.heading}`)
56
- if (section.introduction) lines.push(String(section.introduction), '')
57
- for (const entry of section.entries ?? []) {
58
- if (typeof entry?.heading !== 'string' || entry.heading.length === 0) {
59
- throw new TypeError(`section '${section.heading}' contains an entry without a heading`)
60
- }
61
- lines.push(`### ${entry.heading}`)
62
- for (const paragraph of entry.paragraphs ?? []) lines.push(String(paragraph))
63
- if (entry.call !== undefined) lines.push(`Call: ${JSON.stringify(entry.call)}`)
64
- if (entry.json !== undefined) {
65
- lines.push('```json', JSON.stringify(entry.json, null, 2), '```')
66
- }
67
- lines.push('')
68
- }
69
- }
70
- return lines.join('\n').trimEnd()
71
- }
72
-
73
- /** Materialize live observations and assemble the blind reviewer task. */
74
- export async function materializeReviewExperiment(experiment) {
75
- const sections = await experiment.observe()
76
- const observations = renderObservationSections(sections)
77
- return {
78
- experimentId: experiment.id,
79
- observations,
80
- task: experiment.prompt.replace(OBSERVATIONS_PLACEHOLDER, observations),
81
- }
82
- }
83
-
84
- /**
85
- * Execute the same materialized task through N fresh executor calls.
86
- * The executor is intentionally generic: `(task, context) => result`.
87
- */
88
- export async function executeReviewExperiment(experiment, executor, options = {}) {
89
- if (typeof executor !== 'function') throw new TypeError('review executor must be a function')
90
- const runs = options.runs ?? experiment.defaultRuns
91
- if (!Number.isInteger(runs) || runs < 1) throw new TypeError('runs must be a positive integer')
92
-
93
- // Observe once. Every reviewer sees byte-identical evidence, so variance is
94
- // attributable to interpretation rather than fixture/projection drift.
95
- const materialized = await materializeReviewExperiment(experiment)
96
- const attempts = []
97
- for (let index = 1; index <= runs; index += 1) {
98
- try {
99
- const result = await executor(materialized.task, {
100
- experiment,
101
- experimentId: experiment.id,
102
- index,
103
- runs,
104
- })
105
- attempts.push({ index, ok: true, result })
106
- } catch (error) {
107
- attempts.push({
108
- index,
109
- ok: false,
110
- error: error instanceof Error ? error.message : String(error),
111
- result: error?.result,
112
- })
113
- }
114
- }
115
- return { ...materialized, runs, attempts }
116
- }
117
-
118
- export { OBSERVATIONS_PLACEHOLDER }
1
+ /**
2
+ * Model-independent review experiment primitives.
3
+ *
4
+ * A review experiment separates four concerns:
5
+ * - frozen inputs owned by the plugin;
6
+ * - live observation/projection of those inputs;
7
+ * - the blind prompt shown to an independent reviewer;
8
+ * - the hidden human rubric used after the run.
9
+ *
10
+ * Nothing in this module knows how a model is invoked. Callers provide an
11
+ * executor (dsh headless is one adapter) when they want to run the assembled
12
+ * task.
13
+ */
14
+
15
+ const OBSERVATIONS_PLACEHOLDER = '{{EVAL_OBSERVATIONS}}'
16
+
17
+ /** Define and validate a human-graded review experiment. */
18
+ export function defineReviewExperiment(definition) {
19
+ if (definition === null || typeof definition !== 'object') {
20
+ throw new TypeError('review experiment must be an object')
21
+ }
22
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(definition.id ?? '')) {
23
+ throw new TypeError('review experiment id must be a non-empty path-safe string')
24
+ }
25
+ if (typeof definition.prompt !== 'string' || definition.prompt.split(OBSERVATIONS_PLACEHOLDER).length !== 2) {
26
+ throw new TypeError(`review experiment '${definition.id}': prompt must contain exactly one ${OBSERVATIONS_PLACEHOLDER}`)
27
+ }
28
+ if (typeof definition.observe !== 'function') {
29
+ throw new TypeError(`review experiment '${definition.id}': observe must be a function`)
30
+ }
31
+ if (!(typeof definition.rubric === 'string' || definition.rubric instanceof URL)) {
32
+ throw new TypeError(`review experiment '${definition.id}': rubric must identify the hidden grading standard`)
33
+ }
34
+ const defaultRuns = definition.defaultRuns ?? 3
35
+ if (!Number.isInteger(defaultRuns) || defaultRuns < 1) {
36
+ throw new TypeError(`review experiment '${definition.id}': defaultRuns must be a positive integer`)
37
+ }
38
+ return Object.freeze({
39
+ ...definition,
40
+ kind: 'review',
41
+ defaultRuns,
42
+ })
43
+ }
44
+
45
+ /** Render the standard observation document consumed by a blind reviewer. */
46
+ export function renderObservationSections(sections) {
47
+ if (!Array.isArray(sections) || sections.length === 0) {
48
+ throw new TypeError('review observations must contain at least one section')
49
+ }
50
+ const lines = []
51
+ for (const section of sections) {
52
+ if (typeof section?.heading !== 'string' || section.heading.length === 0) {
53
+ throw new TypeError('every observation section needs a heading')
54
+ }
55
+ lines.push(`## ${section.heading}`)
56
+ if (section.introduction) lines.push(String(section.introduction), '')
57
+ for (const entry of section.entries ?? []) {
58
+ if (typeof entry?.heading !== 'string' || entry.heading.length === 0) {
59
+ throw new TypeError(`section '${section.heading}' contains an entry without a heading`)
60
+ }
61
+ lines.push(`### ${entry.heading}`)
62
+ for (const paragraph of entry.paragraphs ?? []) lines.push(String(paragraph))
63
+ if (entry.call !== undefined) lines.push(`Call: ${JSON.stringify(entry.call)}`)
64
+ if (entry.json !== undefined) {
65
+ lines.push('```json', JSON.stringify(entry.json, null, 2), '```')
66
+ }
67
+ lines.push('')
68
+ }
69
+ }
70
+ return lines.join('\n').trimEnd()
71
+ }
72
+
73
+ /** Materialize live observations and assemble the blind reviewer task. */
74
+ export async function materializeReviewExperiment(experiment) {
75
+ const sections = await experiment.observe()
76
+ const observations = renderObservationSections(sections)
77
+ return {
78
+ experimentId: experiment.id,
79
+ observations,
80
+ task: experiment.prompt.replace(OBSERVATIONS_PLACEHOLDER, observations),
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Execute the same materialized task through N fresh executor calls.
86
+ * The executor is intentionally generic: `(task, context) => result`.
87
+ */
88
+ export async function executeReviewExperiment(experiment, executor, options = {}) {
89
+ if (typeof executor !== 'function') throw new TypeError('review executor must be a function')
90
+ const runs = options.runs ?? experiment.defaultRuns
91
+ if (!Number.isInteger(runs) || runs < 1) throw new TypeError('runs must be a positive integer')
92
+
93
+ // Observe once. Every reviewer sees byte-identical evidence, so variance is
94
+ // attributable to interpretation rather than fixture/projection drift.
95
+ const materialized = await materializeReviewExperiment(experiment)
96
+ const attempts = []
97
+ for (let index = 1; index <= runs; index += 1) {
98
+ try {
99
+ const result = await executor(materialized.task, {
100
+ experiment,
101
+ experimentId: experiment.id,
102
+ index,
103
+ runs,
104
+ })
105
+ attempts.push({ index, ok: true, result })
106
+ } catch (error) {
107
+ attempts.push({
108
+ index,
109
+ ok: false,
110
+ error: error instanceof Error ? error.message : String(error),
111
+ result: error?.result,
112
+ })
113
+ }
114
+ }
115
+ return { ...materialized, runs, attempts }
116
+ }
117
+
118
+ export { OBSERVATIONS_PLACEHOLDER }
package/src/index.mjs CHANGED
@@ -25,6 +25,10 @@ export {
25
25
  toolMounted,
26
26
  userMessageTextIncludes,
27
27
  userMessageTextExcludes,
28
+ subagentDispatched,
29
+ subagentCompleted,
30
+ subagentDispatchCount,
31
+ subagentCompletedCount,
28
32
  } from './assertions.mjs'
29
33
 
30
34
  export { toolCallStep, textStep } from './mock/script.mjs'
@@ -1,73 +1,73 @@
1
- /**
2
- * The eval mock-LLM adapter, loaded as a Cordis plugin through the eval
3
- * overlay (`name: file://...` insert). It registers the keyless `eval-mock`
4
- * provider and replays a scripted StreamChunk sequence: one script step per
5
- * model call, so a case controls exactly which tool calls and final text the
6
- * "model" produces. Pattern precedent:
7
- * deepseek-harness/examples/headless-agent/tests/fixtures/cli-mock-llm.ts.
8
- *
9
- * Deterministic layer purpose: verify the eval runner/trace/assertion
10
- * pipeline without an API key, and drive the plugin's tool execution path
11
- * (a scripted tool call runs through the real tool pipeline).
12
- */
13
-
14
- import { readFileSync } from 'node:fs'
15
- import { LlmAdapter } from '@deepseek-ai/dsh-llm'
16
-
17
- /** Environment variable pointing at this run's mock script JSON. */
18
- const SCRIPT_ENV = 'DSH_EVAL_MOCK_SCRIPT'
19
-
20
- /** Adapter that yields one scripted chunk list per stream() call. */
21
- class EvalMockAdapter extends LlmAdapter {
22
- constructor(script) {
23
- super()
24
- this.steps = script.steps ?? []
25
- this.cursor = 0
26
- }
27
-
28
- async resolveModel(provider, model) {
29
- return {
30
- provider,
31
- id: model,
32
- name: model,
33
- reasoning: {
34
- efforts: [
35
- { id: 'off', name: 'Off' },
36
- { id: 'high', name: 'High' },
37
- ],
38
- defaultEffort: 'off',
39
- },
40
- }
41
- }
42
-
43
- async * stream(_options) {
44
- const step = this.steps[this.cursor]
45
- this.cursor += 1
46
- if (step === undefined) {
47
- // Script exhausted: finish the loop loudly-but-gracefully so the run
48
- // still produces a trace a case can assert on.
49
- const text = `eval-mock: script exhausted after ${this.cursor - 1} step(s)`
50
- yield { type: 'block-start', index: 0, blockType: 'text' }
51
- yield { type: 'text-delta', index: 0, text }
52
- yield { type: 'block-end', index: 0, block: { type: 'text', text } }
53
- yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
54
- yield { type: 'finish', reason: { kind: 'stop' } }
55
- return
56
- }
57
- for (const chunk of step) yield chunk
58
- }
59
- }
60
-
61
- export const name = 'eval-mock-llm'
62
-
63
- export const inject = ['llm']
64
-
65
- /** Register the `eval-mock` adapter from the scripted run. */
66
- export function apply(ctx) {
67
- const scriptPath = process.env[SCRIPT_ENV]
68
- if (scriptPath === undefined) {
69
- throw new Error(`eval-mock-llm: ${SCRIPT_ENV} must point at the run's mock script JSON`)
70
- }
71
- const script = JSON.parse(readFileSync(scriptPath, 'utf8'))
72
- ctx.llm.registerAdapter(['eval-mock'], new EvalMockAdapter(script))
73
- }
1
+ /**
2
+ * The eval mock-LLM adapter, loaded as a Cordis plugin through the eval
3
+ * overlay (`name: file://...` insert). It registers the keyless `eval-mock`
4
+ * provider and replays a scripted StreamChunk sequence: one script step per
5
+ * model call, so a case controls exactly which tool calls and final text the
6
+ * "model" produces. Pattern precedent:
7
+ * deepseek-harness/examples/headless-agent/tests/fixtures/cli-mock-llm.ts.
8
+ *
9
+ * Deterministic layer purpose: verify the eval runner/trace/assertion
10
+ * pipeline without an API key, and drive the plugin's tool execution path
11
+ * (a scripted tool call runs through the real tool pipeline).
12
+ */
13
+
14
+ import { readFileSync } from 'node:fs'
15
+ import { LlmAdapter } from '@deepseek-ai/dsh-llm'
16
+
17
+ /** Environment variable pointing at this run's mock script JSON. */
18
+ const SCRIPT_ENV = 'DSH_EVAL_MOCK_SCRIPT'
19
+
20
+ /** Adapter that yields one scripted chunk list per stream() call. */
21
+ class EvalMockAdapter extends LlmAdapter {
22
+ constructor(script) {
23
+ super()
24
+ this.steps = script.steps ?? []
25
+ this.cursor = 0
26
+ }
27
+
28
+ async resolveModel(provider, model) {
29
+ return {
30
+ provider,
31
+ id: model,
32
+ name: model,
33
+ reasoning: {
34
+ efforts: [
35
+ { id: 'off', name: 'Off' },
36
+ { id: 'high', name: 'High' },
37
+ ],
38
+ defaultEffort: 'off',
39
+ },
40
+ }
41
+ }
42
+
43
+ async * stream(_options) {
44
+ const step = this.steps[this.cursor]
45
+ this.cursor += 1
46
+ if (step === undefined) {
47
+ // Script exhausted: finish the loop loudly-but-gracefully so the run
48
+ // still produces a trace a case can assert on.
49
+ const text = `eval-mock: script exhausted after ${this.cursor - 1} step(s)`
50
+ yield { type: 'block-start', index: 0, blockType: 'text' }
51
+ yield { type: 'text-delta', index: 0, text }
52
+ yield { type: 'block-end', index: 0, block: { type: 'text', text } }
53
+ yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
54
+ yield { type: 'finish', reason: { kind: 'stop' } }
55
+ return
56
+ }
57
+ for (const chunk of step) yield chunk
58
+ }
59
+ }
60
+
61
+ export const name = 'eval-mock-llm'
62
+
63
+ export const inject = ['llm']
64
+
65
+ /** Register the `eval-mock` adapter from the scripted run. */
66
+ export function apply(ctx) {
67
+ const scriptPath = process.env[SCRIPT_ENV]
68
+ if (scriptPath === undefined) {
69
+ throw new Error(`eval-mock-llm: ${SCRIPT_ENV} must point at the run's mock script JSON`)
70
+ }
71
+ const script = JSON.parse(readFileSync(scriptPath, 'utf8'))
72
+ ctx.llm.registerAdapter(['eval-mock'], new EvalMockAdapter(script))
73
+ }
@@ -1,49 +1,49 @@
1
- /**
2
- * Script builders for the eval mock-LLM layer. A mock script is
3
- * `{ steps: ChunkStep[] }` where each step is the exact StreamChunk list one
4
- * model call yields (its `finish` chunk included); the adapter consumes one
5
- * step per stream() call. Cases build steps with these helpers instead of
6
- * hand-writing chunk JSON.
7
- */
8
-
9
- let autoId = 0
10
-
11
- /** Next deterministic call id for generated steps. */
12
- function nextCallId() {
13
- autoId += 1
14
- return `eval-mock-call-${autoId}`
15
- }
16
-
17
- /**
18
- * One model call that requests a single tool invocation, then finishes with
19
- * `kind: 'tool-calls'` so the loop executes the tool and calls back.
20
- * @param {string} name - the model-facing tool name to call.
21
- * @param {object} [args] - the tool arguments (serialized as the model's raw JSON).
22
- * @param {{ id?: string }} [options] - override the generated call id.
23
- */
24
- export function toolCallStep(name, args = {}, options = {}) {
25
- const id = options.id ?? nextCallId()
26
- const json = JSON.stringify(args)
27
- return [
28
- { type: 'block-start', index: 0, blockType: 'tool-call' },
29
- { type: 'tool-call-delta', index: 0, id, name, argumentsDelta: json },
30
- { type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: json } },
31
- { type: 'usage', usage: { inputTokens: 5, outputTokens: 5 } },
32
- { type: 'finish', reason: { kind: 'tool-calls' } },
33
- ]
34
- }
35
-
36
- /**
37
- * One model call that answers plain text and stops the loop
38
- * (`kind: 'stop'`).
39
- * @param {string} text - the assistant reply.
40
- */
41
- export function textStep(text) {
42
- return [
43
- { type: 'block-start', index: 0, blockType: 'text' },
44
- { type: 'text-delta', index: 0, text },
45
- { type: 'block-end', index: 0, block: { type: 'text', text } },
46
- { type: 'usage', usage: { inputTokens: 5, outputTokens: 5 } },
47
- { type: 'finish', reason: { kind: 'stop' } },
48
- ]
49
- }
1
+ /**
2
+ * Script builders for the eval mock-LLM layer. A mock script is
3
+ * `{ steps: ChunkStep[] }` where each step is the exact StreamChunk list one
4
+ * model call yields (its `finish` chunk included); the adapter consumes one
5
+ * step per stream() call. Cases build steps with these helpers instead of
6
+ * hand-writing chunk JSON.
7
+ */
8
+
9
+ let autoId = 0
10
+
11
+ /** Next deterministic call id for generated steps. */
12
+ function nextCallId() {
13
+ autoId += 1
14
+ return `eval-mock-call-${autoId}`
15
+ }
16
+
17
+ /**
18
+ * One model call that requests a single tool invocation, then finishes with
19
+ * `kind: 'tool-calls'` so the loop executes the tool and calls back.
20
+ * @param {string} name - the model-facing tool name to call.
21
+ * @param {object} [args] - the tool arguments (serialized as the model's raw JSON).
22
+ * @param {{ id?: string }} [options] - override the generated call id.
23
+ */
24
+ export function toolCallStep(name, args = {}, options = {}) {
25
+ const id = options.id ?? nextCallId()
26
+ const json = JSON.stringify(args)
27
+ return [
28
+ { type: 'block-start', index: 0, blockType: 'tool-call' },
29
+ { type: 'tool-call-delta', index: 0, id, name, argumentsDelta: json },
30
+ { type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: json } },
31
+ { type: 'usage', usage: { inputTokens: 5, outputTokens: 5 } },
32
+ { type: 'finish', reason: { kind: 'tool-calls' } },
33
+ ]
34
+ }
35
+
36
+ /**
37
+ * One model call that answers plain text and stops the loop
38
+ * (`kind: 'stop'`).
39
+ * @param {string} text - the assistant reply.
40
+ */
41
+ export function textStep(text) {
42
+ return [
43
+ { type: 'block-start', index: 0, blockType: 'text' },
44
+ { type: 'text-delta', index: 0, text },
45
+ { type: 'block-end', index: 0, block: { type: 'text', text } },
46
+ { type: 'usage', usage: { inputTokens: 5, outputTokens: 5 } },
47
+ { type: 'finish', reason: { kind: 'stop' } },
48
+ ]
49
+ }
package/src/overlay.mjs CHANGED
@@ -19,6 +19,9 @@ const FRAMEWORK_ROOT = fileURLToPath(new URL('..', import.meta.url))
19
19
  /** The scripted mock adapter plugin, referenced from generated overlays. */
20
20
  const MOCK_ADAPTER_PATH = join(FRAMEWORK_ROOT, 'src', 'mock', 'mock-adapter.mjs')
21
21
 
22
+ /** The multi-turn driver plugin, mounted when a case declares `followups`. */
23
+ const MULTI_TURN_DRIVER_PATH = join(FRAMEWORK_ROOT, 'src', 'driver', 'multi-turn-driver.mjs')
24
+
22
25
  /** JSON double-quoted strings are valid YAML scalars — enough for this emitter. */
23
26
  function yamlScalar(value) {
24
27
  if (typeof value === 'boolean' || typeof value === 'number') return String(value)
@@ -92,5 +95,15 @@ export function buildOverlayYaml(parts) {
92
95
  lines.push(' - id: eval-mock-llm')
93
96
  lines.push(` name: ${yamlScalar(pathToFileURL(MOCK_ADAPTER_PATH).href)}`)
94
97
  }
98
+ if (parts.followups !== undefined) {
99
+ // Cross-turn driving replaces the one-shot headless runner: it exits at
100
+ // the FIRST idle and aborts every in-process background subagent at
101
+ // teardown, so fire-and-forget children need the driver's longer lifetime.
102
+ lines.push('- id: headless-runner')
103
+ lines.push(' disabled: true')
104
+ lines.push('- insert:')
105
+ lines.push(' - id: eval-multi-turn-driver')
106
+ lines.push(` name: ${yamlScalar(pathToFileURL(MULTI_TURN_DRIVER_PATH).href)}`)
107
+ }
95
108
  return `${lines.join('\n')}\n`
96
109
  }
@@ -68,7 +68,20 @@ export function renderReviewReport(parts) {
68
68
  if (attempt.ok) {
69
69
  lines.push(`### run ${attempt.index} — ok`)
70
70
  lines.push('')
71
- lines.push('```text', attempt.result.stdout.trimEnd(), '```')
71
+ // The answer, not the raw final message: adapters derive an
72
+ // answer that survives tail interactions (gate splices); stdout
73
+ // is the fallback for executors without trace-derived answers.
74
+ lines.push('```text', (attempt.result?.answer ?? attempt.result?.stdout ?? '').trimEnd(), '```')
75
+ // Traceability: the grader can always recover the full conversation
76
+ // from the persisted per-run artifacts. The divergence note matches
77
+ // the bin's write predicate exactly (both fields defined and
78
+ // differing) — a stdout-less executor writes no run-N.stdout.txt,
79
+ // so it must not be cited either.
80
+ const diverged = attempt.result?.answer !== undefined
81
+ && attempt.result?.stdout !== undefined
82
+ && attempt.result.answer !== attempt.result.stdout
83
+ lines.push('')
84
+ lines.push(`- transcript: \`run-${attempt.index}.stderr.txt\`${diverged ? ` (answer differs from the final message — see \`run-${attempt.index}.stdout.txt\`)` : ''}`)
72
85
  } else {
73
86
  lines.push(`### run ${attempt.index} — FAIL`)
74
87
  lines.push('')
package/src/runner.mjs CHANGED
@@ -34,7 +34,7 @@ import { tmpdir } from 'node:os'
34
34
  import { isAbsolute, join, resolve } from 'node:path'
35
35
  import { fileURLToPath } from 'node:url'
36
36
  import { loadTraceDir } from './trace.mjs'
37
- import { validateRowConfig, validateDisableRows } from './discovery.mjs'
37
+ import { validateRowConfig, validateDisableRows, validateFollowups } from './discovery.mjs'
38
38
  import { CLI_RELATIVE_PATH } from './cli.mjs'
39
39
  import { buildOverlayYaml } from './overlay.mjs'
40
40
  import { resolveRealDshHome, stageSandboxHome, teardownSandbox, spawnHeadlessDsh } from './sandbox.mjs'
@@ -48,10 +48,17 @@ const FRAMEWORK_ROOT = fileURLToPath(new URL('..', import.meta.url))
48
48
  * Case shape: `{ id, task, mode?: 'real' | 'mock', expect: Matcher[],
49
49
  * script?: { steps: ChunkStep[] }, persona?: string, disableRows?: string[],
50
50
  * rowConfig?: Record<string, Record<string, unknown>>,
51
+ * followups?: string[], settleTimeoutMs?: number,
51
52
  * prepare?: (workspace: string) => void | Promise<void>,
52
53
  * inspect?: (workspace: string, helpers: { trace }) => void | Promise<void>,
53
54
  * timeoutMs?: number }`
54
55
  *
56
+ * `followups` opts into cross-turn driving: the overlay swaps the one-shot
57
+ * `headless-runner` row for the eval multi-turn driver, which — before each
58
+ * followup — waits for background subagent children to settle (bounded by
59
+ * `settleTimeoutMs`), keeping the process alive so fire-and-forget children
60
+ * (turn-close defer fixers) can run to completion between turns.
61
+ *
55
62
  * @param {object} evalCase - the case under test.
56
63
  * @param {object} options
57
64
  * @param {string} options.profile - the dsh profile booting the run (plugin installed there).
@@ -106,6 +113,15 @@ export async function runEvalCase(evalCase, options) {
106
113
  writeFileSync(scriptPath, JSON.stringify(evalCase.script))
107
114
  env.DSH_EVAL_MOCK_SCRIPT = scriptPath
108
115
  }
116
+ if (evalCase.followups !== undefined) {
117
+ const planPath = join(runDir, 'driver-plan.json')
118
+ writeFileSync(planPath, JSON.stringify({
119
+ task: evalCase.task,
120
+ followups: evalCase.followups,
121
+ ...(evalCase.settleTimeoutMs === undefined ? {} : { settleTimeoutMs: evalCase.settleTimeoutMs }),
122
+ }))
123
+ env.DSH_EVAL_DRIVER_PLAN = planPath
124
+ }
109
125
 
110
126
  if (evalCase.disableRows !== undefined) {
111
127
  validateDisableRows(evalCase.disableRows, `case '${evalCase.id}'`)
@@ -113,6 +129,9 @@ export async function runEvalCase(evalCase, options) {
113
129
  if (evalCase.rowConfig !== undefined) {
114
130
  validateRowConfig(evalCase.rowConfig, `case '${evalCase.id}'`)
115
131
  }
132
+ if (evalCase.followups !== undefined) {
133
+ validateFollowups(evalCase.followups, `case '${evalCase.id}'`)
134
+ }
116
135
  const overlayPath = join(runDir, 'eval-overlay.yml')
117
136
  writeFileSync(overlayPath, buildOverlayYaml({
118
137
  sessionsRoot,
@@ -120,6 +139,7 @@ export async function runEvalCase(evalCase, options) {
120
139
  disableRows: evalCase.disableRows,
121
140
  rowConfig: evalCase.rowConfig,
122
141
  mock: mode === 'mock',
142
+ ...(evalCase.followups === undefined ? {} : { followups: evalCase.followups }),
123
143
  }))
124
144
 
125
145
  const { stdout, stderr, exitCode, timedOut } = await spawnHeadlessDsh({