Skip to main content

deepstrike_core/runtime/
repair.rs

1use crate::context::text::truncate_with_suffix;
2use crate::runtime::session::{ProviderReplay, SessionEvent};
3use crate::types::message::{Content, ContentPart, Message, Role, ToolCall};
4
5/// Sanitize text for recovery paths: ensure valid UTF-8 and apply an optional
6/// byte cap derived from the caller's context config. When `max_bytes` is 0
7/// no cap is applied.
8pub 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    // Char count / 4 approximation — more accurate than byte count for CJK.
24    (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
38/// Normalize a single `LlmCompleted` for recovery (message fields only).
39///
40/// Provider-neutral: the stored `provider_replay` envelope is left untouched.
41/// The core never synthesizes a protocol-specific replay shape — legacy
42/// reconstruction is the responsibility of the target provider in the SDK.
43pub 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
55/// Repair event log entries in place for recovery minimum set completeness.
56pub 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
77/// Pending tool calls after the last assistant turn in preloaded history.
78pub 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
109/// Reconstructs messages from committed session events. `Compressed` owns the fallback summary;
110/// `PageOut` is the only event that may own an archive reference.
111pub 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                // Multimodal parity: the live path seeds `attachments` into history before the
144                // first render (gated behind `!resume_mid_run`), so on resume that seed is skipped
145                // and the image/audio must be recovered from the persisted `run_started` event —
146                // otherwise the model sees only the goal text and the attachment is silently lost.
147                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        // Provider-neutral: no fabricated native_blocks.
272        assert!(replay.is_none());
273        // Message is still normalized (token count backfilled).
274        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        // Re-serialize: the envelope is preserved verbatim.
313        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        // A crash-and-resume rebuilds history from the session log rather than re-seeding the live
363        // attachments (that seed is gated behind `!resume_mid_run`). If reconstruction flattens the
364        // initial turn to text, the image is silently lost — this pins that it survives as Parts.
365        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        // Pass an explicit byte cap: 300 bytes
427        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}