use serde::{Deserialize, Serialize};
use crate::event::AgentEvent;
pub const PROTOCOL_VERSION: u32 = 1;
pub const USER: &str = "user";
pub fn is_inbound(request: &Request) -> bool {
matches!(
request,
Request::Notify { .. } | Request::Interrupt { .. } | Request::Wake { .. }
)
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionId(pub String);
impl SessionId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn user() -> Self {
Self(USER.to_string())
}
pub fn agent(id: impl AsRef<str>) -> Self {
Self(format!("agent:{}", id.as_ref()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<String> for SessionId {
fn from(id: String) -> Self {
Self(id)
}
}
impl From<&str> for SessionId {
fn from(id: &str) -> Self {
Self(id.to_string())
}
}
impl std::fmt::Display for SessionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemberState {
#[default]
Idle,
Running,
Done,
Failed,
}
impl MemberState {
pub fn label(self) -> &'static str {
match self {
MemberState::Idle => "idle",
MemberState::Running => "running",
MemberState::Done => "done",
MemberState::Failed => "failed",
}
}
}
#[cfg(test)]
mod member_state_tests {
use super::*;
use serde_json::json;
#[test]
fn serde_is_snake_case() {
assert_eq!(
serde_json::to_value(MemberState::Running).unwrap(),
json!("running")
);
assert_eq!(
serde_json::from_str::<MemberState>("\"failed\"").unwrap(),
MemberState::Failed
);
assert_eq!(MemberState::Done.label(), "done");
}
#[test]
fn absent_state_field_defaults_to_idle() {
let info: SessionInfo = serde_json::from_value(json!({"id": "agent:w1"})).unwrap();
assert_eq!(info.id, SessionId::agent("w1"));
assert_eq!(info.state, MemberState::Idle);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionInfo {
pub id: SessionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<u64>,
#[serde(default)]
pub state: MemberState,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Request {
Submit { text: String },
Notify { content: String },
#[serde(alias = "steer")]
Interrupt { content: String },
#[serde(alias = "follow_up")]
Wake { content: String },
Cancel,
SetModel { model: String },
SetEffort { effort: Option<String> },
Compact { instructions: Option<String> },
SetPlanMode { on: bool },
SideAsk { text: String },
GetHistory,
Status,
ListSessions,
ListModels,
Define {
name: Option<String>,
model: Option<String>,
role: Option<String>,
tools: Option<Vec<String>>,
base_url: Option<String>,
api_key: Option<String>,
#[serde(default)]
read_only: bool,
#[serde(default)]
effort: Option<String>,
},
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Frame<P> {
pub v: u32,
pub id: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply_to: Option<u64>,
pub session: SessionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sender: Option<SessionId>,
#[serde(flatten)]
pub body: P,
}
impl<P> Frame<P> {
pub fn new(id: u64, session: SessionId, body: P) -> Self {
Self {
v: PROTOCOL_VERSION,
id,
reply_to: None,
session,
sender: None,
body,
}
}
}
pub type RequestFrame = Frame<Request>;
pub type EventFrame = Frame<AgentEvent>;
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{TodoItem, TodoStatus};
use serde::de::DeserializeOwned;
use serde_json::json;
fn roundtrip<P>(p: P, expected_tag: &str)
where
P: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug,
{
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["type"], expected_tag);
let back: P = serde_json::from_value(v).unwrap();
assert_eq!(back, p);
}
#[test]
fn request_roundtrip_all_variants() {
roundtrip(Request::Submit { text: "hi".into() }, "submit");
roundtrip(Request::Cancel, "cancel");
roundtrip(Request::SetModel { model: "m1".into() }, "set_model");
roundtrip(
Request::SetEffort {
effort: Some("high".into()),
},
"set_effort",
);
roundtrip(Request::SetEffort { effort: None }, "set_effort");
roundtrip(
Request::Compact {
instructions: Some("focus on the parser".into()),
},
"compact",
);
roundtrip(Request::Compact { instructions: None }, "compact");
roundtrip(Request::SetPlanMode { on: true }, "set_plan_mode");
roundtrip(Request::Notify { content: "n".into() }, "notify");
roundtrip(Request::Interrupt { content: "i".into() }, "interrupt");
roundtrip(Request::Wake { content: "w".into() }, "wake");
roundtrip(Request::SideAsk { text: "why?".into() }, "side_ask");
roundtrip(Request::Status, "status");
roundtrip(Request::ListSessions, "list_sessions");
roundtrip(Request::ListModels, "list_models");
roundtrip(
Request::Define {
name: Some("w1".into()),
model: Some("m".into()),
role: Some("reviewer".into()),
tools: Some(vec!["read".into()]),
base_url: Some("http://w/v1".into()),
api_key: Some("k".into()),
read_only: true,
effort: Some("high".into()),
},
"define",
);
roundtrip(
Request::Define {
name: None,
model: None,
role: None,
tools: None,
base_url: None,
api_key: None,
read_only: false,
effort: None,
},
"define",
);
}
#[test]
fn list_models_roundtrips_and_session_info_carries_effort_and_window() {
roundtrip(Request::ListModels, "list_models");
let info = SessionInfo {
id: SessionId::new("root"),
model: Some("m1".into()),
effort: Some("high".into()),
context_window: Some(200_000),
state: MemberState::Idle,
};
let v = serde_json::to_value(&info).unwrap();
assert_eq!(v["effort"], "high");
assert_eq!(v["context_window"], 200_000);
assert_eq!(serde_json::from_value::<SessionInfo>(v).unwrap(), info);
let bare: SessionInfo = serde_json::from_value(json!({"id": "w1"})).unwrap();
assert!(bare.effort.is_none() && bare.context_window.is_none());
let out = serde_json::to_value(SessionInfo {
id: SessionId::new("w1"),
model: None,
effort: None,
context_window: None,
state: MemberState::Idle,
})
.unwrap();
assert!(out.get("effort").is_none() && out.get("context_window").is_none());
}
#[test]
fn legacy_verb_tags_deserialize_to_canonical_variants() {
let steer: Request = serde_json::from_str(r#"{"type":"steer","content":"s"}"#).unwrap();
assert_eq!(steer, Request::Interrupt { content: "s".into() });
let follow: Request = serde_json::from_str(r#"{"type":"follow_up","content":"f"}"#).unwrap();
assert_eq!(follow, Request::Wake { content: "f".into() });
}
#[test]
fn request_unknown_tag_is_caught_not_fatal() {
let back: Request = serde_json::from_str(r#"{"type":"from_the_future"}"#).unwrap();
assert_eq!(back, Request::Unknown);
}
#[test]
fn is_inbound_marks_only_the_delivery_verbs() {
assert!(is_inbound(&Request::Notify { content: "x".into() }));
assert!(is_inbound(&Request::Interrupt { content: "x".into() }));
assert!(is_inbound(&Request::Wake { content: "x".into() }));
assert!(!is_inbound(&Request::Submit { text: "x".into() }));
assert!(!is_inbound(&Request::GetHistory));
assert!(!is_inbound(&Request::Status));
assert!(!is_inbound(&Request::SideAsk { text: "x".into() }));
assert!(!is_inbound(&Request::SetPlanMode { on: true }));
assert!(!is_inbound(&Request::Cancel));
assert!(!is_inbound(&Request::ListSessions));
assert!(!is_inbound(&Request::ListModels));
assert!(!is_inbound(&Request::Define {
name: None,
model: None,
role: None,
tools: None,
base_url: None,
api_key: None,
read_only: false,
effort: None,
}));
}
#[test]
fn frame_flattens_body_and_omits_absent_reply_to() {
let f = Frame::new(7, SessionId::new("s1"), Request::Cancel);
let v = serde_json::to_value(&f).unwrap();
assert_eq!(
v,
json!({"v": PROTOCOL_VERSION, "id": 7, "session": "s1", "type": "cancel"})
);
let back: Frame<Request> = serde_json::from_value(v).unwrap();
assert_eq!(back, f);
}
#[test]
fn frame_carries_reply_to_and_an_event_body() {
let f = EventFrame {
v: PROTOCOL_VERSION,
id: 8,
reply_to: Some(7),
session: SessionId::from("s1"),
sender: None,
body: AgentEvent::AgentEnd,
};
let v = serde_json::to_value(&f).unwrap();
assert_eq!(
v,
json!({"v": PROTOCOL_VERSION, "id": 8, "reply_to": 7, "session": "s1", "type": "agent_end"})
);
let back: EventFrame = serde_json::from_value(v.clone()).unwrap();
assert_eq!(serde_json::to_value(&back).unwrap(), v);
}
#[test]
fn frame_carries_a_todo_event_body() {
let f = EventFrame {
v: PROTOCOL_VERSION,
id: 9,
reply_to: None,
session: SessionId::from("s1"),
sender: None,
body: AgentEvent::Todo {
todos: vec![TodoItem {
content: "write tests".into(),
status: TodoStatus::Pending,
}],
},
};
let v = serde_json::to_value(&f).unwrap();
assert_eq!(
v,
json!({
"v": PROTOCOL_VERSION, "id": 9, "session": "s1", "type": "todo",
"todos": [{"content": "write tests", "status": "pending"}],
})
);
let back: EventFrame = serde_json::from_value(v.clone()).unwrap();
assert_eq!(serde_json::to_value(&back).unwrap(), v);
}
#[test]
fn session_id_serializes_transparently() {
assert_eq!(
serde_json::to_value(SessionId::new("abc")).unwrap(),
json!("abc")
);
assert_eq!(SessionId::new("abc").as_str(), "abc");
assert_eq!(SessionId::new("abc").to_string(), "abc");
assert_eq!(SessionId::from("x"), SessionId(String::from("x")));
assert_eq!(SessionId::user().as_str(), USER);
assert_eq!(SessionId::agent("abc").as_str(), "agent:abc");
}
}