mod common;
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use agent_os_client::config::AgentOsConfig;
use agent_os_client::fs::MkdirOptions;
use agent_os_client::{AgentOs, CreateSessionOptions};
const LLMOCK_SENTINEL: &str = "PONG_FROM_LLMOCK";
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("repo root")
}
fn pi_module_cwd() -> Option<String> {
if let Ok(env) = std::env::var("AGENT_OS_PI_MODULE_CWD") {
if !env.is_empty() {
return Some(env);
}
}
let root = repo_root();
let in_repo_adapter = root.join("node_modules/@rivet-dev/agent-os-pi/dist/adapter.js");
in_repo_adapter
.is_file()
.then(|| root.to_string_lossy().into_owned())
}
struct LlmockServer {
child: Child,
url: String,
}
impl LlmockServer {
fn start() -> Self {
let root = repo_root();
let mut child = Command::new("node")
.arg(root.join("crates/client/tests/helpers/llmock-server.mjs"))
.current_dir(&root)
.env("LLMOCK_SENTINEL", LLMOCK_SENTINEL)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn host llmock server (is node on PATH?)");
let stdout = child.stdout.take().expect("llmock stdout");
let mut reader = BufReader::new(stdout);
let mut line = String::new();
loop {
line.clear();
let read = reader.read_line(&mut line).expect("read llmock stdout");
assert_ne!(read, 0, "llmock exited before printing its URL");
if let Some(url) = line.trim().strip_prefix("LLMOCK_URL=") {
return Self {
child,
url: url.to_string(),
};
}
}
}
fn port(&self) -> u16 {
self.url
.rsplit(':')
.next()
.and_then(|tail| tail.trim_end_matches('/').parse().ok())
.expect("parse llmock port")
}
}
impl Drop for LlmockServer {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[tokio::test]
async fn pi_session_create_prompt_close() {
if !common::sidecar_available() {
eprintln!("skipping pi_session_create_prompt_close: sidecar binary not built");
return;
}
let Some(module_cwd) = pi_module_cwd() else {
eprintln!(
"skipping pi_session_create_prompt_close: no built Pi adapter \
(build it, or set AGENT_OS_PI_MODULE_CWD)"
);
return;
};
let llmock = LlmockServer::start();
let url = llmock.url.clone();
let port = llmock.port();
common::ensure_sidecar_env();
let os = AgentOs::create(AgentOsConfig {
module_access_cwd: Some(module_cwd),
loopback_exempt_ports: vec![port],
..Default::default()
})
.await
.expect("create VM for pi prompt");
os.mkdir("/home/user/.pi/agent", MkdirOptions { recursive: true })
.await
.expect("mkdir .pi/agent");
let models = serde_json::json!({
"providers": { "anthropic": { "baseUrl": url, "apiKey": "mock-key" } }
})
.to_string();
os.write_file("/home/user/.pi/agent/models.json", models.as_str())
.await
.expect("write models.json");
os.mkdir("/home/user/workspace", MkdirOptions { recursive: true })
.await
.expect("mkdir workspace");
let mut env = BTreeMap::new();
env.insert("HOME".to_string(), "/home/user".to_string());
env.insert("ANTHROPIC_API_KEY".to_string(), "mock-key".to_string());
env.insert("ANTHROPIC_BASE_URL".to_string(), url.clone());
env.insert("PI_SKIP_VERSION_CHECK".to_string(), "1".to_string());
let session = os
.create_session(
"pi",
CreateSessionOptions {
cwd: Some("/home/user/workspace".to_string()),
env,
skip_os_instructions: true,
..Default::default()
},
)
.await
.expect("create_session(\"pi\") must succeed against a built Pi tree");
assert!(!session.session_id.is_empty(), "session id must be non-empty");
assert!(
os.list_sessions()
.iter()
.any(|s| s.session_id == session.session_id),
"created session must appear in list_sessions"
);
let result = tokio::time::timeout(
Duration::from_secs(60),
os.prompt(&session.session_id, "Reply with the sentinel."),
)
.await
.expect("prompt timed out")
.expect("prompt must succeed");
assert!(
result.text.contains(LLMOCK_SENTINEL),
"prompt response must contain the llmock sentinel; got: {:?}",
result.text
);
os.close_session(&session.session_id).ok();
}