1use crate::event::AgentEvent;
2use crate::session::{
3 SessionId, SessionRuntime, SessionStore, current_unix_timestamp, session_title_from_events,
4};
5use anyhow::Result;
6use std::sync::{Arc, Mutex};
7use tokio::sync::mpsc;
8
9type EventReceiverSlot = Arc<Mutex<Option<mpsc::UnboundedReceiver<AgentEvent>>>>;
10
11pub(crate) struct SessionEventReceiver {
12 slot: EventReceiverSlot,
13 rx: Option<mpsc::UnboundedReceiver<AgentEvent>>,
14}
15
16impl SessionEventReceiver {
17 pub(crate) async fn recv(&mut self) -> Option<AgentEvent> {
18 match self.rx.as_mut() {
19 Some(rx) => rx.recv().await,
20 None => None,
21 }
22 }
23
24 pub(crate) fn try_recv(&mut self) -> Result<AgentEvent, mpsc::error::TryRecvError> {
25 match self.rx.as_mut() {
26 Some(rx) => rx.try_recv(),
27 None => Err(mpsc::error::TryRecvError::Disconnected),
28 }
29 }
30}
31
32impl Drop for SessionEventReceiver {
33 fn drop(&mut self) {
34 let Some(rx) = self.rx.take() else {
35 return;
36 };
37 let mut slot = self.slot.lock().unwrap_or_else(|e| e.into_inner());
38 if slot.is_none() {
39 *slot = Some(rx);
40 } else {
41 tracing::warn!("session event stream receiver was replaced before checkout returned");
42 }
43 }
44}
45
46pub struct SessionState {
47 id: SessionId,
48 created_at: u64,
49 updated_at: u64,
50 title: Option<String>,
51 turn_sequence: u64,
52 started: bool,
53 requested_id: Option<SessionId>,
54 runtime: Option<SessionRuntime>,
55 event_rx: EventReceiverSlot,
56 events: Vec<AgentEvent>,
57 initial_events: Vec<AgentEvent>,
58 initial_created_at: Option<u64>,
59 initial_updated_at: Option<u64>,
60}
61
62impl SessionState {
63 pub fn new(requested_id: Option<SessionId>) -> Self {
64 let now = current_unix_timestamp();
65 Self {
66 id: requested_id.clone().unwrap_or_else(SessionStore::create_id),
67 created_at: now,
68 updated_at: now,
69 title: None,
70 turn_sequence: 0,
71 started: false,
72 requested_id,
73 runtime: None,
74 event_rx: Arc::new(Mutex::new(None)),
75 events: Vec::new(),
76 initial_events: Vec::new(),
77 initial_created_at: None,
78 initial_updated_at: None,
79 }
80 }
81
82 pub fn new_with_history(
83 requested_id: Option<SessionId>,
84 events: Vec<AgentEvent>,
85 created_at: Option<u64>,
86 updated_at: Option<u64>,
87 ) -> Self {
88 Self {
89 initial_events: events,
90 initial_created_at: created_at,
91 initial_updated_at: updated_at,
92 ..Self::new(requested_id)
93 }
94 }
95
96 pub fn start(&mut self) {
97 let now = current_unix_timestamp();
98 let initial_events = std::mem::take(&mut self.initial_events);
99 self.id = self
100 .requested_id
101 .take()
102 .unwrap_or_else(SessionStore::create_id);
103 self.created_at = self.initial_created_at.take().unwrap_or(now);
104 self.updated_at = self.initial_updated_at.take().unwrap_or(self.created_at);
105 self.title = session_title_from_events(&initial_events);
106 self.turn_sequence = initial_events
107 .iter()
108 .filter(|event| matches!(event, AgentEvent::UserTaskSubmitted { .. }))
109 .count() as u64;
110 self.started = true;
111 self.events = initial_events;
112 self.runtime = None;
113 *self.event_rx.lock().unwrap_or_else(|e| e.into_inner()) = None;
114 }
115
116 pub fn id(&self) -> &SessionId {
117 &self.id
118 }
119
120 pub fn title(&self) -> Option<&str> {
121 self.title.as_deref()
122 }
123
124 pub fn set_title(&mut self, title: Option<String>) {
126 self.title = title.filter(|t| !t.trim().is_empty());
127 }
128
129 pub fn events(&self) -> &[AgentEvent] {
130 &self.events
131 }
132
133 pub fn created_at(&self) -> u64 {
134 self.created_at
135 }
136
137 pub fn updated_at(&self) -> u64 {
138 self.updated_at
139 }
140
141 pub fn set_updated_at(&mut self, ts: u64) {
142 self.updated_at = ts;
143 }
144
145 pub fn started(&self) -> bool {
146 self.started
147 }
148
149 pub fn runtime(&self) -> Option<&SessionRuntime> {
150 self.runtime.as_ref()
151 }
152
153 pub fn set_runtime(
154 &mut self,
155 runtime: SessionRuntime,
156 event_rx: mpsc::UnboundedReceiver<AgentEvent>,
157 ) {
158 self.runtime = Some(runtime);
159 *self.event_rx.lock().unwrap_or_else(|e| e.into_inner()) = Some(event_rx);
160 }
161
162 pub(crate) fn take_event_rx(&mut self) -> Option<SessionEventReceiver> {
163 let rx = self
164 .event_rx
165 .lock()
166 .unwrap_or_else(|e| e.into_inner())
167 .take()?;
168 Some(SessionEventReceiver {
169 slot: Arc::clone(&self.event_rx),
170 rx: Some(rx),
171 })
172 }
173
174 pub fn next_turn_id(&mut self) -> String {
175 self.turn_sequence += 1;
176 format!("{}-turn-{}", self.id.as_str(), self.turn_sequence)
177 }
178
179 pub fn push_event(&mut self, event: AgentEvent) {
180 self.events.push(event);
184 self.updated_at = current_unix_timestamp();
185 }
186
187 pub fn replace_events(&mut self, events: Vec<AgentEvent>) {
189 self.turn_sequence = events
190 .iter()
191 .filter(|event| matches!(event, AgentEvent::UserTaskSubmitted { .. }))
192 .count() as u64;
193 self.events = events;
194 self.updated_at = current_unix_timestamp();
195 self.force_update_title_from_events();
196 }
197
198 pub fn truncate_events_to_user_turns(&mut self, keep_user_turns: usize) {
202 let mut seen = 0usize;
203 let mut cut = self.events.len();
204 for (i, event) in self.events.iter().enumerate() {
205 if matches!(event, AgentEvent::UserTaskSubmitted { .. }) {
206 if seen == keep_user_turns {
207 cut = i;
208 break;
209 }
210 seen += 1;
211 }
212 }
213 self.events.truncate(cut);
214 self.turn_sequence = keep_user_turns as u64;
215 self.updated_at = current_unix_timestamp();
216 self.force_update_title_from_events();
217 }
218
219 pub fn update_title_from_events(&mut self) {
224 if self.title.is_some() {
225 return;
226 }
227 self.title = session_title_from_events(&self.events);
228 }
229
230 pub fn force_update_title_from_events(&mut self) {
232 self.title = session_title_from_events(&self.events);
233 }
234
235 pub fn snapshot(
236 &self,
237 project_dir: &std::path::Path,
238 session_store: &SessionStore,
239 event_bus: &crate::runtime::EventBus,
240 goal: Option<crate::goal::types::SessionGoal>,
241 ) -> Result<crate::session::SessionSnapshot> {
242 let snapshot = tokio::task::block_in_place(|| {
247 let memory = session_store.load_memory(project_dir);
248 let snap = crate::session::SessionSnapshot {
249 version: crate::session::SessionSnapshot::CURRENT_VERSION,
250 id: self.id.clone(),
251 title: self.title.clone(),
252 project: project_dir.to_path_buf(),
253 created_at: self.created_at,
254 updated_at: current_unix_timestamp(),
255 events: self.events.clone(),
256 memory,
257 goal,
258 usage: None,
259 };
260 session_store.save(&snap)?;
261 Ok::<_, anyhow::Error>(snap)
262 })?;
263 event_bus.publish(crate::event::RuntimeEventKind::SessionSaved {
264 session_id: snapshot.id.as_str().to_string(),
265 });
266 Ok(snapshot)
267 }
268
269 pub async fn snapshot_async(
270 &self,
271 project_dir: &std::path::Path,
272 session_store: &SessionStore,
273 event_bus: &crate::runtime::EventBus,
274 goal: Option<crate::goal::types::SessionGoal>,
275 ) -> Result<crate::session::SessionSnapshot> {
276 let memory = session_store
277 .load_memory_async(project_dir.to_path_buf())
278 .await;
279 let snapshot = crate::session::SessionSnapshot {
280 version: crate::session::SessionSnapshot::CURRENT_VERSION,
281 id: self.id.clone(),
282 title: self.title.clone(),
283 project: project_dir.to_path_buf(),
284 created_at: self.created_at,
285 updated_at: current_unix_timestamp(),
286 events: self.events.clone(),
287 memory,
288 goal,
289 usage: None,
290 };
291 session_store.save_async(snapshot.clone()).await?;
292 event_bus.publish(crate::event::RuntimeEventKind::SessionSaved {
293 session_id: snapshot.id.as_str().to_string(),
294 });
295 Ok(snapshot)
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::event::AgentEvent;
303
304 #[test]
305 fn push_event_does_not_recompute_title_eagerly() {
306 let mut state = SessionState::new(None);
307 state.start();
308 state.push_event(AgentEvent::UserTaskSubmitted {
309 text: "build a dashboard".to_string(),
310 content_parts: vec![],
311 submitted_at: None,
312 });
313 assert!(
314 state.title().is_none(),
315 "title should stay lazy until update_title_from_events"
316 );
317 state.update_title_from_events();
318 assert_eq!(state.title(), Some("build a dashboard"));
319 }
320
321 #[test]
322 fn update_title_prefers_model_heading_over_user_task() {
323 let mut state = SessionState::new(None);
324 state.start();
325 state.push_event(AgentEvent::UserTaskSubmitted {
326 text: "build a dashboard".to_string(),
327 content_parts: vec![],
328 submitted_at: None,
329 });
330 state.push_event(AgentEvent::ModelOutput {
331 text: "## Analytics Board\n\nDone.".to_string(),
332 thinking: None,
333 });
334 state.update_title_from_events();
335 assert_eq!(state.title(), Some("Analytics Board"));
336 }
337}