1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct TrialResult {
18 pub task_id: String,
19 pub model: String,
20 pub bash_commands: Vec<String>,
22 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
59pub 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
159pub 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 .arg("--")
201 .arg(&task.prompt)
202 .current_dir(&workdir)
203 .stdout(Stdio::piped())
204 .stderr(Stdio::null());
205 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
265fn 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}