Skip to main content

jig/
runner.rs

1//! Trial runner.
2//!
3//! Spawns `claude -p --output-format stream-json` in a fixture
4//! workdir, captures the event stream, extracts bash commands,
5//! aggregates the result summary.
6
7use crate::schema::{AgentShape, Task};
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use std::io::{BufRead, BufReader};
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13use std::time::{Duration, Instant};
14
15/// One trial outcome. The judge consumes this; the report aggregates it.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct TrialResult {
18    pub task_id: String,
19    pub model: String,
20    /// Every bash command the agent tried, in order.
21    pub bash_commands: Vec<String>,
22    /// Assistant messages (text content), in order.
23    pub assistant_texts: Vec<String>,
24    pub num_turns: u32,
25    pub input_tokens: u64,
26    pub output_tokens: u64,
27    pub cost_usd: f64,
28    pub duration_ms: u64,
29    pub terminal_reason: String,
30    pub is_error: bool,
31    pub completed_under_turn_cap: bool,
32    pub final_text: String,
33    pub setup_failed: bool,
34    pub timed_out: bool,
35}
36
37impl TrialResult {
38    fn failed_setup(task_id: &str, model: &str) -> Self {
39        Self {
40            task_id: task_id.into(),
41            model: model.into(),
42            bash_commands: vec![],
43            assistant_texts: vec![],
44            num_turns: 0,
45            input_tokens: 0,
46            output_tokens: 0,
47            cost_usd: 0.0,
48            duration_ms: 0,
49            terminal_reason: "setup_failed".into(),
50            is_error: true,
51            completed_under_turn_cap: false,
52            final_text: String::new(),
53            setup_failed: true,
54            timed_out: false,
55        }
56    }
57}
58
59/// Parse a stream-json output into a TrialResult. Pure function so
60/// it's testable against recorded fixtures.
61pub fn parse_event_stream(
62    lines: impl Iterator<Item = String>,
63    task_id: &str,
64    model: &str,
65    turn_cap: u32,
66) -> TrialResult {
67    let mut bash_commands = Vec::new();
68    let mut assistant_texts = Vec::new();
69    let mut final_result: Option<serde_json::Value> = None;
70
71    for line in lines {
72        if line.trim().is_empty() {
73            continue;
74        }
75        let event: serde_json::Value = match serde_json::from_str(&line) {
76            Ok(v) => v,
77            Err(_) => continue,
78        };
79
80        if event.get("type").and_then(|v| v.as_str()) == Some("assistant")
81            && let Some(content) = event.pointer("/message/content").and_then(|v| v.as_array())
82        {
83            for item in content {
84                match item.get("type").and_then(|v| v.as_str()) {
85                    Some("tool_use") => {
86                        if item.get("name").and_then(|v| v.as_str()) == Some("Bash")
87                            && let Some(cmd) =
88                                item.pointer("/input/command").and_then(|v| v.as_str())
89                        {
90                            bash_commands.push(cmd.to_string());
91                        }
92                    }
93                    Some("text") => {
94                        if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
95                            assistant_texts.push(text.to_string());
96                        }
97                    }
98                    _ => {}
99                }
100            }
101        }
102
103        if event.get("type").and_then(|v| v.as_str()) == Some("result") {
104            final_result = Some(event);
105        }
106    }
107
108    let r = final_result.as_ref();
109    let num_turns = r
110        .and_then(|v| v.get("num_turns"))
111        .and_then(|v| v.as_u64())
112        .unwrap_or(0) as u32;
113    let input_tokens = r
114        .and_then(|v| v.pointer("/usage/input_tokens"))
115        .and_then(|v| v.as_u64())
116        .unwrap_or(0);
117    let output_tokens = r
118        .and_then(|v| v.pointer("/usage/output_tokens"))
119        .and_then(|v| v.as_u64())
120        .unwrap_or(0);
121    let cost_usd = r
122        .and_then(|v| v.get("total_cost_usd"))
123        .and_then(|v| v.as_f64())
124        .unwrap_or(0.0);
125    let terminal_reason = r
126        .and_then(|v| v.get("terminal_reason"))
127        .and_then(|v| v.as_str())
128        .unwrap_or("no_result")
129        .to_string();
130    let is_error = r
131        .and_then(|v| v.get("is_error"))
132        .and_then(|v| v.as_bool())
133        .unwrap_or(true);
134    let final_text = r
135        .and_then(|v| v.get("result"))
136        .and_then(|v| v.as_str())
137        .unwrap_or("")
138        .to_string();
139
140    TrialResult {
141        task_id: task_id.into(),
142        model: model.into(),
143        bash_commands,
144        assistant_texts,
145        num_turns,
146        input_tokens,
147        output_tokens,
148        cost_usd,
149        duration_ms: 0,
150        terminal_reason,
151        is_error,
152        completed_under_turn_cap: num_turns <= turn_cap && !is_error,
153        final_text,
154        setup_failed: false,
155        timed_out: false,
156    }
157}
158
159/// Spawn claude and capture the trial. Errors only on IO / spawn
160/// failures; agent failures surface inside `TrialResult`.
161///
162/// `base_dir` anchors relative fixture and workdir paths (typically
163/// the directory containing the `agent-shape.toml`). Absolute paths
164/// in the config are respected as-is.
165pub fn run_trial(
166    config: &AgentShape,
167    task: &Task,
168    model: &str,
169    base_dir: &Path,
170) -> Result<TrialResult> {
171    let workdir = resolve_path(&config.fixture.workdir, base_dir);
172    let setup = resolve_path(&config.fixture.setup, base_dir);
173
174    if !run_fixture_cmd(
175        setup.to_string_lossy().as_ref(),
176        base_dir,
177        &config.fixture.strip_env,
178    ) {
179        return Ok(TrialResult::failed_setup(&task.id, model));
180    }
181
182    let start = Instant::now();
183
184    let mut cmd = Command::new("claude");
185    cmd.args([
186        "-p",
187        "--print",
188        "--output-format",
189        "stream-json",
190        "--verbose",
191        "--bare",
192        "--dangerously-skip-permissions",
193        "--model",
194        model,
195        "--add-dir",
196        workdir.to_string_lossy().as_ref(),
197    ])
198    // `--add-dir` is variadic; the separator stops clap from
199    // consuming the prompt as another dir.
200    .arg("--")
201    .arg(&task.prompt)
202    .current_dir(&workdir)
203    .stdout(Stdio::piped())
204    .stderr(Stdio::null());
205    // Strip any caller-side env vars that would leak the developer's
206    // session into the trial. Configured per fixture so jig stays
207    // subject-agnostic.
208    for var in &config.fixture.strip_env {
209        cmd.env_remove(var);
210    }
211    let mut child = cmd.spawn().context("spawn claude subprocess")?;
212
213    let stdout = child.stdout.take().context("take stdout")?;
214    let reader = BufReader::new(stdout);
215    let timeout = Duration::from_secs(config.run.timeout_seconds);
216
217    let mut collected = Vec::new();
218    let mut timed_out = false;
219
220    for line in reader.lines() {
221        let line = match line {
222            Ok(l) => l,
223            Err(_) => break,
224        };
225        collected.push(line);
226        if start.elapsed() > timeout {
227            timed_out = true;
228            let _ = child.kill();
229            break;
230        }
231    }
232
233    let _ = child.wait();
234    let duration_ms = start.elapsed().as_millis() as u64;
235
236    if let Some(cleanup) = &config.fixture.cleanup {
237        let resolved = resolve_path(cleanup, base_dir);
238        let _ = run_fixture_cmd(
239            resolved.to_string_lossy().as_ref(),
240            base_dir,
241            &config.fixture.strip_env,
242        );
243    }
244
245    let mut result =
246        parse_event_stream(collected.into_iter(), &task.id, model, config.run.turn_cap);
247    result.duration_ms = duration_ms;
248    result.timed_out = timed_out;
249    if timed_out {
250        result.terminal_reason = "timeout".into();
251        result.is_error = true;
252    }
253    Ok(result)
254}
255
256fn run_fixture_cmd(cmd: &str, cwd: &Path, strip_env: &[String]) -> bool {
257    let mut c = Command::new("sh");
258    c.arg("-c").arg(cmd).current_dir(cwd);
259    for var in strip_env {
260        c.env_remove(var);
261    }
262    c.status().map(|s| s.success()).unwrap_or(false)
263}
264
265/// Resolve a possibly-relative path against a base directory.
266/// Absolute paths and anything starting with `~` or `$` is returned
267/// unchanged (the shell will handle the latter when the path is fed
268/// to `sh -c`).
269fn resolve_path(path: &str, base: &Path) -> PathBuf {
270    let p = Path::new(path);
271    if p.is_absolute() || path.starts_with('~') || path.starts_with('$') {
272        p.to_path_buf()
273    } else {
274        base.join(p)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    const FIXTURE: &str = include_str!("../tests/fixtures/stream-two-bash.jsonl");
283
284    #[test]
285    fn extracts_bash_commands_in_order() {
286        let lines = FIXTURE.lines().map(|s| s.to_string());
287        let result = parse_event_stream(lines, "t1", "claude-sonnet-4-6", 3);
288        assert_eq!(
289            result.bash_commands,
290            vec!["ls".to_string(), "pwd".to_string()]
291        );
292    }
293
294    #[test]
295    fn extracts_final_metrics() {
296        let lines = FIXTURE.lines().map(|s| s.to_string());
297        let result = parse_event_stream(lines, "t1", "claude-sonnet-4-6", 3);
298        assert_eq!(result.num_turns, 3);
299        assert_eq!(result.input_tokens, 42);
300        assert_eq!(result.output_tokens, 100);
301        assert!((result.cost_usd - 0.0125).abs() < 1e-9);
302        assert_eq!(result.terminal_reason, "completed");
303        assert!(!result.is_error);
304        assert_eq!(result.final_text, "done");
305    }
306
307    #[test]
308    fn turn_cap_flag_reflects_comparison() {
309        let lines = FIXTURE.lines().map(|s| s.to_string());
310        let under = parse_event_stream(lines, "t1", "m", 3);
311        assert!(under.completed_under_turn_cap);
312
313        let lines2 = FIXTURE.lines().map(|s| s.to_string());
314        let over = parse_event_stream(lines2, "t1", "m", 2);
315        assert!(!over.completed_under_turn_cap);
316    }
317
318    #[test]
319    fn empty_stream_yields_error_state() {
320        let lines = std::iter::empty::<String>();
321        let result = parse_event_stream(lines, "t1", "m", 3);
322        assert!(result.is_error);
323        assert_eq!(result.terminal_reason, "no_result");
324        assert!(result.bash_commands.is_empty());
325    }
326
327    #[test]
328    fn resolve_path_absolute_is_passthrough() {
329        let r = resolve_path("/tmp/fx", Path::new("/repo"));
330        assert_eq!(r, PathBuf::from("/tmp/fx"));
331    }
332
333    #[test]
334    fn resolve_path_relative_anchors_to_base() {
335        let r = resolve_path("fixtures/fx", Path::new("/repo"));
336        assert_eq!(r, PathBuf::from("/repo/fixtures/fx"));
337    }
338
339    #[test]
340    fn resolve_path_env_var_is_passthrough() {
341        let r = resolve_path("$HOME/fx", Path::new("/repo"));
342        assert_eq!(r, PathBuf::from("$HOME/fx"));
343    }
344
345    #[test]
346    fn ignores_malformed_json_lines() {
347        let lines = [
348            r#"{"type":"system","subtype":"init"}"#.to_string(),
349            "not json".to_string(),
350            "".to_string(),
351            r#"{"type":"result","subtype":"success","is_error":false,"num_turns":1,"result":"ok","terminal_reason":"completed","total_cost_usd":0.001,"usage":{"input_tokens":1,"output_tokens":1}}"#.to_string(),
352        ];
353        let result = parse_event_stream(lines.into_iter(), "t1", "m", 3);
354        assert_eq!(result.num_turns, 1);
355        assert_eq!(result.final_text, "ok");
356    }
357}