Skip to main content

harn_cli/commands/
eval_coding_agent.rs

1//! `harn eval coding-agent` - empirical preset/provider benchmark for a
2//! small coding-agent fixture suite.
3//!
4//! ## Dispatch boundary
5//!
6//! The **matrix execution pipeline** (fixture resolution, model
7//! discovery, per-cell `execute_run` invocation, Ollama snapshot/
8//! cleanup, scoring, rollups, native/text comparisons, follow-up
9//! generation, baseline diff) stays in Rust. Every cell drives the
10//! embedded `coding_agent_suite.harn` driver through `execute_run`,
11//! which itself reaches into VM internals (`commands::run`,
12//! `harn_vm::llm`, `commands::local::runtime`) that are not exposed as
13//! script capabilities.
14//!
15//! The **rendering layer** (the `summary.md` body, the `followups.md`
16//! body, the one-line human stdout summary, the `--json` pretty form)
17//! is delegated to
18//! `crates/harn-stdlib/src/stdlib/cli/eval/coding_agent.harn`. The
19//! Rust shim pre-serialises the assembled `EvalSummary` to JSON,
20//! forwards it via [`CODING_AGENT_SUMMARY_ENV`], dispatches four
21//! times (markdown for `summary.md`, followups for `followups.md`,
22//! then either the summary line or the `--json` pretty form for
23//! stdout), and writes the captured payloads to disk / real stdout.
24//!
25//! The on-disk JSON artifacts (`summary.json`, `per_run.jsonl`,
26//! `local_readiness.json`) stay on the serde-driven Rust path because
27//! Harn's `json_stringify_pretty` sorts dict keys alphabetically and
28//! the on-disk format is consumed by the experiment driver in
29//! `experiments/step-judge/run.sh`, the local-readiness regression
30//! check, and hosted ingestion — all of which depend on the serde
31//! struct-field byte order.
32
33mod live_verify;
34
35use std::collections::{BTreeMap, BTreeSet};
36use std::ffi::OsString;
37use std::fs;
38use std::io::Write as _;
39use std::path::{Path, PathBuf};
40
41use harn_vm::clock::{Clock, RealClock};
42use harn_vm::orchestration::EvalPackCase;
43use serde::Serialize;
44use serde_json::Value as JsonValue;
45
46use crate::cli::EvalCodingAgentArgs;
47use crate::commands::eval_coding_agent_preset::{
48    resolve_step_judge_json, resolve_structural_validator_json,
49};
50use crate::commands::eval_model_selector::{
51    resolve_selector, selector_is_local, selector_label, ModelSelector,
52};
53use crate::commands::local::runtime::{local_provider_ids, snapshot_provider};
54use crate::commands::local_readiness;
55use crate::commands::tool_mode_parity::{
56    self, ToolModeParityFixtureInput, ToolModeParityPairSummary, TOOL_MODE_PARITY_DIRECTORY,
57    TOOL_MODE_PARITY_FIXTURE_SUITE, TOOL_MODE_PARITY_OVERLAY_FILENAME,
58};
59use crate::dispatch;
60use crate::env_guard::ScopedEnvVar;
61#[cfg(test)]
62use live_verify::{coding_agent_live_verify_cases, tool_format_override_warning_line};
63use live_verify::{
64    fixture_description, fixture_id, fixture_name, fixture_tool_sequence, resolve_fixtures,
65    run_matrix_entry,
66};
67
68/// Env var the embedded `cli/eval/coding_agent` script reads to pick
69/// up the pre-serialised [`EvalSummary`]. The Rust shim does all the
70/// matrix execution and scoring and hands the script the assembled
71/// summary so it only has to format it.
72const CODING_AGENT_SUMMARY_ENV: &str = "HARN_EVAL_CODING_AGENT_SUMMARY_JSON";
73const CODING_AGENT_EVAL_PACK_ID: &str = "coding-agent";
74
75/// Env var the script reads to pick the rendering mode — one of
76/// `"markdown"` (summary.md body), `"followups"` (followups.md body),
77/// `"summary"` (one-line stdout summary), or `"json"` (--json pretty
78/// form). Defaulted to `"summary"` if unset so the script stays robust
79/// against future Rust-side bugs.
80const CODING_AGENT_MODE_ENV: &str = "HARN_EVAL_CODING_AGENT_MODE";
81const TOOL_FORMAT_OVERRIDE_WARNING_PREFIX: &str = "warning: tool_format override:";
82
83/// Serialises the dispatch-render path so concurrent in-process
84/// callers (the existing `eval_coding_agent_cli` integration test plus
85/// any future fanout caller) don't race on the global env vars the
86/// Rust shim sets to hand the report off to the .harn script. The CLI
87/// binary itself is single-call, so this mutex is uncontended in
88/// production; in tests it serialises the dispatch window only —
89/// matrix execution still parallelises freely.
90///
91/// Matches the other eval render shims so the cross-script env-var handoff
92/// stays consistent across the eval cluster.
93static DISPATCH_RENDER_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
94
95const CODING_AGENT_SUITE_HARN: &str = include_str!("../../assets/evals/coding_agent_suite.harn");
96#[derive(Debug, Clone, Serialize)]
97struct LoadedEnvKey {
98    key: String,
99    source: String,
100}
101
102#[derive(Debug)]
103struct EnvOverlay {
104    previous: Vec<(OsString, Option<OsString>)>,
105}
106
107impl Drop for EnvOverlay {
108    fn drop(&mut self) {
109        for (key, previous) in self.previous.iter().rev() {
110            if let Some(value) = previous {
111                std::env::set_var(key, value);
112            } else {
113                std::env::remove_var(key);
114            }
115        }
116    }
117}
118
119#[derive(Debug, Clone, Serialize)]
120struct RunReport {
121    run_id: String,
122    fixture_id: String,
123    fixture_name: String,
124    fixture_tool_sequence: String,
125    selector: ModelSelector,
126    tool_format: String,
127    status: String,
128    passed: bool,
129    skipped: bool,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    skipped_reason: Option<String>,
132    output_dir: String,
133    transcript_events_path: String,
134    workspace_root: Option<String>,
135    elapsed_ms: u64,
136    duration_ms: u64,
137    iterations: i64,
138    input_tokens: i64,
139    output_tokens: i64,
140    cost_usd: f64,
141    pricing_known: bool,
142    tool_calls: usize,
143    rejected_tool_calls: usize,
144    tool_sequence: Vec<String>,
145    successful_tools: Vec<String>,
146    transcript_event_count: usize,
147    verification_success: bool,
148    harn_exit_code: i32,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    error: Option<String>,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    stderr_excerpt: Option<String>,
153    local_cleanup: Option<LocalCleanupReport>,
154}
155
156#[derive(Debug, Clone, Serialize)]
157struct LocalCleanupReport {
158    provider: String,
159    model: String,
160    initially_loaded: bool,
161    action: String,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    detail: Option<String>,
164}
165
166#[derive(Debug, Clone, Serialize)]
167struct FormatComparison {
168    fixture_id: String,
169    selector: ModelSelector,
170    native_run_id: Option<String>,
171    text_run_id: Option<String>,
172    native_evidence_path: Option<String>,
173    text_evidence_path: Option<String>,
174    native_status: Option<String>,
175    text_status: Option<String>,
176    native_passed: Option<bool>,
177    text_passed: Option<bool>,
178    native_tool_call_count: Option<usize>,
179    text_tool_call_count: Option<usize>,
180    native_rejected_tool_call_count: Option<usize>,
181    text_rejected_tool_call_count: Option<usize>,
182    verifier_match: Option<bool>,
183    tool_sequence_match: Option<bool>,
184    rejected_tool_call_delta_text_minus_native: Option<i64>,
185    token_delta_text_minus_native: Option<i64>,
186    iteration_delta_text_minus_native: Option<i64>,
187    equivalent: Option<bool>,
188    divergence_reasons: Vec<String>,
189    evidence_paths: Vec<String>,
190}
191
192#[derive(Debug, Clone, Serialize)]
193struct FollowupSuggestion {
194    title: String,
195    body: String,
196    labels: Vec<String>,
197    run_ids: Vec<String>,
198}
199
200#[derive(Debug, Clone, Serialize)]
201struct FixtureReport {
202    id: String,
203    name: String,
204    tool_sequence: String,
205    description: String,
206}
207
208#[derive(Debug, Clone, Serialize)]
209struct RollupReport {
210    key: String,
211    total_runs: usize,
212    passed_runs: usize,
213    failed_runs: usize,
214    skipped_runs: usize,
215    total_cost_usd: f64,
216}
217
218#[derive(Debug, Clone, Serialize)]
219struct EvalRollups {
220    by_fixture: Vec<RollupReport>,
221    by_provider: Vec<RollupReport>,
222    by_model: Vec<RollupReport>,
223    by_tool_format: Vec<RollupReport>,
224    by_tool_sequence: Vec<RollupReport>,
225}
226
227#[derive(Debug, Clone, Serialize)]
228struct EvalSummary {
229    schema_version: u32,
230    fixture_ids: Vec<String>,
231    fixtures: Vec<FixtureReport>,
232    output_dir: String,
233    models: Vec<ModelSelector>,
234    tool_formats: Vec<String>,
235    env_keys_loaded: Vec<LoadedEnvKey>,
236    total_runs: usize,
237    passed_runs: usize,
238    failed_runs: usize,
239    skipped_runs: usize,
240    diverged_comparisons: usize,
241    total_cost_usd: f64,
242    rollups: EvalRollups,
243    runs: Vec<RunReport>,
244    comparisons: Vec<FormatComparison>,
245    parity_by_pair: Vec<ToolModeParityPairSummary>,
246    followups: Vec<FollowupSuggestion>,
247    /// Step-judge preset applied to all runs in this invocation, if any.
248    /// Used by the experiment driver (experiments/step-judge/run.sh) to
249    /// group repeat invocations into cells.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    step_judge_preset: Option<String>,
252    /// Free-form label for grouping repeat invocations (e.g.
253    /// "replicate-1", "probe-rubric-adversarial"). Empty when unset.
254    #[serde(skip_serializing_if = "String::is_empty")]
255    run_label: String,
256    /// Optional per-fixture diff against a prior run's `summary.json`,
257    /// listing regressions (baseline passed, this cell failed) and
258    /// recoveries (baseline failed, this cell passed) plus aggregate
259    /// counts and a net lift in percentage points. Populated when the
260    /// caller passes `--baseline-comparison-against <path>` (harn#2318).
261    #[serde(skip_serializing_if = "Option::is_none")]
262    baseline_comparison: Option<BaselineComparison>,
263}
264
265#[derive(Debug, Clone, Serialize, Default)]
266struct BaselineComparison {
267    /// `output_dir` or `run_label` of the baseline summary, for context.
268    baseline_label: String,
269    /// Resolved path to the baseline `summary.json` that was diffed against.
270    baseline_path: String,
271    regressions: Vec<FixtureStatusDelta>,
272    recoveries: Vec<FixtureStatusDelta>,
273    /// Fixtures that passed in both runs.
274    unchanged_passes: Vec<String>,
275    /// Fixtures that failed in both runs.
276    unchanged_failures: Vec<String>,
277    /// Fixtures present in only one of the two runs (skipped from the
278    /// diff but listed for visibility).
279    missing_in_baseline: Vec<String>,
280    missing_in_cell: Vec<String>,
281    regressions_count: usize,
282    recoveries_count: usize,
283    /// `(recoveries_count - regressions_count) / total_fixtures_compared * 100`,
284    /// rounded to one decimal place. Negative when the cell regresses more
285    /// than it recovers.
286    net_lift_pp: f64,
287}
288
289#[derive(Debug, Clone, Serialize)]
290struct FixtureStatusDelta {
291    fixture_id: String,
292    baseline_status: String,
293    cell_status: String,
294}
295
296pub async fn run(args: EvalCodingAgentArgs) -> i32 {
297    let output_dir = args.output.clone().unwrap_or_else(default_output_dir);
298    if let Err(error) = fs::create_dir_all(&output_dir) {
299        eprintln!("error: failed to create {}: {error}", output_dir.display());
300        return 1;
301    }
302
303    let (_env_guard, env_keys_loaded) = match load_env_files(&args.env_files) {
304        Ok(loaded) => loaded,
305        Err(error) => {
306            eprintln!("error: {error}");
307            return 1;
308        }
309    };
310
311    let fixtures = match resolve_fixtures(&args.fixtures, &args.python) {
312        Ok(fixtures) => fixtures,
313        Err(error) => {
314            eprintln!("error: {error}");
315            return 2;
316        }
317    };
318    let models = match resolve_models(&args).await {
319        Ok(models) => models,
320        Err(error) => {
321            eprintln!("error: {error}");
322            return 1;
323        }
324    };
325    let tool_formats = match normalize_tool_formats(&args.tool_formats) {
326        Ok(formats) => formats,
327        Err(error) => {
328            eprintln!("error: {error}");
329            return 2;
330        }
331    };
332    let matrix = build_matrix(&fixtures, &models, &tool_formats, args.max_runs);
333    if matrix.is_empty() {
334        eprintln!("error: no coding-agent benchmark runs selected");
335        return 2;
336    }
337
338    let mut reports = Vec::new();
339    let mut had_error = false;
340    for (fixture, selector, tool_format) in matrix {
341        let report = run_matrix_entry(&args, &output_dir, fixture, selector, tool_format).await;
342        if !report.passed && !report.skipped {
343            had_error = true;
344        }
345        if report.skipped && args.fail_on_unauthorized {
346            had_error = true;
347        }
348        eprintln!(
349            "{} {} {}: {}",
350            report.fixture_id,
351            selector_label(&report.selector),
352            report.tool_format,
353            report.status
354        );
355        reports.push(report);
356    }
357
358    let baseline_comparison = match &args.baseline_comparison_against {
359        Some(path) => match load_baseline_comparison(path, &reports) {
360            Ok(comparison) => Some(comparison),
361            Err(error) => {
362                eprintln!("error: --baseline-comparison-against: {error}");
363                return 1;
364            }
365        },
366        None => None,
367    };
368    let summary = build_summary(
369        &output_dir,
370        fixtures,
371        models,
372        tool_formats,
373        env_keys_loaded,
374        reports,
375        args.step_judge
376            .clone()
377            .filter(|s| !s.is_empty() && s != "none"),
378        args.run_label.clone(),
379        baseline_comparison,
380    );
381    // The JSON artifacts (summary.json, per_run.jsonl,
382    // local_readiness.json) always stay on the serde-driven Rust path —
383    // see module docstring for the byte-format rationale. They write
384    // before any rendering so a render failure doesn't leave a partially
385    // written report directory.
386    if let Err(error) = write_json_artifacts(&output_dir, &summary) {
387        eprintln!("error: failed to write benchmark outputs: {error}");
388        return 1;
389    }
390
391    if let Err(code) = write_markdown_artifacts_dispatch(&output_dir, &summary).await {
392        return code;
393    }
394    announce_output_paths(&output_dir);
395    if args.json {
396        if let Err(code) = print_json_dispatch(&summary).await {
397            return code;
398        }
399    } else if let Err(code) = print_summary_dispatch(&summary).await {
400        return code;
401    }
402
403    i32::from(had_error)
404}
405
406fn script_argv(
407    args: &EvalCodingAgentArgs,
408    fixture: &EvalPackCase,
409    selector: &ModelSelector,
410    tool_format: &str,
411    run_dir: &Path,
412) -> Vec<String> {
413    let mut argv = vec![
414        "--fixture".to_string(),
415        fixture_id(fixture).to_string(),
416        "--task".to_string(),
417        fixture.task.clone().unwrap_or_default(),
418        "--output-dir".to_string(),
419        run_dir.display().to_string(),
420        "--provider".to_string(),
421        selector.provider.clone(),
422        "--model".to_string(),
423        selector.model.clone(),
424        "--tool-format".to_string(),
425        tool_format.to_string(),
426        "--max-iterations".to_string(),
427        args.max_iterations.to_string(),
428        "--python".to_string(),
429        args.python.clone(),
430    ];
431    if selector.provider == "mock" {
432        argv.push("--seed-mock".to_string());
433    }
434    if let Some(json) = resolve_step_judge_json(args, selector) {
435        argv.push("--step-judge-json".to_string());
436        argv.push(json);
437    }
438    if let Some(reason) = args
439        .override_reason
440        .as_deref()
441        .map(str::trim)
442        .filter(|reason| !reason.is_empty())
443    {
444        argv.push("--override-reason".to_string());
445        argv.push(reason.to_string());
446    }
447    if let Some(json) = resolve_structural_validator_json(args) {
448        argv.push("--structural-validator-json".to_string());
449        argv.push(json);
450    }
451    argv
452}
453
454async fn resolve_models(args: &EvalCodingAgentArgs) -> Result<Vec<ModelSelector>, String> {
455    let mut seen = BTreeSet::new();
456    let mut out = Vec::new();
457    for raw in normalize_model_selector_args(&args.models) {
458        let trimmed = raw.trim();
459        if trimmed.is_empty() {
460            continue;
461        }
462        let selector = resolve_selector(trimmed);
463        if seen.insert(selector_label(&selector)) {
464            out.push(selector);
465        }
466    }
467    if args.include_local {
468        for selector in discover_local_models(args).await {
469            if seen.insert(selector_label(&selector)) {
470                out.push(selector);
471            }
472        }
473    }
474    Ok(out)
475}
476
477fn normalize_model_selector_args(raw_models: &[String]) -> Vec<String> {
478    let mut out = Vec::new();
479    let mut index = 0;
480    while index < raw_models.len() {
481        let current = raw_models[index].trim();
482        if current.starts_with("provider=") && index + 1 < raw_models.len() {
483            let next = raw_models[index + 1].trim();
484            if next.starts_with("model=") {
485                out.push(format!("{current},{next}"));
486                index += 2;
487                continue;
488            }
489        }
490        out.push(current.to_string());
491        index += 1;
492    }
493    out
494}
495
496async fn discover_local_models(args: &EvalCodingAgentArgs) -> Vec<ModelSelector> {
497    let providers = if args.local_providers.is_empty() {
498        local_provider_ids(None)
499    } else {
500        args.local_providers.clone()
501    };
502    let mut selectors = Vec::new();
503    let mut seen = BTreeSet::new();
504    for provider in providers {
505        if selectors.len() >= args.max_local_models {
506            break;
507        }
508        let Ok(snapshot) = snapshot_provider(&provider, Path::new(".")).await else {
509            continue;
510        };
511        if !snapshot.reachable {
512            continue;
513        }
514        let mut models = snapshot
515            .loaded_models
516            .iter()
517            .map(|model| model.name.clone())
518            .collect::<Vec<_>>();
519        models.extend(snapshot.served_models);
520        for model in models {
521            if selectors.len() >= args.max_local_models {
522                break;
523            }
524            let selector = ModelSelector {
525                selector: format!("{provider}:{model}"),
526                provider: provider.clone(),
527                model,
528            };
529            if seen.insert(selector_label(&selector)) {
530                selectors.push(selector);
531            }
532        }
533    }
534    selectors
535}
536
537fn normalize_tool_formats(raw_formats: &[String]) -> Result<Vec<String>, String> {
538    let mut seen = BTreeSet::new();
539    let mut out = Vec::new();
540    for raw in raw_formats {
541        let format = raw.trim().to_ascii_lowercase();
542        if format.is_empty() {
543            continue;
544        }
545        if !matches!(format.as_str(), "native" | "text" | "json") {
546            return Err(format!(
547                "unsupported --tool-format `{format}`; expected `native`, `text`, or `json`"
548            ));
549        }
550        if seen.insert(format.clone()) {
551            out.push(format);
552        }
553    }
554    Ok(out)
555}
556
557fn build_matrix(
558    fixtures: &[EvalPackCase],
559    models: &[ModelSelector],
560    tool_formats: &[String],
561    max_runs: Option<usize>,
562) -> Vec<(EvalPackCase, ModelSelector, String)> {
563    if max_runs == Some(0) {
564        return Vec::new();
565    }
566    let mut matrix = Vec::new();
567    for fixture in fixtures {
568        for selector in models {
569            for tool_format in tool_formats {
570                matrix.push((fixture.clone(), selector.clone(), tool_format.clone()));
571                if max_runs.is_some_and(|limit| matrix.len() >= limit) {
572                    return matrix;
573                }
574            }
575        }
576    }
577    matrix
578}
579
580#[allow(clippy::too_many_arguments)]
581fn build_summary(
582    output_dir: &Path,
583    fixtures: Vec<EvalPackCase>,
584    models: Vec<ModelSelector>,
585    tool_formats: Vec<String>,
586    env_keys_loaded: Vec<LoadedEnvKey>,
587    runs: Vec<RunReport>,
588    step_judge_preset: Option<String>,
589    run_label: String,
590    baseline_comparison: Option<BaselineComparison>,
591) -> EvalSummary {
592    let passed_runs = runs.iter().filter(|run| run.passed).count();
593    let skipped_runs = runs.iter().filter(|run| run.skipped).count();
594    let failed_runs = runs
595        .iter()
596        .filter(|run| !run.passed && !run.skipped)
597        .count();
598    let total_cost_usd = runs.iter().map(|run| run.cost_usd).sum();
599    let rollups = build_rollups(&runs);
600    let comparisons = compare_formats(&runs);
601    let parity_by_pair = build_parity_by_pair(&comparisons);
602    let diverged_comparisons = comparisons
603        .iter()
604        .filter(|comparison| !comparison.divergence_reasons.is_empty())
605        .count();
606    let followups = suggest_followups(&runs, &comparisons);
607    EvalSummary {
608        schema_version: 3,
609        fixture_ids: fixtures
610            .iter()
611            .map(|fixture| fixture_id(fixture).to_string())
612            .collect(),
613        fixtures: fixtures
614            .iter()
615            .map(|fixture| FixtureReport {
616                id: fixture_id(fixture).to_string(),
617                name: fixture_name(fixture),
618                tool_sequence: fixture_tool_sequence(fixture),
619                description: fixture_description(fixture),
620            })
621            .collect(),
622        output_dir: output_dir.display().to_string(),
623        models,
624        tool_formats,
625        env_keys_loaded,
626        total_runs: runs.len(),
627        passed_runs,
628        failed_runs,
629        skipped_runs,
630        diverged_comparisons,
631        total_cost_usd,
632        rollups,
633        runs,
634        comparisons,
635        parity_by_pair,
636        followups,
637        step_judge_preset,
638        run_label,
639        baseline_comparison,
640    }
641}
642
643fn load_baseline_comparison(path: &Path, runs: &[RunReport]) -> Result<BaselineComparison, String> {
644    let resolved = if path.is_dir() {
645        path.join("summary.json")
646    } else {
647        path.to_path_buf()
648    };
649    let raw = fs::read_to_string(&resolved)
650        .map_err(|e| format!("failed to read {}: {e}", resolved.display()))?;
651    let baseline: serde_json::Value = serde_json::from_str(&raw)
652        .map_err(|e| format!("failed to parse {} as JSON: {e}", resolved.display()))?;
653    let baseline_runs = baseline
654        .get("runs")
655        .and_then(|v| v.as_array())
656        .ok_or_else(|| format!("{} has no `runs` array", resolved.display()))?;
657    // Index baseline status by fixture_id. When the baseline has multiple
658    // runs per fixture (e.g. native + text), prefer the first passing run
659    // so a fixture passes the comparison if ANY baseline variant did.
660    let mut baseline_status: BTreeMap<String, &str> = BTreeMap::new();
661    for run in baseline_runs {
662        let fixture_id = match run.get("fixture_id").and_then(|v| v.as_str()) {
663            Some(id) => id.to_string(),
664            None => continue,
665        };
666        let passed = run.get("passed").and_then(|v| v.as_bool()).unwrap_or(false);
667        let skipped = run
668            .get("skipped")
669            .and_then(|v| v.as_bool())
670            .unwrap_or(false);
671        let status = if skipped {
672            "skipped"
673        } else if passed {
674            "passed"
675        } else {
676            "failed"
677        };
678        baseline_status
679            .entry(fixture_id)
680            .and_modify(|existing| {
681                if *existing != "passed" && status == "passed" {
682                    *existing = status;
683                }
684            })
685            .or_insert(status);
686    }
687    let mut cell_status: BTreeMap<String, &str> = BTreeMap::new();
688    for run in runs {
689        let status = if run.skipped {
690            "skipped"
691        } else if run.passed {
692            "passed"
693        } else {
694            "failed"
695        };
696        cell_status
697            .entry(run.fixture_id.clone())
698            .and_modify(|existing| {
699                if *existing != "passed" && status == "passed" {
700                    *existing = status;
701                }
702            })
703            .or_insert(status);
704    }
705    let mut regressions = Vec::new();
706    let mut recoveries = Vec::new();
707    let mut unchanged_passes = Vec::new();
708    let mut unchanged_failures = Vec::new();
709    let mut missing_in_baseline = Vec::new();
710    let mut missing_in_cell = Vec::new();
711    for (fixture, cell) in &cell_status {
712        match baseline_status.get(fixture) {
713            None => missing_in_baseline.push(fixture.clone()),
714            Some(base) => match (*base, *cell) {
715                ("passed", "passed") => unchanged_passes.push(fixture.clone()),
716                ("passed", _) => regressions.push(FixtureStatusDelta {
717                    fixture_id: fixture.clone(),
718                    baseline_status: (*base).to_string(),
719                    cell_status: (*cell).to_string(),
720                }),
721                (_, "passed") => recoveries.push(FixtureStatusDelta {
722                    fixture_id: fixture.clone(),
723                    baseline_status: (*base).to_string(),
724                    cell_status: (*cell).to_string(),
725                }),
726                _ => unchanged_failures.push(fixture.clone()),
727            },
728        }
729    }
730    for fixture in baseline_status.keys() {
731        if !cell_status.contains_key(fixture) {
732            missing_in_cell.push(fixture.clone());
733        }
734    }
735    let baseline_label = baseline
736        .get("run_label")
737        .and_then(|v| v.as_str())
738        .filter(|s| !s.is_empty())
739        .or_else(|| baseline.get("output_dir").and_then(|v| v.as_str()))
740        .unwrap_or("")
741        .to_string();
742    let regressions_count = regressions.len();
743    let recoveries_count = recoveries.len();
744    let total_compared =
745        regressions_count + recoveries_count + unchanged_passes.len() + unchanged_failures.len();
746    let net_lift_pp = if total_compared == 0 {
747        0.0
748    } else {
749        let raw =
750            (recoveries_count as f64 - regressions_count as f64) / total_compared as f64 * 100.0;
751        (raw * 10.0).round() / 10.0
752    };
753    Ok(BaselineComparison {
754        baseline_label,
755        baseline_path: resolved.display().to_string(),
756        regressions,
757        recoveries,
758        unchanged_passes,
759        unchanged_failures,
760        missing_in_baseline,
761        missing_in_cell,
762        regressions_count,
763        recoveries_count,
764        net_lift_pp,
765    })
766}
767
768fn build_rollups(runs: &[RunReport]) -> EvalRollups {
769    EvalRollups {
770        by_fixture: rollup_by(runs, |run| run.fixture_id.clone()),
771        by_provider: rollup_by(runs, |run| run.selector.provider.clone()),
772        by_model: rollup_by(runs, |run| run.selector.model.clone()),
773        by_tool_format: rollup_by(runs, |run| run.tool_format.clone()),
774        by_tool_sequence: rollup_by(runs, |run| run.fixture_tool_sequence.clone()),
775    }
776}
777
778fn rollup_by<F>(runs: &[RunReport], key_for: F) -> Vec<RollupReport>
779where
780    F: Fn(&RunReport) -> String,
781{
782    let mut grouped: BTreeMap<String, RollupReport> = BTreeMap::new();
783    for run in runs {
784        let key = key_for(run);
785        let entry = grouped.entry(key.clone()).or_insert_with(|| RollupReport {
786            key,
787            total_runs: 0,
788            passed_runs: 0,
789            failed_runs: 0,
790            skipped_runs: 0,
791            total_cost_usd: 0.0,
792        });
793        entry.total_runs += 1;
794        if run.passed {
795            entry.passed_runs += 1;
796        } else if run.skipped {
797            entry.skipped_runs += 1;
798        } else {
799            entry.failed_runs += 1;
800        }
801        entry.total_cost_usd += run.cost_usd;
802    }
803    grouped.into_values().collect()
804}
805
806fn compare_formats(runs: &[RunReport]) -> Vec<FormatComparison> {
807    let mut grouped: BTreeMap<String, Vec<&RunReport>> = BTreeMap::new();
808    for run in runs {
809        grouped
810            .entry(format!(
811                "{}\0{}",
812                run.fixture_id,
813                selector_label(&run.selector)
814            ))
815            .or_default()
816            .push(run);
817    }
818    let mut out = Vec::new();
819    for group in grouped.values() {
820        let Some(first) = group.first() else {
821            continue;
822        };
823        let native = group
824            .iter()
825            .find(|run| run.tool_format == "native")
826            .copied();
827        let text = group.iter().find(|run| run.tool_format == "text").copied();
828        if native.is_none() && text.is_none() {
829            continue;
830        }
831        let pair = native.zip(text);
832        let mut divergence_reasons = Vec::new();
833        if let Some((native, text)) = pair {
834            if native.status != text.status {
835                divergence_reasons.push(format!(
836                    "status differs: native={} text={}",
837                    native.status, text.status
838                ));
839            }
840            if native.passed != text.passed {
841                divergence_reasons.push(format!(
842                    "pass result differs: native={} text={}",
843                    native.passed, text.passed
844                ));
845            }
846            if native.verification_success != text.verification_success {
847                divergence_reasons.push(format!(
848                    "verifier result differs: native={} text={}",
849                    native.verification_success, text.verification_success
850                ));
851            }
852            if native.tool_sequence != text.tool_sequence {
853                divergence_reasons.push(format!(
854                    "tool sequence differs: native=[{}] text=[{}]",
855                    native.tool_sequence.join(", "),
856                    text.tool_sequence.join(", ")
857                ));
858            }
859            if native.rejected_tool_calls != text.rejected_tool_calls {
860                divergence_reasons.push(format!(
861                    "rejected tool-call recovery differs: native={} text={}",
862                    native.rejected_tool_calls, text.rejected_tool_calls
863                ));
864            }
865        }
866        let evidence_paths = [native, text]
867            .into_iter()
868            .flatten()
869            .map(|run| run.transcript_events_path.clone())
870            .collect::<Vec<_>>();
871        out.push(FormatComparison {
872            fixture_id: first.fixture_id.clone(),
873            selector: first.selector.clone(),
874            native_run_id: native.map(|run| run.run_id.clone()),
875            text_run_id: text.map(|run| run.run_id.clone()),
876            native_evidence_path: native.map(|run| run.transcript_events_path.clone()),
877            text_evidence_path: text.map(|run| run.transcript_events_path.clone()),
878            native_status: native.map(|run| run.status.clone()),
879            text_status: text.map(|run| run.status.clone()),
880            native_passed: native.map(|run| run.passed),
881            text_passed: text.map(|run| run.passed),
882            native_tool_call_count: native.map(|run| run.tool_calls),
883            text_tool_call_count: text.map(|run| run.tool_calls),
884            native_rejected_tool_call_count: native.map(|run| run.rejected_tool_calls),
885            text_rejected_tool_call_count: text.map(|run| run.rejected_tool_calls),
886            verifier_match: pair
887                .map(|(native, text)| native.verification_success == text.verification_success),
888            tool_sequence_match: pair
889                .map(|(native, text)| native.tool_sequence == text.tool_sequence),
890            rejected_tool_call_delta_text_minus_native: pair.map(|(native, text)| {
891                text.rejected_tool_calls as i64 - native.rejected_tool_calls as i64
892            }),
893            token_delta_text_minus_native: pair.map(|(native, text)| {
894                (text.input_tokens + text.output_tokens)
895                    - (native.input_tokens + native.output_tokens)
896            }),
897            iteration_delta_text_minus_native: pair
898                .map(|(native, text)| text.iterations - native.iterations),
899            equivalent: pair.map(|(native, text)| {
900                native.status == text.status
901                    && native.passed == text.passed
902                    && native.skipped == text.skipped
903                    && native.verification_success == text.verification_success
904                    && native.tool_sequence == text.tool_sequence
905                    && native.rejected_tool_calls == text.rejected_tool_calls
906            }),
907            divergence_reasons,
908            evidence_paths,
909        });
910    }
911    out
912}
913
914fn build_parity_by_pair(comparisons: &[FormatComparison]) -> Vec<ToolModeParityPairSummary> {
915    let fixture_inputs = comparisons
916        .iter()
917        .filter_map(parity_fixture_input)
918        .collect::<Vec<_>>();
919    let fixture_reports = tool_mode_parity::build_fixture_reports(&fixture_inputs);
920    tool_mode_parity::build_pair_summaries(&fixture_reports)
921}
922
923fn parity_fixture_input(comparison: &FormatComparison) -> Option<ToolModeParityFixtureInput> {
924    let native_verdict = comparison.native_status.clone()?;
925    let text_verdict = comparison.text_status.clone()?;
926    if native_verdict == "skipped" || text_verdict == "skipped" {
927        return None;
928    }
929    Some(ToolModeParityFixtureInput {
930        provider: comparison.selector.provider.clone(),
931        model: comparison.selector.model.clone(),
932        fixture_id: comparison.fixture_id.clone(),
933        native_verdict,
934        text_verdict,
935        native_passed: comparison.native_passed?,
936        text_passed: comparison.text_passed?,
937        agreement: comparison.equivalent?,
938        verifier_agreement: comparison.verifier_match?,
939        native_tool_call_count: comparison.native_tool_call_count?,
940        text_tool_call_count: comparison.text_tool_call_count?,
941        native_rejected_tool_call_count: comparison.native_rejected_tool_call_count?,
942        text_rejected_tool_call_count: comparison.text_rejected_tool_call_count?,
943        native_evidence_path: comparison.native_evidence_path.clone()?,
944        text_evidence_path: comparison.text_evidence_path.clone()?,
945    })
946}
947
948fn suggest_followups(
949    runs: &[RunReport],
950    comparisons: &[FormatComparison],
951) -> Vec<FollowupSuggestion> {
952    let mut out = Vec::new();
953    let failed = runs
954        .iter()
955        .filter(|run| !run.passed && !run.skipped)
956        .map(|run| run.run_id.clone())
957        .collect::<Vec<_>>();
958    if !failed.is_empty() {
959        out.push(FollowupSuggestion {
960            title: "Normalize coding-agent fixture failures across provider presets".to_string(),
961            body: "One or more fixture/provider/tool-format runs failed. Inspect the run directories and decide whether the gap belongs in provider adapters, preset prompting, transcript handling, or host-tool ergonomics.".to_string(),
962            labels: vec!["eval".to_string(), "providers".to_string()],
963            run_ids: failed,
964        });
965    }
966
967    let rejected = runs
968        .iter()
969        .filter(|run| run.rejected_tool_calls > 0)
970        .map(|run| run.run_id.clone())
971        .collect::<Vec<_>>();
972    if !rejected.is_empty() {
973        out.push(FollowupSuggestion {
974            title: "Abstract rejected tool-call recovery in agent transcripts".to_string(),
975            body: "Some runs recovered after rejected tool calls. Add runtime support or preset guidance so harness authors can distinguish recoverable provider/tool-shape noise from user-relevant transcript events.".to_string(),
976            labels: vec!["agents".to_string(), "transcripts".to_string()],
977            run_ids: rejected,
978        });
979    }
980
981    let mismatched = comparisons
982        .iter()
983        .filter(|comparison| !comparison.divergence_reasons.is_empty())
984        .map(|comparison| {
985            format!(
986                "{}:{} ({})",
987                comparison.fixture_id,
988                selector_label(&comparison.selector),
989                comparison.divergence_reasons.join("; ")
990            )
991        })
992        .collect::<Vec<_>>();
993    if !mismatched.is_empty() {
994        let run_ids = comparisons
995            .iter()
996            .filter(|comparison| !comparison.divergence_reasons.is_empty())
997            .flat_map(|comparison| {
998                [
999                    comparison.native_run_id.clone(),
1000                    comparison.text_run_id.clone(),
1001                ]
1002            })
1003            .flatten()
1004            .collect::<Vec<_>>();
1005        out.push(FollowupSuggestion {
1006            title: "Make native/text tool modes behaviorally interchangeable for preset harnesses"
1007                .to_string(),
1008            body: format!(
1009                "Native and text tool modes diverged for: {}. The preset/runtime boundary should hide provider tool-channel differences where possible.",
1010                mismatched.join(", ")
1011            ),
1012            labels: vec!["agents".to_string(), "tools".to_string()],
1013            run_ids,
1014        });
1015    }
1016
1017    let unknown_pricing = runs
1018        .iter()
1019        .filter(|run| {
1020            !run.skipped
1021                && !run.pricing_known
1022                && !matches!(run.selector.provider.as_str(), "mock" | "fake")
1023                && !selector_is_local(&run.selector)
1024        })
1025        .map(|run| run.run_id.clone())
1026        .collect::<Vec<_>>();
1027    if !unknown_pricing.is_empty() {
1028        out.push(FollowupSuggestion {
1029            title: "Fill provider pricing metadata for benchmarked models".to_string(),
1030            body: "At least one live provider/model produced usage metrics but had no pricing entry, which weakens cost comparisons in eval reports.".to_string(),
1031            labels: vec!["providers".to_string(), "docs".to_string()],
1032            run_ids: unknown_pricing,
1033        });
1034    }
1035    out
1036}
1037
1038fn write_json_artifacts(output_dir: &Path, summary: &EvalSummary) -> Result<(), String> {
1039    write_json_pretty(&output_dir.join("summary.json"), summary)?;
1040    write_jsonl(&output_dir.join("per_run.jsonl"), &summary.runs)?;
1041    let summary_value = serde_json::to_value(summary).map_err(|error| error.to_string())?;
1042    let readiness = local_readiness::report_from_summary_json(
1043        &summary_value,
1044        output_dir.display().to_string(),
1045    )?;
1046    write_json_pretty(&output_dir.join("local_readiness.json"), &readiness)?;
1047    let generated_at = RealClock::new()
1048        .now_utc()
1049        .format(&time::format_description::well_known::Rfc3339)
1050        .map_err(|error| format!("failed to format parity overlay timestamp: {error}"))?;
1051    let parity_dir = output_dir.join(TOOL_MODE_PARITY_DIRECTORY);
1052    let parity_reports = tool_mode_parity::build_fixture_reports(
1053        &summary
1054            .comparisons
1055            .iter()
1056            .filter_map(parity_fixture_input)
1057            .collect::<Vec<_>>(),
1058    );
1059    for report in &parity_reports {
1060        let path = parity_dir
1061            .join(sanitize_id(&format!(
1062                "{}__{}:{}",
1063                report.fixture_id, report.provider, report.model
1064            )))
1065            .join("parity.json");
1066        tool_mode_parity::write_fixture_report(&path, report)?;
1067    }
1068    let overlay = tool_mode_parity::build_overlay(
1069        &summary.parity_by_pair,
1070        &generated_at,
1071        TOOL_MODE_PARITY_FIXTURE_SUITE,
1072        output_dir,
1073    );
1074    tool_mode_parity::write_overlay(
1075        &output_dir.join(TOOL_MODE_PARITY_OVERLAY_FILENAME),
1076        &overlay,
1077    )?;
1078    Ok(())
1079}
1080
1081fn announce_output_paths(output_dir: &Path) {
1082    eprintln!(
1083        "wrote {}, {}, {}, {}, {}, {}, and {}",
1084        output_dir.join("summary.json").display(),
1085        output_dir.join("per_run.jsonl").display(),
1086        output_dir.join("local_readiness.json").display(),
1087        output_dir.join(TOOL_MODE_PARITY_DIRECTORY).display(),
1088        output_dir.join(TOOL_MODE_PARITY_OVERLAY_FILENAME).display(),
1089        output_dir.join("summary.md").display(),
1090        output_dir.join("followups.md").display()
1091    );
1092}
1093
1094// ─── Dispatch (.harn) render path ────────────────────────────────────────
1095
1096async fn write_markdown_artifacts_dispatch(
1097    output_dir: &Path,
1098    summary: &EvalSummary,
1099) -> Result<(), i32> {
1100    let markdown = render_via_dispatch(summary, "markdown").await?;
1101    if let Err(error) = fs::write(output_dir.join("summary.md"), markdown) {
1102        eprintln!("error: failed to write summary.md: {error}");
1103        return Err(1);
1104    }
1105    let followups = render_via_dispatch(summary, "followups").await?;
1106    if let Err(error) = fs::write(output_dir.join("followups.md"), followups) {
1107        eprintln!("error: failed to write followups.md: {error}");
1108        return Err(1);
1109    }
1110    Ok(())
1111}
1112
1113async fn print_summary_dispatch(summary: &EvalSummary) -> Result<(), i32> {
1114    let payload = render_via_dispatch(summary, "summary").await?;
1115    print!("{payload}");
1116    if !payload.ends_with('\n') {
1117        println!();
1118    }
1119    Ok(())
1120}
1121
1122async fn print_json_dispatch(summary: &EvalSummary) -> Result<(), i32> {
1123    let payload = render_via_dispatch(summary, "json").await?;
1124    print!("{payload}");
1125    if !payload.ends_with('\n') {
1126        println!();
1127    }
1128    Ok(())
1129}
1130
1131/// Dispatch to the embedded `cli/eval/coding_agent.harn` script for one
1132/// of the four rendering modes (markdown / followups / summary / json).
1133/// Returns the captured stdout on success, or a propagated exit code
1134/// on failure.
1135///
1136/// **Concurrency.** Held under [`DISPATCH_RENDER_LOCK`] so concurrent
1137/// in-process callers don't race on the global env vars the Rust shim
1138/// sets to hand the report to the script. See the lock's docstring
1139/// for the trade-off rationale.
1140async fn render_via_dispatch(summary: &EvalSummary, mode: &str) -> Result<String, i32> {
1141    let summary_json = match serde_json::to_string(summary) {
1142        Ok(json) => json,
1143        Err(error) => {
1144            eprintln!("error: failed to serialise EvalSummary for dispatch: {error}");
1145            return Err(1);
1146        }
1147    };
1148    let _guard = DISPATCH_RENDER_LOCK.lock().await;
1149    let _summary = ScopedEnvVar::set(CODING_AGENT_SUMMARY_ENV, &summary_json);
1150    let _mode = ScopedEnvVar::set(CODING_AGENT_MODE_ENV, mode);
1151
1152    let outcome = dispatch::run_embedded_script("eval/coding_agent", Vec::new(), false).await;
1153    if !outcome.stderr.is_empty() {
1154        let _ = std::io::stderr().write_all(outcome.stderr.as_bytes());
1155    }
1156    if outcome.exit_code != 0 {
1157        return Err(outcome.exit_code);
1158    }
1159    Ok(outcome.stdout)
1160}
1161
1162fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
1163    let body = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?;
1164    fs::write(path, format!("{body}\n")).map_err(|error| error.to_string())
1165}
1166
1167fn write_jsonl<T: Serialize>(path: &Path, items: &[T]) -> Result<(), String> {
1168    let mut body = String::new();
1169    for item in items {
1170        let line = serde_json::to_string(item).map_err(|error| error.to_string())?;
1171        body.push_str(&line);
1172        body.push('\n');
1173    }
1174    fs::write(path, body).map_err(|error| error.to_string())
1175}
1176
1177fn read_run_summary(run_dir: &Path) -> Option<JsonValue> {
1178    let raw = fs::read_to_string(run_dir.join("summary.json")).ok()?;
1179    serde_json::from_str(&raw).ok()
1180}
1181
1182fn parse_last_json_line(stdout: &str) -> Option<JsonValue> {
1183    stdout
1184        .lines()
1185        .rev()
1186        .map(str::trim)
1187        .filter(|line| !line.is_empty())
1188        .find_map(|line| serde_json::from_str::<JsonValue>(line).ok())
1189}
1190
1191fn string_array(value: Option<&JsonValue>) -> Vec<String> {
1192    value
1193        .and_then(JsonValue::as_array)
1194        .map(|values| {
1195            values
1196                .iter()
1197                .filter_map(JsonValue::as_str)
1198                .map(str::to_string)
1199                .collect()
1200        })
1201        .unwrap_or_default()
1202}
1203
1204fn non_empty_string_array(value: Option<&JsonValue>) -> Option<Vec<String>> {
1205    let values = string_array(value);
1206    (!values.is_empty()).then_some(values)
1207}
1208
1209fn tool_call_sequence(value: Option<&JsonValue>) -> Option<Vec<String>> {
1210    let calls = value.and_then(JsonValue::as_array)?;
1211    let mut sequence = Vec::new();
1212    for call in calls {
1213        if let Some(name) = call
1214            .get("name")
1215            .or_else(|| call.get("tool_name"))
1216            .and_then(JsonValue::as_str)
1217        {
1218            sequence.push(name.to_string());
1219        }
1220    }
1221    (!sequence.is_empty()).then_some(sequence)
1222}
1223
1224fn run_id_for(fixture: &EvalPackCase, selector: &ModelSelector, tool_format: &str) -> String {
1225    sanitize_id(&format!(
1226        "{}__{}__{}",
1227        fixture_id(fixture),
1228        selector_label(selector),
1229        tool_format
1230    ))
1231}
1232
1233fn sanitize_id(raw: &str) -> String {
1234    let mut out = String::new();
1235    for ch in raw.chars() {
1236        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1237            out.push(ch);
1238        } else {
1239            out.push('_');
1240        }
1241    }
1242    out.trim_matches('_').to_string()
1243}
1244
1245fn default_output_dir() -> PathBuf {
1246    PathBuf::from(".harn-runs")
1247        .join("coding-agent-bench")
1248        .join("latest")
1249}
1250
1251fn excerpt(text: &str) -> Option<String> {
1252    let trimmed = text.trim();
1253    if trimmed.is_empty() {
1254        return None;
1255    }
1256    let max = 4000;
1257    if trimmed.len() <= max {
1258        return Some(trimmed.to_string());
1259    }
1260    let mut truncated = String::new();
1261    for ch in trimmed.chars().take(max) {
1262        truncated.push(ch);
1263    }
1264    truncated.push_str("...");
1265    Some(truncated)
1266}
1267
1268fn load_env_files(paths: &[PathBuf]) -> Result<(EnvOverlay, Vec<LoadedEnvKey>), String> {
1269    let mut previous = Vec::new();
1270    let mut loaded = Vec::new();
1271    let mut touched = BTreeSet::new();
1272    for path in paths {
1273        let path = expand_home(path);
1274        let raw = fs::read_to_string(&path)
1275            .map_err(|error| format!("failed to read env file {}: {error}", path.display()))?;
1276        for (line_no, line) in raw.lines().enumerate() {
1277            let Some((key, value)) = parse_env_line(line).map_err(|error| {
1278                format!("{}:{}: {error}", path.display(), line_no.saturating_add(1))
1279            })?
1280            else {
1281                continue;
1282            };
1283            if touched.insert(key.clone()) {
1284                previous.push((OsString::from(&key), std::env::var_os(&key)));
1285            }
1286            std::env::set_var(&key, value);
1287            loaded.push(LoadedEnvKey {
1288                key,
1289                source: path.display().to_string(),
1290            });
1291        }
1292    }
1293    Ok((EnvOverlay { previous }, loaded))
1294}
1295
1296fn parse_env_line(line: &str) -> Result<Option<(String, String)>, String> {
1297    let trimmed = line.trim();
1298    if trimmed.is_empty() || trimmed.starts_with('#') {
1299        return Ok(None);
1300    }
1301    let trimmed = trimmed.strip_prefix("export ").unwrap_or(trimmed).trim();
1302    let Some((key, value)) = trimmed.split_once('=') else {
1303        return Err("expected KEY=VALUE".to_string());
1304    };
1305    let key = key.trim();
1306    if key.is_empty() {
1307        return Err("empty key".to_string());
1308    }
1309    if !key
1310        .chars()
1311        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1312    {
1313        return Err(format!("invalid key `{key}`"));
1314    }
1315    Ok(Some((key.to_string(), unquote_env_value(value.trim()))))
1316}
1317
1318fn unquote_env_value(value: &str) -> String {
1319    if value.len() >= 2 {
1320        let bytes = value.as_bytes();
1321        if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
1322            || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'')
1323        {
1324            return value[1..value.len() - 1].to_string();
1325        }
1326    }
1327    value.to_string()
1328}
1329
1330fn expand_home(path: &Path) -> PathBuf {
1331    let raw = path.to_string_lossy();
1332    if raw == "~" {
1333        return harn_vm::user_dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
1334    }
1335    if let Some(rest) = raw.strip_prefix("~/") {
1336        if let Some(home) = harn_vm::user_dirs::home_dir() {
1337            return home.join(rest);
1338        }
1339    }
1340    path.to_path_buf()
1341}
1342
1343#[cfg(test)]
1344#[path = "eval_coding_agent_tests.rs"]
1345mod tests;