//! Session and thread model for turn-based agent interactions. //! //! A Session contains one or more Threads. Each Thread represents a //! conversation/interaction sequence with the agent. Threads contain //! Turns, which are request/response pairs. //! //! This model supports: //! - Undo: Roll back to a previous turn //! - Interrupt: Cancel the current turn mid-execution //! - Compaction: Summarize old turns to save context //! - Resume: Continue from a saved checkpoint use std::collections::{HashMap, HashSet}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::llm::ChatMessage; /// A session containing one or more threads. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { /// Unique session ID. pub id: Uuid, /// User ID that owns this session. pub user_id: String, /// Active thread ID. pub active_thread: Option, /// All threads in this session. pub threads: HashMap, /// When the session was created. pub created_at: DateTime, /// When the session was last active. pub last_active_at: DateTime, /// Session metadata. pub metadata: serde_json::Value, /// Tools that have been auto-approved for this session ("always approve"). #[serde(default)] pub auto_approved_tools: HashSet, } impl Session { /// Create a new session. pub fn new(user_id: impl Into) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4(), user_id: user_id.into(), active_thread: None, threads: HashMap::new(), created_at: now, last_active_at: now, metadata: serde_json::Value::Null, auto_approved_tools: HashSet::new(), } } /// Check if a tool has been auto-approved for this session. pub fn is_tool_auto_approved(&self, tool_name: &str) -> bool { self.auto_approved_tools.contains(tool_name) } /// Add a tool to the auto-approved set. pub fn auto_approve_tool(&mut self, tool_name: impl Into) { self.auto_approved_tools.insert(tool_name.into()); } /// Create a new thread in this session. pub fn create_thread(&mut self) -> &mut Thread { let thread = Thread::new(self.id); let thread_id = thread.id; self.threads.insert(thread_id, thread); self.active_thread = Some(thread_id); self.last_active_at = Utc::now(); self.threads.get_mut(&thread_id).expect("just inserted") } /// Get the active thread. pub fn active_thread(&self) -> Option<&Thread> { self.active_thread.and_then(|id| self.threads.get(&id)) } /// Get the active thread mutably. pub fn active_thread_mut(&mut self) -> Option<&mut Thread> { self.active_thread.and_then(|id| self.threads.get_mut(&id)) } /// Get or create the active thread. pub fn get_or_create_thread(&mut self) -> &mut Thread { if self.active_thread.is_none() { self.create_thread(); } self.active_thread_mut().expect("just created") } /// Switch to a different thread. pub fn switch_thread(&mut self, thread_id: Uuid) -> bool { if self.threads.contains_key(&thread_id) { self.active_thread = Some(thread_id); self.last_active_at = Utc::now(); true } else { false } } } /// State of a thread. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ThreadState { /// Thread is idle, waiting for input. Idle, /// Thread is processing a turn. Processing, /// Thread is waiting for user approval. AwaitingApproval, /// Thread has completed (no more turns expected). Completed, /// Thread was interrupted. Interrupted, } /// Pending auth token request. /// /// When `tool_auth` returns `awaiting_token`, the thread enters auth mode. /// The next user message is intercepted before entering the normal pipeline /// (no logging, no turn creation, no history) and routed directly to the /// credential store. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingAuth { /// Extension name to authenticate. pub extension_name: String, } /// Pending tool approval request stored on a thread. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingApproval { /// Unique request ID. pub request_id: Uuid, /// Tool name requiring approval. pub tool_name: String, /// Tool parameters. pub parameters: serde_json::Value, /// Description of what the tool will do. pub description: String, /// Tool call ID from LLM (for proper context continuation). pub tool_call_id: String, /// Context messages at the time of the request (to resume from). pub context_messages: Vec, } /// A conversation thread within a session. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Thread { /// Unique thread ID. pub id: Uuid, /// Parent session ID. pub session_id: Uuid, /// Current state. pub state: ThreadState, /// Turns in this thread. pub turns: Vec, /// When the thread was created. pub created_at: DateTime, /// When the thread was last updated. pub updated_at: DateTime, /// Thread metadata (e.g., title, tags). pub metadata: serde_json::Value, /// Pending approval request (when state is AwaitingApproval). #[serde(default)] pub pending_approval: Option, /// Pending auth token request (thread is in auth mode). #[serde(default)] pub pending_auth: Option, } impl Thread { /// Create a new thread. pub fn new(session_id: Uuid) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4(), session_id, state: ThreadState::Idle, turns: Vec::new(), created_at: now, updated_at: now, metadata: serde_json::Value::Null, pending_approval: None, pending_auth: None, } } /// Get the current turn number (1-indexed for display). pub fn turn_number(&self) -> usize { self.turns.len() + 1 } /// Get the last turn. pub fn last_turn(&self) -> Option<&Turn> { self.turns.last() } /// Get the last turn mutably. pub fn last_turn_mut(&mut self) -> Option<&mut Turn> { self.turns.last_mut() } /// Start a new turn with user input. pub fn start_turn(&mut self, user_input: impl Into) -> &mut Turn { let turn_number = self.turns.len(); let turn = Turn::new(turn_number, user_input); self.turns.push(turn); self.state = ThreadState::Processing; self.updated_at = Utc::now(); self.turns.last_mut().expect("just pushed") } /// Complete the current turn with a response. pub fn complete_turn(&mut self, response: impl Into) { if let Some(turn) = self.turns.last_mut() { turn.complete(response); } self.state = ThreadState::Idle; self.updated_at = Utc::now(); } /// Fail the current turn with an error. pub fn fail_turn(&mut self, error: impl Into) { if let Some(turn) = self.turns.last_mut() { turn.fail(error); } self.state = ThreadState::Idle; self.updated_at = Utc::now(); } /// Mark the thread as awaiting approval with pending request details. pub fn await_approval(&mut self, pending: PendingApproval) { self.state = ThreadState::AwaitingApproval; self.pending_approval = Some(pending); self.updated_at = Utc::now(); } /// Take the pending approval (clearing it from the thread). pub fn take_pending_approval(&mut self) -> Option { self.pending_approval.take() } /// Clear pending approval and return to idle state. pub fn clear_pending_approval(&mut self) { self.pending_approval = None; self.state = ThreadState::Idle; self.updated_at = Utc::now(); } /// Enter auth mode: next user message will be routed directly to /// the credential store, bypassing the normal pipeline entirely. pub fn enter_auth_mode(&mut self, extension_name: String) { self.pending_auth = Some(PendingAuth { extension_name }); self.updated_at = Utc::now(); } /// Take the pending auth (clearing auth mode). pub fn take_pending_auth(&mut self) -> Option { self.pending_auth.take() } /// Interrupt the current turn. pub fn interrupt(&mut self) { if let Some(turn) = self.turns.last_mut() { turn.interrupt(); } self.state = ThreadState::Interrupted; self.updated_at = Utc::now(); } /// Resume after interruption. pub fn resume(&mut self) { if self.state == ThreadState::Interrupted { self.state = ThreadState::Idle; self.updated_at = Utc::now(); } } /// Get all messages for context building. pub fn messages(&self) -> Vec { let mut messages = Vec::new(); for turn in &self.turns { messages.push(ChatMessage::user(&turn.user_input)); if let Some(ref response) = turn.response { messages.push(ChatMessage::assistant(response)); } } messages } /// Truncate turns to a specific count (keeping most recent). pub fn truncate_turns(&mut self, keep: usize) { if self.turns.len() > keep { let drain_count = self.turns.len() - keep; self.turns.drain(0..drain_count); // Re-number remaining turns for (i, turn) in self.turns.iter_mut().enumerate() { turn.turn_number = i; } } } /// Restore thread state from a checkpoint's messages. /// /// Clears existing turns and rebuilds from message pairs. /// Messages should alternate: user, assistant, user, assistant... pub fn restore_from_messages(&mut self, messages: Vec) { self.turns.clear(); self.state = ThreadState::Idle; // Messages alternate: user, assistant, user, assistant... let mut iter = messages.into_iter().peekable(); let mut turn_number = 0; while let Some(msg) = iter.next() { if msg.role == crate::llm::Role::User { let mut turn = Turn::new(turn_number, &msg.content); // Check if next is assistant response if let Some(next) = iter.peek() { if next.role == crate::llm::Role::Assistant { let response = iter.next().expect("peeked"); turn.complete(&response.content); } } self.turns.push(turn); turn_number += 1; } } self.updated_at = Utc::now(); } } /// State of a turn. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum TurnState { /// Turn is being processed. Processing, /// Turn completed successfully. Completed, /// Turn failed with an error. Failed, /// Turn was interrupted. Interrupted, } /// A single turn (request/response pair) in a thread. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Turn { /// Turn number (0-indexed). pub turn_number: usize, /// User input that started this turn. pub user_input: String, /// Agent response (if completed). pub response: Option, /// Tool calls made during this turn. pub tool_calls: Vec, /// Turn state. pub state: TurnState, /// When the turn started. pub started_at: DateTime, /// When the turn completed. pub completed_at: Option>, /// Error message (if failed). pub error: Option, } impl Turn { /// Create a new turn. pub fn new(turn_number: usize, user_input: impl Into) -> Self { Self { turn_number, user_input: user_input.into(), response: None, tool_calls: Vec::new(), state: TurnState::Processing, started_at: Utc::now(), completed_at: None, error: None, } } /// Complete this turn. pub fn complete(&mut self, response: impl Into) { self.response = Some(response.into()); self.state = TurnState::Completed; self.completed_at = Some(Utc::now()); } /// Fail this turn. pub fn fail(&mut self, error: impl Into) { self.error = Some(error.into()); self.state = TurnState::Failed; self.completed_at = Some(Utc::now()); } /// Interrupt this turn. pub fn interrupt(&mut self) { self.state = TurnState::Interrupted; self.completed_at = Some(Utc::now()); } /// Record a tool call. pub fn record_tool_call(&mut self, name: impl Into, params: serde_json::Value) { self.tool_calls.push(TurnToolCall { name: name.into(), parameters: params, result: None, error: None, }); } /// Record tool call result. pub fn record_tool_result(&mut self, result: serde_json::Value) { if let Some(call) = self.tool_calls.last_mut() { call.result = Some(result); } } /// Record tool call error. pub fn record_tool_error(&mut self, error: impl Into) { if let Some(call) = self.tool_calls.last_mut() { call.error = Some(error.into()); } } } /// Record of a tool call made during a turn. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TurnToolCall { /// Tool name. pub name: String, /// Parameters passed to the tool. pub parameters: serde_json::Value, /// Result from the tool (if successful). pub result: Option, /// Error from the tool (if failed). pub error: Option, } #[cfg(test)] mod tests { use super::*; #[test] fn test_session_creation() { let mut session = Session::new("user-123"); assert!(session.active_thread.is_none()); session.create_thread(); assert!(session.active_thread.is_some()); } #[test] fn test_thread_turns() { let mut thread = Thread::new(Uuid::new_v4()); thread.start_turn("Hello"); assert_eq!(thread.state, ThreadState::Processing); assert_eq!(thread.turns.len(), 1); thread.complete_turn("Hi there!"); assert_eq!(thread.state, ThreadState::Idle); assert_eq!(thread.turns[0].response, Some("Hi there!".to_string())); } #[test] fn test_thread_messages() { let mut thread = Thread::new(Uuid::new_v4()); thread.start_turn("First message"); thread.complete_turn("First response"); thread.start_turn("Second message"); thread.complete_turn("Second response"); let messages = thread.messages(); assert_eq!(messages.len(), 4); } #[test] fn test_turn_tool_calls() { let mut turn = Turn::new(0, "Test input"); turn.record_tool_call("echo", serde_json::json!({"message": "test"})); turn.record_tool_result(serde_json::json!("test")); assert_eq!(turn.tool_calls.len(), 1); assert!(turn.tool_calls[0].result.is_some()); } #[test] fn test_restore_from_messages() { let mut thread = Thread::new(Uuid::new_v4()); // First add some turns thread.start_turn("Original message"); thread.complete_turn("Original response"); // Now restore from different messages let messages = vec![ ChatMessage::user("Hello"), ChatMessage::assistant("Hi there!"), ChatMessage::user("How are you?"), ChatMessage::assistant("I'm good!"), ]; thread.restore_from_messages(messages); assert_eq!(thread.turns.len(), 2); assert_eq!(thread.turns[0].user_input, "Hello"); assert_eq!(thread.turns[0].response, Some("Hi there!".to_string())); assert_eq!(thread.turns[1].user_input, "How are you?"); assert_eq!(thread.turns[1].response, Some("I'm good!".to_string())); assert_eq!(thread.state, ThreadState::Idle); } #[test] fn test_restore_from_messages_incomplete_turn() { let mut thread = Thread::new(Uuid::new_v4()); // Messages with incomplete last turn (no assistant response) let messages = vec![ ChatMessage::user("Hello"), ChatMessage::assistant("Hi there!"), ChatMessage::user("How are you?"), ]; thread.restore_from_messages(messages); assert_eq!(thread.turns.len(), 2); assert_eq!(thread.turns[1].user_input, "How are you?"); assert!(thread.turns[1].response.is_none()); } #[test] fn test_enter_auth_mode() { let mut thread = Thread::new(Uuid::new_v4()); assert!(thread.pending_auth.is_none()); thread.enter_auth_mode("telegram".to_string()); assert!(thread.pending_auth.is_some()); assert_eq!( thread.pending_auth.as_ref().unwrap().extension_name, "telegram" ); } #[test] fn test_take_pending_auth() { let mut thread = Thread::new(Uuid::new_v4()); thread.enter_auth_mode("notion".to_string()); let pending = thread.take_pending_auth(); assert!(pending.is_some()); assert_eq!(pending.unwrap().extension_name, "notion"); // Should be cleared after take assert!(thread.pending_auth.is_none()); assert!(thread.take_pending_auth().is_none()); } #[test] fn test_pending_auth_serialization() { let mut thread = Thread::new(Uuid::new_v4()); thread.enter_auth_mode("openai".to_string()); let json = serde_json::to_string(&thread).expect("should serialize"); assert!(json.contains("pending_auth")); assert!(json.contains("openai")); let restored: Thread = serde_json::from_str(&json).expect("should deserialize"); assert!(restored.pending_auth.is_some()); assert_eq!(restored.pending_auth.unwrap().extension_name, "openai"); } #[test] fn test_pending_auth_default_none() { // Deserialization of old data without pending_auth should default to None let mut thread = Thread::new(Uuid::new_v4()); thread.pending_auth = None; let json = serde_json::to_string(&thread).expect("serialize"); // Remove the pending_auth field to simulate old data let json = json.replace(",\"pending_auth\":null", ""); let restored: Thread = serde_json::from_str(&json).expect("should deserialize"); assert!(restored.pending_auth.is_none()); } }