300 lines
8.8 KiB
Rust
300 lines
8.8 KiB
Rust
use super::*;
|
|
|
|
use crate::tools::spec::ToolContext;
|
|
use serde_json::{Value, json};
|
|
use tempfile::tempdir;
|
|
|
|
fn echo_command(message: &str) -> String {
|
|
format!("echo {message}")
|
|
}
|
|
|
|
fn sleep_command(seconds: u64) -> String {
|
|
#[cfg(windows)]
|
|
{
|
|
let ping_count = seconds.saturating_add(1);
|
|
let ps_path = r#"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"#;
|
|
format!(
|
|
"\"{ps_path}\" -NoProfile -Command \"Start-Sleep -Seconds {seconds}\" || ping 127.0.0.1 -n {ping_count} > NUL"
|
|
)
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
format!("sleep {seconds}")
|
|
}
|
|
}
|
|
|
|
fn sleep_then_echo_command(seconds: u64, message: &str) -> String {
|
|
#[cfg(windows)]
|
|
{
|
|
let ping_count = seconds.saturating_add(1);
|
|
let ps_path = r#"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"#;
|
|
format!(
|
|
"\"{ps_path}\" -NoProfile -Command \"Start-Sleep -Seconds {seconds}; Write-Output {message}\" || (ping 127.0.0.1 -n {ping_count} > NUL && echo {message})"
|
|
)
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
format!("sleep {seconds} && echo {message}")
|
|
}
|
|
}
|
|
|
|
fn echo_stdin_command() -> String {
|
|
#[cfg(windows)]
|
|
{
|
|
"more".to_string()
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
"cat".to_string()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_sync_execution() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut manager = ShellManager::new(tmp.path().to_path_buf());
|
|
|
|
let result = manager
|
|
.execute(&echo_command("hello"), None, 5000, false)
|
|
.expect("execute");
|
|
|
|
assert_eq!(result.status, ShellStatus::Completed);
|
|
assert!(result.stdout.contains("hello"));
|
|
assert!(result.task_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_background_execution() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut manager = ShellManager::new(tmp.path().to_path_buf());
|
|
|
|
let result = manager
|
|
.execute(&sleep_then_echo_command(1, "done"), None, 5000, true)
|
|
.expect("execute");
|
|
|
|
assert_eq!(result.status, ShellStatus::Running);
|
|
assert!(result.task_id.is_some());
|
|
|
|
let task_id = result
|
|
.task_id
|
|
.expect("background execution should return task_id");
|
|
|
|
// Wait for completion
|
|
let final_result = manager
|
|
.get_output(&task_id, true, 5000)
|
|
.expect("get_output");
|
|
|
|
assert_eq!(final_result.status, ShellStatus::Completed);
|
|
assert!(final_result.stdout.contains("done"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_timeout() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut manager = ShellManager::new(tmp.path().to_path_buf());
|
|
|
|
let result = manager
|
|
.execute(&sleep_command(10), None, 1000, false)
|
|
.expect("execute");
|
|
|
|
assert_eq!(result.status, ShellStatus::TimedOut);
|
|
}
|
|
|
|
#[test]
|
|
fn test_kill() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut manager = ShellManager::new(tmp.path().to_path_buf());
|
|
|
|
let result = manager
|
|
.execute(&sleep_command(60), None, 5000, true)
|
|
.expect("execute");
|
|
|
|
let task_id = result
|
|
.task_id
|
|
.expect("background execution should return task_id");
|
|
|
|
// Kill it
|
|
let killed = manager.kill(&task_id).expect("kill");
|
|
assert_eq!(killed.status, ShellStatus::Killed);
|
|
}
|
|
|
|
#[test]
|
|
fn test_write_stdin_streams_output() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut manager = ShellManager::new(tmp.path().to_path_buf());
|
|
|
|
let result = manager
|
|
.execute_with_options(&echo_stdin_command(), None, 5000, true, None, false, None)
|
|
.expect("execute");
|
|
|
|
let task_id = result
|
|
.task_id
|
|
.expect("background execution should return task_id");
|
|
|
|
manager
|
|
.write_stdin(&task_id, "hello\n", true)
|
|
.expect("write stdin");
|
|
|
|
let delta = manager
|
|
.get_output_delta(&task_id, true, 5000)
|
|
.expect("get_output_delta");
|
|
|
|
assert!(delta.result.stdout.contains("hello"));
|
|
|
|
let delta2 = manager
|
|
.get_output_delta(&task_id, false, 0)
|
|
.expect("get_output_delta");
|
|
assert!(delta2.result.stdout.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_job_list_poll_cancel_and_stale_snapshot() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut manager = ShellManager::new(tmp.path().to_path_buf());
|
|
|
|
let started = manager
|
|
.execute(&sleep_then_echo_command(1, "done"), None, 5000, true)
|
|
.expect("execute");
|
|
let task_id = started.task_id.expect("task id");
|
|
manager
|
|
.tag_linked_task(&task_id, Some("task_123".to_string()))
|
|
.expect("tag linked task");
|
|
|
|
let running = manager.list_jobs();
|
|
let job = running
|
|
.iter()
|
|
.find(|job| job.id == task_id)
|
|
.expect("running job");
|
|
assert_eq!(job.status, ShellStatus::Running);
|
|
assert_eq!(job.linked_task_id.as_deref(), Some("task_123"));
|
|
assert!(job.command.contains("done"));
|
|
assert_eq!(job.cwd, tmp.path());
|
|
|
|
let completed = manager
|
|
.poll_delta(&task_id, true, 5000)
|
|
.expect("poll delta");
|
|
assert_eq!(completed.result.status, ShellStatus::Completed);
|
|
assert!(completed.result.stdout.contains("done"));
|
|
|
|
let detail = manager.inspect_job(&task_id).expect("inspect");
|
|
assert!(detail.stdout.contains("done"));
|
|
assert_eq!(detail.snapshot.status, ShellStatus::Completed);
|
|
|
|
manager.remember_stale_job(
|
|
"shell_stale",
|
|
"cargo test",
|
|
tmp.path().to_path_buf(),
|
|
Some("task_old".to_string()),
|
|
);
|
|
let stale = manager
|
|
.list_jobs()
|
|
.into_iter()
|
|
.find(|job| job.id == "shell_stale")
|
|
.expect("stale job");
|
|
assert!(stale.stale);
|
|
assert_eq!(stale.linked_task_id.as_deref(), Some("task_old"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_job_cancel_updates_completion_state() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut manager = ShellManager::new(tmp.path().to_path_buf());
|
|
|
|
let started = manager
|
|
.execute(&sleep_command(60), None, 5000, true)
|
|
.expect("execute");
|
|
let task_id = started.task_id.expect("task id");
|
|
|
|
let killed = manager.kill(&task_id).expect("kill");
|
|
assert_eq!(killed.status, ShellStatus::Killed);
|
|
let job = manager.inspect_job(&task_id).expect("inspect");
|
|
assert_eq!(job.snapshot.status, ShellStatus::Killed);
|
|
assert!(!job.snapshot.stdin_available);
|
|
}
|
|
|
|
#[test]
|
|
fn test_output_truncation() {
|
|
let long_output = "x".repeat(50_000);
|
|
let (truncated, _meta) = truncate_with_meta(&long_output);
|
|
|
|
assert!(truncated.len() < long_output.len());
|
|
assert!(truncated.contains("truncated"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_with_meta_reports_omission_counts() {
|
|
let long_output = format!("line1\nline2\n{}", "x".repeat(60_000));
|
|
let (truncated, meta) = truncate_with_meta(&long_output);
|
|
|
|
assert!(meta.truncated);
|
|
assert!(meta.original_len >= long_output.len());
|
|
assert!(meta.omitted > 0);
|
|
assert!(truncated.contains("bytes omitted"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_summarize_output_strips_truncation_note() {
|
|
let long_output = "x".repeat(60_000);
|
|
let (truncated, _meta) = truncate_with_meta(&long_output);
|
|
let summary = summarize_output(&truncated);
|
|
assert!(!summary.contains("Output truncated at"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_exec_shell_metadata_includes_summaries() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let ctx = ToolContext::new(tmp.path());
|
|
let tool = ExecShellTool;
|
|
|
|
let result = tool
|
|
.execute(json!({"command": echo_command("hello")}), &ctx)
|
|
.await
|
|
.expect("execute");
|
|
assert!(result.success);
|
|
|
|
let meta = result.metadata.expect("metadata");
|
|
let summary = meta
|
|
.get("summary")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
assert!(summary.contains("hello"));
|
|
assert!(meta.get("stdout_len").is_some());
|
|
assert!(meta.get("stdout_truncated").is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_exec_shell_foreground_cancel_kills_process() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let cancel_token = tokio_util::sync::CancellationToken::new();
|
|
let ctx = ToolContext::new(tmp.path()).with_cancel_token(cancel_token.clone());
|
|
let command = sleep_command(30);
|
|
|
|
let task = tokio::spawn(async move {
|
|
ExecShellTool
|
|
.execute(
|
|
json!({
|
|
"command": command,
|
|
"timeout_ms": 600_000
|
|
}),
|
|
&ctx,
|
|
)
|
|
.await
|
|
.expect("execute")
|
|
});
|
|
|
|
tokio::time::sleep(Duration::from_millis(150)).await;
|
|
cancel_token.cancel();
|
|
|
|
let result = tokio::time::timeout(Duration::from_secs(5), task)
|
|
.await
|
|
.expect("foreground shell should observe cancellation")
|
|
.expect("task should not panic");
|
|
|
|
assert!(!result.success);
|
|
assert!(result.content.contains("Command canceled"));
|
|
let meta = result.metadata.expect("metadata");
|
|
assert_eq!(meta.get("status").and_then(Value::as_str), Some("Killed"));
|
|
assert_eq!(meta.get("canceled").and_then(Value::as_bool), Some(true));
|
|
}
|