use std::future::IntoFuture;
use std::process::Command;
use trusty_agents_common::connectors::{
AgentSpec, BackendParams, ConnectorTestKit, CreateSessionReq,
};
use super::*;
struct HomeGuard(Option<String>);
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.0 {
Some(ref p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
fn set_home(home: &std::path::Path) -> HomeGuard {
let prior = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", home) };
HomeGuard(prior)
}
async fn spawn_test_daemon() -> (tempfile::TempDir, String) {
use crate::daemon::{api, state::DaemonState};
let root_guard = crate::test_support::hermetic_temp_dir();
let state = std::sync::Arc::new(
DaemonState::with_root_isolated_managed(root_guard.path().to_owned()).await,
);
let router = api::router(std::sync::Arc::clone(&state));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(
axum::serve(
listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.into_future(),
);
(root_guard, format!("http://{addr}"))
}
async fn spawn_slow_stub_daemon(delay: std::time::Duration) -> String {
use axum::routing::post;
let spawn_body = serde_json::json!({
"id": "11111111-1111-1111-1111-111111111111",
"name": "stub-session",
"state": "active",
});
let router = axum::Router::new().route(
"/api/v1/sessions/managed",
post(move || async move {
tokio::time::sleep(delay).await;
axum::Json(spawn_body)
})
.get(move || async move {
tokio::time::sleep(delay).await;
axum::Json(serde_json::json!({ "sessions": [] }))
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, router).into_future());
format!("http://{addr}")
}
fn client_bounded_at(bound: std::time::Duration) -> reqwest::Client {
reqwest::Client::builder()
.timeout(bound)
.build()
.expect("build bounded test client")
}
#[tokio::test]
async fn create_session_outlives_the_default_request_timeout() {
let url = spawn_slow_stub_daemon(std::time::Duration::from_millis(400)).await;
let connector = TmConnector::with_client(
client_bounded_at(std::time::Duration::from_millis(100)),
url,
);
let req = CreateSessionReq {
task: "provision me".into(),
name_hint: None,
agent: None,
backend: BackendParams::Tm {
repo_url: "file:///dev/null".into(),
git_ref: "main".into(),
runtime: None,
ephemeral: true,
},
};
let info = connector.create_session(req).await.expect(
"create_session must apply the provisioning timeout override, not the \
client-level default",
);
assert_eq!(info.id, "11111111-1111-1111-1111-111111111111");
}
#[tokio::test]
async fn list_sessions_is_bound_by_the_client_level_timeout() {
let url = spawn_slow_stub_daemon(std::time::Duration::from_millis(400)).await;
let connector = TmConnector::with_client(
client_bounded_at(std::time::Duration::from_millis(100)),
url,
);
let err = connector
.list_sessions()
.await
.expect_err("a 400ms response must not beat a 100ms client-level bound");
match err {
ConnectorError::Transport(ref msg) => assert!(
!msg.starts_with("failed to decode daemon response"),
"expected a transport-level timeout, got a decode failure: {msg}"
),
other => panic!("expected ConnectorError::Transport, got {other:?}"),
}
}
const REPOS_ROOT_ENV: &str = "TRUSTY_MPM_REPOS_ROOT";
fn managed_checkout_in(root: &std::path::Path, owner: &str, repo: &str) -> String {
let dir = root.join(owner).join(repo);
std::fs::create_dir_all(&dir).expect("managed checkout dir");
let origin = format!("https://github.com/{owner}/{repo}");
let setup: Vec<Vec<&str>> = vec![
vec!["init", "-q", "-b", "main"],
vec!["config", "user.email", "connector-test@example.com"],
vec!["config", "user.name", "connector-test"],
vec!["remote", "add", "origin", &origin],
];
for args in setup {
let out = Command::new("git")
.args(&args)
.current_dir(&dir)
.output()
.expect("git");
assert!(out.status.success(), "git {args:?} must succeed");
}
std::fs::write(dir.join("README.md"), "seed").expect("write seed file");
for args in [vec!["add", "."], vec!["commit", "-q", "-m", "seed"]] {
let out = Command::new("git")
.args(&args)
.current_dir(&dir)
.output()
.expect("git");
assert!(out.status.success(), "git {args:?} must succeed");
}
dir.to_string_lossy().into_owned()
}
#[tokio::test]
async fn create_session_wrong_backend_params_is_invalid_request() {
let connector = TmConnector::with_daemon_url("http://127.0.0.1:0");
let req = CreateSessionReq {
task: "irrelevant".into(),
name_hint: None,
agent: None,
backend: BackendParams::Tcode {
project: std::path::PathBuf::from("/tmp/proj"),
},
};
let err = connector.create_session(req).await.unwrap_err();
assert!(
matches!(err, ConnectorError::InvalidRequest(_)),
"expected InvalidRequest, got {err:?}"
);
}
#[tokio::test]
async fn list_sessions_empty_fleet_returns_empty_vec() {
let (_daemon_root, url) = spawn_test_daemon().await;
let connector = TmConnector::with_daemon_url(url);
let sessions = connector.list_sessions().await.expect("list_sessions");
assert!(sessions.is_empty(), "fresh daemon must have no sessions");
}
#[tokio::test]
async fn session_status_unknown_id_is_not_found() {
let (_daemon_root, url) = spawn_test_daemon().await;
let connector = TmConnector::with_daemon_url(url);
ConnectorTestKit::assert_status_not_found_for_unknown_id(
&connector,
"00000000-0000-0000-0000-000000000000",
)
.await;
}
#[tokio::test]
async fn send_input_unknown_id_is_not_found() {
let (_daemon_root, url) = spawn_test_daemon().await;
let connector = TmConnector::with_daemon_url(url);
ConnectorTestKit::assert_send_not_found_for_unknown_id(
&connector,
"00000000-0000-0000-0000-000000000000",
)
.await;
}
#[tokio::test]
async fn attach_unknown_id_is_not_found() {
let (_daemon_root, url) = spawn_test_daemon().await;
let connector = TmConnector::with_daemon_url(url);
let err = connector
.attach("00000000-0000-0000-0000-000000000000")
.await
.unwrap_err();
assert!(err.is_not_found(), "expected NotFound, got {err:?}");
}
#[tokio::test]
async fn delegate_unknown_session_is_backend_error() {
let (_daemon_root, url) = spawn_test_daemon().await;
let connector = TmConnector::with_daemon_url(url);
let spec = AgentSpec {
agent_name: "research".into(),
task: "find the bug".into(),
tier: None,
};
let err = connector
.delegate("00000000-0000-0000-0000-000000000000", &spec)
.await
.unwrap_err();
assert!(
matches!(err, ConnectorError::Backend(_)),
"expected Backend error for an unknown session, got {err:?}"
);
}
#[tokio::test]
#[serial_test::serial]
async fn create_session_full_lifecycle() {
let (_daemon_root, url) = spawn_test_daemon().await;
let connector = TmConnector::with_daemon_url(url.clone());
let prev_workspace_root =
std::env::var(crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV).ok();
let prev_repos_root = std::env::var(REPOS_ROOT_ENV).ok();
unsafe {
std::env::set_var(
crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV,
_daemon_root.path(),
);
std::env::set_var(REPOS_ROOT_ENV, _daemon_root.path());
}
let repo_url = managed_checkout_in(_daemon_root.path(), "an-owner", "a-repo");
let _home_guard = set_home(_daemon_root.path());
let req = CreateSessionReq {
task: "list files".into(),
name_hint: None,
agent: None,
backend: BackendParams::Tm {
repo_url,
git_ref: "main".into(),
runtime: None,
ephemeral: true,
},
};
let info = connector
.create_session(req)
.await
.expect("create_session must succeed for a well-formed request");
assert!(
!info.id.is_empty(),
"create_session must return a non-empty session id"
);
let listed = connector
.list_sessions()
.await
.expect("list_sessions must succeed");
assert!(
listed.iter().any(|s| s.id == info.id),
"list_sessions must include the just-created session {}",
info.id
);
let status = connector
.session_status(&info.id)
.await
.expect("session_status must succeed for a known id");
assert_eq!(
status.id, info.id,
"session_status must echo back the id it was asked about"
);
let raw: ManagedSessionSummary = reqwest::Client::new()
.get(format!("{url}/api/v1/sessions/managed/{}", info.id))
.send()
.await
.expect("raw status GET must succeed")
.json()
.await
.expect("raw status GET must decode as ManagedSessionSummary");
let persisted_state = raw.persisted_state.as_deref().unwrap_or(&raw.state);
match persisted_state {
"active" => {
connector
.send_input(&info.id, "echo hello")
.await
.expect("send_input must succeed for an Active session");
}
other => {
let err = connector
.send_input(&info.id, "echo hello")
.await
.expect_err(&format!(
"send_input must be refused for a non-Active persisted state \
({other}), never silently accepted"
));
assert!(
matches!(err, ConnectorError::Backend(_)),
"expected a Backend refusal for persisted state {other:?}, got {err:?}"
);
}
}
unsafe {
match prev_repos_root {
Some(v) => std::env::set_var(REPOS_ROOT_ENV, v),
None => std::env::remove_var(REPOS_ROOT_ENV),
}
match prev_workspace_root {
Some(v) => std::env::set_var(crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV, v),
None => std::env::remove_var(crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV),
}
}
assert_eq!(
info.task.as_deref(),
Some("list files"),
"create_session must echo back the task"
);
let attach = connector.attach(&info.id).await.expect("attach");
match attach {
AttachHandle::ShellCommand(cmd) => {
assert!(
cmd.contains("tmux attach"),
"tm attach handle must be a tmux attach command, got {cmd:?}"
);
}
other => panic!("tm connector must return ShellCommand, got {other:?}"),
}
let spec = AgentSpec {
agent_name: "research".into(),
task: "find the bug".into(),
tier: Some("haiku".into()),
};
let handle = connector
.delegate(&info.id, &spec)
.await
.expect("delegate must succeed against a live session");
assert!(
!handle.delegate_id.is_empty(),
"delegate must return a non-empty delegate_id"
);
}