use super::*;
#[tokio::test]
async fn top_level_default_client_is_bounded_against_a_stalled_daemon() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind stalling listener");
let addr = listener.local_addr().expect("read local_addr");
let client = default_client();
let url = format!("http://{addr}/");
let start = std::time::Instant::now();
let result = client.get(&url).send().await;
let elapsed = start.elapsed();
assert!(result.is_err(), "expected the stalled request to time out");
assert!(
elapsed < std::time::Duration::from_secs(15),
"request took {elapsed:?}, expected it to be bounded by the ~10s production \
timeout, not the OS-level socket timeout (issue #2517 regression)"
);
}
#[test]
fn base_url_is_stored() {
let client = DaemonClient::new("http://127.0.0.1:7880");
assert_eq!(client.base_url(), "http://127.0.0.1:7880");
}
#[test]
fn with_client_reuses_passed_client() {
let configured = reqwest::Client::new();
let client = DaemonClient::with_client(configured, "http://127.0.0.1:7880");
assert_eq!(client.base_url(), "http://127.0.0.1:7880");
}
#[test]
fn set_base_url_repoints_client() {
let mut client = DaemonClient::new("http://127.0.0.1:7880");
client.set_base_url("http://127.0.0.1:54321");
assert_eq!(client.base_url(), "http://127.0.0.1:54321");
}
#[tokio::test]
async fn launch_session_errors_when_daemon_unreachable() {
let home = crate::test_support::hermetic_temp_dir();
let client = DaemonClient::new("http://127.0.0.1:0").with_home(home.path());
let result = client.launch_session("/tmp/no-such-project").await;
assert!(result.is_err(), "expected launch to fail with no daemon");
}
#[tokio::test]
async fn launch_session_writes_nothing_when_daemon_unreachable() {
let home = crate::test_support::hermetic_temp_dir();
let project = crate::test_support::hermetic_temp_dir();
let client = DaemonClient::new("http://127.0.0.1:0").with_home(home.path());
let workdir = project.path().to_string_lossy().into_owned();
let result = client.launch_session(&workdir).await;
assert!(result.is_err(), "expected launch to fail with no daemon");
for (what, dir) in [("project", project.path()), ("home", home.path())] {
let written: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert!(
written.is_empty(),
"{what} gained {written:?} on a failed launch"
);
}
}
#[tokio::test]
async fn launch_session_prepares_under_the_pinned_home_before_tmux() {
use std::future::IntoFuture as _;
use std::os::unix::fs::PermissionsExt as _;
let router = axum::Router::new()
.route("/health", axum::routing::get(|| async { "ok" }))
.route(
"/sessions",
axum::routing::post(|| async {
axum::Json(serde_json::json!({"name": "tm-8719-fake"}))
}),
);
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());
let bin_dir = tempfile::tempdir().unwrap();
let tmux = bin_dir.path().join("fake-tmux");
std::fs::write(&tmux, "#!/bin/sh\necho 'fake tmux refuses' >&2\nexit 1\n").unwrap();
std::fs::set_permissions(&tmux, std::fs::Permissions::from_mode(0o755)).unwrap();
let home = crate::test_support::hermetic_temp_dir();
let project = crate::test_support::hermetic_temp_dir();
let workdir = project.path().to_string_lossy().into_owned();
let client = DaemonClient::new(format!("http://{addr}")).with_home(home.path());
let result = crate::core::tmux::with_tmux_binary(tmux, client.launch_session(&workdir)).await;
let err = result.expect_err("a failed tmux create must fail the launch");
assert!(
err.to_string()
.contains("failed to create tmux session tm-8719-fake"),
"{err}"
);
for written in [".claude.json", ".claude"] {
assert!(
home.path().join(written).exists(),
"prep did not write {written} under the pinned home"
);
}
}
#[tokio::test]
async fn connect_session_errors_when_daemon_unreachable() {
let client = DaemonClient::new("http://127.0.0.1:0");
let result = client.connect_session("/tmp/no-such-project").await;
assert!(result.is_err(), "expected connect to fail with no daemon");
}
async fn spawn_test_daemon() -> String {
spawn_test_daemon_with_state().await.0
}
async fn spawn_test_daemon_with_state()
-> (String, std::sync::Arc<crate::daemon::state::DaemonState>) {
use std::future::IntoFuture as _;
use crate::daemon::{api, state::DaemonState};
let root = tempfile::tempdir().unwrap().keep();
let state = std::sync::Arc::new(DaemonState::with_root_isolated_managed(root).await);
let router = api::router(state.clone());
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}"), state)
}
#[tokio::test]
async fn decommission_conflict_surfaces_the_guard_reason() {
use crate::session_manager::{ManagedSessionId, ManagedSessionState, SessionRecord};
let (url, state) = spawn_test_daemon_with_state().await;
let mgr = state.session_manager().await;
let shared = std::path::PathBuf::from("/tmp/trusty-mpm-7877-shared-workspace");
let sibling = ManagedSessionId::new();
let target = ManagedSessionId::new();
for id in [sibling, target] {
let record = SessionRecord {
id,
tmux_name: format!("tm-7877-client-{id}"),
cwd: shared.clone(),
task: "task".into(),
state: ManagedSessionState::Active,
created_at: chrono::Utc::now(),
last_activity_at: None,
workspace_path: Some(shared.clone()),
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
};
mgr.store
.write()
.await
.upsert(record)
.await
.expect("seed record");
}
let client = DaemonClient::new(url);
let err = client
.decommission_managed_session(&target.to_string())
.await
.expect_err("a sibling Active session claiming the same workspace must refuse");
let msg = err.to_string();
assert!(
msg.contains(&sibling.to_string()),
"the guard's reason must reach the CLI naming the blocking session, got: {msg}"
);
assert!(
!msg.contains("(500") && !msg.contains("server error"),
"a guard refusal must never look like a server fault, got: {msg}"
);
}
#[tokio::test]
async fn spawn_managed_session_surfaces_daemon_error_body() {
let url = spawn_test_daemon().await;
let client = DaemonClient::new(url);
let req = ManagedSpawnRequest {
repo_url: "https://github.com/acme/unregistered".to_string(),
git_ref: "main".to_string(),
task: "do it".to_string(),
name_hint: None,
runtime: None,
inject_task: None,
deliverable_id: Some("11111111-1111-1111-1111-111111111111".to_string()),
force_new: false,
};
let err = client
.spawn_managed_session(&req)
.await
.expect_err("no project is registered for this repo_url");
let msg = err.to_string();
assert!(
msg.contains("acme/unregistered") || msg.contains("unregistered"),
"error should carry the daemon's body message, not just the bare status: {msg}"
);
assert!(msg.contains("404"), "{msg}");
}
#[test]
fn session_row_deserializes_tmux_name() {
let json = serde_json::json!({
"id": "abcd1234-5678-90ab-cdef-1234567890ab",
"workdir": "/tmp/proj",
"status": "Active",
"active_delegations": 1,
"tmux_name": "tmpm-quiet-falcon"
});
let row: SessionRow = serde_json::from_value(json).unwrap();
assert_eq!(row.tmux_name, "tmpm-quiet-falcon");
}
#[test]
fn session_row_defaults_tmux_name_when_absent() {
let json = serde_json::json!({
"id": "abcd1234-5678-90ab-cdef-1234567890ab",
"workdir": "/tmp/proj",
"status": "Active"
});
let row: SessionRow = serde_json::from_value(json).unwrap();
assert_eq!(row.tmux_name, "");
assert_eq!(row.last_seen.secs_since_epoch, 0);
}
#[test]
fn events_deserialize_from_record_shape() {
let json = serde_json::json!({
"session": "abcd1234-5678-90ab-cdef-1234567890ab",
"event": "PreToolUse",
"at": "2024-01-01T00:00:00Z",
"payload": {}
});
let row: EventRow = serde_json::from_value(json).unwrap();
assert_eq!(row.event, crate::core::hook::HookEvent::PreToolUse);
assert_eq!(row.at, "2024-01-01T00:00:00Z");
}
#[test]
fn events_default_payload_when_absent() {
let json = serde_json::json!({
"session": "abcd1234-5678-90ab-cdef-1234567890ab",
"event": "Stop",
"at": "2024-01-01T00:00:00Z"
});
let row: EventRow = serde_json::from_value(json).unwrap();
assert!(row.payload.is_null());
}
#[test]
fn breakers_deserialize_from_api_shape() {
let json = serde_json::json!({
"agent": "research",
"breaker": { "state": "closed", "consecutive_failures": 0 }
});
#[derive(serde::Deserialize)]
struct WireBreaker {
state: String,
consecutive_failures: u32,
}
#[derive(serde::Deserialize)]
struct WireRow {
agent: String,
breaker: WireBreaker,
}
let row: WireRow = serde_json::from_value(json).unwrap();
assert_eq!(row.agent, "research");
assert_eq!(row.breaker.state, "closed");
assert_eq!(row.breaker.consecutive_failures, 0);
}
#[test]
fn tmux_session_row_accepts_name() {
let obj = serde_json::json!({"name": "tmpm-quiet-falcon"});
assert_eq!(
obj.get("name").and_then(|v| v.as_str()),
Some("tmpm-quiet-falcon")
);
let plain = serde_json::json!("external-shell");
assert_eq!(plain.as_str(), Some("external-shell"));
}
#[test]
fn snapshot_text_handles_each_shape() {
assert_eq!(snapshot_text(&serde_json::json!("plain")), "plain");
assert_eq!(
snapshot_text(&serde_json::json!({"content": "from content"})),
"from content"
);
assert_eq!(
snapshot_text(&serde_json::json!({"lines": ["a", "b"]})),
"a\nb"
);
}
#[test]
fn pair_request_deserializes() {
let json = serde_json::json!({"code": "A4X9KZ", "expires_in_seconds": 300});
let req: PairRequest = serde_json::from_value(json).unwrap();
assert_eq!(req.code, "A4X9KZ");
assert_eq!(req.expires_in_seconds, 300);
}
#[test]
fn pair_confirm_deserializes_failure() {
let json = serde_json::json!({"success": false, "error": "invalid or expired code"});
let confirm: PairConfirm = serde_json::from_value(json).unwrap();
assert!(!confirm.success);
assert_eq!(confirm.error.as_deref(), Some("invalid or expired code"));
assert_eq!(confirm.chat_id, None);
}
#[test]
fn llm_chat_message_round_trips() {
let msg = ChatMessage {
role: "user".into(),
content: "hello".into(),
};
let json = serde_json::to_value(&msg).unwrap();
assert_eq!(json["role"], "user");
assert_eq!(json["content"], "hello");
let back: ChatMessage = serde_json::from_value(json).unwrap();
assert_eq!(back, msg);
}
#[test]
fn chat_message_constructors_set_role() {
assert_eq!(ChatMessage::user("x").role, "user");
assert_eq!(ChatMessage::assistant("y").role, "assistant");
assert_eq!(ChatMessage::user("x").content, "x");
}
#[test]
fn llm_chat_response_deserializes() {
let json = serde_json::json!({
"reply": "hi there",
"history": [
{ "role": "user", "content": "hello" },
{ "role": "assistant", "content": "hi there" },
],
});
let outcome: LlmChatOutcome = serde_json::from_value(json).unwrap();
assert_eq!(outcome.reply, "hi there");
assert_eq!(outcome.history.len(), 2);
assert_eq!(outcome.history[1].role, "assistant");
}
#[test]
fn coordinator_context_deserializes() {
let json = serde_json::json!({
"sessions": [{
"id": "00000000-0000-0000-0000-000000000000",
"name": "tmpm-aipowerranking",
"prefix": "aipowerranking",
"workdir": "/tmp/proj",
"status": "Active",
"active_delegations": 3,
"recent_output": ["building…"],
}],
"recent_events": [],
"generated_at": "2026-05-19T00:00:00Z",
});
let context: CoordinatorContext = serde_json::from_value(json).unwrap();
assert_eq!(context.sessions.len(), 1);
assert_eq!(context.sessions[0].prefix, "aipowerranking");
assert_eq!(context.sessions[0].active_delegations, 3);
assert_eq!(context.sessions[0].last_summary, None);
assert!(!context.sessions[0].summarizing);
}
#[test]
fn coordinator_session_carries_summary_fields() {
let json = serde_json::json!({
"sessions": [{
"id": "00000000-0000-0000-0000-000000000000",
"name": "tmpm-aipowerranking",
"prefix": "aipowerranking",
"workdir": "/tmp/proj",
"status": "Active",
"active_delegations": 0,
"recent_output": [],
"last_summary": "Writing the parser tests",
"summarizing": true,
}],
});
let context: CoordinatorContext = serde_json::from_value(json).unwrap();
assert_eq!(
context.sessions[0].last_summary.as_deref(),
Some("Writing the parser tests")
);
assert!(context.sessions[0].summarizing);
}
#[test]
fn coordinator_chat_outcome_deserializes() {
let json = serde_json::json!({
"reply": "Sent to tmpm-foo: run tests",
"routed_to_session": "tmpm-foo",
"command_output": "tests passed",
});
let outcome: CoordinatorChatOutcome = serde_json::from_value(json).unwrap();
assert_eq!(outcome.routed_to_session.as_deref(), Some("tmpm-foo"));
assert_eq!(outcome.command_output.as_deref(), Some("tests passed"));
let json = serde_json::json!({ "reply": "two sessions are active" });
let outcome: CoordinatorChatOutcome = serde_json::from_value(json).unwrap();
assert_eq!(outcome.reply, "two sessions are active");
assert!(outcome.routed_to_session.is_none());
assert!(outcome.actions_taken.is_none());
assert!(outcome.conv_id.is_none());
}
#[test]
fn coordinator_chat_outcome_deserializes_actions() {
let json = serde_json::json!({
"reply": "ran a health check and listed the fleet",
"actions_taken": ["sessions.health", "sessions.list"],
"conv_id": "conv-42",
});
let outcome: CoordinatorChatOutcome = serde_json::from_value(json).unwrap();
assert_eq!(
outcome.actions_taken.as_deref(),
Some(["sessions.health".to_string(), "sessions.list".to_string()].as_slice())
);
assert_eq!(outcome.conv_id.as_deref(), Some("conv-42"));
}
#[test]
fn coordinator_chat_serializes_actions_flag() {
let history = [ChatMessage::user("hi"), ChatMessage::assistant("hello")];
let body = coordinator_chat_body("spin up a session", &history, true);
assert_eq!(body["message"], "spin up a session");
assert_eq!(body["actions"], true);
assert_eq!(body["history"][0]["role"], "user");
assert_eq!(body["history"][1]["content"], "hello");
let passive = coordinator_chat_body("just chatting", &[], false);
assert_eq!(passive["actions"], false);
}
#[test]
fn pair_status_deserializes() {
let json = serde_json::json!({"paired": true, "chat_id": 12345678});
let status: PairStatus = serde_json::from_value(json).unwrap();
assert!(status.paired);
assert_eq!(status.chat_id, Some(12345678));
}
#[test]
fn catalog_stale_health_body_wire_shape() {
#[derive(serde::Deserialize)]
struct HealthBody {
#[serde(default)]
catalog_stale: bool,
}
let stale: HealthBody =
serde_json::from_value(serde_json::json!({"status":"ok","catalog_stale":true})).unwrap();
assert!(stale.catalog_stale);
let fresh: HealthBody =
serde_json::from_value(serde_json::json!({"status":"ok","catalog_stale":false})).unwrap();
assert!(!fresh.catalog_stale);
let legacy: HealthBody = serde_json::from_value(serde_json::json!({"status":"ok"})).unwrap();
assert!(!legacy.catalog_stale, "missing field defaults to false");
}
#[test]
fn managed_session_summary_deserializes() {
let json = serde_json::json!({
"id": "00000000-0000-0000-0000-000000000001",
"name": "tmpm-brave-otter",
"state": "running",
"workspace_path": "/tmp/ws",
"repo_url": "https://example.com/r.git",
"branch": "main",
"created_at": "2026-06-19T00:00:00Z",
"last_activity_at": "2026-06-19T01:00:00Z",
"pending_decision": "overwrite?",
"proposed_default": "yes",
"source_id": "owner/repo",
});
let s: ManagedSessionSummary = serde_json::from_value(json).unwrap();
assert_eq!(s.id, "00000000-0000-0000-0000-000000000001");
assert_eq!(s.name, "tmpm-brave-otter");
assert_eq!(s.state, "running");
assert_eq!(s.created_at.as_deref(), Some("2026-06-19T00:00:00Z"));
assert_eq!(s.pending_decision.as_deref(), Some("overwrite?"));
assert_eq!(s.source_id.as_deref(), Some("owner/repo"));
let lean = serde_json::json!({"id": "x", "name": "n", "state": "stopped"});
let s: ManagedSessionSummary = serde_json::from_value(lean).unwrap();
assert_eq!(s.id, "x");
assert!(s.workspace_path.is_none());
assert!(s.created_at.is_none());
assert!(s.pending_decision.is_none());
assert!(s.source_id.is_none());
}
#[test]
fn decommission_outcome_round_trips_daemon_response() {
let removed = serde_json::json!({
"id": "00000000-0000-0000-0000-000000000001",
"name": "tmpm-brave-otter",
"state": "decommissioned",
"workspace_path": serde_json::Value::Null,
"workspace_removed": true,
"workspace_path_was": "/tmp/base/.worktrees/abc",
});
let o: ManagedDecommissionOutcome = serde_json::from_value(removed).unwrap();
assert_eq!(o.summary.id, "00000000-0000-0000-0000-000000000001");
assert_eq!(o.summary.state, "decommissioned");
assert_eq!(o.workspace_removed, Some(true));
assert_eq!(
o.workspace_path_was.as_deref(),
Some("/tmp/base/.worktrees/abc")
);
let kept = serde_json::json!({
"id": "x", "name": "n", "state": "decommissioned", "workspace_removed": false
});
let o: ManagedDecommissionOutcome = serde_json::from_value(kept).unwrap();
assert_eq!(o.workspace_removed, Some(false));
assert!(o.workspace_path_was.is_none());
let silent = serde_json::json!({"id": "x", "name": "n", "state": "decommissioned"});
let o: ManagedDecommissionOutcome = serde_json::from_value(silent).unwrap();
assert_eq!(o.workspace_removed, None);
}
#[test]
fn decommission_outcome_keeps_unmodelled_daemon_fields() {
let body = serde_json::json!({
"id": "x",
"name": "n",
"state": "decommissioned",
"workspace_removed": false,
"workspace_removal_error": "worktree holds uncommitted work",
});
let o: ManagedDecommissionOutcome = serde_json::from_value(body).unwrap();
assert_eq!(o.workspace_removed, Some(false));
assert_eq!(
o.unrecognized
.get("workspace_removal_error")
.and_then(|v| v.as_str()),
Some("worktree holds uncommitted work"),
"an unmodelled daemon field must be retained, not dropped: {:?}",
o.unrecognized
);
for modelled in ["id", "name", "state", "workspace_removed"] {
assert!(
!o.unrecognized.contains_key(modelled),
"{modelled} is modelled and must not be collected as unrecognized"
);
}
}
#[test]
fn managed_session_summary_carries_the_kind() {
use crate::session_manager::SessionKind;
let record: crate::session_manager::SessionRecord = serde_json::from_value(serde_json::json!({
"id": "00000000-0000-0000-0000-000000000001", "task": "t", "tmux_name": "tm-arch",
"cwd": "/tmp", "state": "active", "created_at": "2026-09-30T00:00:00Z",
"kind": "supervisor",
}))
.unwrap();
let summary = crate::daemon::managed_routes::summary::record_to_summary(&record);
let wire = serde_json::to_value(summary).unwrap();
let s: ManagedSessionSummary = serde_json::from_value(wire).unwrap();
assert_eq!(s.kind, Some(SessionKind::Supervisor));
let omitted = serde_json::json!({"id": "x", "name": "n", "state": "stopped"});
let s: ManagedSessionSummary = serde_json::from_value(omitted).unwrap();
assert_eq!(s.kind, None);
}
#[test]
fn managed_session_summary_deserializes_unresumable_flag() {
let with_flag = serde_json::json!({
"id": "x", "name": "n", "state": "stopped", "unresumable": true
});
let s: ManagedSessionSummary = serde_json::from_value(with_flag).unwrap();
assert!(s.unresumable, "unresumable: true must deserialize to true");
let omitted = serde_json::json!({"id": "x", "name": "n", "state": "stopped"});
let s: ManagedSessionSummary = serde_json::from_value(omitted).unwrap();
assert!(
!s.unresumable,
"an older daemon omitting `unresumable` must default to false"
);
}
#[test]
fn managed_session_summary_deserializes_slot_and_deleted() {
let with_fields = serde_json::json!({
"id": "x", "name": "n", "state": "deleted", "slot": 3, "deleted": true
});
let s: ManagedSessionSummary = serde_json::from_value(with_fields).unwrap();
assert_eq!(s.slot, 3);
assert!(s.deleted);
let omitted = serde_json::json!({"id": "x", "name": "n", "state": "stopped"});
let s: ManagedSessionSummary = serde_json::from_value(omitted).unwrap();
assert_eq!(
s.slot, 0,
"an older daemon omitting `slot` must default to 0"
);
assert!(
!s.deleted,
"an older daemon omitting `deleted` must default to false"
);
}
#[test]
fn managed_session_summary_deserializes_stale_assets_flag() {
let with_flag = serde_json::json!({
"id": "x", "name": "n", "state": "active", "stale_assets": true
});
let s: ManagedSessionSummary = serde_json::from_value(with_flag).unwrap();
assert!(
s.stale_assets,
"stale_assets: true must deserialize to true"
);
let omitted = serde_json::json!({"id": "x", "name": "n", "state": "active"});
let s: ManagedSessionSummary = serde_json::from_value(omitted).unwrap();
assert!(
!s.stale_assets,
"an older daemon omitting `stale_assets` must default to false"
);
}
#[test]
fn managed_session_summary_defaults_stale_assets_unchecked_false() {
let with_flag = serde_json::json!({
"id": "x", "name": "n", "state": "stopped", "stale_assets_unchecked": true
});
let s: ManagedSessionSummary = serde_json::from_value(with_flag).unwrap();
assert!(
s.stale_assets_unchecked,
"stale_assets_unchecked: true must deserialize to true"
);
let omitted = serde_json::json!({"id": "x", "name": "n", "state": "stopped"});
let s: ManagedSessionSummary = serde_json::from_value(omitted).unwrap();
assert!(
!s.stale_assets_unchecked,
"an older daemon omitting `stale_assets_unchecked` probed everything — \
its verdicts must stay authoritative, not be repainted as unknown"
);
}
#[test]
fn managed_list_response_deserializes() {
let json = serde_json::json!({
"sessions": [
{"id": "a", "name": "tmpm-a", "state": "running"},
{"id": "b", "name": "tmpm-b", "state": "stopped"},
],
});
let body: ManagedListResponse = serde_json::from_value(json).unwrap();
assert_eq!(body.sessions.len(), 2);
assert_eq!(body.sessions[1].name, "tmpm-b");
let empty: ManagedListResponse = serde_json::from_value(serde_json::json!({})).unwrap();
assert!(empty.sessions.is_empty());
}
#[test]
fn managed_spawn_request_serializes() {
let req = ManagedSpawnRequest {
repo_url: "https://example.com/r.git".to_string(),
git_ref: "main".to_string(),
task: "do the thing".to_string(),
name_hint: Some("tmpm-custom".to_string()),
runtime: Some("tcode".to_string()),
inject_task: None,
deliverable_id: Some("11111111-1111-1111-1111-111111111111".to_string()),
force_new: false,
};
let v = serde_json::to_value(&req).unwrap();
assert_eq!(v["ref"], "main");
assert_eq!(v["repo_url"], "https://example.com/r.git");
assert_eq!(v["name_hint"], "tmpm-custom");
assert_eq!(v["runtime"], "tcode");
assert_eq!(
v["deliverable_id"], "11111111-1111-1111-1111-111111111111",
"Some(deliverable_id) must serialize as its string value"
);
assert!(v.get("git_ref").is_none(), "must use wire key `ref`");
let bare = ManagedSpawnRequest {
repo_url: "r".to_string(),
git_ref: "r".to_string(),
task: "t".to_string(),
name_hint: None,
runtime: None,
inject_task: None,
deliverable_id: None,
force_new: false,
};
let v = serde_json::to_value(&bare).unwrap();
assert!(v.get("name_hint").is_none(), "None name_hint is omitted");
assert!(v.get("runtime").is_none(), "None runtime is omitted");
assert!(
v.get("inject_task").is_none(),
"None inject_task is omitted"
);
assert!(
v.get("deliverable_id").is_none(),
"None deliverable_id is omitted (#2379, same additive pattern as inject_task)"
);
assert!(
v.get("force_new").is_none(),
"false force_new is omitted (#2450) so the wire matches the pre-existing shape"
);
}
#[test]
fn managed_spawn_response_deserializes() {
let json = serde_json::json!({
"id": "id-1",
"name": "tmpm-x",
"state": "running",
"created_at": "2026-06-19T00:00:00Z",
"attach_cmd": "tmux attach-session -t '=tmpm-x'",
"runtime": "claude-code",
});
let r: ManagedSpawnResponse = serde_json::from_value(json).unwrap();
assert_eq!(r.id, "id-1");
assert_eq!(r.created_at.as_deref(), Some("2026-06-19T00:00:00Z"));
assert_eq!(r.attach_cmd, "tmux attach-session -t '=tmpm-x'");
assert_eq!(r.runtime, "claude-code");
let lean = serde_json::json!({"id": "id-2", "name": "tmpm-y", "state": "running"});
let r: ManagedSpawnResponse = serde_json::from_value(lean).unwrap();
assert!(r.created_at.is_none());
assert_eq!(r.attach_cmd, "");
assert_eq!(r.runtime, "");
}
#[test]
fn managed_adopt_request_serializes() {
let req = ManagedAdoptRequest {
tmux_name: "tmpm-hand-started".to_string(),
cwd: "/Users/op/work/proj".to_string(),
task: Some("drive it".to_string()),
runtime: Some("claude-code".to_string()),
};
let v = serde_json::to_value(&req).unwrap();
assert_eq!(v["tmux_name"], "tmpm-hand-started");
assert_eq!(v["cwd"], "/Users/op/work/proj");
assert_eq!(v["task"], "drive it");
assert_eq!(v["runtime"], "claude-code");
let bare = ManagedAdoptRequest {
tmux_name: "my-cli-session".to_string(),
cwd: "/x".to_string(),
task: None,
runtime: None,
};
let v = serde_json::to_value(&bare).unwrap();
assert_eq!(v["tmux_name"], "my-cli-session");
assert!(v.get("task").is_none(), "None task is omitted");
assert!(v.get("runtime").is_none(), "None runtime is omitted");
}
#[test]
fn managed_adopt_response_deserializes() {
let json = serde_json::json!({
"id": "id-9",
"name": "tmpm-hand-started",
"state": "active",
"cwd": "/Users/op/work/proj",
"runtime": "claude-code",
"attach_cmd": "tmux attach-session -t '=tmpm-hand-started'",
});
let r: ManagedAdoptResponse = serde_json::from_value(json).unwrap();
assert_eq!(r.id, "id-9");
assert_eq!(r.name, "tmpm-hand-started");
assert_eq!(r.state, "active");
assert_eq!(r.cwd, "/Users/op/work/proj");
assert_eq!(r.runtime, "claude-code");
assert_eq!(r.attach_cmd, "tmux attach-session -t '=tmpm-hand-started'");
let lean = serde_json::json!({"id": "id-10", "name": "x", "state": "active"});
let r: ManagedAdoptResponse = serde_json::from_value(lean).unwrap();
assert_eq!(r.cwd, "");
assert_eq!(r.runtime, "");
assert_eq!(r.attach_cmd, "");
}
#[test]
fn managed_send_and_answer_round_trip() {
let v = serde_json::to_value(ManagedSendInputRequest {
text: "hello".to_string(),
})
.unwrap();
assert_eq!(v["text"], "hello");
let sent: ManagedSendInputResponse =
serde_json::from_value(serde_json::json!({"sent": true, "tmux_name": "tmpm-x"})).unwrap();
assert!(sent.sent);
assert_eq!(sent.tmux_name, "tmpm-x");
let v = serde_json::to_value(ManagedAnswerRequest {
answer: "yes".to_string(),
})
.unwrap();
assert_eq!(v["answer"], "yes");
let answered: ManagedAnswerResponse =
serde_json::from_value(serde_json::json!({"injected": true, "tmux_name": "tmpm-x"}))
.unwrap();
assert!(answered.injected);
}
#[test]
fn managed_attach_cmd_response_deserializes() {
let r: ManagedAttachCmdResponse = serde_json::from_value(
serde_json::json!({"attach_cmd": "tmux attach-session -t '=tmpm-x'"}),
)
.unwrap();
assert_eq!(r.attach_cmd, "tmux attach-session -t '=tmpm-x'");
}
#[test]
fn managed_activity_response_deserializes() {
let json = serde_json::json!({
"raw_pane": "line1\nline2",
"runtime_active": true,
"state": "working",
"summary": "running tests",
"confidence": 0.8_f32,
"cache_hit": false,
"input_tokens": 10,
"output_tokens": 5,
"latency_ms": 42,
"total_input_tokens": 100,
"total_output_tokens": 50,
"classification": "working",
"pending_decision": "overwrite?",
"proposed_default": "yes",
});
let a: ManagedActivityResponse = serde_json::from_value(json).unwrap();
assert_eq!(a.state, "working");
assert!(a.runtime_active);
assert_eq!(a.classification.as_deref(), Some("working"));
assert_eq!(a.pending_decision.as_deref(), Some("overwrite?"));
let json = serde_json::json!({
"raw_pane": "pane",
"runtime_active": false,
"state": "unknown",
"summary": "",
"confidence": 0.0_f32,
"cache_hit": false,
"input_tokens": 0,
"output_tokens": 0,
"latency_ms": 0,
"total_input_tokens": 0,
"total_output_tokens": 0,
"classification": null,
});
let a: ManagedActivityResponse = serde_json::from_value(json).unwrap();
assert_eq!(a.raw_pane, "pane");
assert!(a.classification.is_none());
assert!(a.pending_decision.is_none());
}
#[test]
fn managed_session_urls_use_managed_route_family() {
let client = DaemonClient::new("http://127.0.0.1:7880");
assert_eq!(client.base_url(), "http://127.0.0.1:7880");
let base = client.base_url();
assert_eq!(
format!("{base}/api/v1/sessions/managed/{id}/resume", id = "abc"),
"http://127.0.0.1:7880/api/v1/sessions/managed/abc/resume"
);
assert_eq!(
format!("{base}/sessions/{id}/resume", id = "abc"),
"http://127.0.0.1:7880/sessions/abc/resume"
);
}
#[test]
fn health_snapshot_deserializes() {
let full: HealthSnapshot = serde_json::from_value(serde_json::json!({
"status": "ok",
"catalog_stale": true,
"catalog_unknown": false,
"catalog_changes": ["agents/foo"],
"version": "0.42.0",
"supervised": false,
"pid": 98606,
"unsupervised_forced": true,
}))
.expect("full health body parses");
assert_eq!(full.status, "ok");
assert!(full.catalog_stale);
assert!(!full.catalog_unknown);
assert_eq!(full.version, "0.42.0");
assert_eq!(
full.supervised,
Some(false),
"supervised:false must deserialize as Some(false)"
);
assert_eq!(full.pid, Some(98606));
assert!(full.unsupervised_forced);
let minimal: HealthSnapshot =
serde_json::from_value(serde_json::json!({ "status": "ok" })).expect("minimal body parses");
assert_eq!(minimal.status, "ok");
assert!(!minimal.catalog_stale);
assert!(!minimal.catalog_unknown);
assert_eq!(minimal.version, "", "older daemon omitting version → empty");
assert!(
!minimal.unsupervised_forced,
"absent force flag reads as not-forced"
);
}
#[test]
fn health_snapshot_supervised_is_none_when_absent() {
let minimal: HealthSnapshot =
serde_json::from_value(serde_json::json!({ "status": "ok" })).expect("minimal body parses");
assert_eq!(
minimal.supervised, None,
"absent supervised field must be None, not a defaulted bool"
);
}
#[test]
fn health_snapshot_build_id_is_empty_when_absent() {
let minimal: HealthSnapshot =
serde_json::from_value(serde_json::json!({ "status": "ok" })).expect("minimal body parses");
assert_eq!(minimal.build_id, "");
let full: HealthSnapshot = serde_json::from_value(serde_json::json!({
"status": "ok",
"build_id": "1757731440:74125312",
}))
.expect("body with the field parses");
assert_eq!(full.build_id, "1757731440:74125312");
}
#[test]
fn health_snapshot_pid_is_none_when_absent() {
let minimal: HealthSnapshot =
serde_json::from_value(serde_json::json!({ "status": "ok" })).expect("minimal body parses");
assert_eq!(minimal.pid, None);
}
#[test]
fn health_snapshot_degraded_defaults_to_empty() {
let minimal: HealthSnapshot =
serde_json::from_value(serde_json::json!({ "status": "ok" })).expect("minimal body parses");
assert!(minimal.degraded.is_empty());
let suspended: HealthSnapshot = serde_json::from_value(serde_json::json!({
"status": "ok",
"degraded": ["pr-cleanup sweep suspended: run `gh auth login`"],
}))
.expect("body with the field parses");
assert_eq!(suspended.degraded.len(), 1);
assert!(suspended.degraded[0].contains("gh auth login"));
}
#[test]
fn health_snapshot_launchd_supervision_defaults_to_empty() {
let minimal: HealthSnapshot =
serde_json::from_value(serde_json::json!({ "status": "ok" })).expect("minimal body parses");
assert_eq!(minimal.launchd_supervision, "");
let full: HealthSnapshot = serde_json::from_value(serde_json::json!({
"status": "ok",
"launchd_supervision": "unknown",
}))
.expect("body with the field parses");
assert_eq!(full.launchd_supervision, "unknown");
}
#[test]
fn managed_list_response_carries_store_health_when_degraded() {
let json = serde_json::json!({
"sessions": [],
"store_health": {
"message": "/x/sessions.json is corrupt",
"corrupt": true,
"observed_at": "2026-08-06T09:01:17Z",
},
});
let body: ManagedListResponse = serde_json::from_value(json).unwrap();
let health = body.store_health.expect("store_health must deserialize");
assert!(health.corrupt);
assert_eq!(health.message, "/x/sessions.json is corrupt");
let older: ManagedListResponse =
serde_json::from_value(serde_json::json!({"sessions": []})).unwrap();
assert!(
older.store_health.is_none(),
"a daemon that omits the field must still deserialize"
);
}