1use crate::context::text::truncate_with_suffix;
2use crate::runtime::session::{ProviderReplay, SessionEvent};
3use crate::types::message::{Content, ContentPart, CoreMessage, Role, ToolCall};
4
5pub fn sanitize_recovery_text(text: &str) -> String {
9 sanitize_recovery_text_bounded(text, 0)
10}
11
12pub fn sanitize_recovery_text_bounded(text: &str, max_bytes: usize) -> String {
13 if text.is_empty() {
14 return String::new();
15 }
16 if max_bytes > 0 && text.len() > max_bytes {
17 return truncate_with_suffix(text, max_bytes, "… [replay truncated]");
18 }
19 text.to_owned()
20}
21
22fn normalize_assistant_message_with_cap(message: &mut CoreMessage, max_bytes: usize) {
23 if let Content::Text(text) = &mut message.content {
24 *text = sanitize_recovery_text_bounded(text, max_bytes);
25 }
26}
27
28pub fn repair_llm_completed(
33 message: &mut CoreMessage,
34 provider_replay: &mut Option<ProviderReplay>,
35) {
36 repair_llm_completed_with_cap(message, provider_replay, 0);
37}
38
39pub fn repair_llm_completed_with_cap(
40 message: &mut CoreMessage,
41 _provider_replay: &mut Option<ProviderReplay>,
42 max_bytes: usize,
43) {
44 normalize_assistant_message_with_cap(message, max_bytes);
45}
46
47pub fn repair_events(events: Vec<SessionEvent>) -> Vec<SessionEvent> {
49 repair_events_with_cap(events, 0)
50}
51
52pub fn repair_events_with_cap(events: Vec<SessionEvent>, max_bytes: usize) -> Vec<SessionEvent> {
53 events
54 .into_iter()
55 .map(|mut event| {
56 if let SessionEvent::LlmCompleted {
57 ref mut message,
58 ref mut provider_replay,
59 ..
60 } = event
61 {
62 repair_llm_completed_with_cap(message, provider_replay, max_bytes);
63 }
64 event
65 })
66 .collect()
67}
68
69pub fn pending_tool_calls_from_messages(messages: &[CoreMessage]) -> Vec<ToolCall> {
71 let Some(assistant_idx) = messages
72 .iter()
73 .rposition(|m| m.role == Role::Assistant && !m.tool_calls.is_empty())
74 else {
75 return Vec::new();
76 };
77
78 let assistant = &messages[assistant_idx];
79 let mut completed = std::collections::HashSet::new();
80 for msg in &messages[assistant_idx + 1..] {
81 if msg.role != Role::Tool {
82 continue;
83 }
84 if let Content::Parts(parts) = &msg.content {
85 for part in parts {
86 if let ContentPart::ToolResult { call_id, .. } = part {
87 completed.insert(call_id.clone());
88 }
89 }
90 }
91 }
92
93 assistant
94 .tool_calls
95 .iter()
96 .filter(|tc| !completed.contains(&tc.id))
97 .cloned()
98 .collect()
99}
100
101pub fn reconstruct_messages_with_fallback<F>(
104 events: &[SessionEvent],
105 _session_id: &str,
106 max_bytes: usize,
107 mut load_archive: F,
108) -> Vec<CoreMessage>
109where
110 F: FnMut(&str) -> Result<Vec<CoreMessage>, crate::context::fault::ContextFault>,
111{
112 let mut messages = Vec::new();
113 for (event_index, event) in events.iter().enumerate() {
114 match event {
115 SessionEvent::RunStarted {
116 goal,
117 criteria,
118 attachments,
119 ..
120 } => {
121 let user_text = if criteria.is_empty() {
122 goal.clone()
123 } else {
124 format!(
125 "{}\n\nCriteria:\n{}",
126 goal,
127 criteria
128 .iter()
129 .enumerate()
130 .map(|(i, c)| format!("{}. {}", i + 1, c))
131 .collect::<Vec<_>>()
132 .join("\n")
133 )
134 };
135 let content = if attachments.is_empty() {
140 Content::Text(user_text)
141 } else {
142 let mut parts = Vec::with_capacity(attachments.len() + 1);
143 if !user_text.is_empty() {
144 parts.push(ContentPart::Text { text: user_text });
145 }
146 parts.extend(attachments.iter().cloned());
147 Content::Parts(parts)
148 };
149 messages.push(CoreMessage {
150 role: Role::User,
151 content,
152 tool_calls: vec![],
153 });
154 }
155 SessionEvent::LlmCompleted { message, .. } => {
156 let mut msg = message.clone();
157 if let Content::Text(text) = &mut msg.content {
158 *text = sanitize_recovery_text_bounded(text, max_bytes);
159 }
160 messages.push(msg);
161 }
162 SessionEvent::ToolCompleted { results, .. } => {
163 for r in results {
164 let output = match &r.output {
165 Content::Text(t) => sanitize_recovery_text_bounded(t, max_bytes),
166 Content::Parts(_) => String::new(),
167 };
168 messages.push(CoreMessage {
169 role: Role::Tool,
170 content: Content::Parts(vec![ContentPart::ToolResult {
171 call_id: r.call_id.clone(),
172 output,
173 is_error: r.is_error,
174 durable_content: r.durable_content.clone(),
175 }]),
176 tool_calls: vec![],
177 });
178 }
179 }
180 SessionEvent::Compressed { turn, summary, .. } => {
181 let page_out_will_supply_archive = events[event_index + 1..].iter().any(|event| {
182 matches!(
183 event,
184 SessionEvent::PageOut {
185 turn: page_out_turn,
186 archive_ref: Some(reference),
187 ..
188 } if page_out_turn == turn && !reference.is_empty()
189 )
190 });
191 if !page_out_will_supply_archive {
192 if let Some(sum) = summary {
193 let system_text = format!("[Compressed context: turn {}]\n{}", turn, sum);
194 messages.push(CoreMessage {
195 role: Role::System,
196 content: Content::Text(system_text),
197 tool_calls: vec![],
198 });
199 }
200 }
201 }
202 SessionEvent::PageOut {
203 turn,
204 summary,
205 archive_ref: Some(archive_ref),
206 ..
207 } if !archive_ref.is_empty() => match load_archive(archive_ref) {
208 Ok(archived_messages) => {
209 for mut message in archived_messages {
210 if let Content::Text(text) = &mut message.content {
211 *text = sanitize_recovery_text_bounded(text, max_bytes);
212 }
213 messages.push(message);
214 }
215 }
216 Err(_) => {
217 if let Some(summary) = summary {
218 messages.push(CoreMessage {
219 role: Role::System,
220 content: Content::Text(format!(
221 "[Compressed context: turn {}]\n{}",
222 turn, summary
223 )),
224 tool_calls: vec![],
225 });
226 }
227 }
228 },
229 SessionEvent::Rollbacked {
230 checkpoint_history_len,
231 ..
232 } => {
233 messages.truncate(*checkpoint_history_len as usize);
234 }
235 _ => {}
236 }
237 }
238 messages
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use compact_str::CompactString;
245
246 #[test]
247 fn repair_does_not_synthesize_provider_replay_for_tool_turns() {
248 let mut message = CoreMessage {
249 role: Role::Assistant,
250 content: Content::Text("checking".into()),
251 tool_calls: vec![ToolCall {
252 id: CompactString::new("c1"),
253 name: CompactString::new("ping"),
254 arguments: serde_json::json!({}),
255 }],
256 };
257 let mut replay: Option<ProviderReplay> = None;
258 repair_llm_completed(&mut message, &mut replay);
259 assert!(replay.is_none());
261 assert_eq!(message.content.as_text(), Some("checking"));
262 }
263
264 #[test]
265 fn repair_passes_stored_replay_through() {
266 let mut message = CoreMessage {
267 role: Role::Assistant,
268 content: Content::Text("x".into()),
269 tool_calls: vec![],
270 };
271 let mut replay = Some(ProviderReplay {
272 protocol: "openai-chat".into(),
273 provider: Some("deepseek".into()),
274 model: None,
275 native_blocks: None,
276 reasoning_content: Some("trace".into()),
277 reasoning_details: None,
278 native_message: None,
279 tool_calls: None,
280 });
281 repair_llm_completed(&mut message, &mut replay);
282 assert_eq!(
283 replay.as_ref().and_then(|r| r.reasoning_content.as_deref()),
284 Some("trace")
285 );
286 }
287
288 #[test]
289 fn provider_replay_round_trips_the_canonical_envelope() {
290 let json = serde_json::json!({
291 "provider": "deepseek",
292 "protocol": "openai-chat",
293 "model": "deepseek-v4-flash",
294 "reasoning_content": "trace",
295 "reasoning_details": [{"type": "reasoning.text", "text": "trace"}],
296 "tool_calls": [{"id": "c1"}]
297 });
298 let replay: ProviderReplay = serde_json::from_value(json.clone()).expect("parse");
299 assert_eq!(replay.reasoning_content.as_deref(), Some("trace"));
300 assert_eq!(replay.provider.as_deref(), Some("deepseek"));
301 assert_eq!(replay.protocol, "openai-chat");
302 assert_eq!(serde_json::to_value(&replay).expect("serialize"), json);
304 }
305
306 #[test]
307 fn versioned_or_protocol_less_replay_is_rejected() {
308 assert!(
309 serde_json::from_value::<ProviderReplay>(serde_json::json!({
310 "schema_version": 2,
311 "protocol": "openai-chat"
312 }))
313 .is_err()
314 );
315 assert!(
316 serde_json::from_value::<ProviderReplay>(serde_json::json!({
317 "reasoning_content": "trace"
318 }))
319 .is_err()
320 );
321 }
322
323 #[test]
324 fn reconstruct_ignores_categorized_kernel_os_events() {
325 use crate::runtime::session::SessionEvent;
326
327 let events = vec![
328 SessionEvent::RunStarted {
329 run_id: "r1".into(),
330 goal: "g".into(),
331 criteria: vec![],
332 agent_id: None,
333 system_prompt: None,
334 attachments: vec![],
335 },
336 SessionEvent::PageOut {
337 turn: 1,
338 action: Some("auto_compact".into()),
339 summary: Some("sum".into()),
340 tier_hint: Some("durable".into()),
341 message_count: 3,
342 archive_ref: None,
343 },
344 SessionEvent::SignalDeliveryDisposed {
345 turn: 1,
346 operation_id: "op".into(),
347 delivery_id: "delivery".into(),
348 attempt: 1,
349 signal_id: "sig-1".into(),
350 disposition: "queue".into(),
351 queue_depth: 1,
352 },
353 ];
354 let messages = reconstruct_messages_with_fallback(&events, "s1", 0, |_| {
355 Err(crate::context::fault::ContextFault::MissingArchive {
356 session_id: "s1".into(),
357 seq: 0,
358 })
359 });
360 assert_eq!(messages.len(), 1);
361 assert_eq!(messages[0].role, Role::User);
362 }
363
364 #[test]
365 fn reconstruct_preserves_run_started_attachments_as_content_parts() {
366 use crate::runtime::session::SessionEvent;
367 use crate::types::message::{Content, ContentPart};
368
369 let events = vec![SessionEvent::RunStarted {
373 run_id: "r1".into(),
374 goal: "describe this".into(),
375 criteria: vec![],
376 agent_id: None,
377 system_prompt: None,
378 attachments: vec![ContentPart::Image {
379 source: crate::types::durable_content::DurableSource::Base64 {
380 data: "QUJD".into(),
381 },
382 media_type: Some("image/png".into()),
383 detail: None,
384 }],
385 }];
386 let messages = reconstruct_messages_with_fallback(&events, "s1", 0, |_| {
387 Err(crate::context::fault::ContextFault::MissingArchive {
388 session_id: "s1".into(),
389 seq: 0,
390 })
391 });
392 assert_eq!(messages.len(), 1);
393 let Content::Parts(parts) = &messages[0].content else {
394 panic!("resumed multimodal run must reconstruct to Content::Parts, not flattened text");
395 };
396 assert!(matches!(&parts[0], ContentPart::Text { text } if text == "describe this"));
397 assert!(
398 parts
399 .iter()
400 .any(|p| matches!(p, ContentPart::Image { source: crate::types::durable_content::DurableSource::Base64 { data: d }, .. } if d == "QUJD"))
401 );
402 }
403
404 #[test]
405 fn reconstruct_loads_archive_from_committed_page_out_event() {
406 use crate::runtime::session::SessionEvent;
407
408 let events = vec![
409 SessionEvent::Compressed {
410 turn: 2,
411 archived_seq_range: (0, 4),
412 action: Some("auto_compact".into()),
413 summary: Some("fallback".into()),
414 summary_tokens: Some(1),
415 preserved_refs: vec![],
416 },
417 SessionEvent::PageOut {
418 turn: 2,
419 action: Some("auto_compact".into()),
420 summary: Some("fallback".into()),
421 tier_hint: Some("semantic".into()),
422 message_count: 1,
423 archive_ref: Some("archive://turn-2".into()),
424 },
425 ];
426
427 let messages = reconstruct_messages_with_fallback(&events, "s1", 1024, |reference| {
428 assert_eq!(reference, "archive://turn-2");
429 Ok(vec![CoreMessage::user("restored archive")])
430 });
431
432 assert_eq!(messages.len(), 1);
433 assert_eq!(messages[0].content.as_text(), Some("restored archive"));
434 }
435
436 #[test]
437 fn sanitize_recovery_text_bounded_respects_cjk_boundary() {
438 let text = "你".repeat(20_000);
439 let out = sanitize_recovery_text_bounded(&text, 300);
441 assert!(out.ends_with("… [replay truncated]"));
442 assert!(std::str::from_utf8(out.as_bytes()).is_ok());
443 }
444}