use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentRecord {
pub agent_id: String,
pub role: String,
#[serde(default)]
pub labels: Vec<String>,
pub endpoint: String,
pub pid: u32,
#[serde(default)]
pub version: String,
pub started_at: DateTime<Utc>,
pub lease_expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSpec {
pub assignment: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub messages: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ParentFrame {
Run(RunSpec),
Cancel,
Message {
text: String,
},
ApprovalReply {
id: String,
approved: bool,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ChildFrame {
Event { event: serde_json::Value },
ApprovalRequest { id: String, body: serde_json::Value },
Terminal {
status: TerminalStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
result: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
transcript: Vec<serde_json::Value>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TerminalStatus {
Completed,
Error,
Cancelled,
Suspended,
}
impl ParentFrame {
pub fn to_text(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
}
pub fn from_text(s: &str) -> serde_json::Result<Self> {
serde_json::from_str(s)
}
}
impl ChildFrame {
pub fn to_text(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
}
pub fn from_text(s: &str) -> serde_json::Result<Self> {
serde_json::from_str(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parent_frames_round_trip() {
for f in [
ParentFrame::Run(RunSpec {
assignment: "do x".into(),
reasoning_effort: None,
messages: Vec::new(),
}),
ParentFrame::Cancel,
ParentFrame::Message { text: "hi".into() },
] {
assert_eq!(ParentFrame::from_text(&f.to_text()).unwrap(), f);
}
}
#[test]
fn child_frames_round_trip() {
let e = ChildFrame::Event {
event: serde_json::json!({"type":"token","content":"hi"}),
};
assert_eq!(ChildFrame::from_text(&e.to_text()).unwrap(), e);
let t = ChildFrame::Terminal {
status: TerminalStatus::Completed,
result: Some("done".into()),
error: None,
transcript: Vec::new(),
};
assert_eq!(ChildFrame::from_text(&t.to_text()).unwrap(), t);
let s = ChildFrame::Terminal {
status: TerminalStatus::Suspended,
result: None,
error: None,
transcript: vec![serde_json::json!({"role":"assistant","content":"x"})],
};
assert_eq!(ChildFrame::from_text(&s.to_text()).unwrap(), s);
let areq = ChildFrame::ApprovalRequest {
id: "a1".into(),
body: serde_json::json!({
"tool_name": "Write",
"permission_type": "WriteFile",
"resource": "/tmp/x",
"question": "approve?",
}),
};
assert_eq!(ChildFrame::from_text(&areq.to_text()).unwrap(), areq);
let areply = ParentFrame::ApprovalReply {
id: "a1".into(),
approved: true,
};
assert_eq!(ParentFrame::from_text(&areply.to_text()).unwrap(), areply);
}
#[test]
fn run_frame_tag_is_stable() {
let f = ParentFrame::Run(RunSpec {
assignment: "a".into(),
reasoning_effort: Some("high".into()),
messages: Vec::new(),
});
let v: serde_json::Value = serde_json::from_str(&f.to_text()).unwrap();
assert_eq!(v["kind"], "run");
assert_eq!(v["assignment"], "a");
}
#[test]
fn run_frame_without_messages_parses_backward_compat() {
let parsed = ParentFrame::from_text(r#"{"kind":"run","assignment":"x"}"#).unwrap();
match parsed {
ParentFrame::Run(spec) => {
assert_eq!(spec.assignment, "x");
assert!(spec.messages.is_empty());
}
other => panic!("expected run frame, got {other:?}"),
}
}
}