use super::*;
use crate::core::paths::FrameworkPaths;
use crate::core::session::{ControlModel, Session, SessionStatus};
use axum::http::{HeaderMap, StatusCode};
use serial_test::serial;
fn hermetic_shared() -> (Arc<DaemonState>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir for hermetic DaemonState");
let paths = FrameworkPaths::under(dir.path());
let state = Arc::new(DaemonState::with_paths(&paths));
(state, dir)
}
fn state_with_session() -> (Arc<DaemonState>, SessionId) {
let (state, _dir) = hermetic_shared();
let id = SessionId::new();
let mut session = Session::new(id, "/tmp/p", ControlModel::Tmux, None);
session.status = SessionStatus::Active;
state.register_session(session);
(state, id)
}
#[tokio::test]
async fn health_reports_ok_status() {
let state = DaemonState::shared();
let Json(body) = health(State(state)).await;
assert_eq!(body.status, "ok");
}
#[tokio::test]
async fn health_reports_catalog_unknown_without_catalog() {
let tmp = tempfile::TempDir::new().unwrap();
let state = Arc::new(DaemonState::with_root(tmp.path().to_path_buf()));
let Json(body) = health(State(state)).await;
assert_eq!(body.status, "ok");
assert!(
body.catalog_unknown,
"no catalog synced → unknown: {body:?}"
);
assert!(!body.catalog_stale, "unknown must not be stale");
assert!(body.catalog_changes.is_empty());
}
#[tokio::test]
async fn health_response_serializes_supervised_field() {
let state = DaemonState::shared();
let Json(body) = health(State(state)).await;
assert!(
body.supervised,
"supervised defaults true before daemon_run sets it"
);
let value = serde_json::to_value(&body).expect("HealthResponse must serialize");
assert_eq!(
value.get("supervised"),
Some(&serde_json::Value::Bool(true)),
"wire shape must carry `supervised`: {value}"
);
}
#[tokio::test]
async fn health_publishes_no_degraded_subsystem_when_idle() {
let state = DaemonState::shared();
let Json(body) = health(State(state)).await;
assert!(
body.degraded.is_empty(),
"an idle daemon reports nothing degraded: {:?}",
body.degraded
);
let value = serde_json::to_value(&body).expect("HealthResponse must serialize");
assert_eq!(
value.get("degraded"),
Some(&serde_json::Value::Array(Vec::new())),
"wire shape must carry `degraded`: {value}"
);
}
#[tokio::test]
async fn health_response_serializes_launchd_supervision_field() {
let state = DaemonState::shared();
state.set_launchd_supervision("unknown");
let Json(body) = health(State(state)).await;
assert_eq!(body.launchd_supervision, "unknown");
let value = serde_json::to_value(&body).expect("HealthResponse must serialize");
assert_eq!(
value.get("launchd_supervision"),
Some(&serde_json::Value::String("unknown".into())),
"wire shape must carry `launchd_supervision`: {value}"
);
}
#[tokio::test]
async fn health_response_serializes_pid_field() {
let state = DaemonState::shared();
let Json(body) = health(State(state)).await;
assert_eq!(
body.pid,
std::process::id(),
"pid must identify the responding process"
);
let value = serde_json::to_value(&body).expect("HealthResponse must serialize");
assert_eq!(
value.get("pid").and_then(serde_json::Value::as_u64),
Some(u64::from(std::process::id())),
"wire shape must carry `pid`: {value}"
);
}
#[tokio::test]
async fn health_response_serializes_forced_field() {
let state = DaemonState::shared();
let Json(body) = health(State(state)).await;
assert!(
!body.unsupervised_forced,
"force flag defaults false before daemon_run sets it"
);
let value = serde_json::to_value(&body).expect("HealthResponse must serialize");
assert_eq!(
value.get("unsupervised_forced"),
Some(&serde_json::Value::Bool(false)),
"wire shape must carry `unsupervised_forced`: {value}"
);
}
#[tokio::test]
async fn health_response_serializes_build_id_field() {
let state = DaemonState::shared();
assert_eq!(
state.build_identity(),
"",
"empty until daemon_run records the startup fingerprint"
);
state.set_build_identity("1757731440:74125312");
let Json(body) = health(State(state)).await;
assert_eq!(body.build_id, "1757731440:74125312");
let value = serde_json::to_value(&body).expect("HealthResponse must serialize");
assert_eq!(
value.get("build_id"),
Some(&serde_json::Value::String("1757731440:74125312".to_owned())),
"wire shape must carry `build_id`: {value}"
);
}
#[tokio::test]
async fn health_response_serializes_version_field() {
let state = DaemonState::shared();
let Json(body) = health(State(state)).await;
assert_eq!(body.version, env!("CARGO_PKG_VERSION"));
let value = serde_json::to_value(&body).expect("HealthResponse must serialize");
assert_eq!(
value.get("version"),
Some(&serde_json::Value::String(
env!("CARGO_PKG_VERSION").to_owned()
)),
"wire shape must carry `version`: {value}"
);
}
#[tokio::test]
async fn current_project_found_and_missing() {
let state = DaemonState::shared();
let _ = register_project(
State(Arc::clone(&state)),
Json(RegisterProject {
path: "/work/demo".into(),
}),
)
.await;
let ok = current_project(
State(Arc::clone(&state)),
Query(CurrentProjectQuery {
path: "/work/demo".into(),
}),
)
.await;
assert!(ok.is_ok());
let err = current_project(
State(state),
Query(CurrentProjectQuery {
path: "/work/missing".into(),
}),
)
.await
.unwrap_err();
assert_eq!(err.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn discover_projects_returns_array() {
let state = DaemonState::shared();
let resp = discover_projects(State(state)).await;
for project in &resp.0.projects {
assert!(!project.path.is_empty());
}
}
#[tokio::test]
async fn register_session_associates_project() {
let state = DaemonState::shared();
let Json(body) = register_session(
State(Arc::clone(&state)),
Json(RegisterSession {
project: "/work/demo".into(),
project_path: Some("/work/demo".into()),
name: None,
workdir: None,
}),
)
.await
.expect("registration-only path succeeds");
let id = body.id.0.to_string();
let listed = state.list_sessions();
let session = listed
.iter()
.find(|s| s.id.0.to_string() == id)
.expect("session registered");
assert_eq!(session.project_path, Some(PathBuf::from("/work/demo")));
}
#[tokio::test]
async fn list_sessions_filters_by_project() {
let state = DaemonState::shared();
let _ = register_session(
State(Arc::clone(&state)),
Json(RegisterSession {
project: "/work/demo".into(),
project_path: Some("/work/demo".into()),
name: None,
workdir: None,
}),
)
.await;
let _ = register_session(
State(Arc::clone(&state)),
Json(RegisterSession {
project: "/work/other".into(),
project_path: Some("/work/other".into()),
name: None,
workdir: None,
}),
)
.await;
let Json(all) = list_sessions(State(Arc::clone(&state)), Query(SessionQuery::default())).await;
assert_eq!(all.sessions.len(), 2);
let Json(scoped) = list_sessions(
State(state),
Query(SessionQuery {
project: Some("/work/demo".into()),
}),
)
.await;
assert_eq!(scoped.sessions.len(), 1);
}
#[tokio::test]
async fn hook_relay_ingests_known_event() {
let (state, id) = state_with_session();
let post = HookPost {
session_id: id.0.to_string(),
event: HookEvent::PostToolUse,
payload: serde_json::json!({"tool": "Edit"}),
};
let result = ingest_hook(State(state.clone()), Json(post)).await;
assert!(result.is_ok());
assert_eq!(state.recent_hook_events().len(), 1);
}
#[tokio::test]
async fn register_and_remove_session() {
let state = DaemonState::shared();
let Json(body) = register_session(
State(state.clone()),
Json(RegisterSession {
project: "/tmp/new".into(),
project_path: None,
name: None,
workdir: None,
}),
)
.await
.expect("registration-only path succeeds");
let id = body.id.0.to_string();
assert_eq!(state.list_sessions().len(), 1);
assert!(
remove_session(State(state.clone()), Path(id.clone()))
.await
.is_ok()
);
let err = remove_session(State(state), Path(id)).await.unwrap_err();
assert_eq!(err.status(), StatusCode::NOT_FOUND);
}
fn register_legacy_record(state: &DaemonState, tmux_name: &str) -> SessionId {
let id = SessionId::new();
let mut session = Session::new(id, "/tmp/p", ControlModel::Tmux, None);
session.status = SessionStatus::Starting;
session.tmux_name = tmux_name.to_string();
state.register_session(session);
id
}
#[tokio::test]
async fn remove_session_by_name_removes_legacy_registry_record() {
let ws = std::path::PathBuf::from("/tmp/test-ws-7834-stop-by-name");
let (state, managed_id, tmux_name) = make_state_with_active_managed(ws).await;
let id = register_legacy_record(&state, &tmux_name);
let Json(body) = remove_session(State(Arc::clone(&state)), Path(tmux_name.clone()))
.await
.expect("stop by friendly name must resolve a legacy-registry record");
assert_eq!(
body.removed, tmux_name,
"the response echoes what was asked"
);
assert!(
state.session(id).is_none(),
"the legacy registry entry must be gone after stop-by-name"
);
let mgr = state.session_manager().await;
let record = mgr.get(&managed_id).await.expect("managed record readable");
assert_eq!(
record.state,
crate::session_manager::ManagedSessionState::Decommissioned,
"teardown must run against the resolved record's tmux_name"
);
}
#[tokio::test]
async fn remove_session_by_id_still_removes_legacy_registry_record() {
let (state, _dir) = hermetic_shared();
let id = register_legacy_record(&state, "tm-verify-clone");
let Json(body) = remove_session(State(Arc::clone(&state)), Path(id.0.to_string()))
.await
.expect("stop by uuid must still remove a legacy-registry record");
assert_eq!(body.removed, id.0.to_string());
assert!(state.session(id).is_none(), "the entry must be gone");
}
#[tokio::test]
async fn remove_session_unknown_name_is_404() {
let (state, _dir) = hermetic_shared();
let err = remove_session(State(state), Path("tm-no-such-session".to_string()))
.await
.unwrap_err();
assert_eq!(err.status(), StatusCode::NOT_FOUND);
assert!(
err.to_string().contains("tm-no-such-session"),
"the refusal must name the session: {err}"
);
}
#[tokio::test]
async fn get_session_returns_session() {
let (state, id) = state_with_session();
let Json(session) = get_session(State(state), Path(id.0.to_string()))
.await
.expect("known id resolves");
assert_eq!(session.id, id);
assert_eq!(session.workdir, "/tmp/p");
}
#[tokio::test]
async fn get_session_unknown_is_404() {
let state = DaemonState::shared();
let err = get_session(State(state), Path(SessionId::new().0.to_string()))
.await
.unwrap_err();
assert_eq!(err.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_session_malformed_id_is_400() {
let state = DaemonState::shared();
let err = get_session(State(state), Path("not-a-uuid".to_string()))
.await
.unwrap_err();
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn connect_session_registers_without_deploy() {
let state = DaemonState::shared();
let Json(body) = connect_session(
State(Arc::clone(&state)),
Json(RegisterSession {
project: "/tmp/connect".into(),
project_path: Some("/tmp/connect".into()),
name: Some("tmpm-connect".into()),
workdir: None,
}),
)
.await
.expect("connect registration succeeds");
assert_eq!(body.name, "tmpm-connect");
let listed = state.list_sessions();
let session = listed
.iter()
.find(|s| s.id == body.id)
.expect("session registered via connect");
assert_eq!(session.workdir, "/tmp/connect");
}
#[tokio::test]
async fn registered_session_has_friendly_tmux_name() {
let state = DaemonState::shared();
let Json(body) = register_session(
State(Arc::clone(&state)),
Json(RegisterSession {
project: "/tmp/friendly".into(),
project_path: None,
name: None,
workdir: None,
}),
)
.await
.expect("registration-only path succeeds");
let id = body.id.0.to_string();
let listed = state.list_sessions();
let session = listed
.iter()
.find(|s| s.id.0.to_string() == id)
.expect("session registered");
assert!(
session.tmux_name.starts_with("tm-"),
"friendly name: {}",
session.tmux_name
);
assert!(session.tmux_name.len() <= 25);
}
#[tokio::test]
async fn reap_sessions_returns_removed_count() {
let (state, _) = state_with_session();
let Json(body) = reap_sessions(State(Arc::clone(&state))).await;
let removed = body.removed;
assert!(removed <= 1, "at most the one test session is reaped");
assert_eq!(state.list_sessions().len(), 1 - removed);
}
#[tokio::test]
async fn spawn_session_without_claude_returns_422() {
let _claude = crate::daemon::services::tmux_service::set_claude_lookup_override(Some(None));
let state = DaemonState::shared();
let err = register_session(
State(Arc::clone(&state)),
Json(RegisterSession {
project: "/tmp/spawn-no-claude".into(),
project_path: Some("/tmp/spawn-no-claude".into()),
name: None,
workdir: Some("/tmp/spawn-no-claude".into()),
}),
)
.await
.expect_err("spawn mode without claude must error");
assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert!(
state.list_sessions().is_empty(),
"no session should be registered on 422"
);
}
#[tokio::test]
#[serial]
async fn spawn_session_without_tmux_returns_422_on_no_tmux_host() {
let _claude = crate::daemon::services::tmux_service::set_claude_lookup_override(Some(Some(
"/fake/claude".into(),
)));
let state = DaemonState::shared();
let outcome = register_session(
State(Arc::clone(&state)),
Json(RegisterSession {
project: "/tmp/spawn-no-tmux".into(),
project_path: Some("/tmp/spawn-no-tmux".into()),
name: Some("tmpm-spawn-test-no-tmux".into()),
workdir: Some("/tmp".into()),
}),
)
.await;
if crate::daemon::tmux::TmuxDriver::is_available() {
if let Ok(driver) = crate::daemon::tmux::TmuxDriver::discover() {
let _ = driver.kill_session("tmpm-spawn-test-no-tmux");
}
for s in state.list_sessions() {
state.remove_session(s.id);
}
let _ = outcome;
} else {
let err = outcome.expect_err("spawn mode without tmux must error");
assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert!(
state.list_sessions().is_empty(),
"no session should be registered on 422"
);
}
}
#[tokio::test]
async fn registration_only_path_ignores_missing_claude() {
let _claude = crate::daemon::services::tmux_service::set_claude_lookup_override(Some(None));
let state = DaemonState::shared();
let Json(body) = register_session(
State(Arc::clone(&state)),
Json(RegisterSession {
project: "/tmp/no-spawn".into(),
project_path: None,
name: None,
workdir: None,
}),
)
.await
.expect("registration-only path must succeed regardless of claude availability");
assert_eq!(state.list_sessions().len(), 1);
assert_eq!(state.list_sessions()[0].id, body.id);
}
#[tokio::test]
async fn register_session_returns_id_even_without_tmux() {
let state = DaemonState::shared();
let Json(body) = register_session(
State(Arc::clone(&state)),
Json(RegisterSession {
project: "/tmp/no-tmux".into(),
project_path: None,
name: None,
workdir: None,
}),
)
.await
.expect("registration-only path succeeds");
let id_str = body.id.0.to_string();
let listed = state.list_sessions();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id.0.to_string(), id_str);
}
#[tokio::test]
async fn hook_relay_rejects_bad_session_id() {
let (state, _) = state_with_session();
let post = HookPost {
session_id: "not-a-uuid".into(),
event: HookEvent::Stop,
payload: serde_json::Value::Null,
};
let err = ingest_hook(State(state), Json(post)).await.unwrap_err();
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn hook_relay_runs_with_disabled_overseer() {
let (state, id) = state_with_session();
let post = HookPost {
session_id: id.0.to_string(),
event: HookEvent::PreToolUse,
payload: serde_json::json!({"tool": "Bash", "input": {"command": "ls"}}),
};
let result = ingest_hook(State(state.clone()), Json(post)).await;
assert!(result.is_ok());
assert_eq!(state.recent_hook_events().len(), 1);
}
#[tokio::test]
async fn session_start_auto_registers_unknown_session() {
let state = DaemonState::shared();
let new_id = crate::core::session::SessionId::new();
assert!(state.session(new_id).is_none());
let post = HookPost {
session_id: new_id.0.to_string(),
event: HookEvent::SessionStart,
payload: serde_json::Value::Null,
};
let result = ingest_hook(State(state.clone()), Json(post)).await;
assert!(result.is_ok());
let registered = state.session(new_id).expect("session auto-registered");
assert_eq!(registered.id, new_id);
assert_eq!(
registered.status,
crate::core::session::SessionStatus::Active
);
}
#[tokio::test]
async fn non_session_start_event_does_not_auto_register() {
let (state, _dir) = hermetic_shared();
let unknown = crate::core::session::SessionId::new();
let post = HookPost {
session_id: unknown.0.to_string(),
event: HookEvent::PreToolUse,
payload: serde_json::json!({"tool": "Bash"}),
};
let _ = ingest_hook(State(state.clone()), Json(post)).await;
assert!(state.session(unknown).is_none());
}
#[tokio::test]
async fn session_start_for_known_session_does_not_duplicate() {
let (state, id) = state_with_session();
let before = state.list_sessions().len();
let post = HookPost {
session_id: id.0.to_string(),
event: HookEvent::SessionStart,
payload: serde_json::Value::Null,
};
let result = ingest_hook(State(state.clone()), Json(post)).await;
assert!(result.is_ok());
assert_eq!(state.list_sessions().len(), before);
}
#[tokio::test]
async fn llm_chat_without_overseer_is_503() {
let (state, _dir) = hermetic_shared();
let err = llm_chat(
State(state),
Json(LlmChatRequest {
message: "hello".into(),
history: Vec::new(),
}),
)
.await
.unwrap_err();
assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn coordinator_context_returns_snapshot() {
let (state, _id) = state_with_session();
let snapshot = coordinator_context(State(state)).await;
assert_eq!(snapshot.sessions.len(), 1);
}
#[tokio::test]
async fn coordinator_chat_without_overseer_is_503() {
let (state, _dir) = hermetic_shared();
let err = coordinator_chat(
State(state),
HeaderMap::new(),
Json(CoordinatorChatRequest {
message: "what is happening?".into(),
history: Vec::new(),
conv_id: None,
actions: None,
}),
)
.await
.unwrap_err();
assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn coordinator_chat_routes_prefixed_message() {
let state = DaemonState::shared();
let id = SessionId::new();
let mut session = Session::new(id, "/tmp/p", ControlModel::Tmux, None);
session.status = SessionStatus::Active;
session.tmux_name = "tmpm-coordtest".to_string();
state.register_session(session);
let resp = coordinator_chat(
State(state),
HeaderMap::new(),
Json(CoordinatorChatRequest {
message: "@coordtest: echo hi".into(),
history: Vec::new(),
conv_id: None,
actions: None,
}),
)
.await
.expect("prefixed routing must not require an LLM");
assert_eq!(resp.routed_to_session.as_deref(), Some("tmpm-coordtest"));
assert!(resp.command_output.is_some());
}
#[cfg(feature = "swagger-ui")]
#[tokio::test]
async fn openapi_spec_is_valid() {
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
let app = router(DaemonState::shared());
let response = app
.oneshot(
Request::builder()
.uri("/api-docs/openapi.json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let spec: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
spec.get("openapi").is_some(),
"spec must have an openapi key"
);
assert!(
spec["info"]["title"]
.as_str()
.unwrap_or_default()
.contains("trusty-mpm"),
"spec title must mention trusty-mpm"
);
}
#[tokio::test]
async fn rpc_rejects_non_loopback_peer() {
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::Request;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use tower::ServiceExt;
let app = router(DaemonState::shared());
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50)), 40000);
let mut req = Request::builder()
.method("POST")
.uri("/rpc")
.header("content-type", "application/json")
.body(Body::from(
r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
))
.unwrap();
req.extensions_mut().insert(ConnectInfo(peer));
let response = app.oneshot(req).await.unwrap();
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"non-loopback /rpc must be forbidden"
);
}
#[tokio::test]
async fn rpc_dispatches_tools_list_for_loopback() {
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::Request;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use tower::ServiceExt;
let app = router(DaemonState::shared());
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 50505);
let mut req = Request::builder()
.method("POST")
.uri("/rpc")
.header("content-type", "application/json")
.body(Body::from(
r#"{"jsonrpc":"2.0","id":7,"method":"tools/list"}"#,
))
.unwrap();
req.extensions_mut().insert(ConnectInfo(peer));
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["id"], 7);
let tools = body["result"]["tools"].as_array().expect("tools array");
let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
for expected in [
"session_new",
"session_stop",
"session_resume",
"session_decommission",
"session_activity",
"session_send",
] {
assert!(
names.contains(&expected),
"loopback /rpc tools/list must advertise `{expected}`; got {names:?}"
);
}
assert!(
tools.len() >= 15,
"loopback /rpc tools/list must advertise at least 15 tools (9 existing + 6 session-lifecycle); got {}",
tools.len()
);
}
#[tokio::test]
async fn pause_then_resume_round_trips() {
let (state, id) = state_with_session();
let Json(body) = pause_session(
State(Arc::clone(&state)),
Path(id.0.to_string()),
Json(PauseRequest {
summary: Some("mid-task".into()),
}),
)
.await
.expect("pause succeeds");
assert!(body.paused);
assert_eq!(body.summary, "mid-task");
let paused = state.session(id).expect("session exists");
assert_eq!(paused.status, SessionStatus::Paused);
assert_eq!(paused.pause_summary.as_deref(), Some("mid-task"));
assert!(paused.paused_at.is_some());
let Json(resumed) = resume_session(State(Arc::clone(&state)), Path(id.0.to_string()))
.await
.expect("resume succeeds");
assert!(resumed.resumed);
let active = state.session(id).expect("session exists");
assert_eq!(active.status, SessionStatus::Active);
assert_eq!(active.paused_at, None);
assert_eq!(active.pause_summary, None);
}
#[tokio::test]
async fn pause_unknown_session_is_404() {
let state = DaemonState::shared();
let err = pause_session(
State(state),
Path(SessionId::new().0.to_string()),
Json(PauseRequest::default()),
)
.await
.unwrap_err();
assert_eq!(err.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn resume_unpaused_session_is_409() {
let (state, id) = state_with_session();
let err = resume_session(State(state), Path(id.0.to_string()))
.await
.unwrap_err();
assert_eq!(err.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn command_to_stopped_session_is_409() {
let state = DaemonState::shared();
let id = SessionId::new();
let mut session = Session::new(id, "/tmp/p", ControlModel::Tmux, None);
session.status = SessionStatus::Stopped;
state.register_session(session);
let err = send_command(
State(state),
Path(id.0.to_string()),
Query(CommandQuery::default()),
Json(CommandRequest {
command: "help".into(),
}),
)
.await
.unwrap_err();
assert_eq!(err.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn output_unknown_session_is_404() {
let state = DaemonState::shared();
let err = get_output(
State(state),
Path(SessionId::new().0.to_string()),
Query(OutputQuery::default()),
)
.await
.unwrap_err();
assert_eq!(err.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn pause_resolves_session_by_friendly_name() {
let (state, id) = state_with_session();
let name = state.session(id).expect("session").tmux_name;
let Json(body) = pause_session(
State(Arc::clone(&state)),
Path(name),
Json(PauseRequest::default()),
)
.await
.expect("pause by name succeeds");
assert!(body.paused);
}
#[test]
fn send_command_compress_query_defaults_off() {
let query: CommandQuery = serde_json::from_str("{}").expect("empty query deserializes");
assert_eq!(query.compress, None);
}
#[test]
fn output_query_defaults() {
let query: OutputQuery = serde_json::from_str("{}").expect("empty query deserializes");
assert_eq!(query.lines, None);
assert_eq!(query.compress, None);
}
#[test]
fn compress_level_roundtrips_serde() {
let json = serde_json::to_string(&CompressionLevel::Summarise).expect("serialize");
assert_eq!(json, "\"summarise\"");
let parsed: CompressionLevel = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, CompressionLevel::Summarise);
}
#[test]
fn compress_level_label_matches_serde() {
assert_eq!(compression_level_label(CompressionLevel::Off), "off");
assert_eq!(compression_level_label(CompressionLevel::Trim), "trim");
assert_eq!(
compression_level_label(CompressionLevel::Summarise),
"summarise"
);
assert_eq!(
compression_level_label(CompressionLevel::Caveman),
"caveman"
);
}
#[test]
fn apply_compression_off_is_passthrough() {
let result = apply_compression(None, "raw pane text");
assert_eq!(result.text, "raw pane text");
assert_eq!(result.level_label, None);
}
#[test]
fn apply_compression_summarise() {
let raw = "x".repeat(100);
let result = apply_compression(Some(CompressionLevel::Summarise), &raw);
assert_eq!(result.level_label.as_deref(), Some("summarise"));
assert_eq!(result.stats.original_bytes, 100);
}
#[tokio::test]
#[serial]
async fn adopt_tmux_session_handles_missing() {
let state = DaemonState::shared();
let result = adopt_tmux_session(
State(state),
Json(AdoptRequest {
session: "trusty-mpm-no-such-session-xyz".into(),
}),
)
.await;
assert_eq!(result.unwrap_err().status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
#[serial]
async fn tmux_snapshot_unknown_session_is_404() {
let state = DaemonState::shared();
let result = tmux_snapshot(State(state), Path("no-such-session-xyz".into())).await;
assert_eq!(result.unwrap_err().status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn create_checkpoint_returns_id() {
let dir = tempfile::tempdir().unwrap();
let state = DaemonState::shared();
let Json(body) = create_checkpoint(
State(Arc::clone(&state)),
Json(CreateCheckpointRequest {
project: dir.path().to_path_buf(),
label: Some("manual".into()),
}),
)
.await
.expect("create succeeds");
assert!(!body.id.is_empty());
let Json(listed) = list_checkpoints(
State(state),
Query(CheckpointQuery {
project: dir.path().to_path_buf(),
}),
)
.await;
assert_eq!(listed.checkpoints.len(), 1);
}
#[tokio::test]
async fn restore_unknown_checkpoint_is_500() {
let dir = tempfile::tempdir().unwrap();
let state = DaemonState::shared();
let err = restore_checkpoint(
State(state),
Json(RestoreRequest {
project: dir.path().to_path_buf(),
checkpoint_id: "no-such-checkpoint".into(),
}),
)
.await
.unwrap_err();
assert_eq!(err, StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn delete_unknown_checkpoint_is_404() {
let dir = tempfile::tempdir().unwrap();
let state = DaemonState::shared();
let err = delete_checkpoint(
State(state),
Path("no-such-checkpoint".into()),
Query(CheckpointQuery {
project: dir.path().to_path_buf(),
}),
)
.await
.unwrap_err();
assert_eq!(err, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn deploy_profile_returns_checkpoint_id() {
let dir = tempfile::tempdir().unwrap();
let state = DaemonState::shared();
let Json(body) = deploy_profile(
State(state),
Json(DeployProfileRequest {
project: dir.path().to_path_buf(),
profile_name: "minimal".into(),
target: None,
}),
)
.await
.expect("deploy succeeds");
assert_eq!(body.deployed, "minimal");
assert!(!body.checkpoint_id.is_empty());
}
#[tokio::test]
async fn deploy_unknown_profile_is_404() {
let dir = tempfile::tempdir().unwrap();
let state = DaemonState::shared();
let err = deploy_profile(
State(state),
Json(DeployProfileRequest {
project: dir.path().to_path_buf(),
profile_name: "no-such-profile".into(),
target: None,
}),
)
.await
.unwrap_err();
assert_eq!(err, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn pair_confirm_rejects_bad_code() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(DaemonState::with_root(dir.path().to_path_buf()));
let _ = pair_request(State(Arc::clone(&state))).await;
let Json(confirm) = pair_confirm(
State(Arc::clone(&state)),
Json(PairConfirmRequest {
code: "ZZZZZZ".into(),
chat_id: 777,
}),
)
.await;
assert!(!confirm.success);
assert!(confirm.error.as_deref().unwrap().contains("invalid"));
let Json(status) = pair_status(State(state)).await;
assert!(!status.paired);
assert!(status.chat_id.is_none());
}
#[tokio::test]
async fn discover_sessions_returns_count() {
let state = DaemonState::shared();
let Json(resp) = discover_sessions(State(state)).await;
assert_eq!(resp.discovered, resp.sessions.len());
}
#[tokio::test]
async fn pair_reset_clears_pairing() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(DaemonState::with_root(dir.path().to_path_buf()));
let Json(resp) = pair_reset(State(Arc::clone(&state))).await;
assert!(resp.reset);
let Json(status) = pair_status(State(state)).await;
assert!(!status.paired);
}
#[tokio::test]
async fn doctor_endpoint_returns_report() {
let state = DaemonState::shared();
let Json(report) = doctor(State(state), Query(DoctorQuery::default())).await;
let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
let expected = [
"instructions",
"agents",
"agent_reachability",
"asset_tier",
"asset_duplicates",
"transcript_saving",
"skills",
"skill_source",
"skill_reachability",
"output_style",
"output_style_staleness",
"output_style_legacy_ids",
"deployment",
"skill_staleness",
"skill_unmanaged",
"skill_project_tier",
"legacy_sources",
"legacy_overrides",
"session_profile",
"pm_guard",
"org_accounts",
"instruction_fold",
"tool_output_compression",
"statusline",
"agent_skills",
"agent_skills_prose_hints",
"memory",
"search",
"search_index_pin",
"worktrees",
"worktree_disk",
"disk_usage",
"worktree_registry",
"rust_build_env",
"base_clone",
"gh_account",
"rtk",
"issue_audit_recent",
"oauth_token",
"launchd_secrets",
"credential_reach",
"hooks_contamination",
"hooks_foreign_conflict",
"hooks_build_tree_binary",
"hooks_missing_tm_group",
"tcc_taint",
"scaffold_tracking",
"push_guard",
"maintenance_config",
"maintenance_processes",
"background_sweeps",
"binary_provenance",
"bundled_asset_lag",
"content",
"session_store",
"session_claudes",
"stop_spool",
"stray_mcp_json",
"session_scope",
"tmux_options",
"launchd_process_type",
"tmux_priority",
"tcp_listeners",
"pty_headroom",
"log_drain",
"startup_context",
"auto_memory",
"ancestor_claude_md",
"session_names",
];
assert_eq!(names, expected);
assert_eq!(report.checks.len(), expected.len());
}
#[tokio::test]
async fn apply_claude_config_unknown_rec_is_404() {
let dir = tempfile::tempdir().unwrap();
let state = DaemonState::shared();
let result = apply_claude_config(
State(state),
Json(ApplyConfigRequest {
project: dir.path().to_path_buf(),
recommendation_id: "no-such-recommendation".into(),
}),
)
.await;
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn ingest_hook_broadcasts_to_subscribers() {
let (state, id) = state_with_session();
let mut rx = state.event_subscribe();
let post = HookPost {
session_id: id.0.to_string(),
event: HookEvent::PostToolUse,
payload: serde_json::json!({"tool": "Edit"}),
};
let result = ingest_hook(State(state.clone()), Json(post)).await;
assert!(result.is_ok());
let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
.await
.expect("broadcast arrived within 1s")
.expect("broadcast value is Ok");
assert_eq!(received["event"], serde_json::json!("PostToolUse"));
assert_eq!(received["session"], serde_json::json!(id.0.to_string()));
}
#[tokio::test]
async fn events_sse_streams_one_frame() {
use axum::body::Body;
use axum::http::Request;
use http_body_util::BodyExt;
use tower::ServiceExt;
let (state, id) = state_with_session();
let app = router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.uri("/events")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|h| h.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(
content_type.starts_with("text/event-stream"),
"expected SSE content-type, got {content_type:?}"
);
state
.clone()
.push_hook_event(crate::core::hook::HookEventRecord::now(
id,
HookEvent::PostToolUse,
serde_json::json!({"tool": "Edit"}),
));
let mut body = response.into_body();
let bytes = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let frame = body.frame().await.expect("body has a frame")?;
if let Ok(data) = frame.into_data()
&& !data.is_empty()
{
return Ok::<_, axum::Error>(data);
}
}
})
.await
.expect("SSE frame arrived within 2s")
.expect("frame read ok");
let text = std::str::from_utf8(&bytes).expect("utf8");
assert!(
text.contains("data:"),
"expected an SSE `data:` line, got {text:?}"
);
assert!(
text.contains("PostToolUse"),
"expected event payload in frame, got {text:?}"
);
assert!(
text.contains(&id.0.to_string()),
"expected session id in frame, got {text:?}"
);
}
#[tokio::test]
async fn session_events_sse_filters_by_session() {
let (state, id) = state_with_session();
let other = SessionId::new();
let mut other_session =
crate::core::session::Session::new(other, "/tmp/other", ControlModel::Tmux, None);
other_session.status = SessionStatus::Active;
state.register_session(other_session);
let stream_response =
stream_session_events(Path(id.0.to_string()), State(Arc::clone(&state))).await;
use axum::response::IntoResponse;
let response = stream_response.into_response();
assert_eq!(response.status(), StatusCode::OK);
state.push_hook_event(crate::core::hook::HookEventRecord::now(
other,
HookEvent::PostToolUse,
serde_json::json!({"tool": "Read"}),
));
state.push_hook_event(crate::core::hook::HookEventRecord::now(
id,
HookEvent::PostToolUse,
serde_json::json!({"tool": "Edit"}),
));
use http_body_util::BodyExt;
let mut body = response.into_body();
let bytes = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let frame = body.frame().await.expect("body has a frame")?;
if let Ok(data) = frame.into_data()
&& !data.is_empty()
{
return Ok::<_, axum::Error>(data);
}
}
})
.await
.expect("SSE frame arrived within 2s")
.expect("frame read ok");
let text = std::str::from_utf8(&bytes).expect("utf8");
assert!(
text.contains(&id.0.to_string()),
"expected subscribed session id in frame, got {text:?}"
);
assert!(
!text.contains(&other.0.to_string()),
"unrelated session id leaked into stream: {text:?}"
);
}
#[tokio::test]
async fn report_bug_not_found_fingerprint_is_graceful() {
let state = DaemonState::shared();
let Json(resp) = report_bug_http(
State(state),
Json(ReportBugApiRequest {
fingerprint: "z".repeat(64),
confirm: false,
}),
)
.await;
assert!(!resp.filed, "filed must be false for unknown fingerprint");
assert!(
resp.note.as_deref().unwrap_or("").contains("not found"),
"note must say 'not found': {:?}",
resp.note
);
}
#[tokio::test]
async fn report_bug_no_confirm_includes_preview() {
use crate::daemon::bug_report::preview::IssuePreview;
use crate::daemon::bug_report::scrubber::ScrubChange;
let preview = IssuePreview {
fingerprint: "a".repeat(64),
title: "Test error".to_string(),
body: "body content without secrets".to_string(),
labels: vec!["bug".to_string()],
scrub_changes: vec![ScrubChange {
pattern: "env-secret",
hint: "redacted API_KEY".to_string(),
}],
};
let wire = super::to_wire_preview(&preview);
assert_eq!(wire.title, "Test error");
assert_eq!(wire.body, "body content without secrets");
assert_eq!(wire.labels, vec!["bug"]);
assert_eq!(wire.scrub_changes.len(), 1);
assert_eq!(wire.scrub_changes[0].pattern, "env-secret");
assert!(
wire.scrub_changes[0].hint.contains("API_KEY"),
"hint must mention what was redacted"
);
let state = DaemonState::shared();
let Json(resp) = report_bug_http(
State(state),
Json(ReportBugApiRequest {
fingerprint: "z".repeat(64),
confirm: false,
}),
)
.await;
assert!(!resp.filed);
assert!(resp.rate_limited.is_none());
}
#[tokio::test]
async fn report_bug_rate_limit_guard_blocks_correctly() {
use crate::daemon::bug_report::ratelimit::{FilingDecision, RateLimitGuard};
use tempfile::tempdir;
let dir = tempdir().unwrap();
let guard = RateLimitGuard::with_config(
dir.path().join("fp.json"),
60, dir.path().join("hourly.json"),
1, );
let fp = "b".repeat(64);
let now = 1_700_000_000i64;
assert!(
guard.check(&fp, now).is_allowed(),
"should be allowed before any filing"
);
guard.record_filed(&fp, now);
let decision = guard.check(&fp, now + 5);
assert!(
!decision.is_allowed(),
"should be blocked after filing within window: {decision:?}"
);
assert!(
matches!(decision, FilingDecision::FingerprintRecentlyFiled { .. }),
"expected FingerprintRecentlyFiled: {decision:?}"
);
assert!(
!decision.block_reason().is_empty(),
"block_reason must be non-empty"
);
let fp2 = "c".repeat(64);
let cap_decision = guard.check(&fp2, now + 5);
assert!(
!cap_decision.is_allowed(),
"hourly cap should block different fp: {cap_decision:?}"
);
assert!(
matches!(cap_decision, FilingDecision::HourlyCapExceeded { .. }),
"expected HourlyCapExceeded: {cap_decision:?}"
);
}
#[test]
#[serial]
fn resolve_token_full_chain_coverage() {
use crate::daemon::bug_report::token::{
APP_ID_ENV_VAR, APP_INSTALL_ID_ENV_VAR, APP_KEY_FILE_ENV_VAR, TOKEN_ENV_VAR,
TOKEN_FILE_ENV_VAR, resolve_token,
};
use crate::secret_source::test_env::{EnvVarGuard, assert_secret_eq};
let scratch = crate::test_support::hermetic_temp_dir();
let _home = EnvVarGuard::set("HOME", scratch.path());
let _file = EnvVarGuard::set(TOKEN_FILE_ENV_VAR, scratch.path().join("no-token-here"));
let _app_id = EnvVarGuard::unset(APP_ID_ENV_VAR);
let _install = EnvVarGuard::unset(APP_INSTALL_ID_ENV_VAR);
let _key_file = EnvVarGuard::unset(APP_KEY_FILE_ENV_VAR);
let sentinel = "ghp_http_test_fix1_resolve"; let pat = EnvVarGuard::set(TOKEN_ENV_VAR, sentinel);
let tok = resolve_token();
drop(pat);
assert_secret_eq(
tok.as_deref(),
Some(sentinel),
"resolve_token must return PAT from env",
);
let _pat_absent = EnvVarGuard::unset(TOKEN_ENV_VAR);
let app = (
EnvVarGuard::set(APP_ID_ENV_VAR, "99999"),
EnvVarGuard::set(APP_INSTALL_ID_ENV_VAR, "88888"),
EnvVarGuard::set(APP_KEY_FILE_ENV_VAR, scratch.path().join("absent.pem")),
);
let app_tok = resolve_token();
drop(app);
assert_secret_eq(
app_tok.as_deref(),
None,
"resolve_token must return None when App PEM is absent",
);
let none_tok = resolve_token();
assert_secret_eq(
none_tok.as_deref(),
None,
"resolve_token must return None when nothing configured",
);
}
async fn make_state_with_active_managed(
ws: std::path::PathBuf,
) -> (
Arc<DaemonState>,
crate::session_manager::ManagedSessionId,
String,
) {
use crate::session_manager::{ManagedSessionState, SessionManager};
use std::sync::Arc as SArc;
struct MinFake {
sessions: std::sync::Mutex<Vec<String>>,
}
impl crate::session_manager::ManagedTmuxDriver for MinFake {
fn create_session(
&self,
name: &str,
_: &str,
) -> Result<(), crate::session_manager::ManagedError> {
self.sessions.lock().unwrap().push(name.to_owned());
Ok(())
}
fn kill_session(&self, name: &str) -> Result<(), crate::session_manager::ManagedError> {
self.sessions.lock().unwrap().retain(|n| n != name);
Ok(())
}
fn send_line(&self, _: &str, _: &str) -> Result<(), crate::session_manager::ManagedError> {
Ok(())
}
fn capture(
&self,
_: &str,
_: usize,
) -> Result<String, crate::session_manager::ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, crate::session_manager::ManagedError> {
Ok(self.sessions.lock().unwrap().clone())
}
fn get_pane_id(&self, _name: &str) -> Option<String> {
Some("%1".to_owned())
}
fn pane_identity(
&self,
pane_id: &str,
) -> Result<
crate::session_manager::pane_identity::PaneIdentity,
crate::session_manager::ManagedError,
> {
Ok(crate::session_manager::pane_identity::PaneIdentity {
pane_id: pane_id.to_owned(),
session_id: "$0".into(),
server: "1:1".into(),
session_name: self
.sessions
.lock()
.unwrap()
.first()
.cloned()
.unwrap_or_default(),
})
}
fn kill_session_id(
&self,
name: &str,
_: &str,
) -> Result<(), crate::session_manager::ManagedError> {
self.kill_session(name)
}
}
let tmp = tempfile::TempDir::new().unwrap();
let fake: SArc<dyn crate::session_manager::ManagedTmuxDriver> = SArc::new(MinFake {
sessions: std::sync::Mutex::new(Vec::new()),
});
let mgr = SessionManager::new(tmp.path(), fake).await.unwrap();
let record = mgr
.create(
"task".into(),
Some(ws.clone()),
None,
Some(ws.clone()),
None,
None,
)
.await
.expect("create managed session");
let id = record.id;
let tmux_name = record.tmux_name.clone();
mgr.set_workspace(&id, ws, ManagedSessionState::Active)
.await
.expect("set Active");
let mgr_arc = SArc::new(mgr);
let state = DaemonState::with_session_manager(mgr_arc);
std::mem::forget(tmp);
(Arc::new(state), id, tmux_name)
}
#[tokio::test]
async fn session_start_hook_correlates_claude_id() {
let ws = std::path::PathBuf::from("/tmp/test-ws-correlate");
let (state, managed_id, _) = make_state_with_active_managed(ws.clone()).await;
let claude_id = "550e8400-e29b-41d4-a716-446655440001";
let post = HookPost {
session_id: claude_id.to_string(),
event: HookEvent::SessionStart,
payload: serde_json::json!({ "cwd": ws.to_str().unwrap() }),
};
let result = ingest_hook(State(Arc::clone(&state)), Json(post)).await;
assert!(
result.is_ok(),
"ingest_hook(SessionStart) must succeed: {result:?}"
);
let mgr = state.session_manager().await;
let record = mgr.get(&managed_id).await.expect("get managed session");
assert_eq!(
record.claude_session_id.as_deref(),
Some(claude_id),
"SessionStart hook must persist claude_session_id on matched managed session (#1744)"
);
}
#[tokio::test]
#[serial_test::serial]
async fn session_start_hook_escalates_ambiguous_cwd_to_error() {
use tracing::instrument::WithSubscriber;
use tracing_subscriber::layer::SubscriberExt;
crate::test_support::enable_event_capture();
use crate::session_manager::{ManagedSessionState, SessionManager};
struct MinFake {
sessions: std::sync::Mutex<Vec<String>>,
}
impl crate::session_manager::ManagedTmuxDriver for MinFake {
fn create_session(
&self,
name: &str,
_: &str,
) -> Result<(), crate::session_manager::ManagedError> {
self.sessions.lock().unwrap().push(name.to_owned());
Ok(())
}
fn kill_session(&self, name: &str) -> Result<(), crate::session_manager::ManagedError> {
self.sessions.lock().unwrap().retain(|n| n != name);
Ok(())
}
fn send_line(&self, _: &str, _: &str) -> Result<(), crate::session_manager::ManagedError> {
Ok(())
}
fn capture(
&self,
_: &str,
_: usize,
) -> Result<String, crate::session_manager::ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, crate::session_manager::ManagedError> {
Ok(self.sessions.lock().unwrap().clone())
}
}
let ws = std::path::PathBuf::from("/tmp/test-ws-ambiguous-cwd-3764");
let tmp = tempfile::TempDir::new().unwrap();
let fake: Arc<dyn crate::session_manager::ManagedTmuxDriver> = Arc::new(MinFake {
sessions: std::sync::Mutex::new(Vec::new()),
});
let mgr = SessionManager::new(tmp.path(), fake).await.unwrap();
for _ in 0..2 {
let record = mgr
.create(
"task".into(),
Some(ws.clone()),
None,
Some(ws.clone()),
None,
None,
)
.await
.expect("create managed session");
mgr.set_workspace(&record.id, ws.clone(), ManagedSessionState::Active)
.await
.expect("set Active");
}
let mgr_arc = Arc::new(mgr);
let state = Arc::new(DaemonState::with_session_manager(mgr_arc));
std::mem::forget(tmp);
let claude_id = "550e8400-e29b-41d4-a716-446655440099";
let post = HookPost {
session_id: claude_id.to_string(),
event: HookEvent::SessionStart,
payload: serde_json::json!({ "cwd": ws.to_str().unwrap() }),
};
let buffer = trusty_common::log_buffer::LogBuffer::new(64);
let subscriber = tracing_subscriber::registry().with(
trusty_common::log_buffer::LogBufferLayer::new(buffer.clone()),
);
let _ = ingest_hook(State(std::sync::Arc::clone(&state)), Json(post))
.with_subscriber(subscriber)
.await
.expect("ingest_hook must still succeed — the collision is reported, not fatal");
let lines = buffer.tail(64);
let line = lines
.iter()
.find(|l| l.contains("Active managed sessions share the same cwd"))
.unwrap_or_else(|| {
panic!("the #1744 ambiguous-cwd collision must be reported. Captured lines: {lines:#?}")
});
assert!(
line.contains("ERROR"),
"a 2+-Active-session cwd collision must log at ERROR, not a lower level (#3764): {line}"
);
}
#[tokio::test]
async fn session_start_hook_reasserting_same_id_is_a_noop() {
let ws = std::path::PathBuf::from("/tmp/test-ws-correlate-reassert-same");
let (state, managed_id, _) = make_state_with_active_managed(ws.clone()).await;
let claude_id = "550e8400-e29b-41d4-a716-446655440012";
for _ in 0..2 {
let post = HookPost {
session_id: claude_id.to_string(),
event: HookEvent::SessionStart,
payload: serde_json::json!({ "cwd": ws.to_str().unwrap() }),
};
let _ = ingest_hook(State(Arc::clone(&state)), Json(post))
.await
.expect("SessionStart must succeed");
}
let mgr = state.session_manager().await;
let record = mgr.get(&managed_id).await.expect("get managed session");
assert_eq!(
record.claude_session_id.as_deref(),
Some(claude_id),
"reasserting the SAME claude_session_id must remain a no-op success, not be blocked (#4337)"
);
}
struct HomeGuard {
prev: Option<String>,
tmp: tempfile::TempDir,
}
impl HomeGuard {
fn set() -> Self {
let tmp = tempfile::tempdir().expect("tempdir");
let prev = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path()) };
Self { prev, tmp }
}
fn path(&self) -> &std::path::Path {
self.tmp.path()
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.prev {
Some(ref p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
fn seed_fake_transcript(home: &std::path::Path, cwd: &std::path::Path, id: &str) {
let encoded = crate::runtime::encode_project_dir(cwd);
let dir = home
.join(".trusty-tools")
.join("trusty-mpm")
.join("claude-config")
.join("projects")
.join(encoded);
std::fs::create_dir_all(&dir).expect("create fake projects dir");
std::fs::write(dir.join(format!("{id}.jsonl")), "{}").expect("write fake transcript");
}
#[tokio::test]
#[serial]
async fn session_start_hook_still_refuses_a_live_subagent_id() {
let home = HomeGuard::set();
let ws = std::path::PathBuf::from("/tmp/test-ws-correlate-live-refused");
let (state, managed_id, _) = make_state_with_active_managed(ws.clone()).await;
let pm_claude_id = "550e8400-e29b-41d4-a716-446655440020";
let post = HookPost {
session_id: pm_claude_id.to_string(),
event: HookEvent::SessionStart,
payload: serde_json::json!({ "cwd": ws.to_str().unwrap() }),
};
let _ = ingest_hook(State(Arc::clone(&state)), Json(post))
.await
.expect("PM SessionStart must succeed");
seed_fake_transcript(home.path(), &ws, pm_claude_id);
let subagent_claude_id = "550e8400-e29b-41d4-a716-446655440021";
let post = HookPost {
session_id: subagent_claude_id.to_string(),
event: HookEvent::SessionStart,
payload: serde_json::json!({ "cwd": ws.to_str().unwrap() }),
};
let _ = ingest_hook(State(Arc::clone(&state)), Json(post))
.await
.expect("subagent SessionStart must still be accepted at the HTTP layer");
let mgr = state.session_manager().await;
let record = mgr.get(&managed_id).await.expect("get managed session");
assert_eq!(
record.claude_session_id.as_deref(),
Some(pm_claude_id),
"a subagent's SessionStart must be refused while the PM's own transcript is still \
live, even with the #4337 stale-id re-sync gate in place"
);
}
#[tokio::test]
#[serial]
async fn session_start_hook_re_correlates_a_stale_id() {
let _home = HomeGuard::set();
let ws = std::path::PathBuf::from("/tmp/test-ws-correlate-stale-resync");
let (state, managed_id, _) = make_state_with_active_managed(ws.clone()).await;
let stale_id = "550e8400-e29b-41d4-a716-446655440030";
let post = HookPost {
session_id: stale_id.to_string(),
event: HookEvent::SessionStart,
payload: serde_json::json!({ "cwd": ws.to_str().unwrap() }),
};
let _ = ingest_hook(State(Arc::clone(&state)), Json(post))
.await
.expect("first SessionStart must succeed");
let new_id = "550e8400-e29b-41d4-a716-446655440031";
let post = HookPost {
session_id: new_id.to_string(),
event: HookEvent::SessionStart,
payload: serde_json::json!({ "cwd": ws.to_str().unwrap() }),
};
let _ = ingest_hook(State(Arc::clone(&state)), Json(post))
.await
.expect("second SessionStart must succeed");
let mgr = state.session_manager().await;
let record = mgr.get(&managed_id).await.expect("get managed session");
assert_eq!(
record.claude_session_id.as_deref(),
Some(new_id),
"a differently-reported id must be accepted once the stored one no longer \
resolves to a live transcript (#4337)"
);
}
#[tokio::test]
async fn session_end_hook_clears_claude_session_id() {
let ws = std::path::PathBuf::from("/tmp/test-ws-session-end-clears-id");
let (state, managed_id, _) = make_state_with_active_managed(ws.clone()).await;
let claude_id = "550e8400-e29b-41d4-a716-446655440040";
{
let mgr = state.session_manager().await;
mgr.set_claude_session_id(&managed_id, claude_id)
.await
.expect("set claude_session_id");
}
let post = HookPost {
session_id: claude_id.to_string(),
event: HookEvent::SessionEnd,
payload: serde_json::json!({}),
};
let _ = ingest_hook(State(Arc::clone(&state)), Json(post))
.await
.expect("ingest_hook(SessionEnd) must succeed");
let mgr = state.session_manager().await;
let record = mgr
.get(&managed_id)
.await
.expect("get managed session after SessionEnd");
assert_eq!(
record.claude_session_id, None,
"SessionEnd must clear claude_session_id (#4337)"
);
}
#[tokio::test]
async fn an_http_session_end_leaves_the_sessions_delegations_live_8980() {
let dir = tempfile::tempdir().expect("temp dir");
let paths = crate::core::paths::FrameworkPaths::under(dir.path());
let state = Arc::new(DaemonState::with_paths(&paths));
let claude_id = "550e8400-e29b-41d4-a716-446655440099";
let session = crate::core::session::SessionId(
uuid::Uuid::parse_str(claude_id).expect("fixture id parses"),
);
let mut d = crate::core::agent::Delegation::observed(
session,
"rust-engineer",
"task",
Some("toolu_orphan".to_string()),
);
d.cwd = Some(std::path::PathBuf::from("/repo"));
d.status = crate::core::agent::DelegationStatus::Running;
state.upsert_delegation(d);
assert_eq!(
state.live_shared_tree_writers(std::path::Path::new("/repo"), None),
vec!["rust-engineer".to_string()],
"fixture check: the record blocks a HEAD write before the session ends"
);
let post = HookPost {
session_id: claude_id.to_string(),
event: HookEvent::SessionEnd,
payload: serde_json::json!({}),
};
let _ = ingest_hook(State(Arc::clone(&state)), Json(post))
.await
.expect("ingest_hook(SessionEnd) must succeed");
assert_eq!(
state.live_shared_tree_writers(std::path::Path::new("/repo"), None),
vec!["rust-engineer".to_string()],
"an unproven SessionEnd must leave the session's agents live writers (#8980)"
);
assert_eq!(
state.delegations_for(session)[0].status,
crate::core::agent::DelegationStatus::Running
);
}
#[tokio::test]
async fn session_start_hook_ordering_race_self_heals_via_session_end() {
let ws = std::path::PathBuf::from("/tmp/test-ws-ordering-race-self-heal");
let (state, managed_id, _) = make_state_with_active_managed(ws.clone()).await;
let subagent_claude_id = "550e8400-e29b-41d4-a716-446655440050";
let post = HookPost {
session_id: subagent_claude_id.to_string(),
event: HookEvent::SessionStart,
payload: serde_json::json!({ "cwd": ws.to_str().unwrap() }),
};
let _ = ingest_hook(State(Arc::clone(&state)), Json(post))
.await
.expect("subagent SessionStart (winning the race) must succeed");
{
let mgr = state.session_manager().await;
let record = mgr.get(&managed_id).await.expect("get after race");
assert_eq!(
record.claude_session_id.as_deref(),
Some(subagent_claude_id),
"the first correlation for a fresh record is unavoidably whoever wins the race"
);
}
let post = HookPost {
session_id: subagent_claude_id.to_string(),
event: HookEvent::SessionEnd,
payload: serde_json::json!({}),
};
let _ = ingest_hook(State(Arc::clone(&state)), Json(post))
.await
.expect("subagent SessionEnd must succeed");
let mgr = state.session_manager().await;
let record = mgr.get(&managed_id).await.expect("get after self-heal");
assert_eq!(
record.claude_session_id, None,
"a race-won wrong id must self-heal to None once its own session ends, not stay \
locked in forever (#4337)"
);
}
#[tokio::test]
async fn session_end_hook_marks_managed_stopped() {
let ws = std::path::PathBuf::from("/tmp/test-ws-session-end");
let (state, managed_id, _) = make_state_with_active_managed(ws.clone()).await;
let claude_id = "550e8400-e29b-41d4-a716-446655440002";
{
let mgr = state.session_manager().await;
mgr.set_claude_session_id(&managed_id, claude_id)
.await
.expect("set claude_session_id for end test");
}
let post = HookPost {
session_id: claude_id.to_string(),
event: HookEvent::SessionEnd,
payload: serde_json::json!({}),
};
let result = ingest_hook(State(Arc::clone(&state)), Json(post)).await;
assert!(
result.is_ok(),
"ingest_hook(SessionEnd) must succeed: {result:?}"
);
let mgr = state.session_manager().await;
let record = mgr
.get(&managed_id)
.await
.expect("get managed session after SessionEnd");
assert_eq!(
record.state,
crate::session_manager::ManagedSessionState::Stopped,
"SessionEnd hook must immediately mark the managed session Stopped (#1744)"
);
}
#[tokio::test]
async fn session_end_hook_does_not_kill_pane() {
use crate::session_manager::{ManagedError, ManagedSessionState, SessionManager};
use std::sync::{Arc as SArc, Mutex};
#[derive(Default)]
struct KillSpyTmuxDriver {
sessions: Mutex<Vec<String>>,
kill_calls: Mutex<Vec<String>>,
}
impl crate::session_manager::ManagedTmuxDriver for KillSpyTmuxDriver {
fn create_session(&self, name: &str, _workdir: &str) -> Result<(), ManagedError> {
self.sessions.lock().unwrap().push(name.to_owned());
Ok(())
}
fn kill_session(&self, name: &str) -> Result<(), ManagedError> {
self.kill_calls.lock().unwrap().push(name.to_owned());
self.sessions.lock().unwrap().retain(|n| n != name);
Ok(())
}
fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _name: &str, _lines: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(self.sessions.lock().unwrap().clone())
}
}
let ws = std::path::PathBuf::from("/tmp/test-ws-session-end-no-kill");
let tmp = tempfile::TempDir::new().unwrap();
let driver = SArc::new(KillSpyTmuxDriver::default());
let mgr = SessionManager::new(tmp.path(), driver.clone() as SArc<_>)
.await
.unwrap();
let record = mgr
.create(
"task".into(),
Some(ws.clone()),
None,
Some(ws.clone()),
None,
None,
)
.await
.expect("create managed session");
let managed_id = record.id;
mgr.set_workspace(&managed_id, ws, ManagedSessionState::Active)
.await
.expect("set Active");
let claude_id = "550e8400-e29b-41d4-a716-446655440003";
mgr.set_claude_session_id(&managed_id, claude_id)
.await
.expect("set claude_session_id for no-kill test");
let mgr_arc = SArc::new(mgr);
let state = Arc::new(DaemonState::with_session_manager(mgr_arc));
std::mem::forget(tmp);
let post = HookPost {
session_id: claude_id.to_string(),
event: HookEvent::SessionEnd,
payload: serde_json::json!({}),
};
let result = ingest_hook(State(Arc::clone(&state)), Json(post)).await;
assert!(
result.is_ok(),
"ingest_hook(SessionEnd) must succeed: {result:?}"
);
let mgr = state.session_manager().await;
let after = mgr
.get(&managed_id)
.await
.expect("get managed session after SessionEnd");
assert_eq!(
after.state,
ManagedSessionState::Stopped,
"SessionEnd hook must still mark the managed session Stopped (#1744)"
);
assert!(
driver.kill_calls.lock().unwrap().is_empty(),
"SessionEnd correlation must NEVER kill the tmux pane (#2454, mirrors #2023 A)"
);
}
fn pane_for_gate_test(name: &str, cmd: &str) -> crate::daemon::orphan_gc::PaneInfo {
crate::daemon::orphan_gc::PaneInfo {
session_name: name.to_string(),
pane_current_command: cmd.to_string(),
pane_pid: Some(4242),
pane_id: None,
pane_current_path: None,
}
}
#[test]
fn session_end_pane_still_live_true_for_running_agent() {
use crate::daemon::orphan_gc::AlwaysIdleProbe;
let panes = vec![pane_for_gate_test("tmpm-live", "claude")];
assert!(
session_end_pane_still_live("tmpm-live", &panes, &AlwaysIdleProbe),
"a pane still running the agent must be classified as still-live"
);
}
#[test]
fn session_end_pane_still_live_false_for_idle_shell() {
use crate::daemon::orphan_gc::AlwaysIdleProbe;
let panes = vec![pane_for_gate_test("tmpm-idle", "zsh")];
assert!(
!session_end_pane_still_live("tmpm-idle", &panes, &AlwaysIdleProbe),
"an idle-shell pane with no live child must NOT be classified as still-live"
);
}
#[test]
fn session_end_pane_still_live_false_when_pane_missing() {
use crate::daemon::orphan_gc::AlwaysIdleProbe;
let panes: Vec<crate::daemon::orphan_gc::PaneInfo> = Vec::new();
assert!(
!session_end_pane_still_live("tmpm-gone", &panes, &AlwaysIdleProbe),
"a missing pane must fail open (not classified as still-live)"
);
}
#[test]
fn session_end_pane_still_live_true_when_any_of_multiple_panes_live() {
use crate::daemon::orphan_gc::AlwaysIdleProbe;
let panes = vec![
pane_for_gate_test("tmpm-split", "zsh"),
pane_for_gate_test("tmpm-split", "claude"),
];
assert!(
session_end_pane_still_live("tmpm-split", &panes, &AlwaysIdleProbe),
"an idle pane listed before a live sibling pane must not mask the live one"
);
}
#[test]
fn session_end_pane_still_live_false_when_all_of_multiple_panes_idle() {
use crate::daemon::orphan_gc::AlwaysIdleProbe;
let panes = vec![
pane_for_gate_test("tmpm-split", "zsh"),
pane_for_gate_test("tmpm-split", "-bash"),
];
assert!(
!session_end_pane_still_live("tmpm-split", &panes, &AlwaysIdleProbe),
"a session whose every pane is idle must not be classified as still-live"
);
}
#[tokio::test(start_paused = true)]
async fn stalled_route_answers_504_at_the_server_deadline() {
use crate::control::actor::SessionActorHandle;
use crate::control::event::BackendKind;
use crate::control::id::ControlSessionId;
use crate::control::state::SessionMetadata;
use axum::body::Body;
use axum::extract::connect_info::MockConnectInfo;
use axum::http::Request;
use tower::ServiceExt;
let (state, _dir) = hermetic_shared();
let id = ControlSessionId::new("deadline-proj", 0);
let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4);
let (event_tx, _) = tokio::sync::broadcast::channel(16);
let metadata = Arc::new(tokio::sync::RwLock::new(SessionMetadata::new(
id.clone(),
"deadline-proj".into(),
BackendKind::StreamJson,
)));
let handle = SessionActorHandle {
command_tx,
event_tx,
write_lock_held: Arc::new(std::sync::atomic::AtomicBool::new(false)),
metadata: Arc::clone(&metadata),
};
state.session_registry.register(id, handle).await;
let _stall = metadata.write().await;
let loopback = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
let app = router(Arc::clone(&state)).layer(MockConnectInfo(loopback));
let started = tokio::time::Instant::now();
let resp = tokio::time::timeout(
std::time::Duration::from_secs(3600),
app.oneshot(
Request::builder()
.uri("/api/v1/control/sessions")
.body(Body::empty())
.unwrap(),
),
)
.await
.expect("the daemon must answer a stalled request itself, not leave it open for an hour")
.unwrap();
assert_eq!(resp.status(), StatusCode::GATEWAY_TIMEOUT);
assert!(
started.elapsed() < std::time::Duration::from_secs(30),
"the deadline must fire before trusty-console's 30 s proxy bound; took {:?}",
started.elapsed()
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["route"], "/api/v1/control/sessions");
}
#[tokio::test(start_paused = true)]
async fn sse_stream_outlives_the_request_deadline() {
use axum::body::Body;
use axum::http::Request;
use http_body_util::BodyExt;
use tower::ServiceExt;
let (state, id) = state_with_session();
let request = Request::builder().uri("/events").body(Body::empty());
let response = router(Arc::clone(&state))
.oneshot(request.unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
tokio::time::sleep(std::time::Duration::from_secs(120)).await;
state
.clone()
.push_hook_event(crate::core::hook::HookEventRecord::now(
id,
HookEvent::PostToolUse,
serde_json::json!({"tool": "Edit"}),
));
let mut body = response.into_body();
let text = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let frame = body.frame().await.expect("stream still open")?;
if let Ok(data) = frame.into_data() {
let text = String::from_utf8_lossy(&data).into_owned();
if text.contains("data:") {
return Ok::<_, axum::Error>(text);
}
}
}
})
.await
.expect("the SSE event arrived after the deadline")
.expect("frame read ok");
assert!(text.contains("PostToolUse"), "unexpected frame: {text:?}");
}