use super::*;
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::AsyncReadExt;
fn text_of(result: &AgentToolResult) -> String {
match &result.content[0] {
UserContentBlock::Text(t) => t.text.clone(),
_ => panic!("expected text content"),
}
}
#[tokio::test]
async fn timeout_kills_child_process() {
const SLEEP_SECS: &str = "47383";
let tool = BashTool;
let started = Instant::now();
let result = tool
.execute(
"t1",
json!({ "command": format!("sleep {SLEEP_SECS}"), "timeout": 1 }),
CancellationToken::new(),
None,
)
.await
.expect("bash tool execute should not error on timeout");
let elapsed = started.elapsed();
assert!(
elapsed.as_secs() < 5,
"timeout path took {elapsed:?}; child kill did not happen in time"
);
let text = match &result.content[0] {
UserContentBlock::Text(t) => t.text.clone(),
_ => panic!("expected text content"),
};
assert!(
text.contains("[timed out after 1s]"),
"expected timeout marker in output, got: {text}"
);
assert!(text.contains("[exit -1]"));
tokio::time::sleep(Duration::from_millis(200)).await;
let pgrep = tokio::process::Command::new("pgrep")
.arg("-f")
.arg(format!("sleep {SLEEP_SECS}"))
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn();
if let Ok(mut child) = pgrep {
let mut buf = String::new();
if let Some(mut s) = child.stdout.take() {
let _ = s.read_to_string(&mut buf).await;
}
let _ = child.wait().await;
assert!(
buf.trim().is_empty(),
"found surviving `sleep {SLEEP_SECS}` process(es) after timeout: pids={buf}"
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn timeout_kills_descendant_processes() {
let tool = BashTool;
let marker = "bash-tool-desc-kill-marker-7f3a9c";
let cmd = format!("(sleep 60 && echo {marker}) & wait");
let started = Instant::now();
let _result = tool
.execute(
"tdesc",
json!({ "command": cmd, "timeout": 1 }),
CancellationToken::new(),
None,
)
.await
.expect("bash tool execute should not error on timeout");
let elapsed = started.elapsed();
assert!(
elapsed.as_secs() < 5,
"timeout path took {elapsed:?}; descendant kill did not happen in time"
);
tokio::time::sleep(Duration::from_millis(200)).await;
let pgrep = tokio::process::Command::new("pgrep")
.arg("-f")
.arg(marker)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn();
if let Ok(mut child) = pgrep {
let mut buf = String::new();
if let Some(mut s) = child.stdout.take() {
let _ = s.read_to_string(&mut buf).await;
}
let _ = child.wait().await;
assert!(
buf.trim().is_empty(),
"found surviving descendant process(es) matching {marker:?}: pids={buf}"
);
}
}
#[tokio::test]
async fn cancellation_kills_child_process() {
const SLEEP_SECS: &str = "47384";
let tool = BashTool;
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(200)).await;
cancel_clone.cancel();
});
let started = Instant::now();
let result = tool
.execute(
"t2",
json!({ "command": format!("sleep {SLEEP_SECS}") }),
cancel,
None,
)
.await
.expect("bash tool should not error on cancellation");
let elapsed = started.elapsed();
assert!(
elapsed.as_secs() < 5,
"cancellation path took {elapsed:?}; child kill did not happen in time"
);
let text = match &result.content[0] {
UserContentBlock::Text(t) => t.text.clone(),
_ => panic!("expected text content"),
};
assert!(
text.contains("[aborted]"),
"expected aborted marker in output, got: {text}"
);
assert!(text.contains("[exit -1]"));
}
#[cfg(unix)]
#[tokio::test]
async fn cancellation_kills_descendant_processes() {
let tool = BashTool;
let marker = "bash-tool-cancel-desc-kill-marker-b21e4d";
let cmd = format!("(sleep 60 && echo {marker}) & wait");
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(300)).await;
cancel_clone.cancel();
});
let started = Instant::now();
let _result = tool
.execute("cdesc", json!({ "command": cmd }), cancel, None)
.await
.expect("bash tool execute should not error on cancellation");
let elapsed = started.elapsed();
assert!(
elapsed.as_secs() < 5,
"cancellation path took {elapsed:?}; descendant kill did not happen in time"
);
tokio::time::sleep(Duration::from_millis(200)).await;
let pgrep = tokio::process::Command::new("pgrep")
.arg("-f")
.arg(marker)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn();
if let Ok(mut child) = pgrep {
let mut buf = String::new();
if let Some(mut s) = child.stdout.take() {
let _ = s.read_to_string(&mut buf).await;
}
let _ = child.wait().await;
assert!(
buf.trim().is_empty(),
"found surviving descendant process(es) matching {marker:?} after cancel: pids={buf}"
);
}
}
#[tokio::test]
async fn high_volume_stderr_does_not_deadlock_stdout() {
let tool = BashTool;
let command = "yes hello | head -c 262144 ; yes world | head -c 262144 1>&2";
let started = Instant::now();
let result = tokio::time::timeout(
Duration::from_secs(10),
tool.execute(
"t3",
json!({ "command": command, "timeout": 10 }),
CancellationToken::new(),
None,
),
)
.await
.expect("bash tool must not hang on high-volume stderr")
.expect("execute returned error");
let elapsed = started.elapsed();
assert!(
elapsed.as_secs() < 8,
"high-volume stderr drain took {elapsed:?}; sequential drain regression?"
);
let text = match &result.content[0] {
UserContentBlock::Text(t) => t.text.clone(),
_ => panic!("expected text content"),
};
assert!(
text.contains("[exit 0]"),
"expected clean exit, got: {text}"
);
assert!(text.contains("[stderr]"));
}
#[tokio::test]
async fn large_output_is_truncated_and_full_text_stored_in_details() {
let tool = BashTool;
let result = tool
.execute(
"big",
json!({ "command": "head -c 300000 /dev/zero | tr '\\0' 'x'" }),
CancellationToken::new(),
None,
)
.await
.expect("large output should not error");
let text = text_of(&result);
assert!(text.contains("[truncated"), "got: {text}");
assert!(text.contains("[exit 0]"), "got: {text}");
let full_text = result.details["full_text"]
.as_str()
.expect("full_text in details");
assert!(full_text.contains(&"x".repeat(300_000)), "full text missing");
assert_eq!(result.details["truncated"], true);
}
#[tokio::test]
async fn ok_path_still_works() {
let tool = BashTool;
let r = tool
.execute(
"t4",
json!({ "command": "echo hello" }),
CancellationToken::new(),
None,
)
.await
.expect("simple echo should not error");
let text = match &r.content[0] {
UserContentBlock::Text(t) => t.text.clone(),
_ => panic!("expected text content"),
};
assert!(text.contains("hello"));
assert!(text.contains("[exit 0]"));
}
#[tokio::test]
async fn concurrent_invocations_do_not_serialize() {
let tool = Arc::new(BashTool);
let started = Instant::now();
let mut handles = Vec::new();
for i in 0..4 {
let tool = tool.clone();
handles.push(tokio::spawn(async move {
tool.execute(
&format!("c{i}"),
json!({ "command": "sleep 0.3 && echo done" }),
CancellationToken::new(),
None,
)
.await
}));
}
for h in handles {
h.await.expect("task join").expect("execute should succeed");
}
let elapsed = started.elapsed();
assert!(
elapsed.as_secs_f64() < 1.5,
"concurrent bash calls serialized? elapsed = {elapsed:?}"
);
}
#[test]
fn resolve_timeout_defaults_and_override() {
assert_eq!(resolve_timeout(&json!({})), 60);
assert_eq!(resolve_timeout(&json!({ "command": "x" })), 60);
assert_eq!(resolve_timeout(&json!({ "timeout": 7 })), 7);
assert_eq!(resolve_timeout(&json!({ "timeout": 0 })), 0);
}
#[tokio::test]
async fn bash_run_in_background_returns_shell_id() {
let tool = BashTool;
let result = tool
.execute(
"b1",
json!({ "command": "echo bg", "run_in_background": true }),
CancellationToken::new(),
None,
)
.await
.expect("bash");
let text = text_of(&result);
assert!(
text.contains("background shell started: shell-"),
"got: {text}"
);
let fg = tool
.execute(
"b2",
json!({ "command": "echo fg" }),
CancellationToken::new(),
None,
)
.await
.expect("bash");
let fg_text = text_of(&fg);
assert!(
fg_text.contains("fg") && fg_text.contains("[exit 0]"),
"got: {fg_text}"
);
}
#[tokio::test]
async fn execute_missing_command_errors() {
let tool = BashTool;
let err = tool
.execute("m1", json!({}), CancellationToken::new(), None)
.await
.expect_err("missing command must fail");
assert_eq!(err.to_string(), "missing `command`");
}
#[tokio::test]
async fn execute_honors_cwd_param() {
let dir = tempfile::tempdir().expect("tempdir");
let cwd = dir.path().to_string_lossy().into_owned();
let canonical_cwd = dir
.path()
.canonicalize()
.expect("canonical tempdir")
.to_string_lossy()
.into_owned();
let tool = BashTool;
let result = tool
.execute(
"m2",
json!({ "command": "pwd", "cwd": cwd }),
CancellationToken::new(),
None,
)
.await
.expect("pwd in tempdir should succeed");
let text = text_of(&result);
assert!(
text.contains(&format!("$ pwd\n{canonical_cwd}\n[exit 0]")),
"got: {text}"
);
}
#[tokio::test]
async fn execute_adds_newline_after_stdout_without_trailing_newline() {
let tool = BashTool;
let result = tool
.execute("m3", json!({ "command": "printf hello" }), CancellationToken::new(), None)
.await
.expect("printf should succeed");
let text = text_of(&result);
assert!(
text.contains("hello\n[exit 0]"),
"stdout without trailing newline must get one before the exit marker: {text}"
);
}
#[tokio::test]
async fn execute_adds_newline_after_stderr_without_trailing_newline() {
let tool = BashTool;
let result = tool
.execute("m4", json!({ "command": "printf err >&2" }), CancellationToken::new(), None)
.await
.expect("printf to stderr should succeed");
let text = text_of(&result);
assert!(
text.contains("[stderr]\nerr\n[exit 0]"),
"stderr without trailing newline must get one before the exit marker: {text}"
);
}