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 keepwarm_cost: f64,
97 pub keepwarm_tokens: crate::session::ConversationTokens,
100 pub queued_message: Option<String>,
102 pub pending_events: Vec<String>,
104}
105
106fn persisted_keepwarm_tokens(
109 t: &crate::session::ConversationTokens,
110) -> agent_core::core::session::KeepwarmTokens {
111 agent_core::core::session::KeepwarmTokens {
112 input: t.input,
113 output: t.output,
114 cache_read: t.cache_read,
115 cache_creation: t.cache_creation,
116 }
117}
118
119fn resumed_keepwarm_tokens(session: &Session) -> crate::session::ConversationTokens {
124 let kw = session.keepwarm_tokens;
125 crate::session::ConversationTokens {
126 input: kw.input.min(session.total_input_tokens),
127 output: kw.output.min(session.total_output_tokens),
128 cache_read: kw.cache_read.min(session.total_cache_read_tokens),
129 cache_creation: kw.cache_creation.min(session.total_cache_creation_tokens),
130 }
131}
132
133impl ConversationState {
134 pub fn new(session: Session) -> Self {
136 Self {
137 session,
138 context_head: ContextHeadPersistence::default(),
139 api_messages: Vec::new(),
140 total_input_tokens: 0,
141 total_output_tokens: 0,
142 total_cache_read_tokens: 0,
143 total_cache_creation_tokens: 0,
144 session_cost: 0.0,
145 keepwarm_cost: 0.0,
146 keepwarm_tokens: Default::default(),
147 queued_message: None,
148 pending_events: Vec::new(),
149 }
150 }
151
152 pub fn from_resumed(mut session: Session) -> Self {
157 crate::engine::interrupt::migrate_legacy_abort_context(
158 &mut session.api_messages,
159 &mut session.abort_context,
160 );
161 Self {
162 api_messages: session.api_messages.clone(),
163 total_input_tokens: session.total_input_tokens,
164 total_output_tokens: session.total_output_tokens,
165 total_cache_read_tokens: session.total_cache_read_tokens,
166 total_cache_creation_tokens: session.total_cache_creation_tokens,
167 session_cost: session.session_cost,
168 keepwarm_cost: session.keepwarm_cost.min(session.session_cost).max(0.0),
169 keepwarm_tokens: resumed_keepwarm_tokens(&session),
170 queued_message: None,
171 pending_events: Vec::new(),
172 session,
173 context_head: ContextHeadPersistence::default(),
174 }
175 }
176
177 pub async fn save(&mut self) -> bool {
182 let Some(session) = self.prepare_save() else {
183 return false;
184 };
185 match session.save().await {
186 Ok(()) => true,
187 Err(e) => {
188 tracing::error!("Failed to save session: {}", e);
189 false
190 }
191 }
192 }
193
194 pub fn prepare_save(&mut self) -> Option<Session> {
200 if self.context_head.is_blocked(&self.session) || self.api_messages.is_empty() {
201 return None;
202 }
203 self.session.api_messages = self.api_messages.clone();
204 self.session.total_input_tokens = self.total_input_tokens;
205 self.session.total_output_tokens = self.total_output_tokens;
206 self.session.total_cache_read_tokens = self.total_cache_read_tokens;
207 self.session.total_cache_creation_tokens = self.total_cache_creation_tokens;
208 self.session.session_cost = self.session_cost;
209 self.session.keepwarm_cost = self.keepwarm_cost;
210 self.session.keepwarm_tokens = persisted_keepwarm_tokens(&self.keepwarm_tokens);
211 self.session.abort_context = None;
214 self.session.updated_at = chrono::Utc::now();
215 self.session.auto_title();
216 Some(self.session.clone())
217 }
218
219 pub async fn persist_context_head(
223 &mut self,
224 session_id: &str,
225 messages: Vec<SharedMessage>,
226 ) -> std::io::Result<()> {
227 let mut candidate = self.session.clone();
228 candidate.api_messages = messages;
229 candidate.total_input_tokens = self.total_input_tokens;
230 candidate.total_output_tokens = self.total_output_tokens;
231 candidate.total_cache_read_tokens = self.total_cache_read_tokens;
232 candidate.total_cache_creation_tokens = self.total_cache_creation_tokens;
233 candidate.session_cost = self.session_cost;
234 candidate.keepwarm_cost = self.keepwarm_cost;
235 candidate.keepwarm_tokens = persisted_keepwarm_tokens(&self.keepwarm_tokens);
236 candidate.abort_context = None;
237 candidate.updated_at = chrono::Utc::now();
238 candidate.auto_title();
239 self.context_head
240 .persist(
241 &mut self.session,
242 &mut self.api_messages,
243 session_id,
244 candidate,
245 )
246 .await
247 }
248
249 pub async fn clear(&mut self, runtime: &Runtime) {
251 self.save().await;
252 self.context_head = ContextHeadPersistence::default();
253 self.api_messages.clear();
254 self.total_input_tokens = 0;
255 self.total_output_tokens = 0;
256 self.total_cache_read_tokens = 0;
257 self.total_cache_creation_tokens = 0;
258 self.session_cost = 0.0;
259 self.keepwarm_cost = 0.0;
260 self.keepwarm_tokens = Default::default();
261 self.queued_message = None;
262 self.pending_events.clear();
263 self.session = Session::new(
264 runtime.model(),
265 runtime.thinking_level(),
266 runtime.system_prompt(),
267 );
268 runtime.reset_context_continuation(&self.session.id, &[]);
269 }
270
271 pub fn snapshot(
275 &self,
276 consecutive_auto_turns: u32,
277 ) -> crate::session::ConversationSnapshot {
278 crate::session::ConversationSnapshot {
279 header: crate::session::SessionHeader::from(&self.session),
280 api_messages: self.api_messages.clone(),
281 messages_len: self.api_messages.len(),
282 tokens: crate::session::ConversationTokens {
283 input: self.total_input_tokens,
284 output: self.total_output_tokens,
285 cache_read: self.total_cache_read_tokens,
286 cache_creation: self.total_cache_creation_tokens,
287 },
288 cost: self.session_cost,
289 keepwarm_cost: self.keepwarm_cost,
290 keepwarm_tokens: self.keepwarm_tokens.clone(),
291 abort_context: None,
293 queued_message: self.queued_message.clone(),
294 pending_events_len: self.pending_events.len(),
295 consecutive_auto_turns,
296 messages_hash_memo: Default::default(),
297 }
298 }
299
300 #[allow(clippy::too_many_arguments)]
307 pub fn add_usage(
308 &mut self,
309 input_tokens: u64,
310 output_tokens: u64,
311 cache_read: u64,
312 cache_creation: u64,
313 cache_creation_5m: Option<u64>,
314 cache_creation_1h: Option<u64>,
315 model: &str,
316 ) {
317 self.total_input_tokens += input_tokens;
318 self.total_output_tokens += output_tokens;
319 self.total_cache_read_tokens += cache_read;
320 self.total_cache_creation_tokens += cache_creation;
321
322 self.session_cost += calculate_cost_optional_split(
323 model,
324 input_tokens,
325 output_tokens,
326 cache_read,
327 cache_creation,
328 cache_creation_5m,
329 cache_creation_1h,
330 );
331 }
332}
333
334#[cfg(test)]
335mod context_head_tests {
336 use super::*;
337 use agent_core::core::context_head::ContextHeadReceipt;
338 #[cfg(unix)]
339 use agent_core::core::session_journal::{save_session_durable_in_dir, SessionPersistence};
340 use serde_json::json;
341 use std::sync::Arc;
342
343 fn fixture() -> (Session, Vec<SharedMessage>, Session) {
344 let mut old = Session::new("synthetic-model", "medium", Some("host authority"));
345 old.id = "synthetic-context-head".into();
346 old.name = Some("synthetic-name".into());
347 old.title = "original title".into();
348 old.parent_session = Some("synthetic-parent".into());
349 old.api_messages = vec![Arc::new(json!({"role":"user","content":"old head"}))];
350 let messages = old.api_messages.clone();
351 let mut candidate = old.clone();
352 candidate.api_messages = vec![Arc::new(json!({"role":"user","content":"candidate"}))];
353 candidate.total_input_tokens = 23;
354 candidate.total_output_tokens = 7;
355 candidate.session_cost = 0.125;
356 candidate.abort_context = Some("latest synthetic abort context".into());
357 (old, messages, candidate)
358 }
359
360 #[cfg(unix)]
361 #[tokio::test]
362 async fn context_head_ack_follows_durable_write_and_adoption() {
363 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
364 let dir = tempfile::tempdir().unwrap();
365 let (mut session, mut messages, candidate) = fixture();
366 let id = session.id.clone();
367 let expected = serde_json::to_value(&candidate).unwrap();
368 let mut state = ContextHeadPersistence::default();
369 let (receipt, mut acknowledged) = ContextHeadReceipt::channel();
370 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
371 let (release_tx, release_rx) = tokio::sync::oneshot::channel();
372 let path = dir.path().to_owned();
373 let result = {
374 let persist = state.persist_with(
375 &mut session,
376 &mut messages,
377 &id,
378 candidate,
379 |candidate| async move {
380 started_tx.send(()).unwrap();
381 release_rx.await.unwrap();
382 save_session_durable_in_dir(&path, &candidate, mode).map(|_| ())
383 },
384 );
385 tokio::pin!(persist);
386 tokio::select! {
387 result = &mut persist => panic!("write unexpectedly completed: {result:?}"),
388 _ = started_rx => {}
389 }
390 assert!(matches!(
391 acknowledged.try_recv(),
392 Err(tokio::sync::oneshot::error::TryRecvError::Empty)
393 ));
394 release_tx.send(()).unwrap();
395 persist.await
396 };
397 assert!(result.is_ok());
398 receipt.complete(result);
399 assert_eq!(acknowledged.await.unwrap(), Ok(()));
400 assert_eq!(serde_json::to_value(&session).unwrap(), expected);
401 assert_eq!(messages, session.api_messages);
402 assert!(!state.is_blocked(&session));
403 let loaded = Session::load_from_dir(dir.path(), &id).unwrap();
404 assert_eq!(loaded.api_messages, messages);
405 assert_eq!(loaded.system_prompt.as_deref(), Some("host authority"));
406 assert_eq!(loaded.name, session.name);
407 assert_eq!(loaded.total_input_tokens, 23);
408 assert_eq!(loaded.total_output_tokens, 7);
409 assert_eq!(loaded.abort_context, session.abort_context);
410 }
411 }
412
413 #[tokio::test]
414 async fn context_head_identity_mismatch_never_writes_or_adopts() {
415 let (mut session, mut messages, candidate) = fixture();
416 let before = serde_json::to_value(&session).unwrap();
417 let mut state = ContextHeadPersistence::default();
418 let (receipt, acknowledged) = ContextHeadReceipt::channel();
419 let result = state
420 .persist_with(
421 &mut session,
422 &mut messages,
423 "another-session",
424 candidate,
425 |_| async { panic!("identity rejection must not reach persistence") },
426 )
427 .await;
428 assert_eq!(
429 result.as_ref().unwrap_err().kind(),
430 std::io::ErrorKind::InvalidInput
431 );
432 receipt.complete(result);
433 assert!(acknowledged.await.unwrap().is_err());
434 assert_eq!(serde_json::to_value(&session).unwrap(), before);
435 assert_eq!(messages, session.api_messages);
436 assert!(state.is_blocked(&session));
437 let mut other = session.clone();
438 other.id = "new-session".into();
439 assert!(!state.is_blocked(&other));
440 }
441
442 #[test]
446 fn cache_totals_survive_save_and_resume() {
447 let mut s = Session::new("synthetic-model", "low", None);
448 s.api_messages = vec![Arc::new(json!({"role":"user","content":"q"}))];
449 let mut conv = ConversationState::from_resumed(s);
450 conv.add_usage(120, 40, 85_679, 6_250, None, None, "synthetic-model");
451 conv.keepwarm_tokens.cache_read = 4_000;
452 conv.keepwarm_tokens.cache_creation = 9_999; let saved = conv.prepare_save().expect("non-empty history saves");
454 assert_eq!(saved.total_cache_read_tokens, 85_679);
455 assert_eq!(saved.total_cache_creation_tokens, 6_250);
456
457 let resumed = ConversationState::from_resumed(saved);
458 assert_eq!(resumed.total_cache_read_tokens, 85_679);
459 assert_eq!(resumed.total_cache_creation_tokens, 6_250);
460 assert_eq!(resumed.keepwarm_tokens.cache_read, 4_000);
461 assert_eq!(resumed.keepwarm_tokens.cache_creation, 6_250);
462 let wire = resumed.snapshot(0);
463 assert_eq!(wire.tokens.cache_read, 85_679);
464 assert_eq!(wire.tokens.cache_creation, 6_250);
465 }
466
467 #[test]
470 fn from_resumed_migrates_a_legacy_abort_context() {
471 let mut legacy = Session::new("synthetic-model", "low", None);
472 legacy.api_messages = vec![Arc::new(json!({"role":"user","content":"do X"}))];
473 legacy.abort_context = Some("(System note — ABORT CONTEXT: …)".into());
474 let conv = ConversationState::from_resumed(legacy);
475 assert!(conv.session.abort_context.is_none());
476 assert_eq!(conv.api_messages.len(), 2);
477 assert_eq!(
478 conv.api_messages[1]["content"],
479 crate::engine::interrupt::InterruptReason::Unknown.marker()
480 );
481 assert_eq!(conv.session.api_messages, conv.api_messages);
482 let again = ConversationState::from_resumed(conv.session.clone());
484 assert_eq!(again.api_messages, conv.api_messages);
485 }
486
487 #[cfg(unix)]
488 #[tokio::test]
489 async fn context_head_post_publish_error_is_not_rollback_or_success() {
490 let dir = tempfile::tempdir().unwrap();
491 let (session, _, candidate) = fixture();
492 let mut conv = ConversationState::from_resumed(session);
493 let id = conv.session.id.clone();
494 let expected = candidate.api_messages.clone();
495 let path = dir.path().to_owned();
496 let (receipt, acknowledged) = ContextHeadReceipt::channel();
497 let result = conv
498 .context_head
499 .persist_with(
500 &mut conv.session,
501 &mut conv.api_messages,
502 &id,
503 candidate,
504 |candidate| async move {
505 save_session_durable_in_dir(&path, &candidate, SessionPersistence::Journal)?;
506 Err(std::io::Error::other("synthetic error after publication"))
507 },
508 )
509 .await;
510 receipt.complete(result);
511 assert!(acknowledged.await.unwrap().is_err());
512 assert!(conv.context_head.is_blocked(&conv.session));
513 assert_eq!(conv.api_messages, expected);
514 assert_eq!(
515 Session::load_from_dir(dir.path(), &id)
516 .unwrap()
517 .api_messages,
518 expected
519 );
520 conv.api_messages.clear();
523 conv.api_messages
524 .push(Arc::new(json!({"role":"user","content":"stale"})));
525 conv.save().await;
526 assert_eq!(conv.session.api_messages, expected);
527 let loaded = Session::load_from_dir(dir.path(), &id).unwrap();
528 let resumed = ConversationState::from_resumed(loaded);
529 assert!(!resumed.context_head.is_blocked(&resumed.session));
530 }
531
532 #[tokio::test]
533 async fn context_head_dropped_write_future_latches_without_mutating_old_head() {
534 let (mut session, mut messages, candidate) = fixture();
535 let before = serde_json::to_value(&session).unwrap();
536 let id = session.id.clone();
537 let mut state = ContextHeadPersistence::default();
538 {
539 let persist = state.persist_with(&mut session, &mut messages, &id, candidate, |_| {
540 std::future::pending::<std::io::Result<()>>()
541 });
542 tokio::pin!(persist);
543 assert!(futures::poll!(&mut persist).is_pending());
544 }
545 assert!(state.is_blocked(&session));
546 assert_eq!(serde_json::to_value(&session).unwrap(), before);
547 assert_eq!(messages, session.api_messages);
548 }
549}