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, #[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 = "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 = "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 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 ); } }