Skip to main content

navi_core/runtime/
session_state.rs

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    /// Set an explicit session title (provisional user-derived or model-named).
125    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        // Titles come from the chat model via `set_session_title` (runtime
181        // applies them through SessionTitleHandle). Fallback derivation from
182        // events is only used on rewind / when no tool title exists yet.
183        self.events.push(event);
184        self.updated_at = current_unix_timestamp();
185    }
186
187    /// Replace the entire event log (used after compaction so reloads stay small).
188    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    /// Drop events from the `(keep_user_turns + 1)`-th `UserTaskSubmitted` onward.
199    ///
200    /// Used when rewinding the live session after the UI edits a past user message.
201    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    /// Derive a title from events only when none is set yet.
220    ///
221    /// Keeps provisional / model-assigned titles stable across mid-turn updates
222    /// and end-of-turn snapshots.
223    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    /// Force re-derive from events (e.g. after rewind).
231    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        // SessionStore I/O is intentionally blocking, but callers invoke this
243        // from the Tokio runtime. Use `block_in_place` so the runtime can
244        // reschedule other tasks on the current thread while the filesystem
245        // work completes.
246        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}