use std::path::Path;
use std::time::Duration;
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, Command};
struct ChatChild {
child: Child,
stdin: ChildStdin,
stderr: BufReader<tokio::process::ChildStderr>,
_home: TempDir,
}
impl ChatChild {
async fn spawn(setup: impl FnOnce(&Path)) -> anyhow::Result<Self> {
let home = TempDir::new()?;
setup(home.path());
let cfg_path = home.path().join(".synaps-cli");
std::fs::create_dir_all(&cfg_path)?;
let config_file = cfg_path.join("config");
if !config_file.exists() {
std::fs::write(&config_file, "")?;
}
let bin = env!("CARGO_BIN_EXE_synaps");
let mut cmd = Command::new(bin);
cmd.arg("chat")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.env("HOME", home.path())
.env("SYNAPS_BASE_DIR", &cfg_path)
.env_remove("ANTHROPIC_API_KEY")
.env_remove("OPENAI_API_KEY")
.env_remove("GROQ_API_KEY")
.env_remove("CEREBRAS_API_KEY")
.env_remove("NVIDIA_API_KEY")
.env_remove("SAMBANOVA_API_KEY")
.env_remove("OPENROUTER_API_KEY")
.env_remove("GOOGLE_API_KEY")
.env_remove("DEEPINFRA_API_KEY")
.env_remove("DEEPINFRA_TOKEN")
.env_remove("HUGGINGFACE_API_KEY")
.env_remove("HF_TOKEN")
.env_remove("FIREWORKS_API_KEY")
.env_remove("HYPERBOLIC_API_KEY")
.env_remove("SCALEWAY_API_KEY")
.env_remove("SILICONFLOW_API_KEY")
.env_remove("TOGETHER_API_KEY")
.env_remove("CHUTES_API_KEY")
.env_remove("CODESTRAL_API_KEY")
.env_remove("PERPLEXITY_API_KEY")
.env_remove("PPLX_API_KEY");
let mut child = cmd.spawn()?;
let stdin = child.stdin.take().expect("piped stdin");
let stderr_raw = child.stderr.take().expect("piped stderr");
let stderr = BufReader::new(stderr_raw);
drop(child.stdout.take());
Ok(Self { child, stdin, stderr, _home: home })
}
async fn wait_stderr_line(
&mut self,
predicate: impl Fn(&str) -> bool,
timeout: Duration,
) -> anyhow::Result<String> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
anyhow::bail!("timeout waiting for expected stderr line");
}
let mut line = String::new();
let n = tokio::time::timeout(
remaining,
self.stderr.read_line(&mut line),
).await??;
if n == 0 {
anyhow::bail!("stderr closed before expected line appeared");
}
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
if predicate(trimmed) {
return Ok(trimmed.to_string());
}
}
}
async fn send(&mut self, text: &str) -> anyhow::Result<()> {
self.stdin.write_all(text.as_bytes()).await?;
self.stdin.flush().await?;
Ok(())
}
async fn wait_exit(&mut self, timeout: Duration) -> anyhow::Result<std::process::ExitStatus> {
Ok(tokio::time::timeout(timeout, self.child.wait()).await??)
}
}
#[tokio::test]
async fn piped_status_then_quit() -> anyhow::Result<()> {
let mut child = ChatChild::spawn(|_| {}).await?;
child.wait_stderr_line(|l| l.contains("synaps"), Duration::from_secs(15)).await?;
child.send("/status\n/quit\n").await?;
child
.wait_stderr_line(|l| l.contains("model:"), Duration::from_secs(5))
.await
.map_err(|_| anyhow::anyhow!("did not see 'model:' in stderr after /status — async stdin may be broken"))?;
let status = child.wait_exit(Duration::from_secs(5)).await
.map_err(|_| anyhow::anyhow!("/quit did not produce clean exit — process hung"))?;
assert!(status.code().is_some(), "process was killed by signal rather than exiting");
Ok(())
}
#[tokio::test]
async fn eof_exits_cleanly() -> anyhow::Result<()> {
let home = TempDir::new()?;
let cfg_path = home.path().join(".synaps-cli");
std::fs::create_dir_all(&cfg_path)?;
std::fs::write(cfg_path.join("config"), "")?;
let bin = env!("CARGO_BIN_EXE_synaps");
let output = tokio::time::timeout(
Duration::from_secs(15),
Command::new(bin)
.arg("chat")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.env("HOME", home.path())
.env("SYNAPS_BASE_DIR", &cfg_path)
.env_remove("ANTHROPIC_API_KEY")
.env_remove("OPENAI_API_KEY")
.output(),
)
.await
.map_err(|_| anyhow::anyhow!("process hung on EOF stdin — async stdin EOF handling broken"))??;
assert!(
output.status.code().is_some(),
"process was killed by signal on EOF"
);
Ok(())
}
#[tokio::test]
async fn crlf_trimmed_correctly() -> anyhow::Result<()> {
let mut child = ChatChild::spawn(|_| {}).await?;
child.wait_stderr_line(|l| l.contains("synaps"), Duration::from_secs(15)).await?;
child.send("/status\r\n/quit\r\n").await?;
child
.wait_stderr_line(|l| l.contains("model:"), Duration::from_secs(5))
.await
.map_err(|_| anyhow::anyhow!("CRLF not trimmed — /status not executed"))?;
let status = child.wait_exit(Duration::from_secs(5)).await
.map_err(|_| anyhow::anyhow!("process hung after CRLF /quit"))?;
assert!(status.code().is_some());
Ok(())
}
mod continuation_policy {
use agent_engine::engine::reactor::{
drain_event_queue, wake_action, WakeAction, AUTO_TURN_CAP,
};
use agent_engine::events::{EventQueue, types::Severity};
use std::sync::Arc;
fn user_msg(text: &str) -> agent_engine::SharedMessage {
Arc::new(serde_json::json!({"role": "user", "content": text}))
}
#[test]
fn run_turn_under_cap() {
let q = EventQueue::new(10);
q.push(agent_engine::events::types::Event::simple(
"test", "completion", Some(Severity::Medium),
)).unwrap();
let mut messages = vec![user_msg("hello")];
let mut pending: Vec<String> = Vec::new();
let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
let action = wake_action(&drained, &messages, false, true, 0);
assert_eq!(action, WakeAction::RunTurn);
}
#[test]
fn parks_at_cap() {
let q = EventQueue::new(10);
q.push(agent_engine::events::types::Event::simple(
"test", "completion", Some(Severity::Medium),
)).unwrap();
let mut messages = vec![user_msg("hello")];
let mut pending: Vec<String> = Vec::new();
let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
let action = wake_action(&drained, &messages, false, true, AUTO_TURN_CAP);
assert_eq!(action, WakeAction::Forward);
}
#[test]
fn cap_resets_under_new_user_input() {
let q = EventQueue::new(10);
q.push(agent_engine::events::types::Event::simple(
"test", "new-event", Some(Severity::Medium),
)).unwrap();
let mut messages = vec![user_msg("new user message after reset")];
let mut pending: Vec<String> = Vec::new();
let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
let action = wake_action(&drained, &messages, false, true, 0);
assert_eq!(action, WakeAction::RunTurn);
}
}