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, CoreMessage, 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 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
28/// Normalize a single `LlmCompleted` for recovery (message fields only).
29///
30/// Provider-neutral: the stored `provider_replay` envelope is left untouched.
31/// The core never synthesizes a protocol-specific replay shape.
32pub 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
47/// Repair event log entries in place for recovery minimum set completeness.
48pub 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
69/// Pending tool calls after the last assistant turn in preloaded history.
70pub 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
101/// Reconstructs messages from committed session events. `Compressed` owns the fallback summary;
102/// `PageOut` is the only event that may own an archive reference.
103pub 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                // Multimodal parity: the live path seeds `attachments` into history before the
136                // first render (gated behind `!resume_mid_run`), so on resume that seed is skipped
137                // and the image/audio must be recovered from the persisted `run_started` event —
138                // otherwise the model sees only the goal text and the attachment is silently lost.
139                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        // Provider-neutral: no fabricated native_blocks.
260        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        // Re-serialize: the envelope is preserved verbatim.
303        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        // A crash-and-resume rebuilds history from the session log rather than re-seeding the live
370        // attachments (that seed is gated behind `!resume_mid_run`). If reconstruction flattens the
371        // initial turn to text, the image is silently lost — this pins that it survives as Parts.
372        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        // Pass an explicit byte cap: 300 bytes
440        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}