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