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 abort_context: Option<String>,
94 pub queued_message: Option<String>,
96 pub pending_events: Vec<String>,
98}
99
100impl ConversationState {
101 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 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 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 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 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 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 #[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 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}