use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
use supercode_harness::tools::{BashTool, Tool, ToolContext, ToolRegistry};
use supercode_harness::{
Agent, ChatMessage, ChatRequest, Config, ContextInjectionBlock, FunctionCall, Provider, Role,
ToolCall, ToolOverride, Usage,
};
fn temp_dir(tag: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering as O};
static N: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"sc-p4e-{tag}-{}-{}",
std::process::id(),
N.fetch_add(1, O::SeqCst)
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[tokio::test]
async fn bash_timeout_secs_default_unchanged_uses_built_in_120s_fallback() {
let ctx = ToolContext::new(temp_dir("bash-default"));
assert_eq!(ctx.bash_timeout_secs, None);
let out = BashTool::default()
.execute(serde_json::json!({"command": "echo hi"}), &ctx)
.await
.unwrap();
assert!(out.contains("hi"));
}
#[tokio::test]
async fn bash_timeout_secs_config_override_times_out_a_slow_command() {
let mut ctx = ToolContext::new(temp_dir("bash-timeout"));
ctx.bash_timeout_secs = Some(1);
let start = std::time::Instant::now();
let err = BashTool::default()
.execute(serde_json::json!({"command": "sleep 5"}), &ctx)
.await
.unwrap_err();
assert!(err.to_string().contains("timed out"), "{err}");
assert!(
start.elapsed() < std::time::Duration::from_secs(4),
"should have timed out around 1s, took {:?}",
start.elapsed()
);
}
#[tokio::test]
async fn bash_timeout_secs_model_supplied_timeout_ms_still_wins() {
let mut ctx = ToolContext::new(temp_dir("bash-timeout-precedence"));
ctx.bash_timeout_secs = Some(60);
let start = std::time::Instant::now();
let err = BashTool::default()
.execute(
serde_json::json!({"command": "sleep 5", "timeout_ms": 300}),
&ctx,
)
.await
.unwrap_err();
assert!(err.to_string().contains("timed out"), "{err}");
assert!(
start.elapsed() < std::time::Duration::from_secs(2),
"explicit timeout_ms=300 should have fired quickly, took {:?}",
start.elapsed()
);
}
#[cfg(unix)]
#[tokio::test]
async fn bash_timeout_kills_the_shell_and_its_worker_process_group() {
let dir = temp_dir("bash-timeout-process-group");
let pidfile = dir.join("pids");
let command = format!(
"sleep 3600 & worker=$!; echo \"$$ $worker\" > {}; wait",
pidfile.display()
);
let ctx = ToolContext::new(dir.clone());
let error = BashTool::default()
.execute(
serde_json::json!({"command": command, "timeout_ms": 300}),
&ctx,
)
.await
.unwrap_err();
assert!(error.to_string().contains("timed out"), "{error}");
let pids = std::fs::read_to_string(&pidfile).expect("shell must record both process ids");
let pids = pids
.split_whitespace()
.map(|pid| pid.parse::<libc::pid_t>().unwrap())
.collect::<Vec<_>>();
assert_eq!(pids.len(), 2);
let mut survivors = Vec::new();
for _ in 0..150 {
survivors = pids
.iter()
.copied()
.filter(|pid| unsafe { libc::kill(*pid, 0) == 0 })
.collect();
if survivors.is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
for pid in &survivors {
unsafe {
libc::kill(*pid, libc::SIGKILL);
}
}
assert!(
survivors.is_empty(),
"timed-out Bash process tree survived: {survivors:?}"
);
std::fs::remove_dir_all(dir).ok();
}
#[cfg(unix)]
#[tokio::test]
async fn cancelling_bash_kills_the_shell_and_its_worker_process_group() {
let dir = temp_dir("bash-cancel-process-group");
let pidfile = dir.join("pids");
let command = format!(
"sleep 3600 & worker=$!; echo \"$$ $worker\" > {}; wait",
pidfile.display()
);
let task_dir = dir.clone();
let task = tokio::spawn(async move {
BashTool::default()
.execute(
serde_json::json!({"command": command, "timeout_ms": 60_000}),
&ToolContext::new(task_dir),
)
.await
});
for _ in 0..100 {
if pidfile.exists() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let pids = std::fs::read_to_string(&pidfile)
.expect("shell must start before cancellation")
.split_whitespace()
.map(|pid| pid.parse::<libc::pid_t>().unwrap())
.collect::<Vec<_>>();
task.abort();
let _ = task.await;
let mut survivors = Vec::new();
for _ in 0..150 {
survivors = pids
.iter()
.copied()
.filter(|pid| unsafe { libc::kill(*pid, 0) == 0 })
.collect();
if survivors.is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
for pid in &survivors {
unsafe {
libc::kill(*pid, libc::SIGKILL);
}
}
assert!(
survivors.is_empty(),
"cancelled Bash process tree survived: {survivors:?}"
);
std::fs::remove_dir_all(dir).ok();
}
#[cfg(unix)]
#[test]
fn escaped_pipe_holder_helper() {
let Ok(pidfile) = std::env::var("SUPERCODE_ESCAPED_PIPE_PIDFILE") else {
return;
};
assert_ne!(
unsafe { libc::setsid() },
-1,
"helper must leave Bash's group"
);
std::fs::write(pidfile, std::process::id().to_string()).unwrap();
std::thread::sleep(std::time::Duration::from_secs(30));
}
#[cfg(unix)]
#[tokio::test]
async fn bash_deadline_still_bounds_a_descendant_that_escapes_with_its_pipe() {
fn quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
let dir = temp_dir("bash-escaped-pipe");
let pidfile = dir.join("escaped-pid");
let executable = std::env::current_exe().unwrap();
let command = format!(
"SUPERCODE_ESCAPED_PIPE_PIDFILE={} {} --exact escaped_pipe_holder_helper --nocapture & while [ ! -s {} ]; do sleep 0.01; done",
quote(&pidfile.to_string_lossy()),
quote(&executable.to_string_lossy()),
quote(&pidfile.to_string_lossy()),
);
let started = std::time::Instant::now();
let error = BashTool::default()
.execute(
serde_json::json!({"command": command, "timeout_ms": 500}),
&ToolContext::new(dir.clone()),
)
.await
.unwrap_err();
let elapsed = started.elapsed();
assert!(error.to_string().contains("timed out"), "{error}");
assert!(
elapsed < std::time::Duration::from_secs(2),
"pipe reader escaped the deadline: {elapsed:?}"
);
let escaped_pid = std::fs::read_to_string(&pidfile)
.unwrap()
.parse::<libc::pid_t>()
.unwrap();
unsafe {
libc::kill(escaped_pid, libc::SIGKILL);
}
std::fs::remove_dir_all(dir).ok();
}
#[cfg(unix)]
#[tokio::test]
async fn successful_bash_exit_reaps_its_background_worker() {
let dir = temp_dir("bash-success-background");
let pidfile = dir.join("worker-pid");
let output = BashTool::default()
.execute(
serde_json::json!({
"command": format!("sleep 3600 & echo $! > {}", pidfile.display()),
"timeout_ms": 2_000
}),
&ToolContext::new(dir.clone()),
)
.await
.unwrap();
assert_eq!(output, "exit code: 0\n(no output)");
let worker_pid = std::fs::read_to_string(&pidfile)
.unwrap()
.trim()
.parse::<libc::pid_t>()
.unwrap();
for _ in 0..100 {
if unsafe { libc::kill(worker_pid, 0) } != 0 {
std::fs::remove_dir_all(dir).ok();
return;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
unsafe {
libc::kill(worker_pid, libc::SIGKILL);
}
panic!("successful Bash left background worker {worker_pid} alive");
}
#[tokio::test]
async fn bash_preserves_combined_output_and_exit_code_format() {
let dir = temp_dir("bash-output-compat");
let output = BashTool::default()
.execute(
serde_json::json!({
"command": "printf stdout; printf stderr >&2; exit 7",
"timeout_ms": 2_000
}),
&ToolContext::new(dir.clone()),
)
.await
.unwrap();
assert_eq!(output, "exit code: 7\nstdout\nstderr");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn bash_timeout_secs_reaches_tool_context_through_the_full_config_chain() {
let harness: supercode_harness::HarnessConfig =
supercode_harness::HarnessConfig::from_toml_str(
r#"
schema_version = 1
[core.tools.bash]
timeout_secs = 45
"#,
)
.unwrap();
let config = harness.resolve_core();
assert_eq!(
config
.tool_overrides
.get("bash")
.and_then(|o| o.timeout_secs),
Some(45)
);
}
#[test]
fn context_injections_default_off_is_a_no_op() {
let config = Config::builder()
.cwd(temp_dir("ctx-inj-default"))
.context_injection_block("nudge", "stay focused")
.build();
let agent = Agent::with_provider(config, Box::new(NoopProvider));
assert!(!agent.history()[0]
.content
.as_deref()
.unwrap_or("")
.contains("stay focused"));
}
#[test]
fn context_injections_on_appends_named_blocks_in_order() {
let config = Config::builder()
.cwd(temp_dir("ctx-inj-on"))
.context_injections(true)
.context_injection_block("first", "alpha content")
.context_injection_block("second", "beta content")
.build();
let agent = Agent::with_provider(config, Box::new(NoopProvider));
let system = agent.history()[0].content.clone().unwrap_or_default();
assert!(system.contains("# first\nalpha content"), "{system}");
assert!(system.contains("# second\nbeta content"), "{system}");
let first_pos = system.find("# first").unwrap();
let second_pos = system.find("# second").unwrap();
assert!(first_pos < second_pos, "blocks must appear in list order");
}
#[test]
fn context_injections_on_with_no_blocks_is_still_a_no_op() {
let config = Config::builder()
.cwd(temp_dir("ctx-inj-empty"))
.context_injections(true)
.build();
let agent = Agent::with_provider(config, Box::new(NoopProvider));
let system = agent.history()[0].content.clone().unwrap_or_default();
assert!(!system.contains('#') || !system.contains("\n\n#"));
}
struct NoopProvider;
#[async_trait]
impl Provider for NoopProvider {
async fn complete(
&self,
_req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode_harness::Result<(ChatMessage, Usage)> {
Ok((ChatMessage::assistant("done"), Usage::default()))
}
}
fn write_long_transcript(dir: &std::path::Path, n: usize) -> std::path::PathBuf {
let mut lines = String::new();
lines.push_str(&serde_json::to_string(&ChatMessage::system("SYS")).unwrap());
lines.push('\n');
for i in 0..n {
lines.push_str(&serde_json::to_string(&ChatMessage::user(format!("msg {i}"))).unwrap());
lines.push('\n');
}
let path = dir.join("hist.jsonl");
std::fs::write(&path, lines).unwrap();
path
}
#[tokio::test]
async fn compaction_enabled_default_true_preserves_existing_trigger_behavior() {
let dir = temp_dir("compaction-default");
let tpath = write_long_transcript(&dir, 10);
let config = Config::builder().cwd(dir).compact_after_messages(6).build();
assert!(config.compaction_enabled, "default must be true");
let mut agent = Agent::with_provider(config, Box::new(NoopProvider));
agent.load_transcript(&tpath).unwrap();
assert!(
agent.maybe_compact(),
"trigger should still fire by default"
);
}
#[tokio::test]
async fn compaction_enabled_false_suppresses_every_trigger() {
let dir = temp_dir("compaction-off");
let tpath = write_long_transcript(&dir, 10);
let mut config = Config::builder().cwd(dir).compact_after_messages(6).build();
config.compaction_enabled = false;
let mut agent = Agent::with_provider(config, Box::new(NoopProvider));
agent.load_transcript(&tpath).unwrap();
assert!(
!agent.maybe_compact(),
"master gate off must suppress the message-count trigger too"
);
}
#[tokio::test]
async fn compaction_enabled_false_suppresses_pressure_trigger_too() {
let dir = temp_dir("compaction-off-pressure");
let tpath = write_long_transcript(&dir, 3);
let mut config = Config::builder().cwd(dir).build();
config.compaction_reserve_tokens = Some(1_000_000_000); config.compaction_enabled = false;
let mut agent = Agent::with_provider(config, Box::new(NoopProvider));
agent.load_transcript(&tpath).unwrap();
assert!(!agent.maybe_compact());
}
struct TwoToolCallProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for TwoToolCallProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode_harness::Result<(ChatMessage, Usage)> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
let msg = ChatMessage {
role: Role::Assistant,
content: None,
content_parts: None,
tool_calls: Some(vec![
ToolCall {
id: "c1".into(),
kind: "function".into(),
function: FunctionCall {
name: "bash".into(),
arguments: r#"{"command":"echo one"}"#.into(),
},
},
ToolCall {
id: "c2".into(),
kind: "function".into(),
function: FunctionCall {
name: "bash".into(),
arguments: r#"{"command":"echo two"}"#.into(),
},
},
]),
tool_call_id: None,
name: None,
metadata: Default::default(),
};
Ok((msg, Usage::default()))
} else {
let tool_msgs: Vec<&ChatMessage> = req
.messages
.iter()
.filter(|m| m.role == Role::Tool)
.collect();
assert_eq!(tool_msgs.len(), 2);
assert!(tool_msgs[0]
.content
.as_deref()
.unwrap_or("")
.contains("one"));
assert!(tool_msgs[1]
.content
.as_deref()
.unwrap_or("")
.contains("two"));
assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("c1"));
assert_eq!(tool_msgs[1].tool_call_id.as_deref(), Some("c2"));
Ok((ChatMessage::assistant("saw both"), Usage::default()))
}
}
}
#[tokio::test]
async fn parallel_tool_calls_default_off_runs_sequentially_and_preserves_order() {
let dir = temp_dir("ptc-default");
let config = Config::builder().cwd(dir).build();
assert!(!config.parallel_tool_calls, "default must be false");
let mut agent = Agent::with_provider(
config,
Box::new(TwoToolCallProvider {
calls: AtomicUsize::new(0),
}),
);
let reply = agent.send("go").await.unwrap();
assert_eq!(reply, "saw both");
}
#[tokio::test]
async fn parallel_tool_calls_on_runs_concurrently_and_preserves_order() {
let dir = temp_dir("ptc-on");
let mut config = Config::builder().cwd(dir).build();
config.parallel_tool_calls = true;
let mut agent = Agent::with_provider(
config,
Box::new(TwoToolCallProvider {
calls: AtomicUsize::new(0),
}),
);
let reply = agent.send("go").await.unwrap();
assert_eq!(reply, "saw both");
}
#[tokio::test]
async fn parallel_tool_calls_on_actually_overlaps_independent_execution() {
let dir = temp_dir("ptc-timing");
let mut config = Config::builder().cwd(dir).build();
config.parallel_tool_calls = true;
struct SleepTwiceProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for SleepTwiceProvider {
async fn complete(
&self,
_req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode_harness::Result<(ChatMessage, Usage)> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
let msg = ChatMessage {
role: Role::Assistant,
content: None,
content_parts: None,
tool_calls: Some(vec![
ToolCall {
id: "c1".into(),
kind: "function".into(),
function: FunctionCall {
name: "bash".into(),
arguments: r#"{"command":"sleep 0.4"}"#.into(),
},
},
ToolCall {
id: "c2".into(),
kind: "function".into(),
function: FunctionCall {
name: "bash".into(),
arguments: r#"{"command":"sleep 0.4"}"#.into(),
},
},
]),
tool_call_id: None,
name: None,
metadata: Default::default(),
};
Ok((msg, Usage::default()))
} else {
Ok((ChatMessage::assistant("done"), Usage::default()))
}
}
}
let mut agent = Agent::with_provider(
config,
Box::new(SleepTwiceProvider {
calls: AtomicUsize::new(0),
}),
);
let start = std::time::Instant::now();
agent.send("go").await.unwrap();
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(750),
"two 400ms sleeps should overlap when parallel_tool_calls is on, took {elapsed:?}"
);
}
#[test]
fn parallel_tool_calls_reaches_config_through_the_full_config_chain() {
let harness: supercode_harness::HarnessConfig =
supercode_harness::HarnessConfig::from_toml_str(
r#"
schema_version = 1
[core]
parallel_tool_calls = true
"#,
)
.unwrap();
let config = harness.resolve_core();
assert!(config.parallel_tool_calls);
}
#[test]
fn tool_override_timeout_secs_defaults_to_none() {
assert_eq!(ToolOverride::default().timeout_secs, None);
}
#[test]
fn context_injection_block_carries_name_and_content() {
let block = ContextInjectionBlock::new("n", "c");
assert_eq!(block.name, "n");
assert_eq!(block.content, "c");
}
#[tokio::test]
async fn default_registry_still_has_a_working_bash_tool() {
let registry = ToolRegistry::with_builtins();
assert!(registry.get("bash").is_some());
}
#[test]
fn session_git_metadata_default_off_never_captures() {
let config = Config::builder().cwd(temp_dir("git-meta-default")).build();
assert!(!config.session_git_metadata);
let agent = Agent::with_provider(config, Box::new(NoopProvider));
assert!(agent.git_metadata().is_none());
}
#[test]
fn session_git_metadata_on_captures_and_persists_losslessly() {
let cwd = std::env::current_dir().unwrap();
let mut dir = cwd.as_path();
loop {
if dir.join(".git").exists() {
break;
}
match dir.parent() {
Some(p) => dir = p,
None => return, }
}
let config = Config::builder()
.cwd(dir.to_path_buf())
.session_git_metadata(true)
.build();
let agent = Agent::with_provider(config, Box::new(NoopProvider));
let Some(record) = agent.git_metadata() else {
return; };
assert!(record.branch.is_some());
let store_dir = temp_dir("git-meta-store");
let store = supercode_harness::SessionStore::open(&store_dir).unwrap();
store.save("sess", "t", "[]").unwrap();
agent.save_git_metadata(&store, "sess").unwrap();
let loaded = store.load_git_metadata("sess").unwrap();
assert_eq!(loaded.as_ref(), Some(record));
}
#[test]
fn session_export_format_reaches_config_through_the_full_config_chain() {
let harness: supercode_harness::HarnessConfig =
supercode_harness::HarnessConfig::from_toml_str(
r#"
schema_version = 1
[core.session]
export_format = "html"
"#,
)
.unwrap();
let config = harness.resolve_core();
assert_eq!(
config.session_export_format,
supercode_harness::human_export::HumanExportFormat::Html
);
}
#[test]
fn session_export_format_default_is_text() {
let config = Config::builder().build();
assert_eq!(
config.session_export_format,
supercode_harness::human_export::HumanExportFormat::Text
);
}
#[test]
fn session_dir_persist_name_retention_days_reach_config_through_the_full_config_chain() {
let harness: supercode_harness::HarnessConfig =
supercode_harness::HarnessConfig::from_toml_str(
r#"
schema_version = 1
[core.session]
dir = "/tmp/custom-sessions"
persist = false
name = "my-session"
retention_days = 14
"#,
)
.unwrap();
let config = harness.resolve_core();
assert_eq!(config.session_dir.as_deref(), Some("/tmp/custom-sessions"));
assert!(!config.session_persist);
assert_eq!(config.session_name.as_deref(), Some("my-session"));
assert_eq!(config.session_retention_days, Some(14));
}
#[test]
fn session_persist_defaults_to_true() {
assert!(Config::builder().build().session_persist);
}