use std::sync::{Arc, Mutex};
mod common;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
const NEMOTRON_MODEL: &str = "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16";
struct CaptureThenFinish {
requests: Arc<Mutex<Vec<serde_json::Value>>>,
}
impl Respond for CaptureThenFinish {
fn respond(&self, request: &Request) -> ResponseTemplate {
let body: serde_json::Value =
serde_json::from_slice(&request.body).expect("chat request is JSON");
self.requests
.lock()
.expect("request capture lock")
.push(body);
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"model": NEMOTRON_MODEL,
"choices": [{
"message": {"role": "assistant", "content": "done"},
"finish_reason": "stop"
}]
}))
}
}
fn write_solve_config(dir: &std::path::Path, uri: &str) -> std::path::PathBuf {
let config_path = dir.join("timer.toml");
std::fs::write(
&config_path,
format!(
r#"default_backend = "nemotron"
[[backends]]
name = "nemotron"
endpoint = "{uri}"
model = "{NEMOTRON_MODEL}"
kind = "openai"
api = "chat_completions"
[backends.capability]
reasoning_replay_scope = "current_user_turn"
[backends.capability.chat_completions]
cognition = true
chat_template_kwargs = true
parallel_tool_calls = false
bounded_reasoning_continuation = true
"#
),
)
.expect("write explicit headless config");
config_path
}
#[test]
fn every_zero_is_rejected_cleanly() {
let fixture = common::isolated_root();
common::newt()
.args(["timer", "schedule", "5m", "watch the pipeline"])
.arg("--every")
.arg("0")
.arg("--dir")
.arg(fixture.path())
.assert()
.failure()
.stderr(predicates::str::contains("greater than zero"));
let timers: Vec<serde_json::Value> = serde_json::from_str(
&std::fs::read_to_string(fixture.path().join("timers.json")).unwrap_or_default(),
)
.unwrap_or_default();
assert!(timers.is_empty(), "no timer persisted for --every 0");
}
#[tokio::test(flavor = "multi_thread")]
async fn fire_run_reaches_solve_entry_point() {
let server = MockServer::start().await;
let requests = Arc::new(Mutex::new(Vec::new()));
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(CaptureThenFinish {
requests: requests.clone(),
})
.mount(&server)
.await;
let fixture = common::isolated_root();
let config_path = write_solve_config(fixture.path(), &server.uri());
let prompt = "check PR #1747 CI is green";
common::newt()
.arg("--config")
.arg(&config_path)
.args(["timer", "schedule", "0s", prompt])
.arg("--dir")
.arg(fixture.path())
.current_dir(fixture.path())
.assert()
.success();
common::newt()
.arg("--config")
.arg(&config_path)
.args(["timer", "fire", "--run"])
.arg("--dir")
.arg(fixture.path())
.current_dir(fixture.path())
.assert()
.success();
let requests = requests.lock().expect("request capture lock");
assert_eq!(
requests.len(),
1,
"the scheduled prompt must reach the headless entry point (one chat turn)"
);
let user_content: Vec<&str> = requests[0]["messages"]
.as_array()
.expect("messages array")
.iter()
.filter(|m| m["role"] == "user")
.filter_map(|m| m["content"].as_str())
.collect();
assert!(
user_content.iter().any(|c| c.contains(prompt)),
"scheduled prompt must reach the wire as the headless instruction: {user_content:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn fire_run_drains_all_due_timers() {
let server = MockServer::start().await;
let requests = Arc::new(Mutex::new(Vec::new()));
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(CaptureThenFinish {
requests: requests.clone(),
})
.mount(&server)
.await;
let fixture = common::isolated_root();
let config_path = write_solve_config(fixture.path(), &server.uri());
let prompts = ["check build A", "check build B", "check build C"];
for p in &prompts {
common::newt()
.arg("--config")
.arg(&config_path)
.args(["timer", "schedule", "0s", p])
.arg("--dir")
.arg(fixture.path())
.current_dir(fixture.path())
.assert()
.success();
}
common::newt()
.arg("--config")
.arg(&config_path)
.args(["timer", "fire", "--run"])
.arg("--dir")
.arg(fixture.path())
.current_dir(fixture.path())
.assert()
.success();
let requests = requests.lock().expect("request capture lock");
assert_eq!(
requests.len(),
prompts.len(),
"every due timer must reach the headless entry point — none silently lost"
);
let seen: Vec<String> = requests
.iter()
.flat_map(|r| {
r["messages"]
.as_array()
.expect("messages array")
.iter()
.filter(|m| m["role"] == "user")
.filter_map(|m| m["content"].as_str().map(String::from))
})
.collect();
for p in &prompts {
assert!(
seen.iter().any(|c| c.contains(p)),
"prompt {p:?} must reach the wire: {seen:?}"
);
}
let timers: Vec<serde_json::Value> = serde_json::from_str(
&std::fs::read_to_string(fixture.path().join("timers.json")).unwrap_or_default(),
)
.unwrap_or_default();
assert!(
timers.is_empty(),
"one-shots removed after success: {timers:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_scheduled_turn_is_not_exempt_from_the_self_verify_gate() {
let server = MockServer::start().await;
let requests = Arc::new(Mutex::new(Vec::new()));
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(CaptureThenFinish {
requests: requests.clone(),
})
.mount(&server)
.await;
let fixture = common::isolated_root();
let config_path = write_solve_config(fixture.path(), &server.uri());
std::fs::write(
fixture.path().join("Cargo.toml"),
"[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
)
.expect("write a manifest the gate can detect");
common::newt()
.arg("--config")
.arg(&config_path)
.args([
"timer",
"schedule",
"0s",
"Run `cargo test` to verify the parser.",
])
.arg("--dir")
.arg(fixture.path())
.current_dir(fixture.path())
.assert()
.success();
common::newt()
.arg("--config")
.arg(&config_path)
.args(["timer", "fire", "--run"])
.arg("--dir")
.arg(fixture.path())
.current_dir(fixture.path())
.assert()
.success();
let requests = requests.lock().expect("request capture lock");
assert_eq!(
requests.len(),
3,
"a scheduled turn that never attempts its requested `cargo test` is \
nudged twice and then accepted: 1 + SELF_VERIFY_CAP model calls, and \
the cap is what stops it being unbounded"
);
let nudged = requests[1]["messages"]
.as_array()
.expect("messages array")
.iter()
.filter_map(|m| m["content"].as_str())
.any(|c| c.contains("you have NOT run the verification this task ships"));
assert!(
nudged,
"the extra rounds must be the self-verify gate: {:?}",
requests[1]["messages"]
);
}