Skip to main content

agent_engine/engine/
session.rs

1//! Engine-level session management — save, load, resume, clear.
2//!
3//! Owns the conversation state that both TUI and headless modes need:
4//! messages, token counts, cost, abort context.
5
6use crate::pricing::calculate_cost_optional_split;
7use crate::SharedMessage;
8use crate::{Runtime, Session};
9
10/// Session-scoped durability barrier state, shared by persistent frontends.
11/// A failed (or dropped in-flight) publication requires explicit reload/new
12/// session before saving or scheduling more inference. Never rollback-save an
13/// old head: `save_durable` can fail after publishing its replacement.
14#[derive(Default)]
15pub struct ContextHeadPersistence {
16    blocked_session: Option<String>,
17}
18
19impl ContextHeadPersistence {
20    pub fn is_blocked(&self, session: &Session) -> bool {
21        self.blocked_session.as_deref() == Some(session.id.as_str())
22    }
23
24    /// Candidate metadata must come from the host's current session, never
25    /// from message content. The event supplies only identity and messages.
26    pub async fn persist(
27        &mut self,
28        session: &mut Session,
29        messages: &mut Vec<SharedMessage>,
30        session_id: &str,
31        candidate: Session,
32    ) -> std::io::Result<()> {
33        self.persist_with(
34            session,
35            messages,
36            session_id,
37            candidate,
38            |candidate| async move { candidate.save_durable().await },
39        )
40        .await
41    }
42
43    async fn persist_with<F, Fut>(
44        &mut self,
45        session: &mut Session,
46        messages: &mut Vec<SharedMessage>,
47        session_id: &str,
48        candidate: Session,
49        save: F,
50    ) -> std::io::Result<()>
51    where
52        F: FnOnce(Session) -> Fut,
53        Fut: std::future::Future<Output = std::io::Result<()>>,
54    {
55        if self.is_blocked(session) {
56            return Err(std::io::Error::other(
57                "context head is unverified; reload the session before continuing",
58            ));
59        }
60        // Latch before any await, including on identity rejection. Dropping
61        // this future cannot authorize a later ordinary save of the old head.
62        self.blocked_session = Some(session.id.clone());
63        if session_id != session.id || candidate.id != session.id {
64            return Err(std::io::Error::new(
65                std::io::ErrorKind::InvalidInput,
66                "context head checkpoint does not match the current session",
67            ));
68        }
69        let result = save(candidate.clone()).await;
70        // Success adopts the durable head. An attempted-save error adopts it
71        // ONLY as conservative recovery state (not a successful commit): a
72        // rename may already have happened. The latch remains set, so neither
73        // post-turn saves nor automatic inference can act on this ambiguity.
74        *messages = candidate.api_messages.clone();
75        *session = candidate;
76        if result.is_ok() {
77            self.blocked_session = None;
78        }
79        result
80    }
81}
82
83/// Conversation state tracked by the engine.
84pub struct ConversationState {
85    pub session: Session,
86    pub context_head: ContextHeadPersistence,
87    pub api_messages: Vec<SharedMessage>,
88    pub total_input_tokens: u64,
89    pub total_output_tokens: u64,
90    pub total_cache_read_tokens: u64,
91    pub total_cache_creation_tokens: u64,
92    pub session_cost: f64,
93    pub abort_context: Option<String>,
94    /// Message queued to send after current stream completes.
95    pub queued_message: Option<String>,
96    /// Events buffered during streaming — drained on stream completion.
97    pub pending_events: Vec<String>,
98}
99
100impl ConversationState {
101    /// Create a new conversation with a fresh session.
102    pub fn new(session: Session) -> Self {
103        Self {
104            session,
105            context_head: ContextHeadPersistence::default(),
106            api_messages: Vec::new(),
107            total_input_tokens: 0,
108            total_output_tokens: 0,
109            total_cache_read_tokens: 0,
110            total_cache_creation_tokens: 0,
111            session_cost: 0.0,
112            abort_context: None,
113            queued_message: None,
114            pending_events: Vec::new(),
115        }
116    }
117
118    /// Create from a resumed session.
119    pub fn from_resumed(session: Session) -> Self {
120        Self {
121            api_messages: session.api_messages.clone(),
122            total_input_tokens: session.total_input_tokens,
123            total_output_tokens: session.total_output_tokens,
124            total_cache_read_tokens: 0,
125            total_cache_creation_tokens: 0,
126            session_cost: session.session_cost,
127            abort_context: session.abort_context.clone(),
128            queued_message: None,
129            pending_events: Vec::new(),
130            session,
131            context_head: ContextHeadPersistence::default(),
132        }
133    }
134
135    /// Save the current conversation state to disk.
136    pub async fn save(&mut self) {
137        if self.context_head.is_blocked(&self.session) || self.api_messages.is_empty() {
138            return;
139        }
140        self.session.api_messages = self.api_messages.clone();
141        self.session.total_input_tokens = self.total_input_tokens;
142        self.session.total_output_tokens = self.total_output_tokens;
143        self.session.session_cost = self.session_cost;
144        self.session.abort_context = self.abort_context.clone();
145        self.session.updated_at = chrono::Utc::now();
146        self.session.auto_title();
147        if let Err(e) = self.session.save().await {
148            tracing::error!("Failed to save session: {}", e);
149        }
150    }
151
152    /// Persist a runtime-requested head using host metadata and latest usage.
153    /// The caller completes the receipt with this exact result and continues
154    /// consuming the runtime's typed terminal error on failure.
155    pub async fn persist_context_head(
156        &mut self,
157        session_id: &str,
158        messages: Vec<SharedMessage>,
159    ) -> std::io::Result<()> {
160        let mut candidate = self.session.clone();
161        candidate.api_messages = messages;
162        candidate.total_input_tokens = self.total_input_tokens;
163        candidate.total_output_tokens = self.total_output_tokens;
164        candidate.session_cost = self.session_cost;
165        candidate.abort_context = self.abort_context.clone();
166        candidate.updated_at = chrono::Utc::now();
167        candidate.auto_title();
168        self.context_head
169            .persist(
170                &mut self.session,
171                &mut self.api_messages,
172                session_id,
173                candidate,
174            )
175            .await
176    }
177
178    /// Clear the current session and start fresh.
179    pub async fn clear(&mut self, runtime: &Runtime) {
180        self.save().await;
181        self.context_head = ContextHeadPersistence::default();
182        self.api_messages.clear();
183        self.total_input_tokens = 0;
184        self.total_output_tokens = 0;
185        self.total_cache_read_tokens = 0;
186        self.total_cache_creation_tokens = 0;
187        self.session_cost = 0.0;
188        self.abort_context = None;
189        self.queued_message = None;
190        self.pending_events.clear();
191        self.session = Session::new(
192            runtime.model(),
193            runtime.thinking_level(),
194            runtime.system_prompt(),
195        );
196        runtime.reset_context_continuation(&self.session.id, &[]);
197    }
198
199    /// Serializable mirror for clients (`SessionEventWire::Conversation`).
200    /// `consecutive_auto_turns` is actor/App-side state, not conversation
201    /// state, so the caller supplies it.
202    pub fn snapshot(
203        &self,
204        consecutive_auto_turns: u32,
205    ) -> crate::session::ConversationSnapshot {
206        crate::session::ConversationSnapshot {
207            header: crate::session::SessionHeader::from(&self.session),
208            api_messages: self.api_messages.clone(),
209            messages_len: self.api_messages.len(),
210            tokens: crate::session::ConversationTokens {
211                input: self.total_input_tokens,
212                output: self.total_output_tokens,
213                cache_read: self.total_cache_read_tokens,
214                cache_creation: self.total_cache_creation_tokens,
215            },
216            cost: self.session_cost,
217            abort_context: self.abort_context.clone(),
218            queued_message: self.queued_message.clone(),
219            pending_events_len: self.pending_events.len(),
220            consecutive_auto_turns,
221        }
222    }
223
224    /// Add usage from a model turn.
225    ///
226    /// `cache_creation_5m` / `cache_creation_1h` are the cache-write TTL
227    /// split. When either is present the cost uses the split rates
228    /// (5m: 1.25×, 1h: 2.0×); when both are `None` the aggregate
229    /// `cache_creation` is billed at the 5m rate (fail-cheap fallback).
230    #[allow(clippy::too_many_arguments)]
231    pub fn add_usage(
232        &mut self,
233        input_tokens: u64,
234        output_tokens: u64,
235        cache_read: u64,
236        cache_creation: u64,
237        cache_creation_5m: Option<u64>,
238        cache_creation_1h: Option<u64>,
239        model: &str,
240    ) {
241        self.total_input_tokens += input_tokens;
242        self.total_output_tokens += output_tokens;
243        self.total_cache_read_tokens += cache_read;
244        self.total_cache_creation_tokens += cache_creation;
245
246        self.session_cost += calculate_cost_optional_split(
247            model,
248            input_tokens,
249            output_tokens,
250            cache_read,
251            cache_creation,
252            cache_creation_5m,
253            cache_creation_1h,
254        );
255    }
256}
257
258#[cfg(test)]
259mod context_head_tests {
260    use super::*;
261    use agent_core::core::context_head::ContextHeadReceipt;
262    use agent_core::core::session_journal::{save_session_durable_in_dir, SessionPersistence};
263    use serde_json::json;
264    use std::sync::Arc;
265
266    fn fixture() -> (Session, Vec<SharedMessage>, Session) {
267        let mut old = Session::new("synthetic-model", "medium", Some("host authority"));
268        old.id = "synthetic-context-head".into();
269        old.name = Some("synthetic-name".into());
270        old.title = "original title".into();
271        old.parent_session = Some("synthetic-parent".into());
272        old.api_messages = vec![Arc::new(json!({"role":"user","content":"old head"}))];
273        let messages = old.api_messages.clone();
274        let mut candidate = old.clone();
275        candidate.api_messages = vec![Arc::new(json!({"role":"user","content":"candidate"}))];
276        candidate.total_input_tokens = 23;
277        candidate.total_output_tokens = 7;
278        candidate.session_cost = 0.125;
279        candidate.abort_context = Some("latest synthetic abort context".into());
280        (old, messages, candidate)
281    }
282
283    #[cfg(unix)]
284    #[tokio::test]
285    async fn context_head_ack_follows_durable_write_and_adoption() {
286        for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
287            let dir = tempfile::tempdir().unwrap();
288            let (mut session, mut messages, candidate) = fixture();
289            let id = session.id.clone();
290            let expected = serde_json::to_value(&candidate).unwrap();
291            let mut state = ContextHeadPersistence::default();
292            let (receipt, mut acknowledged) = ContextHeadReceipt::channel();
293            let (started_tx, started_rx) = tokio::sync::oneshot::channel();
294            let (release_tx, release_rx) = tokio::sync::oneshot::channel();
295            let path = dir.path().to_owned();
296            let result = {
297                let persist = state.persist_with(
298                    &mut session,
299                    &mut messages,
300                    &id,
301                    candidate,
302                    |candidate| async move {
303                        started_tx.send(()).unwrap();
304                        release_rx.await.unwrap();
305                        save_session_durable_in_dir(&path, &candidate, mode).map(|_| ())
306                    },
307                );
308                tokio::pin!(persist);
309                tokio::select! {
310                    result = &mut persist => panic!("write unexpectedly completed: {result:?}"),
311                    _ = started_rx => {}
312                }
313                assert!(matches!(
314                    acknowledged.try_recv(),
315                    Err(tokio::sync::oneshot::error::TryRecvError::Empty)
316                ));
317                release_tx.send(()).unwrap();
318                persist.await
319            };
320            assert!(result.is_ok());
321            receipt.complete(result);
322            assert_eq!(acknowledged.await.unwrap(), Ok(()));
323            assert_eq!(serde_json::to_value(&session).unwrap(), expected);
324            assert_eq!(messages, session.api_messages);
325            assert!(!state.is_blocked(&session));
326            let loaded = Session::load_from_dir(dir.path(), &id).unwrap();
327            assert_eq!(loaded.api_messages, messages);
328            assert_eq!(loaded.system_prompt.as_deref(), Some("host authority"));
329            assert_eq!(loaded.name, session.name);
330            assert_eq!(loaded.total_input_tokens, 23);
331            assert_eq!(loaded.total_output_tokens, 7);
332            assert_eq!(loaded.abort_context, session.abort_context);
333        }
334    }
335
336    #[tokio::test]
337    async fn context_head_identity_mismatch_never_writes_or_adopts() {
338        let (mut session, mut messages, candidate) = fixture();
339        let before = serde_json::to_value(&session).unwrap();
340        let mut state = ContextHeadPersistence::default();
341        let (receipt, acknowledged) = ContextHeadReceipt::channel();
342        let result = state
343            .persist_with(
344                &mut session,
345                &mut messages,
346                "another-session",
347                candidate,
348                |_| async { panic!("identity rejection must not reach persistence") },
349            )
350            .await;
351        assert_eq!(
352            result.as_ref().unwrap_err().kind(),
353            std::io::ErrorKind::InvalidInput
354        );
355        receipt.complete(result);
356        assert!(acknowledged.await.unwrap().is_err());
357        assert_eq!(serde_json::to_value(&session).unwrap(), before);
358        assert_eq!(messages, session.api_messages);
359        assert!(state.is_blocked(&session));
360        let mut other = session.clone();
361        other.id = "new-session".into();
362        assert!(!state.is_blocked(&other));
363    }
364
365    #[cfg(unix)]
366    #[tokio::test]
367    async fn context_head_post_publish_error_is_not_rollback_or_success() {
368        let dir = tempfile::tempdir().unwrap();
369        let (session, _, candidate) = fixture();
370        let mut conv = ConversationState::from_resumed(session);
371        let id = conv.session.id.clone();
372        let expected = candidate.api_messages.clone();
373        let path = dir.path().to_owned();
374        let (receipt, acknowledged) = ContextHeadReceipt::channel();
375        let result = conv
376            .context_head
377            .persist_with(
378                &mut conv.session,
379                &mut conv.api_messages,
380                &id,
381                candidate,
382                |candidate| async move {
383                    save_session_durable_in_dir(&path, &candidate, SessionPersistence::Journal)?;
384                    Err(std::io::Error::other("synthetic error after publication"))
385                },
386            )
387            .await;
388        receipt.complete(result);
389        assert!(acknowledged.await.unwrap().is_err());
390        assert!(conv.context_head.is_blocked(&conv.session));
391        assert_eq!(conv.api_messages, expected);
392        assert_eq!(
393            Session::load_from_dir(dir.path(), &id)
394                .unwrap()
395                .api_messages,
396            expected
397        );
398        // Simulate stale failure repair/UI data. save() must return before it
399        // even copies this old state into the conservatively adopted Session.
400        conv.api_messages.clear();
401        conv.api_messages
402            .push(Arc::new(json!({"role":"user","content":"stale"})));
403        conv.save().await;
404        assert_eq!(conv.session.api_messages, expected);
405        let loaded = Session::load_from_dir(dir.path(), &id).unwrap();
406        let resumed = ConversationState::from_resumed(loaded);
407        assert!(!resumed.context_head.is_blocked(&resumed.session));
408    }
409
410    #[tokio::test]
411    async fn context_head_dropped_write_future_latches_without_mutating_old_head() {
412        let (mut session, mut messages, candidate) = fixture();
413        let before = serde_json::to_value(&session).unwrap();
414        let id = session.id.clone();
415        let mut state = ContextHeadPersistence::default();
416        {
417            let persist = state.persist_with(&mut session, &mut messages, &id, candidate, |_| {
418                std::future::pending::<std::io::Result<()>>()
419            });
420            tokio::pin!(persist);
421            assert!(futures::poll!(&mut persist).is_pending());
422        }
423        assert!(state.is_blocked(&session));
424        assert_eq!(serde_json::to_value(&session).unwrap(), before);
425        assert_eq!(messages, session.api_messages);
426    }
427}