use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: &str = "0.4";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Attachment {
pub kind: String,
pub url: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub filename: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub mime_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub size: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TurnObject {
#[serde(rename = "type")]
pub event_type: &'static str,
pub message: String,
pub session_id: String,
pub from_user: String,
pub protocol_version: &'static str,
pub session_name: String,
pub attachments: Vec<Attachment>,
#[serde(skip_serializing_if = "is_false")]
pub permission: bool,
}
impl TurnObject {
pub fn new(
message: impl Into<String>,
session_id: impl Into<String>,
session_name: impl Into<String>,
from_user: impl Into<String>,
attachments: Vec<Attachment>,
permission: bool,
) -> Self {
Self {
event_type: "turn",
message: message.into(),
session_id: session_id.into(),
from_user: from_user.into(),
protocol_version: PROTOCOL_VERSION,
session_name: session_name.into(),
attachments,
permission,
}
}
pub fn to_ndjson(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct TurnInput {
#[serde(rename = "type", default)]
pub event_type: Option<String>,
#[serde(default)]
pub message: String,
#[serde(default)]
pub session_id: String,
#[serde(default = "default_session_name")]
pub session_name: String,
#[serde(default)]
pub from_user: String,
#[serde(default)]
pub attachments: Vec<Attachment>,
#[serde(default)]
pub permission: bool,
#[serde(default)]
pub protocol_version: String,
}
fn default_session_name() -> String {
"default".to_string()
}
impl TurnInput {
pub fn has_content(&self) -> bool {
!self.message.is_empty() || !self.attachments.is_empty()
}
}
pub fn read_turn() -> Option<TurnInput> {
use std::io::BufRead;
let stdin = std::io::stdin();
let mut line = String::new();
let mut handle = stdin.lock();
if handle.read_line(&mut line).ok()? == 0 {
return None;
}
serde_json::from_str::<TurnInput>(line.trim()).ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PartialRole {
#[default]
Output,
Thinking,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PermissionRequest {
pub request_id: String,
pub tool_name: String,
#[serde(default)]
pub input: serde_json::Value,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub tool_use_id: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentEvent {
Partial {
text: String,
#[serde(default)]
role: Option<PartialRole>,
#[serde(default)]
session_id: Option<String>,
},
Result {
text: String,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
usage: Option<serde_json::Value>,
},
Error {
message: String,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
usage: Option<serde_json::Value>,
},
PermissionRequest(PermissionRequest),
}
impl AgentEvent {
pub fn session_id(&self) -> Option<&str> {
let sid = match self {
Self::Partial { session_id, .. }
| Self::Result { session_id, .. }
| Self::Error { session_id, .. } => session_id.as_deref(),
Self::PermissionRequest(req) => req.session_id.as_deref(),
};
sid.filter(|s| !s.is_empty())
}
}
pub fn parse_event(line: &str) -> Option<AgentEvent> {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
let obj = value.as_object()?;
let ty = obj.get("type").and_then(|v| v.as_str())?;
let _ = ty;
serde_json::from_value::<AgentEvent>(value).ok()
}
#[derive(Debug, Clone, Serialize)]
pub struct PermissionResponse {
#[serde(rename = "type")]
pub event_type: &'static str,
pub request_id: String,
pub behavior: PermissionBehavior,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_input: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl PermissionResponse {
pub fn allow(request_id: impl Into<String>) -> Self {
Self {
event_type: "permission_response",
request_id: request_id.into(),
behavior: PermissionBehavior::Allow,
updated_input: None,
message: None,
}
}
pub fn deny(request_id: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
event_type: "permission_response",
request_id: request_id.into(),
behavior: PermissionBehavior::Deny,
updated_input: None,
message: Some(reason.into()),
}
}
pub fn to_ndjson(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PermissionBehavior {
Allow,
Deny,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum PermissionDefaultPolicy {
#[default]
Allow,
Deny,
DenyLogged,
Ask,
}
fn is_false(b: &bool) -> bool {
!b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn turn_object_serializes_required_and_optional_fields() {
let turn = TurnObject::new("hi", "", "default", "u1", vec![], false);
let json = turn.to_ndjson().unwrap();
assert!(!json.contains("\"permission\""));
assert!(json.contains("\"type\":\"turn\""));
assert!(json.contains("\"message\":\"hi\""));
assert!(json.contains("\"session_id\":\"\""));
assert!(json.contains("\"from_user\":\"u1\""));
assert!(json.contains("\"protocol_version\":\"0.4\""));
assert!(json.contains("\"session_name\":\"default\""));
assert!(json.contains("\"attachments\":[]"));
}
#[test]
fn turn_object_includes_permission_when_true() {
let turn = TurnObject::new("hi", "s1", "feat", "u1", vec![], true);
let json = turn.to_ndjson().unwrap();
assert!(json.contains("\"permission\":true"));
assert!(json.contains("\"session_id\":\"s1\""));
assert!(json.contains("\"session_name\":\"feat\""));
}
#[test]
fn turn_object_with_attachment() {
let att = Attachment {
kind: "image".into(),
url: "https://x/a.png".into(),
filename: Some("a.png".into()),
mime_type: None,
size: None,
};
let turn = TurnObject::new("see", "", "default", "u1", vec![att], false);
let json = turn.to_ndjson().unwrap();
assert!(json.contains("\"kind\":\"image\""));
assert!(json.contains("\"url\":\"https://x/a.png\""));
assert!(json.contains("\"filename\":\"a.png\""));
assert!(!json.contains("mime_type"));
assert!(!json.contains("\"size\""));
}
#[test]
fn parse_partial_event() {
let ev = parse_event(r#"{"type":"partial","text":"hello "}"#).unwrap();
match ev {
AgentEvent::Partial {
text,
role,
session_id,
} => {
assert_eq!(text, "hello ");
assert_eq!(role, None);
assert_eq!(session_id, None);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn parse_partial_with_thinking_role() {
let ev = parse_event(r#"{"type":"partial","text":"hm","role":"thinking"}"#).unwrap();
match ev {
AgentEvent::Partial {
role: Some(PartialRole::Thinking),
..
} => {}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn parse_partial_with_session_id() {
let ev = parse_event(r#"{"type":"partial","text":"hi","session_id":"sess-1"}"#).unwrap();
assert_eq!(ev.session_id(), Some("sess-1"));
}
#[test]
fn parse_result_event() {
let ev = parse_event(r#"{"type":"result","text":"final","session_id":"s1"}"#).unwrap();
match ev {
AgentEvent::Result {
text,
session_id,
usage,
} => {
assert_eq!(text, "final");
assert_eq!(session_id.as_deref(), Some("s1"));
assert!(usage.is_none());
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn parse_result_with_usage() {
let ev = parse_event(
r#"{"type":"result","text":"","usage":{"input_tokens":1,"output_tokens":2}}"#,
)
.unwrap();
match ev {
AgentEvent::Result { usage: Some(u), .. } => {
assert_eq!(u["input_tokens"], 1);
assert_eq!(u["output_tokens"], 2);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn parse_legacy_text_and_session_are_unknown() {
assert!(parse_event(r#"{"type":"text","text":"final"}"#).is_none());
assert!(parse_event(r#"{"type":"session","id":"sess-1"}"#).is_none());
}
#[test]
fn parse_error_event() {
let ev = parse_event(r#"{"type":"error","message":"boom","session_id":"s1"}"#).unwrap();
match ev {
AgentEvent::Error {
message,
session_id,
..
} => {
assert_eq!(message, "boom");
assert_eq!(session_id.as_deref(), Some("s1"));
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn parse_permission_request_event() {
let ev = parse_event(
r#"{"type":"permission_request","request_id":"1","tool_name":"Bash","input":{"command":"echo hi"},"session_id":"s1"}"#,
)
.unwrap();
match ev {
AgentEvent::PermissionRequest(req) => {
assert_eq!(req.request_id, "1");
assert_eq!(req.tool_name, "Bash");
assert_eq!(req.input["command"], "echo hi");
assert!(req.description.is_none());
assert_eq!(req.session_id.as_deref(), Some("s1"));
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn parse_unknown_type_returns_none() {
assert!(parse_event(r#"{"type":"tool_call","text":"x"}"#).is_none());
}
#[test]
fn parse_malformed_json_returns_none() {
assert!(parse_event("not json").is_none());
assert!(parse_event("").is_none());
assert!(parse_event(r#""just a string""#).is_none());
assert!(parse_event("123").is_none());
}
#[test]
fn parse_missing_type_returns_none() {
assert!(parse_event(r#"{"text":"no type"}"#).is_none());
}
#[test]
fn permission_response_allow_serializes() {
let json = PermissionResponse::allow("42").to_ndjson().unwrap();
assert!(json.contains("\"type\":\"permission_response\""));
assert!(json.contains("\"behavior\":\"allow\""));
assert!(json.contains("\"request_id\":\"42\""));
assert!(!json.contains("updated_input"));
assert!(!json.contains("message"));
}
#[test]
fn permission_response_deny_with_reason() {
let json = PermissionResponse::deny("42", "not allowed")
.to_ndjson()
.unwrap();
assert!(json.contains("\"behavior\":\"deny\""));
assert!(json.contains("\"message\":\"not allowed\""));
}
}