1use std::sync::Arc;
2
3use crate::types::{AgentEvent, Message, UserContent};
4
5pub struct EventHandlerConfig {
8 pub session_store: Option<Arc<dyn SessionPersistence>>,
10 pub session_id: Option<String>,
12 pub model_id: String,
14 pub cwd: std::path::PathBuf,
16}
17
18pub struct EventHandlerResult {
20 pub session_id: Option<String>,
22 pub session_title: Option<String>,
24}
25
26pub 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
45fn 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
67pub 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 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 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 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}