use std::future::IntoFuture;
use std::process::Command;
use tempfile::TempDir;
use trusty_agents_common::connectors::{
AgentSpec, BackendParams, ConnectorTestKit, CreateSessionReq,
};
use super::*;
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}"))
}
fn local_bare_repo() -> (TempDir, String) {
let scratch = crate::test_support::hermetic_temp_dir();
let bare = scratch.path().join("origin.git");
let work = scratch.path().join("seed");
assert!(
Command::new("git")
.args(["init", "--bare", "-b", "main"])
.arg(&bare)
.status()
.map(|s| s.success())
.unwrap_or(false),
"git init --bare must succeed"
);
assert!(
Command::new("git")
.args(["clone"])
.arg(&bare)
.arg(&work)
.status()
.map(|s| s.success())
.unwrap_or(false),
"git clone (seed checkout) must succeed"
);
std::fs::write(work.join("README.md"), "seed").expect("write seed file");
for args in [
vec!["-C", work.to_str().unwrap(), "add", "."],
vec![
"-C",
work.to_str().unwrap(),
"-c",
"user.email=connector-test@example.com",
"-c",
"user.name=connector-test",
"commit",
"-m",
"seed",
],
vec!["-C", work.to_str().unwrap(), "push", "origin", "main"],
] {
assert!(
Command::new("git")
.args(&args)
.status()
.map(|s| s.success())
.unwrap_or(false),
"git {args:?} must succeed"
);
}
let url = format!("file://{}", bare.display());
(scratch, url)
}
#[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 (_scratch, repo_url) = local_bare_repo();
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();
unsafe {
std::env::set_var(
crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV,
_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_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"
);
}