use serde::{Deserialize, Serialize};
pub const CONTROL_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ControlRequest {
pub v: u32,
pub request_id: String,
#[serde(rename = "type")]
pub msg_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub signal: Option<String>,
pub sent_at: String,
pub team: String,
pub session_id: String,
pub agent_id: String,
pub sender: String,
pub action: ControlAction,
#[serde(rename = "content", skip_serializing_if = "Option::is_none")]
pub payload: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_ref: Option<ContentRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub elicitation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub decision: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ControlAction {
Stdin,
Interrupt,
ElicitationResponse,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ControlAck {
pub request_id: String,
pub result: ControlResult,
pub duplicate: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
pub acked_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ControlResult {
Ok,
NotLive,
NotFound,
Busy,
Timeout,
Rejected,
InternalError,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContentRef {
pub path: String,
pub size_bytes: u64,
pub sha256: String,
pub mime: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_request_round_trip() {
let req = ControlRequest {
v: CONTROL_SCHEMA_VERSION,
request_id: "req-1".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: "sess-1".to_string(),
agent_id: "arch-ctm".to_string(),
sender: "team-lead".to_string(),
action: ControlAction::Stdin,
payload: Some("hello".to_string()),
content_ref: None,
elicitation_id: None,
decision: None,
};
let json = serde_json::to_string(&req).expect("serialize request");
assert!(
json.contains("\"content\":"),
"serialized ControlRequest must use key \"content\" not \"payload\"; got: {json}"
);
assert!(
!json.contains("\"payload\":"),
"serialized ControlRequest must not contain key \"payload\"; got: {json}"
);
let decoded: ControlRequest = serde_json::from_str(&json).expect("deserialize request");
assert_eq!(decoded, req);
}
#[test]
fn control_ack_round_trip() {
let ack = ControlAck {
request_id: "req-2".to_string(),
result: ControlResult::Ok,
duplicate: false,
detail: Some("accepted".to_string()),
acked_at: "2026-02-21T00:00:01Z".to_string(),
};
let json = serde_json::to_string(&ack).expect("serialize ack");
let decoded: ControlAck = serde_json::from_str(&json).expect("deserialize ack");
assert_eq!(decoded, ack);
}
#[test]
fn content_ref_round_trip() {
let cref = ContentRef {
path: std::env::temp_dir()
.join("input.txt")
.to_string_lossy()
.into_owned(),
size_bytes: 12,
sha256: "abc123".to_string(),
mime: "text/plain".to_string(),
expires_at: Some("2026-02-21T00:10:00Z".to_string()),
};
let json = serde_json::to_string(&cref).expect("serialize content ref");
let decoded: ContentRef = serde_json::from_str(&json).expect("deserialize content ref");
assert_eq!(decoded, cref);
}
}