mod support;
use std::collections::BTreeSet;
use std::fs;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use lilo_rm_core::{
CaptureError, CapturePayload, CaptureResponse, CursorExpiredPayload, DoctorPayload, ErrorCode,
ErrorPayload, EventsPayload, KillByPidPayload, KillByPidResponse, KillOutcome, KilledPayload,
LaunchEnv, LaunchSpec, LauncherStatus, Lifecycle, LifecycleCounts, LifecycleLogAvailability,
LogAvailability, McpBridgePayload, McpBridgeResponse, MigrationState, NudgeFailureReason,
NudgeOutcome, NudgePayload, NudgeResponse, RuntimeCapability, RuntimeEvent, RuntimeKind,
RuntimeResponse, ShimLaunchPayload, SpawnedPayload, StatusPayload, TmuxStatus,
ValidateTargetOutcome, ValidateTargetPayload, ValidateTargetResponse, VersionInfo,
VersionPayload, WatcherCounts, WatchersPayload,
};
use support::{other_session_id, session_id, timestamp};
const FIXTURES: [&str; 17] = [
"ack.json",
"capture.json",
"cursor_expired.json",
"doctor.json",
"error.json",
"events.json",
"killed.json",
"kill_by_pid.json",
"mcp_bridge.json",
"nudge.json",
"shim_launch.json",
"spawned.json",
"status.json",
"stopping.json",
"validate_target.json",
"version.json",
"watchers.json",
];
#[test]
fn runtime_response_v0_5_wire_fixtures_round_trip() {
assert_fixture_set();
for (fixture, expected) in expected_responses() {
let path = fixture_dir().join(fixture);
let json = fs::read_to_string(&path).unwrap_or_else(|error| {
panic!("failed to read {}: {error}", path.display());
});
let actual: RuntimeResponse = serde_json::from_str(&json).unwrap_or_else(|error| {
panic!("failed to parse {}: {error}", path.display());
});
assert_eq!(actual, expected, "{fixture}");
assert_eq!(
serde_json::to_value(&actual).expect("serialize response"),
serde_json::from_str::<serde_json::Value>(&json).expect("parse fixture value"),
"{fixture}"
);
}
}
fn assert_fixture_set() {
let actual = fs::read_dir(fixture_dir())
.expect("fixture dir")
.map(|entry| {
let entry = entry.expect("fixture entry");
entry.file_name().to_string_lossy().into_owned()
})
.filter(|name| name.ends_with(".json"))
.collect::<BTreeSet<_>>();
let expected = FIXTURES
.iter()
.map(|name| (*name).to_owned())
.collect::<BTreeSet<_>>();
assert_eq!(actual, expected);
}
fn expected_responses() -> [(&'static str, RuntimeResponse); 17] {
let session_id = session_id();
[
("ack.json", RuntimeResponse::Ack),
(
"capture.json",
RuntimeResponse::Capture(CapturePayload {
response: CaptureResponse::Failed(CaptureError::NotATmuxTarget),
}),
),
(
"cursor_expired.json",
RuntimeResponse::CursorExpired(CursorExpiredPayload { oldest: 7 }),
),
(
"doctor.json",
RuntimeResponse::Doctor(DoctorPayload {
doctor: v05_doctor_response(),
}),
),
(
"error.json",
RuntimeResponse::Error(ErrorPayload {
code: ErrorCode::RuntimeUnavailable,
message: "no launcher registered for runtime kind: missing-runtime".to_owned(),
}),
),
(
"events.json",
RuntimeResponse::Events(EventsPayload {
events: vec![RuntimeEvent::Lost {
session_id: other_session_id(),
evidence: lilo_rm_core::LostEvidence::PidNotAlive,
}],
cursor: 8,
}),
),
(
"killed.json",
RuntimeResponse::Killed(KilledPayload {
outcome: KillOutcome::AlreadyExited,
}),
),
(
"kill_by_pid.json",
RuntimeResponse::KillByPid(KillByPidPayload {
response: KillByPidResponse {
pid: 77689,
signal: 15,
killed_after_grace: false,
outcome: KillOutcome::Signalled,
},
}),
),
(
"mcp_bridge.json",
RuntimeResponse::McpBridge(McpBridgePayload {
response: McpBridgeResponse {
line: Some(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}".to_owned(),
),
},
}),
),
(
"nudge.json",
RuntimeResponse::Nudge(NudgePayload {
response: NudgeResponse {
delivered: false,
outcome: NudgeOutcome::Unsupported(NudgeFailureReason::HeadlessLifecycle),
},
}),
),
(
"shim_launch.json",
RuntimeResponse::ShimLaunch(ShimLaunchPayload {
launch: v05_launch_spec(),
}),
),
(
"spawned.json",
RuntimeResponse::Spawned(SpawnedPayload {
lifecycle: v05_headless_lifecycle(session_id),
event: RuntimeEvent::Running {
session_id,
runtime_pid: 1,
start_time: timestamp(),
},
log_dir: Some(v05_session_log_dir()),
stdout_path: Some(v05_stdout_path()),
stderr_path: Some(v05_stderr_path()),
}),
),
(
"status.json",
RuntimeResponse::Status(StatusPayload {
lifecycles: vec![v05_headless_lifecycle(session_id)],
}),
),
("stopping.json", RuntimeResponse::Stopping),
(
"validate_target.json",
RuntimeResponse::ValidateTarget(ValidateTargetPayload {
response: ValidateTargetResponse {
valid: false,
outcome: ValidateTargetOutcome::InvalidTarget {
message: "invalid spawn target tmux:not-a-pane; expected headless or tmux:<session>:<window>.<pane>"
.to_owned(),
},
},
}),
),
(
"version.json",
RuntimeResponse::Version(VersionPayload {
version: v05_version_info(),
}),
),
(
"watchers.json",
RuntimeResponse::Watchers(WatchersPayload {
watchers: v05_watcher_counts(),
}),
),
]
}
fn fixture_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/v0_5")
}
fn v05_headless_lifecycle(session_id: uuid::Uuid) -> Lifecycle {
let mut lifecycle = Lifecycle::forking(session_id, RuntimeKind::Claude);
assert!(lifecycle.mark_running(lilo_rm_core::ShimReady {
session_id,
shim_pid: 1,
runtime_pid: 1,
start_time: timestamp(),
tmux_pane: None,
}));
lifecycle.log_availability = Some(LogAvailability::Headless {
stdout_path: v05_stdout_path(),
stderr_path: v05_stderr_path(),
});
lifecycle
}
fn v05_session_log_dir() -> PathBuf {
PathBuf::from(
"/tmp/runtime-matters-v0.5-fixture-home/logs/018f6e28-0000-7000-8000-000000000001",
)
}
fn v05_stdout_path() -> PathBuf {
v05_session_log_dir().join("stdout.log")
}
fn v05_stderr_path() -> PathBuf {
v05_session_log_dir().join("stderr.log")
}
fn v05_launch_spec() -> LaunchSpec {
LaunchSpec {
argv: vec!["/Users/alphab/.local/bin/claude".to_owned()],
env: vec![
LaunchEnv::new("RTM", "1"),
LaunchEnv::new("HELIOY_SESSION_ID", session_id().to_string()),
LaunchEnv::new("HELIOY_RUNTIME", "claude"),
LaunchEnv::new("RTM_SESSION_ID", session_id().to_string()),
LaunchEnv::new("RTM_RUNTIME_KIND", "claude"),
],
cwd: "/tmp/rtm".into(),
shell_resume: None,
}
}
fn v05_doctor_response() -> lilo_rm_core::DoctorResponse {
lilo_rm_core::DoctorResponse {
version: v05_version_info(),
socket_path: "/tmp/runtime-matters-v0.5-fixture-home/rtmd.sock".to_owned(),
uptime_secs: 0,
sqlite: MigrationState {
applied: 2,
total: 2,
applied_descriptions: vec!["lifecycle".to_owned(), "probe state".to_owned()],
pending_descriptions: Vec::new(),
},
lifecycles: LifecycleCounts {
forking: 0,
running: 1,
exited: 0,
lost: 0,
},
watchers: v05_watcher_counts(),
launchers: vec![
LauncherStatus {
runtime: "claude".to_owned(),
command: Some("/Users/alphab/.local/bin/claude".to_owned()),
error: None,
},
LauncherStatus {
runtime: "codex".to_owned(),
command: Some(
"/Users/alphab/.local/share/mise/installs/node/25/bin/codex".to_owned(),
),
error: None,
},
],
tmux: TmuxStatus {
available: true,
version: Some("tmux 3.6a".to_owned()),
error: None,
},
log_availability: vec![LifecycleLogAvailability {
session_id: session_id(),
log_availability: LogAvailability::Headless {
stdout_path: v05_stdout_path(),
stderr_path: v05_stderr_path(),
},
}],
last_probe_sweep: Some(v05_probe_sweep()),
recent_lost: Vec::new(),
}
}
fn v05_version_info() -> VersionInfo {
VersionInfo {
version: "0.2.0".to_owned(),
git_sha: "782b3e5e19c5".to_owned(),
protocol_version: "0.4".to_owned(),
capabilities: vec![
RuntimeCapability::StructuredProtocolErrors,
RuntimeCapability::HeadlessStdioLogPaths,
RuntimeCapability::StatusSessionSetFilter,
RuntimeCapability::StatusUpdatedSinceFilter,
RuntimeCapability::TypedNudgeOutcomes,
RuntimeCapability::ValidateTargetPreflight,
RuntimeCapability::EventsCursor,
RuntimeCapability::EventsLongPoll,
RuntimeCapability::TmuxPaneSnapshot,
],
}
}
fn v05_watcher_counts() -> WatcherCounts {
WatcherCounts {
process_exit_watchers: 1,
shim_sockets: 0,
event_waiters: 0,
}
}
fn v05_probe_sweep() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-05-19T11:49:32.054125Z")
.expect("v0.5 probe sweep")
.with_timezone(&Utc)
}