mod common;
use common::{GateModel, count_lines, run_salvor, text_response, tool_use_response, write_agent};
use serde_json::{Value, json};
use tempfile::tempdir;
fn run_id_from(stdout: &str) -> String {
stdout
.lines()
.next()
.and_then(|line| line.strip_prefix("run "))
.expect("run prints its id first")
.trim()
.to_owned()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_then_list_history_replay() {
let dir = tempdir().expect("tempdir");
let store = dir.path().join("salvor.db");
let count_file = dir.path().join("count.txt");
let model = GateModel::mount(vec![
(
1,
tool_use_response("tu_1", "record", json!({"line": "otters"}), 100, 20),
),
(3, text_response("all done", 150, 30)),
])
.await;
let agent = write_agent(dir.path(), &model.uri(), &count_file, "");
let output = run_salvor(
&store,
&[
"run",
"--agent",
agent.to_str().unwrap(),
"--input",
"\"research otters\"",
],
)
.await;
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "run exits 0: {output:?}");
assert!(
stdout.contains("all done"),
"final output printed: {stdout}"
);
let run_id = run_id_from(&stdout);
assert_eq!(count_lines(&count_file), 1, "record executed exactly once");
let list = run_salvor(&store, &["list"]).await;
let list_out = String::from_utf8_lossy(&list.stdout);
assert!(list.status.success());
assert!(list_out.contains(&run_id), "list names the run: {list_out}");
assert!(
list_out.contains("completed"),
"list shows status: {list_out}"
);
let history = run_salvor(&store, &["history", &run_id]).await;
let history_out = String::from_utf8_lossy(&history.stdout);
assert!(history.status.success());
for expected in [
"RunStarted",
"ModelCallRequested",
"ModelCallCompleted",
"ToolCallRequested",
"record",
"Write",
"ToolCallCompleted",
"RunCompleted",
] {
assert!(
history_out.contains(expected),
"history should contain `{expected}`: {history_out}"
);
}
let history_json = run_salvor(&store, &["history", &run_id, "--json"]).await;
let json_out = String::from_utf8_lossy(&history_json.stdout);
let parsed: Value = serde_json::from_str(&json_out).expect("history --json is JSON");
assert!(parsed.is_array(), "history --json is an array");
assert!(json_out.contains("ModelCallCompleted"));
let replay = run_salvor(&store, &["replay", &run_id, "--dry-run"]).await;
let replay_out = String::from_utf8_lossy(&replay.stdout);
assert!(replay.status.success());
assert!(
replay_out.contains("completed"),
"replay status: {replay_out}"
);
assert!(
replay_out.contains("250"),
"input token total: {replay_out}"
);
assert!(
replay_out.contains("50"),
"output token total: {replay_out}"
);
assert!(replay_out.contains("pending: none"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_emits_progress_on_stderr() {
let dir = tempdir().expect("tempdir");
let store = dir.path().join("salvor.db");
let count_file = dir.path().join("count.txt");
let model = GateModel::mount(vec![(1, text_response("done", 10, 5))]).await;
let agent = write_agent(dir.path(), &model.uri(), &count_file, "");
let store2 = store.clone();
let agent_path = agent.to_str().unwrap().to_owned();
let output = tokio::task::spawn_blocking(move || {
let mut command = assert_cmd::Command::cargo_bin("salvor").expect("salvor binary builds");
command
.arg("--store")
.arg(&store2)
.env("RUST_LOG", "info")
.args(["run", "--agent", &agent_path, "--input", "\"hi\""]);
command.output().expect("salvor runs")
})
.await
.expect("join");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("starting run"),
"lifecycle logged: {stderr}"
);
assert!(
stderr.contains("seq="),
"seq correlation field present: {stderr}"
);
assert!(
stderr.contains("RunStarted"),
"per-event progress: {stderr}"
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("run "));
assert!(stdout.contains("done"));
}