Skip to main content

rho_core/
event_handler.rs

1use std::sync::Arc;
2
3use crate::types::{AgentEvent, Message, UserContent};
4
5/// Configuration for the shared event handler.
6/// Both GUI and CLI provide their own config with the session store + current session state.
7pub struct EventHandlerConfig {
8    /// If set, events will trigger session persistence.
9    pub session_store: Option<Arc<dyn SessionPersistence>>,
10    /// Current session ID (None if no session yet).
11    pub session_id: Option<String>,
12    /// Model name (used when creating new sessions).
13    pub model_id: String,
14    /// Working directory (used when creating new sessions).
15    pub cwd: std::path::PathBuf,
16}
17
18/// Result from processing an event. Contains any state updates the caller should apply.
19pub struct EventHandlerResult {
20    /// Updated session ID (set when a session is created or changed).
21    pub session_id: Option<String>,
22    /// Extracted session title.
23    pub session_title: Option<String>,
24}
25
26/// Trait for session persistence, so the event handler doesn't depend on rho-session directly.
27pub trait SessionPersistence: Send + Sync {
28    fn create_session(
29        &self,
30        model: &str,
31        cwd: &std::path::Path,
32    ) -> Result<String, Box<dyn std::error::Error>>;
33    fn update_title(
34        &self,
35        session_id: &str,
36        title: &str,
37    ) -> Result<(), Box<dyn std::error::Error>>;
38    fn save_messages(
39        &self,
40        session_id: &str,
41        messages: &[Message],
42    ) -> Result<(), Box<dyn std::error::Error>>;
43}
44
45/// Extract a session title from conversation messages.
46fn extract_title(messages: &[Message]) -> String {
47    for msg in messages {
48        if let Message::User {
49            content: UserContent::Text(text),
50            ..
51        } = msg
52        {
53            let trimmed = text.trim();
54            if trimmed.is_empty() {
55                continue;
56            }
57            let first_line = trimmed.lines().next().unwrap_or(trimmed);
58            if first_line.len() > 80 {
59                return format!("{}...", &first_line[..77]);
60            }
61            return first_line.to_string();
62        }
63    }
64    "Untitled session".to_string()
65}
66
67/// Process an agent event, performing any side effects (persistence, etc).
68/// Returns state updates the caller should apply, or None if no action needed.
69pub fn handle_event(
70    event: &AgentEvent,
71    config: &mut EventHandlerConfig,
72) -> Option<EventHandlerResult> {
73    match event {
74        AgentEvent::AgentEnd { messages, .. } => {
75            let store = config.session_store.as_ref()?;
76
77            if messages.is_empty() {
78                return None;
79            }
80
81            // Create session on first AgentEnd if none exists
82            let session_id = if let Some(ref id) = config.session_id {
83                id.clone()
84            } else {
85                match store.create_session(&config.model_id, &config.cwd) {
86                    Ok(id) => {
87                        config.session_id = Some(id.clone());
88                        id
89                    }
90                    Err(_) => return None,
91                }
92            };
93
94            // Update title and save messages
95            let title = extract_title(messages);
96            store.update_title(&session_id, &title).ok();
97            store.save_messages(&session_id, messages).ok();
98
99            Some(EventHandlerResult {
100                session_id: Some(session_id),
101                session_title: Some(title),
102            })
103        }
104        _ => None,
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::types::*;
112    use std::path::PathBuf;
113    use std::sync::Mutex;
114
115    struct MockStore {
116        sessions: Mutex<Vec<(String, String)>>,
117        saved_messages: Mutex<Vec<(String, usize)>>,
118    }
119
120    impl MockStore {
121        fn new() -> Self {
122            Self {
123                sessions: Mutex::new(Vec::new()),
124                saved_messages: Mutex::new(Vec::new()),
125            }
126        }
127    }
128
129    impl SessionPersistence for MockStore {
130        fn create_session(
131            &self,
132            _model: &str,
133            _cwd: &std::path::Path,
134        ) -> Result<String, Box<dyn std::error::Error>> {
135            let mut sessions = self.sessions.lock().unwrap();
136            let id = format!("session-{}", sessions.len());
137            sessions.push((id.clone(), String::new()));
138            Ok(id)
139        }
140
141        fn update_title(
142            &self,
143            session_id: &str,
144            title: &str,
145        ) -> Result<(), Box<dyn std::error::Error>> {
146            let mut sessions = self.sessions.lock().unwrap();
147            for (id, t) in sessions.iter_mut() {
148                if id == session_id {
149                    *t = title.to_string();
150                }
151            }
152            Ok(())
153        }
154
155        fn save_messages(
156            &self,
157            session_id: &str,
158            messages: &[Message],
159        ) -> Result<(), Box<dyn std::error::Error>> {
160            self.saved_messages
161                .lock()
162                .unwrap()
163                .push((session_id.to_string(), messages.len()));
164            Ok(())
165        }
166    }
167
168    #[test]
169    fn handle_event_creates_session_on_first_agent_end() {
170        let store = Arc::new(MockStore::new());
171        let mut config = EventHandlerConfig {
172            session_store: Some(store.clone()),
173            session_id: None,
174            model_id: "claude-sonnet".into(),
175            cwd: PathBuf::from("/tmp"),
176        };
177
178        let messages = vec![Message::User {
179            content: UserContent::Text("hello".into()),
180            timestamp: 1000,
181        }];
182        let event = AgentEvent::AgentEnd {
183            messages: messages.clone(),
184        };
185
186        let result = handle_event(&event, &mut config).unwrap();
187        assert!(result.session_id.is_some());
188        assert_eq!(result.session_title, Some("hello".into()));
189        assert_eq!(store.sessions.lock().unwrap().len(), 1);
190        assert_eq!(store.saved_messages.lock().unwrap().len(), 1);
191    }
192
193    #[test]
194    fn handle_event_reuses_existing_session() {
195        let store = Arc::new(MockStore::new());
196        let mut config = EventHandlerConfig {
197            session_store: Some(store.clone()),
198            session_id: Some("existing-session".into()),
199            model_id: "claude-sonnet".into(),
200            cwd: PathBuf::from("/tmp"),
201        };
202
203        let messages = vec![Message::User {
204            content: UserContent::Text("hello".into()),
205            timestamp: 1000,
206        }];
207        let event = AgentEvent::AgentEnd { messages };
208
209        let result = handle_event(&event, &mut config).unwrap();
210        assert_eq!(result.session_id, Some("existing-session".into()));
211        // Should not create a new session
212        assert_eq!(store.sessions.lock().unwrap().len(), 0);
213    }
214
215    #[test]
216    fn handle_event_ignores_non_agent_end() {
217        let store = Arc::new(MockStore::new());
218        let mut config = EventHandlerConfig {
219            session_store: Some(store),
220            session_id: None,
221            model_id: "claude-sonnet".into(),
222            cwd: PathBuf::from("/tmp"),
223        };
224
225        let result = handle_event(&AgentEvent::AgentStart, &mut config);
226        assert!(result.is_none());
227    }
228
229    #[test]
230    fn handle_event_no_store_returns_none() {
231        let mut config = EventHandlerConfig {
232            session_store: None,
233            session_id: None,
234            model_id: "claude-sonnet".into(),
235            cwd: PathBuf::from("/tmp"),
236        };
237
238        let messages = vec![Message::User {
239            content: UserContent::Text("hello".into()),
240            timestamp: 1000,
241        }];
242        let event = AgentEvent::AgentEnd { messages };
243
244        let result = handle_event(&event, &mut config);
245        assert!(result.is_none());
246    }
247
248    #[test]
249    fn handle_event_empty_messages_returns_none() {
250        let store = Arc::new(MockStore::new());
251        let mut config = EventHandlerConfig {
252            session_store: Some(store),
253            session_id: None,
254            model_id: "claude-sonnet".into(),
255            cwd: PathBuf::from("/tmp"),
256        };
257
258        let event = AgentEvent::AgentEnd {
259            messages: Vec::new(),
260        };
261        let result = handle_event(&event, &mut config);
262        assert!(result.is_none());
263    }
264
265    #[test]
266    fn extract_title_basic() {
267        let messages = vec![Message::User {
268            content: UserContent::Text("Help me write code".into()),
269            timestamp: 0,
270        }];
271        assert_eq!(extract_title(&messages), "Help me write code");
272    }
273
274    #[test]
275    fn extract_title_truncates() {
276        let long = "a".repeat(100);
277        let messages = vec![Message::User {
278            content: UserContent::Text(long),
279            timestamp: 0,
280        }];
281        let title = extract_title(&messages);
282        assert!(title.len() <= 80);
283        assert!(title.ends_with("..."));
284    }
285}