use serde::{Deserialize, Serialize}; use std::collections::HashMap; use crate::providers::ToolCall; /// Provider-private state required to faithfully replay an assistant message. /// /// This is durable conversation data, but it is never presentation data. UI and /// channel projections must not serialize it to end users. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ProviderReasoningState { pub provider: String, pub payload: serde_json::Value, } impl ProviderReasoningState { /// Decode persisted provider state without making conversation history /// unreadable when an old or damaged payload is encountered. pub fn from_json_lossy(value: &str) -> Option { serde_json::from_str(value).ok() } } /// Describes whether a persisted message represents a complete model result. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CompletionStatus { #[default] Completed, Cancelled, Interrupted, } impl CompletionStatus { pub fn as_str(self) -> &'static str { match self { Self::Completed => "completed", Self::Cancelled => "cancelled", Self::Interrupted => "interrupted", } } pub fn from_storage(value: &str) -> Self { match value { "cancelled" => Self::Cancelled, "interrupted" => Self::Interrupted, _ => Self::Completed, } } } // ============================================================================ // ContentBlock - Multimodal content representation (OpenAI-style) // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentBlock { #[serde(rename = "text")] Text { text: String }, #[serde(rename = "image_url")] ImageUrl { image_url: ImageUrlBlock }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageUrlBlock { pub url: String, } impl ContentBlock { pub fn text(content: impl Into) -> Self { Self::Text { text: content.into(), } } pub fn image_url(url: impl Into) -> Self { Self::ImageUrl { image_url: ImageUrlBlock { url: url.into() }, } } } // ============================================================================ // MediaRef - Media reference in ChatMessage (carries type info) // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MediaRef { pub path: String, pub media_type: String, } // ============================================================================ // MediaItem - Media metadata for messages // ============================================================================ #[derive(Debug, Clone)] pub struct MediaItem { pub path: String, // Local file path pub media_type: String, // "image", "audio", "file", "video" pub mime_type: Option, pub original_key: Option, // Feishu file_key for download } impl MediaItem { pub fn new(path: impl Into, media_type: impl Into) -> Self { Self { path: path.into(), media_type: media_type.into(), mime_type: None, original_key: None, } } pub fn to_media_ref(&self) -> MediaRef { MediaRef { path: self.path.clone(), media_type: self.media_type.clone(), } } pub fn from_media_ref(media_ref: &MediaRef) -> Self { Self::new(media_ref.path.clone(), media_ref.media_type.clone()) } } // ============================================================================ // ChatMessage - Used by AgentLoop for LLM conversation history // ============================================================================ /// Whether a message may be surfaced to clients. Hidden messages exist only /// for model replay (internal triggers) and must never appear in history, /// projections or delivery. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum ClientVisibility { #[default] Visible, Hidden, } impl ClientVisibility { pub fn as_str(&self) -> &'static str { match self { Self::Visible => "visible", Self::Hidden => "hidden", } } } /// Where a message Turn originated. Persisted alongside the message so /// clients can render agent-driven continuation output without treating it as /// a user bubble. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum TurnOrigin { #[default] User, AgentContinuation, Scheduled, } impl TurnOrigin { pub fn as_str(&self) -> &'static str { match self { Self::User => "user", Self::AgentContinuation => "agent_continuation", Self::Scheduled => "scheduled", } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatMessage { pub id: String, pub role: String, pub content: String, pub reasoning_content: Option, /// Opaque state used only when replaying history to the same provider. #[serde(skip_serializing_if = "Option::is_none")] pub provider_state: Option, #[serde(skip_serializing_if = "Option::is_none")] pub turn_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub iteration: Option, #[serde(default)] pub completion_status: CompletionStatus, #[serde(default)] pub client_visibility: ClientVisibility, #[serde(default)] pub turn_origin: TurnOrigin, pub media_refs: Vec, pub timestamp: i64, #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tool_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum SourceKind { #[serde(rename = "user_input")] UserInput, #[serde(rename = "system_notification")] SystemNotification, #[serde(rename = "cross_channel")] CrossChannel, #[serde(rename = "external_trigger")] ExternalTrigger, /// A durable signal emitted by a background Agent via `emit_signal`. #[serde(rename = "agent_signal")] AgentSignal, /// A durable background run completion outcome. #[serde(rename = "agent_result")] AgentCompletion, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MessageSource { pub kind: SourceKind, pub from_channel: Option, pub from_session: Option, pub from_user_id: Option, pub system_name: Option, pub task_id: Option, /// Durable Agent run identity for `agent_signal`/`agent_result` sources. #[serde(default)] pub from_run_id: Option, /// Agent definition id for `agent_signal`/`agent_result` sources. #[serde(default)] pub from_agent_id: Option, } impl ChatMessage { pub fn user(content: impl Into) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), role: "user".to_string(), content: content.into(), reasoning_content: None, provider_state: None, turn_id: None, iteration: None, completion_status: CompletionStatus::Completed, media_refs: Vec::new(), timestamp: current_timestamp(), tool_call_id: None, tool_name: None, tool_calls: None, source: None, client_visibility: ClientVisibility::Visible, turn_origin: TurnOrigin::User, } } pub fn user_with_media(content: impl Into, media_refs: Vec) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), role: "user".to_string(), content: content.into(), reasoning_content: None, provider_state: None, turn_id: None, iteration: None, completion_status: CompletionStatus::Completed, media_refs, timestamp: current_timestamp(), tool_call_id: None, tool_name: None, tool_calls: None, source: None, client_visibility: ClientVisibility::Visible, turn_origin: TurnOrigin::User, } } pub fn assistant(content: impl Into) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), role: "assistant".to_string(), content: content.into(), reasoning_content: None, provider_state: None, turn_id: None, iteration: None, completion_status: CompletionStatus::Completed, media_refs: Vec::new(), timestamp: current_timestamp(), tool_call_id: None, tool_name: None, tool_calls: None, source: None, client_visibility: ClientVisibility::Visible, turn_origin: TurnOrigin::User, } } pub fn assistant_with_tool_calls( content: impl Into, tool_calls: Vec, ) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), role: "assistant".to_string(), content: content.into(), reasoning_content: None, provider_state: None, turn_id: None, iteration: None, completion_status: CompletionStatus::Completed, media_refs: Vec::new(), timestamp: current_timestamp(), tool_call_id: None, tool_name: None, tool_calls: Some(tool_calls), source: None, client_visibility: ClientVisibility::Visible, turn_origin: TurnOrigin::User, } } pub fn assistant_with_source(content: impl Into, source: MessageSource) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), role: "assistant".to_string(), content: content.into(), reasoning_content: None, provider_state: None, turn_id: None, iteration: None, completion_status: CompletionStatus::Completed, media_refs: Vec::new(), timestamp: current_timestamp(), tool_call_id: None, tool_name: None, tool_calls: None, source: Some(source), client_visibility: ClientVisibility::Visible, turn_origin: TurnOrigin::User, } } pub fn system(content: impl Into) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), role: "system".to_string(), content: content.into(), reasoning_content: None, provider_state: None, turn_id: None, iteration: None, completion_status: CompletionStatus::Completed, media_refs: Vec::new(), timestamp: current_timestamp(), tool_call_id: None, tool_name: None, tool_calls: None, source: None, client_visibility: ClientVisibility::Visible, turn_origin: TurnOrigin::User, } } pub fn tool( tool_call_id: impl Into, tool_name: impl Into, content: impl Into, ) -> Self { Self::tool_with_media(tool_call_id, tool_name, content, Vec::new()) } pub fn tool_with_media( tool_call_id: impl Into, tool_name: impl Into, content: impl Into, media_refs: Vec, ) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), role: "tool".to_string(), content: content.into(), reasoning_content: None, provider_state: None, turn_id: None, iteration: None, completion_status: CompletionStatus::Completed, media_refs, timestamp: current_timestamp(), tool_call_id: Some(tool_call_id.into()), tool_name: Some(tool_name.into()), tool_calls: None, source: None, client_visibility: ClientVisibility::Visible, turn_origin: TurnOrigin::User, } } pub fn user_with_source(content: impl Into, source: MessageSource) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), role: "user".to_string(), content: content.into(), reasoning_content: None, provider_state: None, turn_id: None, iteration: None, completion_status: CompletionStatus::Completed, media_refs: Vec::new(), timestamp: current_timestamp(), tool_call_id: None, tool_name: None, tool_calls: None, source: Some(source), client_visibility: ClientVisibility::Visible, turn_origin: TurnOrigin::User, } } } #[cfg(test)] mod conversation_message_tests { use super::*; #[test] fn damaged_provider_state_is_ignored() { assert!(ProviderReasoningState::from_json_lossy("not-json").is_none()); } #[test] fn unknown_completion_status_is_backward_compatible() { assert_eq!( CompletionStatus::from_storage("future-status"), CompletionStatus::Completed ); } } // ============================================================================ // InboundMessage - Message from Channel to Bus (user input) // ============================================================================ /// Opaque channel-owned context that may be carried to the corresponding reply. /// Core routing understands `reply_to`; all other platform data remains /// private. `durable_private` holds only values the channel declares safe to /// reuse across turns (thread/root identity); one-shot message/reaction ids /// belong in `private`. #[derive(Debug, Clone, Default)] pub struct ChannelContext { pub reply_to: Option, pub private: HashMap, pub durable_private: HashMap, } /// Public, durable projection of a newly committed conversation message. /// Provider replay state and source identities are deliberately excluded. #[derive(Debug, Clone)] pub struct CommittedMessage { pub id: String, pub seq: i64, pub role: String, pub content: String, pub reasoning_content: Option, pub completion_status: CompletionStatus, pub media_refs: Vec, pub created_at: i64, pub tool_call_id: Option, pub tool_name: Option, pub tool_calls: Option>, pub turn_origin: TurnOrigin, } #[derive(Debug, Clone)] pub struct CommittedTurnDelta { pub session_id: String, /// Highest durable message sequence included in this commit. pub history_revision: i64, pub messages: Vec, } #[derive(Debug, Clone)] pub struct InboundMessage { pub channel: String, pub sender_id: String, pub chat_id: String, /// Client-provided id for optimistic UI reconciliation. Channel-owned /// inputs that do not expose a client id leave this unset; the session /// layer may generate a durable id when it accepts the message. pub client_message_id: Option, pub content: String, pub received_at: i64, pub media: Vec, pub channel_context: ChannelContext, } // ============================================================================ // OutboundMessage - Message from Agent to Channel (bot response) // ============================================================================ #[derive(Debug, Clone)] pub struct OutboundMessage { pub channel: String, pub chat_id: String, pub content: String, pub reply_to: Option, pub media: Vec, pub metadata: HashMap, pub(crate) delivery: Option>>>, } impl OutboundMessage { pub(crate) fn complete_delivery(&self, result: Result<(), String>) { if let Some(delivery) = &self.delivery { delivery.send_replace(Some(result)); } } } // ============================================================================ // ControlMessage - Message for control channel (session management) // Uses SessionCommand from session module // ============================================================================ use crate::channels::base::ChannelError; use crate::session::{SessionCommand, SessionEvent}; use tokio::sync::mpsc; /// Control message containing a session operation and reply channel #[derive(Debug, Clone)] pub struct ControlMessage { pub op: SessionCommand, pub reply_tx: mpsc::Sender>, } // ============================================================================ // Helpers // ============================================================================ pub(crate) fn current_timestamp() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis() as i64 }