use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LifecycleSource {
pub kind: LifecycleSourceKind,
}
impl LifecycleSource {
pub fn new(kind: LifecycleSourceKind) -> Self {
Self { kind }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LifecycleSourceKind {
ClaudeHook,
AtmMcp,
AgentHook,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SocketRequest {
pub version: u32,
pub request_id: String,
pub command: String,
pub payload: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SocketResponse {
pub version: u32,
pub request_id: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<SocketError>,
}
impl SocketResponse {
pub fn is_ok(&self) -> bool {
self.status == "ok"
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SocketError {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStateInfo {
pub state: String,
pub last_transition: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSummary {
pub agent: String,
pub state: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LaunchConfig {
pub agent: String,
pub team: String,
pub command: String,
pub prompt: Option<String>,
pub timeout_secs: u32,
pub env_vars: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LaunchResult {
pub agent: String,
pub pane_id: String,
pub state: String,
pub warning: Option<String>,
}
pub fn launch_agent(config: &LaunchConfig) -> anyhow::Result<Option<LaunchResult>> {
let payload = match serde_json::to_value(config) {
Ok(v) => v,
Err(e) => anyhow::bail!("Failed to serialize LaunchConfig: {e}"),
};
let request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: new_request_id(),
command: "launch".to_string(),
payload,
};
let response = match query_daemon(&request)? {
Some(r) => r,
None => return Ok(None),
};
if !response.is_ok() {
let msg = response
.error
.map(|e| format!("{}: {}", e.code, e.message))
.unwrap_or_else(|| "unknown daemon error".to_string());
anyhow::bail!("Daemon returned error for launch command: {msg}");
}
let payload = match response.payload {
Some(p) => p,
None => return Ok(None),
};
match serde_json::from_value::<LaunchResult>(payload) {
Ok(result) => Ok(Some(result)),
Err(e) => anyhow::bail!("Failed to parse LaunchResult from daemon response: {e}"),
}
}
pub fn daemon_socket_path() -> anyhow::Result<PathBuf> {
let home = crate::home::get_home_dir()?;
Ok(home.join(".claude/daemon/atm-daemon.sock"))
}
pub fn daemon_pid_path() -> anyhow::Result<PathBuf> {
let home = crate::home::get_home_dir()?;
Ok(home.join(".claude/daemon/atm-daemon.pid"))
}
pub fn daemon_is_running() -> bool {
#[cfg(unix)]
{
let pid_path = match daemon_pid_path() {
Ok(p) => p,
Err(_) => return false,
};
if let Ok(content) = std::fs::read_to_string(&pid_path) {
if let Ok(pid) = content.trim().parse::<i32>() {
return pid_alive(pid);
}
}
false
}
#[cfg(not(unix))]
{
false
}
}
pub fn query_daemon(request: &SocketRequest) -> anyhow::Result<Option<SocketResponse>> {
#[cfg(unix)]
{
query_daemon_unix(request)
}
#[cfg(not(unix))]
{
Ok(None)
}
}
pub fn query_agent_state(agent: &str, team: &str) -> anyhow::Result<Option<AgentStateInfo>> {
let request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: new_request_id(),
command: "agent-state".to_string(),
payload: serde_json::json!({ "agent": agent, "team": team }),
};
let response = match query_daemon(&request)? {
Some(r) => r,
None => return Ok(None),
};
if !response.is_ok() {
return Ok(None);
}
let payload = match response.payload {
Some(p) => p,
None => return Ok(None),
};
match serde_json::from_value::<AgentStateInfo>(payload) {
Ok(info) => Ok(Some(info)),
Err(_) => Ok(None),
}
}
pub fn subscribe_to_agent(
subscriber: &str,
agent: &str,
team: &str,
events: &[String],
) -> anyhow::Result<Option<SocketResponse>> {
let request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: new_request_id(),
command: "subscribe".to_string(),
payload: serde_json::json!({
"subscriber": subscriber,
"agent": agent,
"team": team,
"events": events,
}),
};
query_daemon(&request)
}
pub fn unsubscribe_from_agent(
subscriber: &str,
agent: &str,
team: &str,
) -> anyhow::Result<Option<SocketResponse>> {
let request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: new_request_id(),
command: "unsubscribe".to_string(),
payload: serde_json::json!({
"subscriber": subscriber,
"agent": agent,
"team": team,
}),
};
query_daemon(&request)
}
pub fn query_list_agents() -> anyhow::Result<Option<Vec<AgentSummary>>> {
let request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: new_request_id(),
command: "list-agents".to_string(),
payload: serde_json::Value::Object(Default::default()),
};
let response = match query_daemon(&request)? {
Some(r) => r,
None => return Ok(None),
};
if !response.is_ok() {
return Ok(None);
}
let payload = match response.payload {
Some(p) => p,
None => return Ok(None),
};
match serde_json::from_value::<Vec<AgentSummary>>(payload) {
Ok(agents) => Ok(Some(agents)),
Err(_) => Ok(None),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentPaneInfo {
pub pane_id: String,
pub log_path: String,
}
pub fn query_agent_pane(agent: &str) -> anyhow::Result<Option<AgentPaneInfo>> {
let request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: new_request_id(),
command: "agent-pane".to_string(),
payload: serde_json::json!({ "agent": agent }),
};
let response = match query_daemon(&request)? {
Some(r) => r,
None => return Ok(None),
};
if !response.is_ok() {
return Ok(None);
}
let payload = match response.payload {
Some(p) => p,
None => return Ok(None),
};
match serde_json::from_value::<AgentPaneInfo>(payload) {
Ok(info) => Ok(Some(info)),
Err(_) => Ok(None),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionQueryResult {
pub session_id: String,
pub process_id: u32,
pub alive: bool,
}
pub fn query_session(name: &str) -> anyhow::Result<Option<SessionQueryResult>> {
let request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: new_request_id(),
command: "session-query".to_string(),
payload: serde_json::json!({ "name": name }),
};
let response = match query_daemon(&request)? {
Some(r) => r,
None => return Ok(None),
};
if !response.is_ok() {
return Ok(None);
}
let payload = match response.payload {
Some(p) => p,
None => return Ok(None),
};
match serde_json::from_value::<SessionQueryResult>(payload) {
Ok(result) => Ok(Some(result)),
Err(_) => Ok(None),
}
}
pub fn query_agent_stream_state(
agent: &str,
) -> anyhow::Result<Option<crate::daemon_stream::AgentStreamState>> {
let request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: new_request_id(),
command: "agent-stream-state".to_string(),
payload: serde_json::json!({ "agent": agent }),
};
let response = match query_daemon(&request)? {
Some(r) => r,
None => return Ok(None),
};
if !response.is_ok() {
return Ok(None);
}
let payload = match response.payload {
Some(p) => p,
None => return Ok(None),
};
match serde_json::from_value::<crate::daemon_stream::AgentStreamState>(payload) {
Ok(state) => Ok(Some(state)),
Err(_) => Ok(None),
}
}
pub fn send_control(
request: &crate::control::ControlRequest,
) -> anyhow::Result<crate::control::ControlAck> {
let payload = serde_json::to_value(request)
.map_err(|e| anyhow::anyhow!("Failed to serialize ControlRequest: {e}"))?;
let socket_request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: format!(
"sock-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
),
command: "control".to_string(),
payload,
};
let response = match query_daemon(&socket_request)? {
Some(r) => r,
None => anyhow::bail!("Daemon not reachable (socket not found or connection refused)"),
};
if !response.is_ok() {
let msg = response
.error
.map(|e| format!("{}: {}", e.code, e.message))
.unwrap_or_else(|| "unknown daemon error".to_string());
anyhow::bail!("Daemon returned error for control command: {msg}");
}
let payload = response
.payload
.ok_or_else(|| anyhow::anyhow!("Daemon returned ok status but no payload"))?;
serde_json::from_value::<crate::control::ControlAck>(payload)
.map_err(|e| anyhow::anyhow!("Failed to parse ControlAck from daemon response: {e}"))
}
fn new_request_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let id = std::process::id();
format!("req-{id}-{nanos}")
}
#[cfg(unix)]
fn query_daemon_unix(request: &SocketRequest) -> anyhow::Result<Option<SocketResponse>> {
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::time::Duration;
let socket_path = daemon_socket_path()?;
let stream = match UnixStream::connect(&socket_path) {
Ok(s) => s,
Err(_) => return Ok(None),
};
let timeout = Duration::from_millis(500);
stream.set_read_timeout(Some(timeout)).ok();
stream.set_write_timeout(Some(timeout)).ok();
let request_line = serde_json::to_string(request)?;
{
let mut writer = std::io::BufWriter::new(&stream);
writer.write_all(request_line.as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()?;
}
let mut reader = BufReader::new(&stream);
let mut response_line = String::new();
match reader.read_line(&mut response_line) {
Ok(0) | Err(_) => return Ok(None), Ok(_) => {}
}
let response: SocketResponse = match serde_json::from_str(response_line.trim()) {
Ok(r) => r,
Err(_) => return Ok(None),
};
Ok(Some(response))
}
#[cfg(unix)]
fn pid_alive(pid: i32) -> bool {
unsafe extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
let result = unsafe { kill(pid, 0) };
result == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_socket_request_serialization() {
let req = SocketRequest {
version: 1,
request_id: "req-123".to_string(),
command: "agent-state".to_string(),
payload: serde_json::json!({ "agent": "arch-ctm", "team": "atm-dev" }),
};
let json = serde_json::to_string(&req).unwrap();
let decoded: SocketRequest = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.version, 1);
assert_eq!(decoded.request_id, "req-123");
assert_eq!(decoded.command, "agent-state");
}
#[test]
fn test_socket_response_ok_deserialization() {
let json = r#"{"version":1,"request_id":"req-123","status":"ok","payload":{"state":"idle","last_transition":"2026-02-16T22:30:00Z"}}"#;
let resp: SocketResponse = serde_json::from_str(json).unwrap();
assert!(resp.is_ok());
assert_eq!(resp.request_id, "req-123");
assert!(resp.payload.is_some());
assert!(resp.error.is_none());
}
#[test]
fn test_socket_response_error_deserialization() {
let json = r#"{"version":1,"request_id":"req-456","status":"error","error":{"code":"AGENT_NOT_FOUND","message":"Agent 'unknown' is not tracked"}}"#;
let resp: SocketResponse = serde_json::from_str(json).unwrap();
assert!(!resp.is_ok());
let err = resp.error.unwrap();
assert_eq!(err.code, "AGENT_NOT_FOUND");
}
#[test]
fn test_agent_state_info_deserialization() {
let json = r#"{"state":"idle","last_transition":"2026-02-16T22:30:00Z"}"#;
let info: AgentStateInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.state, "idle");
assert_eq!(info.last_transition.as_deref(), Some("2026-02-16T22:30:00Z"));
}
#[test]
fn test_agent_state_info_missing_transition() {
let json = r#"{"state":"launching"}"#;
let info: AgentStateInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.state, "launching");
assert!(info.last_transition.is_none());
}
#[test]
fn test_query_daemon_no_socket_returns_none() {
let req = SocketRequest {
version: PROTOCOL_VERSION,
request_id: "req-test".to_string(),
command: "agent-state".to_string(),
payload: serde_json::json!({}),
};
let result = query_daemon(&req);
assert!(result.is_ok());
}
#[test]
fn test_new_request_id_is_unique() {
let id1 = new_request_id();
std::thread::sleep(std::time::Duration::from_nanos(1000));
let id2 = new_request_id();
assert!(!id1.is_empty());
assert!(!id2.is_empty());
}
#[test]
fn test_daemon_socket_path_contains_expected_suffix() {
let path = daemon_socket_path().unwrap();
assert!(path.to_string_lossy().ends_with("atm-daemon.sock"));
assert!(path.to_string_lossy().contains(".claude/daemon"));
}
#[test]
fn test_daemon_pid_path_contains_expected_suffix() {
let path = daemon_pid_path().unwrap();
assert!(path.to_string_lossy().ends_with("atm-daemon.pid"));
assert!(path.to_string_lossy().contains(".claude/daemon"));
}
#[test]
fn test_query_agent_state_no_daemon_returns_none() {
let result = query_agent_state("arch-ctm", "atm-dev");
assert!(result.is_ok());
}
#[test]
fn test_agent_pane_info_deserialization() {
let json = r#"{"pane_id":"%42","log_path":"/home/user/.claude/logs/arch-ctm.log"}"#;
let info: AgentPaneInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.pane_id, "%42");
assert_eq!(info.log_path, "/home/user/.claude/logs/arch-ctm.log");
}
#[test]
fn test_query_agent_pane_no_daemon_returns_none() {
let result = query_agent_pane("arch-ctm");
assert!(result.is_ok());
}
#[test]
fn test_launch_config_serialization() {
let mut env_vars = std::collections::HashMap::new();
env_vars.insert("EXTRA_VAR".to_string(), "value".to_string());
let config = LaunchConfig {
agent: "arch-ctm".to_string(),
team: "atm-dev".to_string(),
command: "codex --yolo".to_string(),
prompt: Some("Review the bridge module".to_string()),
timeout_secs: 30,
env_vars,
};
let json = serde_json::to_string(&config).unwrap();
let decoded: LaunchConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.agent, "arch-ctm");
assert_eq!(decoded.team, "atm-dev");
assert_eq!(decoded.command, "codex --yolo");
assert_eq!(decoded.prompt.as_deref(), Some("Review the bridge module"));
assert_eq!(decoded.timeout_secs, 30);
assert_eq!(decoded.env_vars.get("EXTRA_VAR").map(String::as_str), Some("value"));
}
#[test]
fn test_launch_config_no_prompt_serialization() {
let config = LaunchConfig {
agent: "worker-1".to_string(),
team: "my-team".to_string(),
command: "codex --yolo".to_string(),
prompt: None,
timeout_secs: 60,
env_vars: std::collections::HashMap::new(),
};
let json = serde_json::to_string(&config).unwrap();
let decoded: LaunchConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.agent, "worker-1");
assert!(decoded.prompt.is_none());
assert!(decoded.env_vars.is_empty());
}
#[test]
fn test_launch_result_serialization() {
let result = LaunchResult {
agent: "arch-ctm".to_string(),
pane_id: "%42".to_string(),
state: "launching".to_string(),
warning: None,
};
let json = serde_json::to_string(&result).unwrap();
let decoded: LaunchResult = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.agent, "arch-ctm");
assert_eq!(decoded.pane_id, "%42");
assert_eq!(decoded.state, "launching");
assert!(decoded.warning.is_none());
}
#[test]
fn test_launch_result_with_warning_serialization() {
let result = LaunchResult {
agent: "arch-ctm".to_string(),
pane_id: "%7".to_string(),
state: "launching".to_string(),
warning: Some("Readiness timeout reached".to_string()),
};
let json = serde_json::to_string(&result).unwrap();
let decoded: LaunchResult = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.warning.as_deref(), Some("Readiness timeout reached"));
}
#[test]
fn test_session_query_result_serialization() {
let result = SessionQueryResult {
session_id: "abc123".to_string(),
process_id: 12345,
alive: true,
};
let json = serde_json::to_string(&result).unwrap();
let decoded: SessionQueryResult = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.session_id, "abc123");
assert_eq!(decoded.process_id, 12345);
assert!(decoded.alive);
}
#[test]
fn test_session_query_result_dead() {
let json = r#"{"session_id":"xyz789","process_id":99,"alive":false}"#;
let result: SessionQueryResult = serde_json::from_str(json).unwrap();
assert_eq!(result.session_id, "xyz789");
assert_eq!(result.process_id, 99);
assert!(!result.alive);
}
#[test]
fn test_query_session_no_daemon_returns_none() {
let result = query_session("team-lead");
assert!(result.is_ok());
}
#[test]
fn test_launch_agent_no_daemon_returns_none() {
let config = LaunchConfig {
agent: "test-agent".to_string(),
team: "test-team".to_string(),
command: "codex --yolo".to_string(),
prompt: None,
timeout_secs: 5,
env_vars: std::collections::HashMap::new(),
};
let result = launch_agent(&config);
assert!(result.is_ok());
}
#[test]
fn test_agent_summary_serialization() {
let summary = AgentSummary {
agent: "arch-ctm".to_string(),
state: "idle".to_string(),
};
let json = serde_json::to_string(&summary).unwrap();
let decoded: AgentSummary = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.agent, "arch-ctm");
assert_eq!(decoded.state, "idle");
}
#[cfg(unix)]
#[test]
fn test_pid_alive_current_process() {
let pid = std::process::id() as i32;
assert!(pid_alive(pid));
}
#[cfg(unix)]
#[test]
fn test_pid_alive_nonexistent_pid() {
assert!(!pid_alive(i32::MAX));
}
#[test]
fn lifecycle_source_kind_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&LifecycleSourceKind::ClaudeHook).unwrap(),
"\"claude_hook\""
);
assert_eq!(
serde_json::to_string(&LifecycleSourceKind::AtmMcp).unwrap(),
"\"atm_mcp\""
);
assert_eq!(
serde_json::to_string(&LifecycleSourceKind::AgentHook).unwrap(),
"\"agent_hook\""
);
assert_eq!(
serde_json::to_string(&LifecycleSourceKind::Unknown).unwrap(),
"\"unknown\""
);
}
#[test]
fn lifecycle_source_kind_deserializes_snake_case() {
let kind: LifecycleSourceKind = serde_json::from_str("\"claude_hook\"").unwrap();
assert_eq!(kind, LifecycleSourceKind::ClaudeHook);
let kind: LifecycleSourceKind = serde_json::from_str("\"atm_mcp\"").unwrap();
assert_eq!(kind, LifecycleSourceKind::AtmMcp);
let kind: LifecycleSourceKind = serde_json::from_str("\"agent_hook\"").unwrap();
assert_eq!(kind, LifecycleSourceKind::AgentHook);
let kind: LifecycleSourceKind = serde_json::from_str("\"unknown\"").unwrap();
assert_eq!(kind, LifecycleSourceKind::Unknown);
}
#[test]
fn lifecycle_source_round_trip() {
let src = LifecycleSource::new(LifecycleSourceKind::AtmMcp);
let json = serde_json::to_string(&src).unwrap();
assert!(json.contains("\"atm_mcp\""), "serialized: {json}");
let decoded: LifecycleSource = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.kind, LifecycleSourceKind::AtmMcp);
}
#[test]
fn hook_event_payload_without_source_is_backward_compatible() {
let json = r#"{
"version": 1,
"request_id": "req-test",
"command": "hook-event",
"payload": {
"event": "session_start",
"agent": "team-lead",
"team": "atm-dev",
"session_id": "abc-123"
}
}"#;
let req: SocketRequest = serde_json::from_str(json).unwrap();
assert!(req.payload.get("source").is_none());
assert_eq!(req.command, "hook-event");
}
#[test]
fn hook_event_payload_with_atm_mcp_source_parses() {
let json = r#"{
"version": 1,
"request_id": "req-mcp",
"command": "hook-event",
"payload": {
"event": "session_start",
"agent": "arch-ctm",
"team": "atm-dev",
"session_id": "codex:abc-123",
"source": {"kind": "atm_mcp"}
}
}"#;
let req: SocketRequest = serde_json::from_str(json).unwrap();
let source: LifecycleSource =
serde_json::from_value(req.payload["source"].clone()).unwrap();
assert_eq!(source.kind, LifecycleSourceKind::AtmMcp);
}
#[test]
fn test_send_control_no_daemon_returns_err() {
use crate::control::{ControlAction, ControlRequest, CONTROL_SCHEMA_VERSION};
let req = ControlRequest {
v: CONTROL_SCHEMA_VERSION,
request_id: "req-test-ctrl".to_string(),
msg_type: "control.stdin.request".to_string(),
signal: None,
sent_at: "2026-02-21T00:00:00Z".to_string(),
team: "atm-dev".to_string(),
session_id: String::new(),
agent_id: "arch-ctm".to_string(),
sender: "tui".to_string(),
action: ControlAction::Stdin,
payload: Some("hello".to_string()),
content_ref: None,
};
let result = send_control(&req);
assert!(
result.is_err(),
"send_control should return Err when daemon is not running"
);
}
#[test]
fn test_send_control_builds_correct_socket_request() {
use crate::control::{ControlAction, ControlRequest, CONTROL_SCHEMA_VERSION};
let req = ControlRequest {
v: CONTROL_SCHEMA_VERSION,
request_id: "req-ctrl-check".to_string(),
msg_type: "control.interrupt.request".to_string(),
signal: Some("interrupt".to_string()),
sent_at: "2026-02-21T00:00:00Z".to_string(),
team: "atm-dev".to_string(),
session_id: String::new(),
agent_id: "arch-ctm".to_string(),
sender: "tui".to_string(),
action: ControlAction::Interrupt,
payload: None,
content_ref: None,
};
let control_payload = serde_json::to_value(&req).expect("serialize ControlRequest");
let socket_req = SocketRequest {
version: PROTOCOL_VERSION,
request_id: "sock-test-123".to_string(),
command: "control".to_string(),
payload: control_payload,
};
assert_ne!(
socket_req.request_id, req.request_id,
"socket-level request_id must differ from control payload request_id"
);
assert_eq!(socket_req.request_id, "sock-test-123");
let json = serde_json::to_string(&socket_req).expect("serialize SocketRequest");
assert!(json.contains("\"command\":\"control\""), "command field missing");
assert!(
json.contains("\"request_id\":\"req-ctrl-check\""),
"control payload request_id must appear inside the payload body"
);
assert!(
json.contains("\"request_id\":\"sock-test-123\""),
"socket-level request_id must appear in the outer envelope"
);
assert!(
json.contains("\"type\":\"control.interrupt.request\""),
"msg_type field missing from control payload"
);
assert!(json.contains("\"interrupt\""), "interrupt signal missing");
}
}