use agx_core::timeline::{Step, StepKind};
use anyhow::{Context, Result};
use serde::Serialize;
use std::fs::OpenOptions;
use std::io::{Read, Write};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const MAX_CAPTURE_BYTES: usize = 4 * 1024 * 1024;
const DEFAULT_TIMEOUT_SECS: u64 = 30;
const POLL_INTERVAL_MS: u64 = 50;
#[derive(Debug, Clone, Copy, Default)]
pub struct ReplayConfig {
pub enabled: bool,
pub allow_shell: bool,
}
#[derive(Debug)]
pub enum ReplayIntent {
NeedsConfirm { input: String },
NotReplayable { reason: &'static str },
FlagMissing { hint: &'static str },
}
#[must_use]
pub fn classify(step: &Step, cfg: &ReplayConfig) -> ReplayIntent {
if !cfg.enabled {
return ReplayIntent::FlagMissing {
hint: "replay requires `--experimental-replay` at launch",
};
}
if step.kind != StepKind::ToolUse {
return ReplayIntent::NotReplayable {
reason: "replay only applies to tool_use steps",
};
}
let tool_name = step.tool_name.as_deref().unwrap_or("");
let is_shell = matches!(tool_name, "Bash" | "bash" | "shell" | "Shell");
if !is_shell {
return ReplayIntent::NotReplayable {
reason: "v1 replays Bash-like tools only (MCP / API backends pending)",
};
}
if !cfg.allow_shell {
return ReplayIntent::FlagMissing {
hint: "shell replay requires `--allow-shell-replay` at launch",
};
}
match extract_shell_command(&step.detail) {
Some(input) if !input.is_empty() => ReplayIntent::NeedsConfirm { input },
_ => ReplayIntent::NotReplayable {
reason: "could not extract shell command from step",
},
}
}
fn extract_shell_command(detail: &str) -> Option<String> {
let after_input = detail.split_once("\nInput:\n")?.1;
let end = after_input
.find("\n\nResult:\n")
.unwrap_or(after_input.len());
let input_json = &after_input[..end];
let v: serde_json::Value = serde_json::from_str(input_json.trim()).ok()?;
v.get("command")
.or_else(|| v.get("cmd"))
.and_then(|x| x.as_str())
.map(str::to_string)
}
#[derive(Debug)]
pub struct ReplayOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
pub duration_ms: u128,
pub timed_out: bool,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
}
pub fn execute_shell(input: &str) -> Result<ReplayOutput> {
execute_shell_with_limits(input, DEFAULT_TIMEOUT_SECS, MAX_CAPTURE_BYTES)
}
pub(crate) fn execute_shell_with_limits(
input: &str,
timeout_secs: u64,
max_capture_bytes: usize,
) -> Result<ReplayOutput> {
let start = std::time::Instant::now();
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c")
.arg(input)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
cmd.process_group(0);
let mut child = cmd.spawn().context("spawning /bin/sh for replay")?;
let stdout_h = child.stdout.take().context("replay child stdout handle")?;
let stderr_h = child.stderr.take().context("replay child stderr handle")?;
let cap = max_capture_bytes;
let t_out = thread::spawn(move || read_capped(stdout_h, cap));
let t_err = thread::spawn(move || read_capped(stderr_h, cap));
let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs);
let mut timed_out = false;
let exit_code = loop {
match child.try_wait().context("polling replay child")? {
Some(status) => break status.code(),
None if std::time::Instant::now() >= deadline => {
kill_process_group(&mut child);
timed_out = true;
let status = child.wait().context("waiting for killed replay child")?;
break status.code();
}
None => thread::sleep(Duration::from_millis(POLL_INTERVAL_MS)),
}
};
let (out_bytes, stdout_truncated) = t_out.join().unwrap_or_else(|_| (Vec::new(), false));
let (err_bytes, stderr_truncated) = t_err.join().unwrap_or_else(|_| (Vec::new(), false));
let duration_ms = start.elapsed().as_millis();
Ok(ReplayOutput {
stdout: String::from_utf8_lossy(&out_bytes).into_owned(),
stderr: String::from_utf8_lossy(&err_bytes).into_owned(),
exit_code,
duration_ms,
timed_out,
stdout_truncated,
stderr_truncated,
})
}
fn kill_process_group(child: &mut Child) {
#[cfg(unix)]
{
let pgid = child.id() as i32;
unsafe {
libc::kill(-pgid, libc::SIGKILL);
}
}
#[cfg(not(unix))]
{
let _ = child.kill();
}
}
fn read_capped<R: Read>(mut stream: R, cap: usize) -> (Vec<u8>, bool) {
let mut buf = Vec::with_capacity(std::cmp::min(cap, 8192));
let mut total = 0usize;
let mut scratch = [0u8; 4096];
let mut truncated = false;
loop {
let remaining = cap.saturating_sub(total);
let want = if remaining == 0 {
scratch.len()
} else {
remaining.min(scratch.len())
};
match stream.read(&mut scratch[..want]) {
Ok(0) => return (buf, truncated),
Ok(n) if remaining == 0 => {
let _ = n;
}
Ok(n) => {
buf.extend_from_slice(&scratch[..n]);
total += n;
if total >= cap {
truncated = true;
}
}
Err(_) => return (buf, truncated),
}
}
}
#[derive(Debug, Serialize)]
struct ReplayLogEntry<'a> {
ts_ms: u128,
step_index: usize,
tool_name: &'a str,
tool_call_id: &'a str,
input: &'a str,
exit_code: Option<i32>,
duration_ms: u128,
timed_out: bool,
stdout_truncated: bool,
stderr_truncated: bool,
stdout: &'a str,
stderr: &'a str,
}
pub fn log_replay(
session_path: &Path,
step_index: usize,
step: &Step,
input: &str,
output: &ReplayOutput,
) -> Result<()> {
let mut sidecar = session_path.as_os_str().to_os_string();
sidecar.push(".replay.log");
let path: std::path::PathBuf = sidecar.into();
let ts_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let entry = ReplayLogEntry {
ts_ms,
step_index,
tool_name: step.tool_name.as_deref().unwrap_or(""),
tool_call_id: step.tool_call_id.as_deref().unwrap_or(""),
input,
exit_code: output.exit_code,
duration_ms: output.duration_ms,
timed_out: output.timed_out,
stdout_truncated: output.stdout_truncated,
stderr_truncated: output.stderr_truncated,
stdout: &output.stdout,
stderr: &output.stderr,
};
let line = serde_json::to_string(&entry)?;
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("opening {}", path.display()))?;
writeln!(f, "{line}")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use agx_core::timeline::{tool_use_step, user_text_step};
#[test]
fn classify_refuses_without_experimental_flag() {
let step = tool_use_step("t1", "Bash", "{\"command\":\"ls\"}");
let cfg = ReplayConfig::default();
assert!(matches!(
classify(&step, &cfg),
ReplayIntent::FlagMissing { .. }
));
}
#[test]
fn classify_refuses_shell_without_allow_shell_flag() {
let step = tool_use_step("t1", "Bash", "{\"command\":\"ls\"}");
let cfg = ReplayConfig {
enabled: true,
allow_shell: false,
};
match classify(&step, &cfg) {
ReplayIntent::FlagMissing { hint } => {
assert!(hint.contains("--allow-shell-replay"));
}
other => panic!("expected FlagMissing, got {other:?}"),
}
}
#[test]
fn classify_rejects_non_tool_use_steps() {
let step = user_text_step("hi");
let cfg = ReplayConfig {
enabled: true,
allow_shell: true,
};
assert!(matches!(
classify(&step, &cfg),
ReplayIntent::NotReplayable { .. }
));
}
#[test]
fn classify_rejects_non_shell_tools() {
let step = tool_use_step("t1", "Read", "{\"file_path\":\"/x\"}");
let cfg = ReplayConfig {
enabled: true,
allow_shell: true,
};
assert!(matches!(
classify(&step, &cfg),
ReplayIntent::NotReplayable { .. }
));
}
#[test]
fn classify_accepts_shell_with_all_gates() {
let step = tool_use_step("t1", "Bash", "{\"command\":\"ls -la\"}");
let cfg = ReplayConfig {
enabled: true,
allow_shell: true,
};
match classify(&step, &cfg) {
ReplayIntent::NeedsConfirm { input } => assert_eq!(input, "ls -la"),
other => panic!("expected NeedsConfirm, got {other:?}"),
}
}
#[test]
fn classify_refuses_malformed_input() {
let step = tool_use_step("t1", "Bash", "not-json-at-all");
let cfg = ReplayConfig {
enabled: true,
allow_shell: true,
};
assert!(matches!(
classify(&step, &cfg),
ReplayIntent::NotReplayable { .. }
));
}
#[test]
fn classify_refuses_empty_command_string() {
let step = tool_use_step("t1", "Bash", "{\"command\":\"\"}");
let cfg = ReplayConfig {
enabled: true,
allow_shell: true,
};
assert!(matches!(
classify(&step, &cfg),
ReplayIntent::NotReplayable { .. }
));
}
#[test]
fn classify_falls_back_to_cmd_field() {
let step = tool_use_step("t1", "Bash", "{\"cmd\":\"echo hi\"}");
let cfg = ReplayConfig {
enabled: true,
allow_shell: true,
};
match classify(&step, &cfg) {
ReplayIntent::NeedsConfirm { input } => assert_eq!(input, "echo hi"),
other => panic!("expected NeedsConfirm, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn execute_shell_captures_exit_and_stdout() {
let out = execute_shell("echo hello && false").expect("shell runs");
assert_eq!(out.exit_code, Some(1));
assert!(out.stdout.contains("hello"));
}
#[test]
fn log_replay_appends_jsonl_sidecar() {
use tempfile::NamedTempFile;
let session = NamedTempFile::new().unwrap();
let step = tool_use_step("t1", "Bash", "{\"command\":\"echo hi\"}");
let output = ReplayOutput {
stdout: "hi\n".into(),
stderr: String::new(),
exit_code: Some(0),
duration_ms: 42,
timed_out: false,
stdout_truncated: false,
stderr_truncated: false,
};
log_replay(session.path(), 7, &step, "echo hi", &output).unwrap();
let mut log_path = session.path().as_os_str().to_os_string();
log_path.push(".replay.log");
let content = std::fs::read_to_string::<std::path::PathBuf>(log_path.into()).unwrap();
assert!(content.contains("\"step_index\":7"));
assert!(content.contains("\"exit_code\":0"));
assert!(content.contains("\"stdout\":\"hi\\n\""));
assert!(content.contains("\"timed_out\":false"));
assert!(content.contains("\"stdout_truncated\":false"));
}
#[cfg(unix)]
#[test]
fn execute_shell_caps_stdout_at_the_limit() {
let out = execute_shell_with_limits("awk 'BEGIN{for(i=0;i<2000;i++)printf \"x\"}'", 10, 64)
.expect("shell runs");
assert_eq!(out.stdout.len(), 64, "stdout capped to exactly 64B");
assert!(out.stdout_truncated, "truncation flag set");
assert!(!out.timed_out, "did not time out");
}
#[cfg(unix)]
#[test]
fn execute_shell_caps_stderr_at_the_limit() {
let out =
execute_shell_with_limits("awk 'BEGIN{for(i=0;i<2000;i++)printf \"x\"}' 1>&2", 10, 64)
.expect("shell runs");
assert_eq!(out.stderr.len(), 64, "stderr capped to exactly 64B");
assert!(out.stderr_truncated, "stderr truncation flag set");
assert!(!out.stdout_truncated, "stdout untouched");
}
#[cfg(unix)]
#[test]
fn execute_shell_times_out_on_long_running() {
let start = std::time::Instant::now();
let out = execute_shell_with_limits("sleep 60", 1, 1024).expect("shell runs");
let elapsed = start.elapsed().as_secs();
assert!(out.timed_out, "timeout flag set");
assert!(
elapsed < 5,
"killed near the deadline, not 60s: elapsed={elapsed}s"
);
}
#[test]
fn log_replay_records_timeout_flag() {
use tempfile::NamedTempFile;
let session = NamedTempFile::new().unwrap();
let step = tool_use_step("t1", "Bash", "{\"command\":\"sleep 999\"}");
let output = ReplayOutput {
stdout: String::new(),
stderr: String::new(),
exit_code: None,
duration_ms: 1000,
timed_out: true,
stdout_truncated: false,
stderr_truncated: false,
};
log_replay(session.path(), 3, &step, "sleep 999", &output).unwrap();
let mut log_path = session.path().as_os_str().to_os_string();
log_path.push(".replay.log");
let content = std::fs::read_to_string::<std::path::PathBuf>(log_path.into()).unwrap();
assert!(
content.contains("\"timed_out\":true"),
"timeout flag in sidecar log"
);
}
}