1use crate::pricing::calculate_cost_optional_split;
7use crate::SharedMessage;
8use crate::{Runtime, Session};
9
10#[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 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 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 *messages = candidate.api_messages.clone();
75 *session = candidate;
76 if result.is_ok() {
77 self.blocked_session = None;
78 }
79 result
80 }
81}
82
83pub 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 queued_message: Option<String>,
95 pub pending_events: Vec<String>,
97}
98
99impl ConversationState {
100 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 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 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 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 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 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 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 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 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 #[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 #[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 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 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}