use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionSummary { pub session_id: String, pub title: String, pub channel_name: String, pub chat_id: String, pub message_count: i64, pub last_active_at: i64, #[serde(default, skip_serializing_if = "Option::is_none")] pub archived_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SlashCommandInfo { pub name: String, pub description: String, pub aliases: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct UploadDescriptor { pub upload_id: String, pub name: String, pub media_type: String, pub mime_type: String, pub size: u64, pub expires_at: i64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MessageAttachment { pub index: u32, pub name: String, pub media_type: String, pub mime_type: String, } impl MessageAttachment { pub fn from_media_ref(index: usize, media_ref: &crate::bus::MediaRef) -> Self { let name = std::path::Path::new(&media_ref.path) .file_name() .map(|name| name.to_string_lossy().into_owned()) .filter(|name| !name.is_empty()) .unwrap_or_else(|| "attachment".to_string()); let mime_type = mime_guess::from_path(&name) .first_or_octet_stream() .to_string(); Self { index: u32::try_from(index).unwrap_or(u32::MAX), name, media_type: media_ref.media_type.clone(), mime_type, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HistoryMessage { pub id: String, pub seq: i64, pub role: String, pub content: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning_content: Option, #[serde(default)] pub completion_status: crate::bus::CompletionStatus, pub created_at: i64, #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub attachments: Vec, } impl From for HistoryMessage { fn from(message: crate::bus::CommittedMessage) -> Self { let attachments = message .media_refs .iter() .enumerate() .map(|(index, media_ref)| MessageAttachment::from_media_ref(index, media_ref)) .collect(); Self { id: message.id, seq: message.seq, role: message.role, content: message.content, reasoning_content: message.reasoning_content, completion_status: message.completion_status, created_at: message.created_at, tool_call_id: message.tool_call_id, tool_name: message.tool_name, tool_calls: message.tool_calls, attachments, } } } impl HistoryMessage { pub fn from_message_meta(message: crate::storage::message::MessageMeta) -> Self { let attachments = message .media_refs .as_deref() .and_then(|refs| serde_json::from_str::>(refs).ok()) .unwrap_or_default() .iter() .enumerate() .map(|(index, media_ref)| MessageAttachment::from_media_ref(index, media_ref)) .collect(); Self { id: message.id, seq: message.seq, role: message.role, content: message.content, reasoning_content: message.reasoning_content, completion_status: message.completion_status, created_at: message.created_at, tool_call_id: message.tool_call_id, tool_name: message.tool_name, tool_calls: message .tool_calls .and_then(|calls| serde_json::from_str(&calls).ok()), attachments, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum WsInbound { #[serde(rename = "user_input")] UserInput { content: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] upload_ids: Vec, /// Stable id generated by the client for optimistic-message /// reconciliation. It is optional for older clients and channels. #[serde(default, skip_serializing_if = "Option::is_none")] client_message_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] channel: Option, #[serde(default, skip_serializing_if = "Option::is_none")] chat_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] sender_id: Option, }, #[serde(rename = "clear_history")] ClearHistory { #[serde(default, skip_serializing_if = "Option::is_none")] chat_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, }, #[serde(rename = "create_session")] CreateSession { #[serde(default, skip_serializing_if = "Option::is_none")] title: Option, }, #[serde(rename = "list_sessions")] ListSessions { #[serde(default)] include_archived: bool, }, #[serde(rename = "load_session")] LoadSession { session_id: String }, #[serde(rename = "get_session_history")] GetSessionHistory { session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] limit: Option, }, #[serde(rename = "get_session_plan")] GetSessionPlan { session_id: String }, #[serde(rename = "get_session_stats")] GetSessionStats { session_id: String }, #[serde(rename = "rename_session")] RenameSession { #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, title: String, }, #[serde(rename = "archive_session")] ArchiveSession { #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, }, #[serde(rename = "delete_session")] DeleteSession { #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, }, #[serde(rename = "get_slash_commands")] GetSlashCommands, #[serde(rename = "ping")] Ping, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum WsOutbound { #[serde(rename = "turn_updated")] TurnUpdated { snapshot: crate::session::TurnSnapshot, }, #[serde(rename = "turn_committed")] TurnCommitted { session_id: String, history_revision: i64, messages: Vec, }, #[serde(rename = "assistant_response")] AssistantResponse { id: String, content: String, role: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] attachments: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, }, #[serde(rename = "error")] Error { code: String, message: String }, #[serde(rename = "session_established")] SessionEstablished { session_id: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] capabilities: Vec, }, #[serde(rename = "session_created")] SessionCreated { session_id: String, title: String }, #[serde(rename = "session_list")] SessionList { sessions: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] current_session_id: Option, }, #[serde(rename = "session_loaded")] SessionLoaded { session_id: String, title: String, message_count: i64, }, #[serde(rename = "session_history")] SessionHistory { session_id: String, messages: Vec, }, #[serde(rename = "session_plan")] SessionPlan { session_id: String, plan: Option, }, #[serde(rename = "session_stats")] SessionStats { stats: crate::session::SessionStats }, #[serde(rename = "plan_updated")] PlanUpdated { session_id: String, reason: String, changed_item_ids: Vec, plan: Option, }, #[serde(rename = "session_renamed")] SessionRenamed { session_id: String, title: String }, #[serde(rename = "session_archived")] SessionArchived { session_id: String }, #[serde(rename = "session_deleted")] SessionDeleted { session_id: String }, #[serde(rename = "history_cleared")] HistoryCleared { session_id: String }, #[serde(rename = "slash_commands_list")] SlashCommandsList { commands: Vec }, #[serde(rename = "pong")] Pong, #[serde(rename = "command_executed")] CommandExecuted { message: String }, #[serde(rename = "system_notification")] SystemNotification { content: String, #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, }, } pub fn parse_inbound(raw: &str) -> Result { serde_json::from_str(raw) } pub fn serialize_inbound(msg: &WsInbound) -> Result { serde_json::to_string(msg) } pub fn serialize_outbound(msg: &WsOutbound) -> Result { serde_json::to_string(msg) } #[cfg(test)] mod tests { use super::*; use crate::session::{TurnId, TurnPhase, TurnState, TurnStatus}; #[test] fn turn_updated_serializes_as_one_complete_snapshot_frame() { let frame = WsOutbound::TurnUpdated { snapshot: TurnState { id: TurnId("turn-1".into()), session_id: "cli_chat:client:dialog".into(), message_id: "message-1".into(), revision: 7, status: TurnStatus::Running, phase: TurnPhase::Responding, blocks: Vec::new(), usage: None, error: None, }, }; let json = serialize_outbound(&frame).unwrap(); let value: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(value["type"], "turn_updated"); assert_eq!(value["snapshot"]["revision"], 7); assert_eq!(value["snapshot"]["status"], "running"); } #[test] fn turn_committed_serializes_revision_and_durable_delta() { let frame = WsOutbound::TurnCommitted { session_id: "session".to_string(), history_revision: 7, messages: vec![HistoryMessage { id: "message".to_string(), seq: 7, role: "assistant".to_string(), content: "done".to_string(), reasoning_content: None, completion_status: crate::bus::CompletionStatus::Completed, created_at: 1, tool_call_id: None, tool_name: None, tool_calls: None, attachments: Vec::new(), }], }; let value = serde_json::to_value(frame).unwrap(); assert_eq!(value["type"], "turn_committed"); assert_eq!(value["history_revision"], 7); assert_eq!(value["messages"][0]["id"], "message"); } #[test] fn user_input_preserves_optional_client_message_id() { let inbound = parse_inbound( r#"{"type":"user_input","content":"hello","client_message_id":"client-1"}"#, ) .unwrap(); match inbound { WsInbound::UserInput { client_message_id, upload_ids, .. } => { assert_eq!(client_message_id.as_deref(), Some("client-1")); assert!(upload_ids.is_empty()); } other => panic!("unexpected frame: {other:?}"), } let serialized = serialize_inbound(&WsInbound::UserInput { content: "hello".to_string(), upload_ids: Vec::new(), client_message_id: Some("client-1".to_string()), channel: None, chat_id: None, sender_id: None, }) .unwrap(); assert!(serialized.contains(r#""client_message_id":"client-1""#)); } #[test] fn history_defaults_new_reasoning_fields_for_old_frames() { let message: HistoryMessage = serde_json::from_value(serde_json::json!({ "id": "message", "seq": 1, "role": "assistant", "content": "answer", "created_at": 1, "attachments": [] })) .unwrap(); assert_eq!(message.reasoning_content, None); assert_eq!( message.completion_status, crate::bus::CompletionStatus::Completed ); } #[test] fn session_stats_request_and_response_use_structured_frames() { let inbound = parse_inbound(r#"{"type":"get_session_stats","session_id":"cli_chat:client:dialog"}"#) .unwrap(); assert!(matches!(inbound, WsInbound::GetSessionStats { .. })); let stats = crate::session::SessionStats { session_id: "cli_chat:client:dialog".into(), title: "stats".into(), provider: "provider".into(), model: "model".into(), user_message_count: 1, history_message_count: 2, lifetime_usage: crate::session::LifetimeUsage { input_tokens: 100, output_tokens: 20, total_tokens: 120, cached_input_tokens: Some(40), request_count: 1, turn_count: 1, tracked_since: Some(1), }, context: crate::session::ContextUsage { configured_window_tokens: 128_000, effective_window_tokens: 128_000, used_tokens: 100, remaining_tokens: 127_900, compression_threshold_tokens: 89_600, source: crate::session::ContextUsageSource::Hybrid, last_observed_prompt_tokens: Some(90), observed_at: Some(1), }, created_at: 1, last_active_at: 2, updated_at: 3, }; let value = serde_json::to_value(WsOutbound::SessionStats { stats }).unwrap(); assert_eq!(value["type"], "session_stats"); assert_eq!(value["stats"]["context"]["source"], "hybrid"); assert_eq!(value["stats"]["lifetime_usage"]["input_tokens"], 100); } }