use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::content::ContentBlock;
use super::session::{SessionConfigOption, SessionId, SessionModeId};
use super::tool_call::{ToolCall, ToolCallUpdate};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNotification {
pub session_id: SessionId,
pub update: SessionUpdate,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentChunk {
pub content: ContentBlock,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<MessageId>,
#[serde(default, rename = "_meta", skip_serializing_if = "BTreeMap::is_empty")]
pub meta: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct MessageId(pub String);
impl MessageId {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
}
impl core::fmt::Display for MessageId {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
core::fmt::Display::fmt(&self.0, formatter)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Plan {
pub entries: Vec<PlanEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanEntry {
pub content: String,
pub priority: PlanEntryPriority,
pub status: PlanEntryStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PlanEntryPriority {
High,
Medium,
Low,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanEntryStatus {
Pending,
InProgress,
Completed,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AvailableCommandsUpdate {
pub available_commands: Vec<AvailableCommand>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AvailableCommand {
pub name: String,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input: Option<AvailableCommandInput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AvailableCommandInput {
Unstructured(UnstructuredCommandInput),
#[serde(untagged)]
Other(serde_json::Value),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UnstructuredCommandInput {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurrentModeUpdate {
pub current_mode_id: SessionModeId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigOptionUpdate {
pub config_options: Vec<SessionConfigOption>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionInfoUpdate {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageUpdate {
pub used: u64,
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<Cost>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Cost {
pub amount: f64,
pub currency: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Notice {
pub level: NoticeSeverity,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum NoticeSeverity {
Info,
Warning,
Error,
Other(String),
}
impl Serialize for NoticeSeverity {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let value = match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Error => "error",
Self::Other(name) => name,
};
serializer.serialize_str(value)
}
}
impl<'de> Deserialize<'de> for NoticeSeverity {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Ok(match raw.as_str() {
"info" => Self::Info,
"warning" => Self::Warning,
"error" => Self::Error,
_ => Self::Other(raw),
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "sessionUpdate", rename_all = "snake_case")]
pub enum SessionUpdate {
UserMessageChunk(ContentChunk),
AgentMessageChunk(ContentChunk),
AgentThoughtChunk(ContentChunk),
ToolCall(ToolCall),
ToolCallUpdate(ToolCallUpdate),
Plan(Plan),
AvailableCommandsUpdate(AvailableCommandsUpdate),
CurrentModeUpdate(CurrentModeUpdate),
ConfigOptionUpdate(ConfigOptionUpdate),
SessionInfoUpdate(SessionInfoUpdate),
UsageUpdate(UsageUpdate),
Notice(Notice),
#[serde(untagged)]
Other(serde_json::Value),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::acp::content::TextContent;
use crate::domain::acp::tool_call::{ToolCallId, ToolCallStatus};
use serde_json::json;
#[test]
fn a_content_chunk_round_trips() -> Result<(), serde_json::Error> {
let chunk = ContentChunk {
content: ContentBlock::Text(TextContent {
text: "hello".to_owned(),
annotations: None,
meta: BTreeMap::new(),
}),
message_id: Some(MessageId::new("msg-1")),
meta: BTreeMap::new(),
};
let value = serde_json::to_value(&chunk)?;
assert_eq!(value.get("messageId"), Some(&json!("msg-1")));
let back: ContentChunk = serde_json::from_value(value)?;
assert_eq!(back, chunk);
Ok(())
}
#[test]
fn plan_entry_priority_string_round_trips() -> Result<(), serde_json::Error> {
for (variant, wire) in [
(PlanEntryPriority::High, "high"),
(PlanEntryPriority::Medium, "medium"),
(PlanEntryPriority::Low, "low"),
] {
let value = serde_json::to_value(variant)?;
assert_eq!(value, json!(wire));
let back: PlanEntryPriority = serde_json::from_value(value)?;
assert_eq!(back, variant);
}
Ok(())
}
#[test]
fn plan_entry_status_string_round_trips() -> Result<(), serde_json::Error> {
for (variant, wire) in [
(PlanEntryStatus::Pending, "pending"),
(PlanEntryStatus::InProgress, "in_progress"),
(PlanEntryStatus::Completed, "completed"),
(PlanEntryStatus::Failed, "failed"),
] {
let value = serde_json::to_value(variant)?;
assert_eq!(value, json!(wire));
let back: PlanEntryStatus = serde_json::from_value(value)?;
assert_eq!(back, variant);
}
Ok(())
}
#[test]
fn a_plan_round_trips() -> Result<(), serde_json::Error> {
let plan = Plan {
entries: vec![PlanEntry {
content: "Read the README".to_owned(),
priority: PlanEntryPriority::High,
status: PlanEntryStatus::InProgress,
}],
};
let value = serde_json::to_value(&plan)?;
let entries = value
.get("entries")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| serde_json::Error::io(std::io::Error::other("missing `entries`")))?;
assert_eq!(entries.len(), 1);
assert_eq!(
entries
.first()
.and_then(|e| e.get("content"))
.and_then(serde_json::Value::as_str),
Some("Read the README"),
);
let back: Plan = serde_json::from_value(value)?;
assert_eq!(back, plan);
Ok(())
}
#[test]
fn notice_severity_round_trips() -> Result<(), serde_json::Error> {
for (variant, wire) in [
(NoticeSeverity::Info, "info"),
(NoticeSeverity::Warning, "warning"),
(NoticeSeverity::Error, "error"),
] {
let value = serde_json::to_value(&variant)?;
assert_eq!(value, json!(wire));
let back: NoticeSeverity = serde_json::from_value(value)?;
assert_eq!(back, variant);
}
let other_value = json!("future_severity");
let back: NoticeSeverity = serde_json::from_value(other_value)?;
assert_eq!(back, NoticeSeverity::Other("future_severity".to_owned()));
Ok(())
}
#[test]
fn an_agent_message_chunk_session_update_round_trips() -> Result<(), serde_json::Error> {
let update = SessionUpdate::AgentMessageChunk(ContentChunk {
content: ContentBlock::Text(TextContent {
text: "hi".to_owned(),
annotations: None,
meta: BTreeMap::new(),
}),
message_id: Some(MessageId::new("msg-1")),
meta: BTreeMap::new(),
});
let value = serde_json::to_value(&update)?;
assert_eq!(
value.get("sessionUpdate"),
Some(&json!("agent_message_chunk"))
);
let back: SessionUpdate = serde_json::from_value(value)?;
assert_eq!(back, update);
Ok(())
}
#[test]
fn a_tool_call_session_update_round_trips() -> Result<(), serde_json::Error> {
let update = SessionUpdate::ToolCall(ToolCall {
tool_call_id: ToolCallId::new("call-1"),
title: "Reading".to_owned(),
name: Some("read_file".to_owned()),
kind: None,
status: Some(ToolCallStatus::InProgress),
content: None,
locations: None,
raw_input: None,
raw_output: None,
});
let value = serde_json::to_value(&update)?;
assert_eq!(value.get("sessionUpdate"), Some(&json!("tool_call")));
assert_eq!(value.get("title"), Some(&json!("Reading")));
let back: SessionUpdate = serde_json::from_value(value)?;
assert_eq!(back, update);
Ok(())
}
#[test]
fn a_usage_update_session_update_round_trips() -> Result<(), serde_json::Error> {
let update = SessionUpdate::UsageUpdate(UsageUpdate {
used: 1024,
size: 200_000,
cost: Some(Cost {
amount: 0.42,
currency: "USD".to_owned(),
}),
});
let value = serde_json::to_value(&update)?;
assert_eq!(value.get("sessionUpdate"), Some(&json!("usage_update")));
let back: SessionUpdate = serde_json::from_value(value)?;
assert_eq!(back, update);
Ok(())
}
#[test]
fn a_notice_session_update_round_trips() -> Result<(), serde_json::Error> {
let update = SessionUpdate::Notice(Notice {
level: NoticeSeverity::Warning,
text: "Rate limit approaching".to_owned(),
});
let value = serde_json::to_value(&update)?;
assert_eq!(value.get("sessionUpdate"), Some(&json!("notice")));
assert_eq!(value.get("level"), Some(&json!("warning")));
let back: SessionUpdate = serde_json::from_value(value)?;
assert_eq!(back, update);
Ok(())
}
#[test]
fn an_unknown_session_update_lands_in_the_other_variant() -> Result<(), serde_json::Error> {
let raw = json!({
"sessionUpdate": "future_update",
"payload": "anything"
});
let update: SessionUpdate = serde_json::from_value(raw)?;
match update {
SessionUpdate::Other(value) => {
assert_eq!(value.get("sessionUpdate"), Some(&json!("future_update")));
}
other => {
return Err(serde_json::Error::io(std::io::Error::other(format!(
"expected Other, got {other:?}"
))));
}
}
Ok(())
}
#[test]
fn a_session_notification_round_trips() -> Result<(), serde_json::Error> {
let notification = SessionNotification {
session_id: SessionId::new("session-1"),
update: SessionUpdate::Plan(Plan { entries: vec![] }),
};
let value = serde_json::to_value(¬ification)?;
assert_eq!(value.get("sessionId"), Some(&json!("session-1")));
let back: SessionNotification = serde_json::from_value(value)?;
assert_eq!(back, notification);
Ok(())
}
}