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.
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    /// Message queued to send after current stream completes.
94    pub queued_message: Option<String>,
95    /// Events buffered during streaming — drained on stream completion.
96    pub pending_events: Vec<String>,
97}
98
99impl ConversationState {
100    /// Create a new conversation with a fresh session.
101    pub fn new(session: Session) -> Self {
102        Self {
103            session,
104            context_head: ContextHeadPersistence::default(),
105            api_messages: Vec::new(),
106            total_input_tokens: 0,
107            total_output_tokens: 0,
108            total_cache_read_tokens: 0,
109            total_cache_creation_tokens: 0,
110            session_cost: 0.0,
111            queued_message: None,
112            pending_events: Vec::new(),
113        }
114    }
115
116    /// Create from a resumed session. A session saved before interruption
117    /// markers existed (legacy `abort_context` recap) is migrated here too —
118    /// `/resume` loads sessions without going through `engine::setup` —
119    /// idempotently: an already-migrated session is untouched.
120    pub fn from_resumed(mut session: Session) -> Self {
121        crate::engine::interrupt::migrate_legacy_abort_context(
122            &mut session.api_messages,
123            &mut session.abort_context,
124        );
125        Self {
126            api_messages: session.api_messages.clone(),
127            total_input_tokens: session.total_input_tokens,
128            total_output_tokens: session.total_output_tokens,
129            total_cache_read_tokens: 0,
130            total_cache_creation_tokens: 0,
131            session_cost: session.session_cost,
132            queued_message: None,
133            pending_events: Vec::new(),
134            session,
135            context_head: ContextHeadPersistence::default(),
136        }
137    }
138
139    /// Save the current conversation state to disk. Returns whether it was
140    /// written: `false` when there is nothing to save (empty history), the
141    /// context head is unverified (`context_head`), or the write failed
142    /// (logged).
143    pub async fn save(&mut self) -> bool {
144        let Some(session) = self.prepare_save() else {
145            return false;
146        };
147        match session.save().await {
148            Ok(()) => true,
149            Err(e) => {
150                tracing::error!("Failed to save session: {}", e);
151                false
152            }
153        }
154    }
155
156    /// Fold the live state into `self.session` and return the snapshot to
157    /// write — or `None` when there is nothing to save (empty history) or
158    /// the context head is unverified. The session actor hands the snapshot
159    /// to its background writer (`session::persister`) instead of awaiting
160    /// the I/O. The clone is cheap: messages are `Arc`-shared.
161    pub fn prepare_save(&mut self) -> Option<Session> {
162        if self.context_head.is_blocked(&self.session) || self.api_messages.is_empty() {
163            return None;
164        }
165        self.session.api_messages = self.api_messages.clone();
166        self.session.total_input_tokens = self.total_input_tokens;
167        self.session.total_output_tokens = self.total_output_tokens;
168        self.session.session_cost = self.session_cost;
169        // Legacy field: an interrupted turn now lives in `api_messages`
170        // (`engine::interrupt`); never write a recap back.
171        self.session.abort_context = None;
172        self.session.updated_at = chrono::Utc::now();
173        self.session.auto_title();
174        Some(self.session.clone())
175    }
176
177    /// Persist a runtime-requested head using host metadata and latest usage.
178    /// The caller completes the receipt with this exact result and continues
179    /// consuming the runtime's typed terminal error on failure.
180    pub async fn persist_context_head(
181        &mut self,
182        session_id: &str,
183        messages: Vec<SharedMessage>,
184    ) -> std::io::Result<()> {
185        let mut candidate = self.session.clone();
186        candidate.api_messages = messages;
187        candidate.total_input_tokens = self.total_input_tokens;
188        candidate.total_output_tokens = self.total_output_tokens;
189        candidate.session_cost = self.session_cost;
190        candidate.abort_context = None;
191        candidate.updated_at = chrono::Utc::now();
192        candidate.auto_title();
193        self.context_head
194            .persist(
195                &mut self.session,
196                &mut self.api_messages,
197                session_id,
198                candidate,
199            )
200            .await
201    }
202
203    /// Clear the current session and start fresh.
204    pub async fn clear(&mut self, runtime: &Runtime) {
205        self.save().await;
206        self.context_head = ContextHeadPersistence::default();
207        self.api_messages.clear();
208        self.total_input_tokens = 0;
209        self.total_output_tokens = 0;
210        self.total_cache_read_tokens = 0;
211        self.total_cache_creation_tokens = 0;
212        self.session_cost = 0.0;
213        self.queued_message = None;
214        self.pending_events.clear();
215        self.session = Session::new(
216            runtime.model(),
217            runtime.thinking_level(),
218            runtime.system_prompt(),
219        );
220        runtime.reset_context_continuation(&self.session.id, &[]);
221    }
222
223    /// Serializable mirror for clients (`SessionEventWire::Conversation`).
224    /// `consecutive_auto_turns` is actor/App-side state, not conversation
225    /// state, so the caller supplies it.
226    pub fn snapshot(
227        &self,
228        consecutive_auto_turns: u32,
229    ) -> crate::session::ConversationSnapshot {
230        crate::session::ConversationSnapshot {
231            header: crate::session::SessionHeader::from(&self.session),
232            api_messages: self.api_messages.clone(),
233            messages_len: self.api_messages.len(),
234            tokens: crate::session::ConversationTokens {
235                input: self.total_input_tokens,
236                output: self.total_output_tokens,
237                cache_read: self.total_cache_read_tokens,
238                cache_creation: self.total_cache_creation_tokens,
239            },
240            cost: self.session_cost,
241            // Wire-compat field (protocol v3); always `None` now.
242            abort_context: None,
243            queued_message: self.queued_message.clone(),
244            pending_events_len: self.pending_events.len(),
245            consecutive_auto_turns,
246            messages_hash_memo: Default::default(),
247        }
248    }
249
250    /// Add usage from a model turn.
251    ///
252    /// `cache_creation_5m` / `cache_creation_1h` are the cache-write TTL
253    /// split. When either is present the cost uses the split rates
254    /// (5m: 1.25×, 1h: 2.0×); when both are `None` the aggregate
255    /// `cache_creation` is billed at the 5m rate (fail-cheap fallback).
256    #[allow(clippy::too_many_arguments)]
257    pub fn add_usage(
258        &mut self,
259        input_tokens: u64,
260        output_tokens: u64,
261        cache_read: u64,
262        cache_creation: u64,
263        cache_creation_5m: Option<u64>,
264        cache_creation_1h: Option<u64>,
265        model: &str,
266    ) {
267        self.total_input_tokens += input_tokens;
268        self.total_output_tokens += output_tokens;
269        self.total_cache_read_tokens += cache_read;
270        self.total_cache_creation_tokens += cache_creation;
271
272        self.session_cost += calculate_cost_optional_split(
273            model,
274            input_tokens,
275            output_tokens,
276            cache_read,
277            cache_creation,
278            cache_creation_5m,
279            cache_creation_1h,
280        );
281    }
282}
283
284#[cfg(test)]
285mod context_head_tests {
286    use super::*;
287    use agent_core::core::context_head::ContextHeadReceipt;
288    use agent_core::core::session_journal::{save_session_durable_in_dir, SessionPersistence};
289    use serde_json::json;
290    use std::sync::Arc;
291
292    fn fixture() -> (Session, Vec<SharedMessage>, Session) {
293        let mut old = Session::new("synthetic-model", "medium", Some("host authority"));
294        old.id = "synthetic-context-head".into();
295        old.name = Some("synthetic-name".into());
296        old.title = "original title".into();
297        old.parent_session = Some("synthetic-parent".into());
298        old.api_messages = vec![Arc::new(json!({"role":"user","content":"old head"}))];
299        let messages = old.api_messages.clone();
300        let mut candidate = old.clone();
301        candidate.api_messages = vec![Arc::new(json!({"role":"user","content":"candidate"}))];
302        candidate.total_input_tokens = 23;
303        candidate.total_output_tokens = 7;
304        candidate.session_cost = 0.125;
305        candidate.abort_context = Some("latest synthetic abort context".into());
306        (old, messages, candidate)
307    }
308
309    #[cfg(unix)]
310    #[tokio::test]
311    async fn context_head_ack_follows_durable_write_and_adoption() {
312        for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
313            let dir = tempfile::tempdir().unwrap();
314            let (mut session, mut messages, candidate) = fixture();
315            let id = session.id.clone();
316            let expected = serde_json::to_value(&candidate).unwrap();
317            let mut state = ContextHeadPersistence::default();
318            let (receipt, mut acknowledged) = ContextHeadReceipt::channel();
319            let (started_tx, started_rx) = tokio::sync::oneshot::channel();
320            let (release_tx, release_rx) = tokio::sync::oneshot::channel();
321            let path = dir.path().to_owned();
322            let result = {
323                let persist = state.persist_with(
324                    &mut session,
325                    &mut messages,
326                    &id,
327                    candidate,
328                    |candidate| async move {
329                        started_tx.send(()).unwrap();
330                        release_rx.await.unwrap();
331                        save_session_durable_in_dir(&path, &candidate, mode).map(|_| ())
332                    },
333                );
334                tokio::pin!(persist);
335                tokio::select! {
336                    result = &mut persist => panic!("write unexpectedly completed: {result:?}"),
337                    _ = started_rx => {}
338                }
339                assert!(matches!(
340                    acknowledged.try_recv(),
341                    Err(tokio::sync::oneshot::error::TryRecvError::Empty)
342                ));
343                release_tx.send(()).unwrap();
344                persist.await
345            };
346            assert!(result.is_ok());
347            receipt.complete(result);
348            assert_eq!(acknowledged.await.unwrap(), Ok(()));
349            assert_eq!(serde_json::to_value(&session).unwrap(), expected);
350            assert_eq!(messages, session.api_messages);
351            assert!(!state.is_blocked(&session));
352            let loaded = Session::load_from_dir(dir.path(), &id).unwrap();
353            assert_eq!(loaded.api_messages, messages);
354            assert_eq!(loaded.system_prompt.as_deref(), Some("host authority"));
355            assert_eq!(loaded.name, session.name);
356            assert_eq!(loaded.total_input_tokens, 23);
357            assert_eq!(loaded.total_output_tokens, 7);
358            assert_eq!(loaded.abort_context, session.abort_context);
359        }
360    }
361
362    #[tokio::test]
363    async fn context_head_identity_mismatch_never_writes_or_adopts() {
364        let (mut session, mut messages, candidate) = fixture();
365        let before = serde_json::to_value(&session).unwrap();
366        let mut state = ContextHeadPersistence::default();
367        let (receipt, acknowledged) = ContextHeadReceipt::channel();
368        let result = state
369            .persist_with(
370                &mut session,
371                &mut messages,
372                "another-session",
373                candidate,
374                |_| async { panic!("identity rejection must not reach persistence") },
375            )
376            .await;
377        assert_eq!(
378            result.as_ref().unwrap_err().kind(),
379            std::io::ErrorKind::InvalidInput
380        );
381        receipt.complete(result);
382        assert!(acknowledged.await.unwrap().is_err());
383        assert_eq!(serde_json::to_value(&session).unwrap(), before);
384        assert_eq!(messages, session.api_messages);
385        assert!(state.is_blocked(&session));
386        let mut other = session.clone();
387        other.id = "new-session".into();
388        assert!(!state.is_blocked(&other));
389    }
390
391    /// `/resume` builds its conversation with `from_resumed` directly: a
392    /// legacy recap is migrated (dropped, marker appended), never carried.
393    #[test]
394    fn from_resumed_migrates_a_legacy_abort_context() {
395        let mut legacy = Session::new("synthetic-model", "low", None);
396        legacy.api_messages = vec![Arc::new(json!({"role":"user","content":"do X"}))];
397        legacy.abort_context = Some("(System note — ABORT CONTEXT: …)".into());
398        let conv = ConversationState::from_resumed(legacy);
399        assert!(conv.session.abort_context.is_none());
400        assert_eq!(conv.api_messages.len(), 2);
401        assert_eq!(
402            conv.api_messages[1]["content"],
403            crate::engine::interrupt::InterruptReason::Unknown.marker()
404        );
405        assert_eq!(conv.session.api_messages, conv.api_messages);
406        // Idempotent for an already-migrated session.
407        let again = ConversationState::from_resumed(conv.session.clone());
408        assert_eq!(again.api_messages, conv.api_messages);
409    }
410
411    #[cfg(unix)]
412    #[tokio::test]
413    async fn context_head_post_publish_error_is_not_rollback_or_success() {
414        let dir = tempfile::tempdir().unwrap();
415        let (session, _, candidate) = fixture();
416        let mut conv = ConversationState::from_resumed(session);
417        let id = conv.session.id.clone();
418        let expected = candidate.api_messages.clone();
419        let path = dir.path().to_owned();
420        let (receipt, acknowledged) = ContextHeadReceipt::channel();
421        let result = conv
422            .context_head
423            .persist_with(
424                &mut conv.session,
425                &mut conv.api_messages,
426                &id,
427                candidate,
428                |candidate| async move {
429                    save_session_durable_in_dir(&path, &candidate, SessionPersistence::Journal)?;
430                    Err(std::io::Error::other("synthetic error after publication"))
431                },
432            )
433            .await;
434        receipt.complete(result);
435        assert!(acknowledged.await.unwrap().is_err());
436        assert!(conv.context_head.is_blocked(&conv.session));
437        assert_eq!(conv.api_messages, expected);
438        assert_eq!(
439            Session::load_from_dir(dir.path(), &id)
440                .unwrap()
441                .api_messages,
442            expected
443        );
444        // Simulate stale failure repair/UI data. save() must return before it
445        // even copies this old state into the conservatively adopted Session.
446        conv.api_messages.clear();
447        conv.api_messages
448            .push(Arc::new(json!({"role":"user","content":"stale"})));
449        conv.save().await;
450        assert_eq!(conv.session.api_messages, expected);
451        let loaded = Session::load_from_dir(dir.path(), &id).unwrap();
452        let resumed = ConversationState::from_resumed(loaded);
453        assert!(!resumed.context_head.is_blocked(&resumed.session));
454    }
455
456    #[tokio::test]
457    async fn context_head_dropped_write_future_latches_without_mutating_old_head() {
458        let (mut session, mut messages, candidate) = fixture();
459        let before = serde_json::to_value(&session).unwrap();
460        let id = session.id.clone();
461        let mut state = ContextHeadPersistence::default();
462        {
463            let persist = state.persist_with(&mut session, &mut messages, &id, candidate, |_| {
464                std::future::pending::<std::io::Result<()>>()
465            });
466            tokio::pin!(persist);
467            assert!(futures::poll!(&mut persist).is_pending());
468        }
469        assert!(state.is_blocked(&session));
470        assert_eq!(serde_json::to_value(&session).unwrap(), before);
471        assert_eq!(messages, session.api_messages);
472    }
473}