mod common;
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use common::{GateModel, count_lines, text_response, tool_use_response, write_agent};
use serde_json::json;
use tempfile::tempdir;
const SALVOR_BIN: &str = env!("CARGO_BIN_EXE_salvor");
fn drain(mut pipe: impl Read + Send + 'static) -> Arc<Mutex<String>> {
let buffer = Arc::new(Mutex::new(String::new()));
let sink = buffer.clone();
std::thread::spawn(move || {
let mut chunk = [0u8; 1024];
while let Ok(n) = pipe.read(&mut chunk) {
if n == 0 {
break;
}
sink.lock()
.unwrap()
.push_str(&String::from_utf8_lossy(&chunk[..n]));
}
});
buffer
}
#[tokio::test]
async fn run_streams_progress_to_stderr_during_the_drive() {
let dir = tempdir().expect("tempdir");
let store_path = dir.path().join("salvor.db");
let count_file = dir.path().join("count.txt");
let never = Arc::new(AtomicBool::new(false));
let model = GateModel::mount_gated(
vec![
(
1,
tool_use_response("tu_write", "record", json!({"line": "otters"}), 100, 20),
),
(3, text_response("published", 150, 30)),
],
Some(3),
never,
Duration::from_secs(30),
)
.await;
let agent = write_agent(dir.path(), &model.uri(), &count_file, "");
let agent_path = agent.to_str().unwrap().to_owned();
let mut child = Command::new(SALVOR_BIN)
.args(["--store", store_path.to_str().unwrap()])
.args([
"run",
"--agent",
&agent_path,
"--input",
"\"publish otters\"",
])
.env("RUST_LOG", "info")
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("spawn salvor run");
let stderr = drain(child.stderr.take().expect("piped stderr"));
let deadline = Instant::now() + Duration::from_secs(15);
loop {
if stderr.lock().unwrap().contains("ToolCallCompleted") {
break;
}
assert!(
Instant::now() < deadline,
"the write's progress line never streamed to stderr: {}",
stderr.lock().unwrap()
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
{
let captured = stderr.lock().unwrap();
for step in [
"RunStarted",
"ModelCallRequested",
"ModelCallCompleted",
"ToolCallRequested",
"ToolCallCompleted",
] {
assert!(
captured.contains(step),
"expected mid-run step `{step}` on stderr, got: {captured}"
);
}
assert!(
!captured.contains("RunCompleted"),
"RunCompleted must not appear while the final turn is still gated: {captured}"
);
assert!(
captured.contains("seq="),
"progress lines carry the seq correlation field: {captured}"
);
}
assert_eq!(count_lines(&count_file), 1, "the write executed once");
child.kill().expect("kill the run process");
child.wait().expect("reap the killed process");
}