Skip to main content

vtcode_core/core/agent/
state.rs

1use crate::llm::provider::Message;
2use hashbrown::{HashMap, HashSet};
3use std::sync::Arc;
4use std::time::Duration;
5use vtcode_macros::StringNewtype;
6
7// ============================================================================
8// Context Manager: Call/Output Pairing Invariants (OpenAI Codex pattern)
9// ============================================================================
10
11/// Unique identifier for a tool call.
12#[derive(Debug, Clone, PartialEq, Eq, Hash, StringNewtype)]
13pub struct ToolCallId(String);
14
15/// Status of a tool execution
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum OutputStatus {
18    Success,
19    Failed,
20    Canceled,
21    Timeout,
22}
23
24impl OutputStatus {
25    /// Convert to string representation
26    pub fn as_str(&self) -> &'static str {
27        match self {
28            Self::Success => "success",
29            Self::Failed => "failed",
30            Self::Canceled => "canceled",
31            Self::Timeout => "timeout",
32        }
33    }
34}
35
36/// Items that participate in call/output pairing for validation
37#[derive(Debug, Clone)]
38pub enum PairableHistoryItem {
39    /// Tool call without output (yet)
40    ToolCall { call_id: ToolCallId, tool_name: String },
41    /// Tool output for a previous call
42    ToolOutput { call_id: ToolCallId, status: OutputStatus },
43}
44
45/// Record of a missing output in conversation history
46#[derive(Debug, Clone)]
47pub struct MissingOutput {
48    pub call_id: ToolCallId,
49    pub tool_name: String,
50}
51
52/// Validation report for conversation history state
53#[derive(Debug, Default, Clone)]
54pub struct HistoryValidationReport {
55    /// Tool calls without corresponding outputs
56    pub missing_outputs: Vec<MissingOutput>,
57    /// Outputs without corresponding calls (orphans)
58    pub orphan_outputs: Vec<ToolCallId>,
59}
60
61impl HistoryValidationReport {
62    /// Check if history is in a valid state
63    pub fn is_valid(&self) -> bool {
64        self.missing_outputs.is_empty() && self.orphan_outputs.is_empty()
65    }
66
67    /// Get a human-readable summary
68    pub fn summary(&self) -> String {
69        if self.is_valid() {
70            "History invariants are valid".to_string()
71        } else {
72            format!("{} missing outputs, {} orphan outputs", self.missing_outputs.len(), self.orphan_outputs.len())
73        }
74    }
75
76    /// Filter out missing outputs that correspond to pending actions.
77    ///
78    /// A "missing output" is expected when the corresponding tool call is still
79    /// in flight (pending action). Removing these from the report prevents
80    /// false positives during crash recovery that would insert synthetic
81    /// outputs for tools that are legitimately still running.
82    pub fn exclude_pending(&mut self, is_pending: impl Fn(&str) -> bool) {
83        self.missing_outputs.retain(|m| !is_pending(m.call_id.as_str()));
84    }
85}
86
87#[cfg(test)]
88#[inline]
89pub(crate) fn record_turn_duration(
90    turn_durations: &mut Vec<u128>,
91    turn_total_ms: &mut u128,
92    turn_max_ms: &mut u128,
93    turn_count: &mut usize,
94    recorded: &mut bool,
95    start: &std::time::Instant,
96) {
97    if !*recorded {
98        let duration_ms = start.elapsed().as_millis();
99        turn_durations.push(duration_ms);
100        *turn_total_ms += duration_ms;
101        if duration_ms > *turn_max_ms {
102            *turn_max_ms = duration_ms;
103        }
104        *turn_count += 1;
105        *recorded = true;
106    }
107}
108
109/// API failure tracking for exponential backoff
110pub struct ApiFailureTracker {
111    pub consecutive_failures: u32,
112    pub last_failure: Option<std::time::Instant>,
113}
114
115impl Default for ApiFailureTracker {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl ApiFailureTracker {
122    pub fn new() -> Self {
123        Self { consecutive_failures: 0, last_failure: None }
124    }
125
126    pub fn record_failure(&mut self) {
127        self.consecutive_failures += 1;
128        self.last_failure = Some(std::time::Instant::now());
129    }
130
131    pub fn reset(&mut self) {
132        self.consecutive_failures = 0;
133        self.last_failure = None;
134    }
135
136    pub fn should_circuit_break(&self) -> bool {
137        self.consecutive_failures >= 3
138    }
139
140    pub fn backoff_duration(&self) -> Duration {
141        let base_ms = 1000;
142        let max_ms = 30000;
143        let backoff_ms = base_ms * 2_u64.pow(self.consecutive_failures.saturating_sub(1));
144        Duration::from_millis(backoff_ms.min(max_ms))
145    }
146}
147
148pub fn summarize_list(items: &[String]) -> String {
149    const MAX_ITEMS: usize = 5;
150    if items.is_empty() {
151        return "none".into();
152    }
153    let shown: Vec<&str> = items.iter().take(MAX_ITEMS).map(|s| s.as_str()).collect();
154    if items.len() > MAX_ITEMS {
155        format!("{} [+{} more]", shown.join(", "), items.len() - MAX_ITEMS)
156    } else {
157        shown.join(", ")
158    }
159}
160
161// ============================================================================
162// Standalone History Invariant Functions
163// ============================================================================
164
165/// Validate that conversation history maintains call/output invariants.
166pub fn validate_history_invariants(messages: &[Message]) -> HistoryValidationReport {
167    let mut call_map: HashMap<String, String> = HashMap::new();
168    let mut output_ids: HashSet<String> = HashSet::new();
169
170    // Scan messages to find tool calls and responses
171    for msg in messages {
172        // Tool calls: assistant messages with tool_calls field
173        if let Some(tool_calls) = &msg.tool_calls {
174            for tool_call in tool_calls {
175                call_map.insert(tool_call.id.clone(), msg.role.to_string());
176            }
177        }
178
179        // Tool responses: messages with tool_call_id set
180        if let Some(tool_call_id) = &msg.tool_call_id {
181            output_ids.insert(tool_call_id.clone());
182        }
183    }
184
185    // Find missing outputs (calls without corresponding responses)
186    let missing_outputs: Vec<_> = call_map
187        .keys()
188        .filter(|call_id| !output_ids.contains(*call_id))
189        .map(|call_id| MissingOutput {
190            call_id: ToolCallId::new(call_id.clone()),
191            tool_name: "unknown".to_string(),
192        })
193        .collect();
194
195    // Find orphan outputs (responses without matching calls)
196    let orphan_outputs: Vec<_> = output_ids
197        .iter()
198        .filter(|output_id| !call_map.contains_key(*output_id))
199        .map(|output_id| ToolCallId::new(output_id.clone()))
200        .collect();
201
202    HistoryValidationReport { missing_outputs, orphan_outputs }
203}
204
205const REQUEST_HISTORY_CANCELLATION_RESULT: &str =
206    "canceled: no tool result was recorded; this bounded placeholder preserves the tool-call protocol.";
207
208#[derive(Debug)]
209struct RequestToolBatch {
210    assistant_index: usize,
211    matched_calls: Vec<bool>,
212    result_indices: Vec<usize>,
213}
214
215#[derive(Debug, Default)]
216struct RequestHistoryAnalysis {
217    batches: Vec<RequestToolBatch>,
218    batch_by_assistant_index: HashMap<usize, usize>,
219    result_batch_by_index: HashMap<usize, usize>,
220    invalid_result_indices: HashSet<usize>,
221}
222
223impl RequestHistoryAnalysis {
224    fn needs_repair(&self) -> bool {
225        if !self.invalid_result_indices.is_empty() {
226            return true;
227        }
228
229        self.batches.iter().any(|batch| {
230            batch.matched_calls.iter().any(|matched| !matched)
231                || batch
232                    .result_indices
233                    .iter()
234                    .enumerate()
235                    .any(|(offset, &result_index)| result_index != batch.assistant_index + offset + 1)
236        })
237    }
238}
239
240fn analyze_request_history(messages: &[Message]) -> RequestHistoryAnalysis {
241    let mut analysis = RequestHistoryAnalysis::default();
242    let mut calls_by_id: HashMap<&str, Vec<(usize, usize)>> = HashMap::new();
243
244    for (message_index, message) in messages.iter().enumerate() {
245        if message.role != crate::llm::provider::MessageRole::Assistant {
246            continue;
247        }
248
249        let Some(tool_calls) = message.tool_calls.as_ref().filter(|calls| !calls.is_empty()) else {
250            continue;
251        };
252
253        let batch_index = analysis.batches.len();
254        let matched_calls = vec![false; tool_calls.len()];
255        analysis.batch_by_assistant_index.insert(message_index, batch_index);
256        analysis.batches.push(RequestToolBatch {
257            assistant_index: message_index,
258            matched_calls,
259            result_indices: Vec::new(),
260        });
261
262        for (call_index, call) in tool_calls.iter().enumerate() {
263            calls_by_id.entry(call.id.as_str()).or_default().push((batch_index, call_index));
264        }
265    }
266
267    for (message_index, message) in messages.iter().enumerate() {
268        let has_tool_call_id = message.tool_call_id.is_some();
269        let is_tool_result = message.role == crate::llm::provider::MessageRole::Tool;
270        if !is_tool_result && !has_tool_call_id {
271            continue;
272        }
273
274        let Some(tool_call_id) = message.tool_call_id.as_deref().filter(|id| !id.is_empty()) else {
275            analysis.invalid_result_indices.insert(message_index);
276            continue;
277        };
278
279        if !is_tool_result {
280            analysis.invalid_result_indices.insert(message_index);
281            continue;
282        }
283
284        let Some(candidates) = calls_by_id.get_mut(tool_call_id) else {
285            analysis.invalid_result_indices.insert(message_index);
286            continue;
287        };
288
289        let Some(&(batch_index, call_index)) = candidates.iter().rev().find(|&&(batch_index, call_index)| {
290            analysis.batches[batch_index].assistant_index < message_index
291                && !analysis.batches[batch_index].matched_calls[call_index]
292        }) else {
293            // This includes results that arrived before their call and duplicate
294            // results after the call has already been matched.
295            analysis.invalid_result_indices.insert(message_index);
296            continue;
297        };
298
299        analysis.batches[batch_index].matched_calls[call_index] = true;
300        analysis.batches[batch_index].result_indices.push(message_index);
301        analysis.result_batch_by_index.insert(message_index, batch_index);
302    }
303
304    analysis
305}
306
307/// Return whether the provider-facing message view needs tool-history repair.
308///
309/// This deliberately analyzes only the request view. Durable session history
310/// is repaired by its own crash-recovery path and is never changed here.
311pub fn request_history_needs_normalization(messages: &[Message]) -> bool {
312    analyze_request_history(messages).needs_repair()
313}
314
315/// Rebuild a shared provider-facing message view with each assistant
316/// tool-call batch followed immediately by its matching results.
317///
318/// The rebuild is request-scoped and idempotent: orphaned, causally early, and
319/// duplicate results are omitted; missing calls receive a bounded cancellation
320/// result; and messages interleaved between a call batch and its results move
321/// after the complete batch. A clean input is returned unchanged; repairs use
322/// copy-on-write and never mutate the source history.
323pub fn normalize_history_for_request_shared(messages: Arc<Vec<Message>>) -> Arc<Vec<Message>> {
324    let analysis = analyze_request_history(&messages);
325    if !analysis.needs_repair() {
326        return messages;
327    }
328
329    let mut normalized = Vec::with_capacity(messages.len());
330    for (message_index, message) in messages.iter().enumerate() {
331        if analysis.invalid_result_indices.contains(&message_index)
332            || analysis.result_batch_by_index.contains_key(&message_index)
333        {
334            continue;
335        }
336
337        let Some(&batch_index) = analysis.batch_by_assistant_index.get(&message_index) else {
338            normalized.push(message.clone());
339            continue;
340        };
341
342        let batch = &analysis.batches[batch_index];
343        normalized.push(message.clone());
344        for &result_index in &batch.result_indices {
345            normalized.push(messages[result_index].clone());
346        }
347        for (call, matched) in message.tool_calls.iter().flatten().zip(&batch.matched_calls) {
348            if !matched {
349                normalized
350                    .push(Message::tool_response(call.id.clone(), REQUEST_HISTORY_CANCELLATION_RESULT.to_owned()));
351            }
352        }
353    }
354
355    Arc::new(normalized)
356}
357
358/// Compatibility wrapper for callers that own a slice rather than shared
359/// request history. The shared implementation avoids cloning clean message
360/// histories; this wrapper retains the historical
361/// owned return type for small slice-based callers and tests.
362pub fn normalize_history_for_request(messages: &[Message]) -> Vec<Message> {
363    Arc::unwrap_or_clone(normalize_history_for_request_shared(Arc::new(messages.to_vec())))
364}
365
366/// Placeholder body for a cleared tool result. Mirrors Anthropic
367/// `clear_tool_uses` semantics as request-only shaping: the durable history
368/// and `ThreadEvent` log keep the original payload.
369const CLEARED_TOOL_RESULT_NOTE: &str = "Older tool result cleared to bound context growth. Full output remains in session logs; re-run the tool if raw bytes are needed.";
370
371/// Cap local clearing at 60% of the history capacity left after the request
372/// prefix and output reserve. Zero capacity is unknown; zero trigger or an
373/// explicit opt-out keeps clearing disabled.
374#[must_use]
375pub fn resolve_tool_result_clearing_trigger(
376    config: &crate::config::VTCodeConfig,
377    provider: &dyn crate::llm::provider::LLMProvider,
378    model: &str,
379    prompt_overhead_tokens: usize,
380    reserved_output_tokens: usize,
381) -> u64 {
382    let clearing = &config.agent.harness.tool_result_clearing;
383    if !clearing.enabled || clearing.trigger_tokens == 0 {
384        return 0;
385    }
386    let capacity = crate::compaction::effective_context_budget(Some(config), provider, model);
387    if capacity == 0 {
388        return clearing.trigger_tokens;
389    }
390    let history_budget = capacity
391        .saturating_sub(reserved_output_tokens)
392        .saturating_sub(prompt_overhead_tokens) as u64;
393    // Divide before multiplying, retaining the remainder to avoid overflow
394    // even at the largest representable context budget.
395    let cap = (history_budget / 5)
396        .saturating_mul(3)
397        .saturating_add((history_budget % 5) * 3 / 5)
398        .max(1);
399    clearing.trigger_tokens.min(cap)
400}
401
402/// Request-only local stand-in for Anthropic `clear_tool_uses_20250919`.
403///
404/// Providers without `context_management.edits` never get native tool-result
405/// clearing, so long histories keep paying full price for every old tool body
406/// on each request. Once estimated history tokens exceed `trigger_tokens`, this
407/// rewrites every tool-result *request* message except the newest
408/// `keep_tool_uses` and their complete originating tool-call batches to a
409/// bounded stub. Small results stay intact when a stub would not save tokens.
410/// `clear_at_least_tokens` is a floor on reclaimed tokens (not a ceiling):
411/// clearing must stub all eligible non-kept results so
412/// re-running on full durable history cannot leave a permanently growing tail.
413///
414/// Contract (same as [`normalize_history_for_request`]):
415/// - never mutates durable session history or `ThreadEvent`s;
416/// - preserves `role`, `tool_call_id`, `origin_tool`, and message order so
417///   provider tool-pairing validation still passes;
418/// - idempotent: re-running on already-stubbed output is a no-op below the
419///   trigger and cannot re-clear stubs usefully.
420///
421/// When `clear_tool_inputs` is set, assistant `tool_calls[].function.arguments`
422/// and freeform `text` for cleared results are replaced with a JSON placeholder
423/// (never prose — providers send those fields verbatim on the wire).
424pub fn clear_old_tool_results(
425    messages: &[Message],
426    trigger_tokens: u64,
427    keep_tool_uses: u32,
428    clear_at_least_tokens: u64,
429    clear_tool_inputs: bool,
430) -> Vec<Message> {
431    clear_old_tool_results_with_recent_batches(
432        messages,
433        trigger_tokens,
434        keep_tool_uses,
435        0,
436        clear_at_least_tokens,
437        clear_tool_inputs,
438    )
439}
440
441/// Request-only clearing that also protects the newest assistant tool-call
442/// batches by originating message order. Count-based protection is additive;
443/// unmatched results retain the compatibility wrapper's count-based fallback.
444pub fn clear_old_tool_results_with_recent_batches(
445    messages: &[Message],
446    trigger_tokens: u64,
447    keep_tool_uses: u32,
448    keep_recent_tool_batches: u32,
449    clear_at_least_tokens: u64,
450    clear_tool_inputs: bool,
451) -> Vec<Message> {
452    if trigger_tokens == 0 || messages.is_empty() {
453        return messages.to_vec();
454    }
455
456    let estimated_tokens: u64 = messages
457        .iter()
458        .map(|message| message.estimate_tokens() as u64)
459        .fold(0u64, u64::saturating_add);
460    if estimated_tokens < trigger_tokens {
461        return messages.to_vec();
462    }
463
464    let tool_indices: Vec<usize> = messages
465        .iter()
466        .enumerate()
467        .filter(|(_, message)| message.role == crate::llm::provider::MessageRole::Tool)
468        .map(|(index, _)| index)
469        .collect();
470    let keep = keep_tool_uses as usize;
471    if tool_indices.len() <= keep {
472        return messages.to_vec();
473    }
474
475    let mut cleared_tokens = 0u64;
476    let analysis = analyze_request_history(messages);
477    let mut retained_batches: HashSet<usize> = tool_indices
478        .iter()
479        .rev()
480        .take(keep)
481        .filter_map(|index| analysis.result_batch_by_index.get(index).copied())
482        .collect();
483    // Analysis records assistant messages in source order, independently of
484    // completion order and reused call IDs.
485    retained_batches.extend((0..analysis.batches.len()).rev().take(keep_recent_tool_batches as usize));
486    let mut cleared_result_count = 0usize;
487    let mut cleared_calls_by_assistant: HashMap<usize, HashSet<&str>> = HashMap::new();
488    // Stub every eligible non-kept tool result (oldest-first). `clear_at_least_tokens`
489    // is a floor on reclaimed tokens; stopping early would leave a permanently
490    // growing tail on the next request that re-shapes full durable history.
491    let mut out = messages.to_vec();
492    for &tool_index in &tool_indices[..tool_indices.len() - keep] {
493        let batch_index = analysis.result_batch_by_index.get(&tool_index);
494        if batch_index.is_some_and(|index| retained_batches.contains(index)) {
495            continue;
496        }
497        let original = &messages[tool_index];
498        let original_tokens = original.estimate_tokens() as u64;
499        let stub = build_cleared_tool_result_stub(original);
500        let stub_tokens = stub.estimate_tokens() as u64;
501        if stub_tokens >= original_tokens {
502            continue;
503        }
504        if let Some(&batch_index) = batch_index
505            && let Some(call_id) = original.tool_call_id.as_deref()
506        {
507            cleared_calls_by_assistant
508                .entry(analysis.batches[batch_index].assistant_index)
509                .or_default()
510                .insert(call_id);
511        }
512        out[tool_index] = stub;
513        cleared_result_count += 1;
514        cleared_tokens = cleared_tokens.saturating_add(original_tokens.saturating_sub(stub_tokens));
515    }
516
517    // The floor is advisory: every eligible result is already stubbed. A
518    // protected batch or small results can leave less reclaimable than the
519    // configured floor.
520    if cleared_tokens < clear_at_least_tokens {
521        tracing::debug!(
522            cleared_tokens,
523            clear_at_least_tokens,
524            "tool-result clearing reclaimed less than the configured floor"
525        );
526    }
527
528    if clear_tool_inputs {
529        for (assistant_index, original_message) in out.iter_mut().enumerate() {
530            let Some(cleared_call_ids) = cleared_calls_by_assistant.remove(&assistant_index) else {
531                continue;
532            };
533            let mut message = original_message.clone();
534            let Some(tool_calls) = message.tool_calls.as_mut() else {
535                continue;
536            };
537            for call in tool_calls.iter_mut() {
538                if cleared_call_ids.contains(call.id.as_str()) {
539                    if let Some(function) = call.function.as_mut() {
540                        // Valid JSON placeholder: providers send `arguments`
541                        // verbatim; prose would break OpenAI tool-call parsing.
542                        function.arguments = CLEARED_TOOL_INPUT_PLACEHOLDER.to_string();
543                    }
544                    // Freeform custom tool payloads ride in `text`, not
545                    // `function.arguments` (see ToolCall::custom).
546                    if call.text.is_some() {
547                        call.text = Some(CLEARED_TOOL_INPUT_PLACEHOLDER.to_string());
548                    }
549                    call.thought_signature = None;
550                }
551            }
552            // Tiny arguments can be cheaper than the JSON placeholder. Do
553            // not spend more tokens on inputs than the results reclaimed.
554            let input_growth =
555                (message.estimate_tokens() as u64).saturating_sub(original_message.estimate_tokens() as u64);
556            if input_growth <= cleared_tokens {
557                *original_message = message;
558                cleared_tokens = cleared_tokens.saturating_sub(input_growth);
559            }
560        }
561    }
562
563    tracing::debug!(
564        original_tokens = estimated_tokens,
565        projected_tokens = out
566            .iter()
567            .map(|message| message.estimate_tokens() as u64)
568            .fold(0u64, u64::saturating_add),
569        cleared_result_count,
570        protected_batch_count = retained_batches.len(),
571        "local tool-result clearing projection"
572    );
573    out
574}
575
576/// JSON placeholder for cleared tool-call inputs (must stay valid JSON).
577const CLEARED_TOOL_INPUT_PLACEHOLDER: &str = "{\"cleared\":\"tool_input\"}";
578
579/// Whether request assembly should apply local [`clear_old_tool_results`].
580///
581/// Local clearing and Anthropic `clear_tool_uses` are mutually exclusive:
582/// native edits already reclaim tool bodies on the wire, so applying both
583/// would double-shape the same history.
584pub fn should_apply_local_tool_result_clearing(
585    provider_name: &str,
586    context_edits: bool,
587    clearing_enabled: bool,
588) -> bool {
589    clearing_enabled && !(provider_name.eq_ignore_ascii_case("anthropic") && context_edits)
590}
591
592fn build_cleared_tool_result_stub(original: &Message) -> Message {
593    let mut stub = original.clone();
594    // serde_json (not `{:?}`) so non-ASCII tool names / call ids stay valid JSON.
595    let body = serde_json::json!({
596        "cleared": "tool_result",
597        "reason": "tool_result_clearing",
598        "note": CLEARED_TOOL_RESULT_NOTE,
599        "tool": original.origin_tool.as_deref().unwrap_or("tool"),
600        "tool_call_id": original.tool_call_id.as_deref().unwrap_or(""),
601    })
602    .to_string();
603    stub.content = crate::llm::provider::MessageContent::Text(body);
604    stub
605}
606
607/// Find a split point that keeps tool-call outputs paired with their calls.
608pub fn safe_history_split_point(messages: &[Message], conversation_len: usize, preferred_split_at: usize) -> usize {
609    if preferred_split_at == 0 || preferred_split_at >= conversation_len {
610        return preferred_split_at;
611    }
612
613    let mut call_indices: HashMap<&str, usize> = HashMap::new();
614    for (i, msg) in messages.iter().enumerate() {
615        if let Some(tool_calls) = &msg.tool_calls {
616            for call in tool_calls {
617                call_indices.insert(&call.id, i);
618            }
619        }
620    }
621
622    let mut safe_split_at = preferred_split_at;
623    loop {
624        if safe_split_at == 0 {
625            break;
626        }
627
628        let has_orphan = ((safe_split_at + 1)..messages.len()).any(|i| {
629            messages
630                .get(i)
631                .and_then(|msg| msg.tool_call_id.as_ref())
632                .and_then(|id| call_indices.get(id.as_str()))
633                .is_some_and(|&call_idx| call_idx <= safe_split_at)
634        });
635
636        if !has_orphan {
637            break;
638        }
639
640        safe_split_at -= 1;
641    }
642
643    safe_split_at
644}
645
646/// Ensure all tool calls have corresponding outputs in the message list.
647pub fn ensure_call_outputs_present(messages: &mut Vec<Message>) {
648    let report = validate_history_invariants(messages);
649
650    // Create synthetic outputs for missing calls in reverse order to avoid index shifting
651    for missing in report.missing_outputs.iter().rev() {
652        let synthetic_message = Message::tool_response(
653            missing.call_id.as_str().to_string(),
654            "canceled: Tool execution was interrupted. This synthetic output was created \
655             during history normalization to maintain conversation invariants."
656                .to_string(),
657        );
658
659        tracing::warn!("Creating synthetic output for call {} due to missing execution result", missing.call_id);
660
661        // Find the position to insert: right after the corresponding call
662        let insert_pos = messages
663            .iter()
664            .position(|msg| {
665                msg.tool_calls
666                    .as_ref()
667                    .is_some_and(|calls| calls.iter().any(|call| call.id == missing.call_id.as_str()))
668            })
669            .map(|pos| pos + 1);
670
671        if let Some(pos) = insert_pos {
672            messages.insert(pos, synthetic_message);
673        } else {
674            // If we can't find the call, just append the synthetic output
675            messages.push(synthetic_message);
676        }
677    }
678}
679
680/// Remove outputs without corresponding calls (orphaned outputs) from the message list.
681pub fn remove_orphan_outputs(messages: &mut Vec<Message>) {
682    let report = validate_history_invariants(messages);
683
684    if report.orphan_outputs.is_empty() {
685        return;
686    }
687
688    let orphan_ids: HashSet<String> = report.orphan_outputs.iter().map(|id| id.as_str().to_string()).collect();
689
690    let initial_len = messages.len();
691
692    // Retain only messages that either:
693    // - Don't have a tool_call_id (not a tool response)
694    // - Have a tool_call_id that matches an existing call
695    messages.retain(|msg| {
696        if let Some(tool_call_id) = msg.tool_call_id.as_ref()
697            && orphan_ids.contains(tool_call_id)
698        {
699            tracing::warn!("Removing orphan output for call {}", tool_call_id);
700            return false;
701        }
702        true
703    });
704
705    if messages.len() != initial_len {
706        tracing::info!("Removed {} orphan outputs", initial_len - messages.len());
707    }
708}
709
710/// Normalize history to enforce call/output pairing invariants.
711pub fn normalize_history(messages: &mut Vec<Message>) {
712    ensure_call_outputs_present(messages);
713    remove_orphan_outputs(messages);
714
715    // Log if issues were found
716    let report = validate_history_invariants(messages);
717    if !report.is_valid() {
718        tracing::warn!("History validation: {}", report.summary());
719    } else {
720        tracing::debug!("History normalized successfully");
721    }
722}
723
724/// Recover from crashed or interrupted session by fixing history invariants.
725pub fn recover_history_from_crash(messages: &mut Vec<Message>) {
726    let report = validate_history_invariants(messages);
727
728    if !report.missing_outputs.is_empty() {
729        tracing::warn!("Found {} missing outputs during recovery", report.missing_outputs.len());
730        ensure_call_outputs_present(messages);
731    }
732
733    if !report.orphan_outputs.is_empty() {
734        tracing::warn!("Found {} orphan outputs during recovery", report.orphan_outputs.len());
735        remove_orphan_outputs(messages);
736    }
737
738    if report.is_valid() {
739        tracing::debug!("History invariants are valid");
740    }
741}
742
743// ============================================================================
744// Tests: Context Manager - Call/Output Pairing Invariants
745// ============================================================================
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use crate::llm::provider::{Message, MessageRole};
751    /// Helper: Create test messages
752    fn make_tool_call(call_id: &str, tool_name: &str) -> Message {
753        Message::assistant_with_tools(
754            "".to_string(),
755            vec![crate::llm::provider::ToolCall::function(
756                call_id.to_string(),
757                tool_name.to_string(),
758                "{}".to_string(),
759            )],
760        )
761    }
762
763    fn make_tool_response(call_id: &str, content: &str) -> Message {
764        Message::tool_response(call_id.to_string(), content.to_string())
765    }
766
767    /// Test: Valid history with matched calls and outputs
768    #[test]
769    fn test_validate_history_valid_matched_pairs() {
770        let mut messages = vec![
771            make_tool_call("call_1", "list_files"),
772            make_tool_response("call_1", "file1.rs\nfile2.rs"),
773        ];
774
775        let report = validate_history_invariants(&messages);
776        assert!(report.is_valid(), "Valid paired call/output should pass");
777        assert!(report.missing_outputs.is_empty());
778        assert!(report.orphan_outputs.is_empty());
779
780        // Normalize should be a no-op
781        normalize_history(&mut messages);
782        assert_eq!(messages.len(), 2);
783    }
784
785    /// Test: Missing output (tool call without response)
786    #[test]
787    fn test_validate_history_missing_output() {
788        let messages = vec![make_tool_call("call_1", "list_files")];
789
790        let report = validate_history_invariants(&messages);
791        assert!(!report.is_valid());
792        assert_eq!(report.missing_outputs.len(), 1);
793        assert_eq!(report.missing_outputs[0].call_id.as_str(), "call_1");
794        assert!(report.orphan_outputs.is_empty());
795    }
796
797    /// Test: Orphan output (response without corresponding call)
798    #[test]
799    fn test_validate_history_orphan_output() {
800        let messages = vec![make_tool_response("orphan_call", "Some result")];
801
802        let report = validate_history_invariants(&messages);
803        assert!(!report.is_valid());
804        assert!(report.missing_outputs.is_empty());
805        assert_eq!(report.orphan_outputs.len(), 1);
806        assert_eq!(report.orphan_outputs[0].as_str(), "orphan_call");
807    }
808
809    /// Test: ensure_call_outputs_present creates synthetic outputs
810    #[test]
811    fn test_ensure_call_outputs_present() {
812        let mut messages = vec![make_tool_call("call_1", "list_files")];
813        let initial_len = messages.len();
814
815        ensure_call_outputs_present(&mut messages);
816
817        assert_eq!(messages.len(), initial_len + 1);
818        let last_msg = &messages[initial_len];
819        assert_eq!(last_msg.tool_call_id, Some("call_1".to_string()));
820        assert!(last_msg.content.as_text().contains("canceled"));
821
822        let report = validate_history_invariants(&messages);
823        assert!(report.is_valid());
824    }
825
826    /// Test: remove_orphan_outputs filters out orphaned responses
827    #[test]
828    fn test_remove_orphan_outputs() {
829        let mut messages = vec![
830            make_tool_call("call_1", "list_files"),
831            make_tool_response("call_1", "valid result"),
832            make_tool_response("orphan_call", "orphan result"),
833        ];
834
835        let initial_len = messages.len();
836        remove_orphan_outputs(&mut messages);
837
838        assert_eq!(messages.len(), initial_len - 1);
839        assert!(
840            messages
841                .iter()
842                .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "call_1"))
843        );
844        assert!(
845            !messages
846                .iter()
847                .any(|msg| { msg.tool_call_id.as_ref().is_some_and(|id| id == "orphan_call") })
848        );
849
850        let report = validate_history_invariants(&messages);
851        assert!(report.is_valid());
852    }
853
854    /// Test: normalize() applies both fixes (synthetic output + orphan removal)
855    #[test]
856    fn test_normalize_combined_fixes() {
857        let mut messages = vec![
858            make_tool_call("call_1", "read_file"),
859            make_tool_call("call_2", "write_file"),
860            make_tool_response("call_2", "written"),
861            make_tool_response("orphan", "orphan result"),
862        ];
863
864        normalize_history(&mut messages);
865
866        let report = validate_history_invariants(&messages);
867        assert!(report.is_valid());
868        assert!(
869            messages
870                .iter()
871                .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "call_1"))
872        );
873        assert!(
874            !messages
875                .iter()
876                .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "orphan"))
877        );
878    }
879
880    #[test]
881    fn request_normalization_shared_reuses_clean_history_arc() {
882        let messages = Arc::new(vec![Message::user("clean history".to_string())]);
883
884        let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
885
886        assert!(Arc::ptr_eq(&messages, &normalized));
887    }
888
889    #[test]
890    fn request_normalization_reused_ids_match_causally_preceding_batches() {
891        let messages = Arc::new(vec![
892            make_tool_response("reused", "early result"),
893            make_tool_call("reused", "read_file"),
894            make_tool_response("reused", "first result"),
895            make_tool_call("reused", "write_file"),
896            Message::user("interleaved".to_string()),
897            make_tool_response("reused", "second result"),
898            make_tool_response("reused", "duplicate result"),
899        ]);
900        let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
901        assert_eq!(
902            normalized.as_ref(),
903            &vec![
904                make_tool_call("reused", "read_file"),
905                make_tool_response("reused", "first result"),
906                make_tool_call("reused", "write_file"),
907                make_tool_response("reused", "second result"),
908                Message::user("interleaved".to_string()),
909            ]
910        );
911        assert_eq!(messages.len(), 7);
912        assert_eq!(messages[0].content.as_text(), "early result");
913    }
914
915    #[test]
916    fn request_normalization_shared_repairs_asymmetric_batch_without_mutating_source() {
917        let messages = Arc::new(vec![
918            Message::assistant_with_tools(
919                "three calls".to_string(),
920                ["call_a", "call_b", "call_c"]
921                    .into_iter()
922                    .map(|id| {
923                        crate::llm::provider::ToolCall::function(
924                            id.to_string(),
925                            "read_file".to_string(),
926                            "{}".to_string(),
927                        )
928                    })
929                    .collect(),
930            ),
931            Message::user("interleaved request".to_string()),
932            make_tool_response("call_b", "result B"),
933            make_tool_response("call_a", "result A"),
934            make_tool_response("call_b", "duplicate B"),
935        ]);
936        let source_before = messages.as_ref().clone();
937
938        let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
939
940        assert!(!Arc::ptr_eq(&messages, &normalized));
941        assert_eq!(normalized.len(), 5);
942        assert_eq!(normalized[0], source_before[0]);
943        assert_eq!(normalized[1], make_tool_response("call_b", "result B"));
944        assert_eq!(normalized[2], make_tool_response("call_a", "result A"));
945        assert_eq!(normalized[3].role, MessageRole::Tool);
946        assert_eq!(normalized[3].tool_call_id.as_deref(), Some("call_c"));
947        assert_eq!(
948            normalized[3].content.as_text(),
949            "canceled: no tool result was recorded; this bounded placeholder preserves the tool-call protocol."
950        );
951        assert_eq!(normalized[4], Message::user("interleaved request".to_string()));
952        assert_eq!(messages.as_ref(), &source_before);
953        let repeated = normalize_history_for_request_shared(Arc::clone(&normalized));
954        assert!(Arc::ptr_eq(&normalized, &repeated));
955    }
956
957    #[test]
958    fn request_normalization_groups_split_results_after_intervening_messages() {
959        let messages = vec![
960            Message::assistant_with_tools(
961                "".to_string(),
962                vec![
963                    crate::llm::provider::ToolCall::function(
964                        "call_1".to_string(),
965                        "read_file".to_string(),
966                        "{}".to_string(),
967                    ),
968                    crate::llm::provider::ToolCall::function(
969                        "call_2".to_string(),
970                        "read_file".to_string(),
971                        "{}".to_string(),
972                    ),
973                ],
974            ),
975            Message::system("intervening system note".to_string()),
976            make_tool_response("call_2", "result two"),
977            Message::user("intervening user note".to_string()),
978            make_tool_response("call_1", "result one"),
979            Message::assistant("done".to_string()),
980        ];
981
982        let normalized = normalize_history_for_request(&messages);
983
984        assert_eq!(normalized[0].role, MessageRole::Assistant);
985        assert_eq!(normalized[1].tool_call_id.as_deref(), Some("call_2"));
986        assert_eq!(normalized[1].content.as_text(), "result two");
987        assert_eq!(normalized[2].tool_call_id.as_deref(), Some("call_1"));
988        assert_eq!(normalized[2].content.as_text(), "result one");
989        assert_eq!(normalized[3].role, MessageRole::System);
990        assert_eq!(normalized[4].role, MessageRole::User);
991        assert_eq!(normalized[5].role, MessageRole::Assistant);
992    }
993
994    #[test]
995    fn request_normalization_drops_early_orphan_duplicate_and_synthesizes_missing_results() {
996        let messages = vec![
997            make_tool_response("call_1", "causally early"),
998            make_tool_response("orphan", "orphaned"),
999            make_tool_call("call_1", "read_file"),
1000            make_tool_call("call_2", "read_file"),
1001            make_tool_response("call_1", "valid"),
1002            make_tool_response("call_1", "duplicate"),
1003        ];
1004
1005        let normalized = normalize_history_for_request(&messages);
1006
1007        assert_eq!(normalized.len(), 4);
1008        assert_eq!(normalized[0].role, MessageRole::Assistant);
1009        assert_eq!(normalized[0].tool_calls.as_ref().map(Vec::len), Some(1));
1010        assert_eq!(normalized[1].tool_call_id.as_deref(), Some("call_1"));
1011        assert_eq!(normalized[1].content.as_text(), "valid");
1012        assert_eq!(normalized[2].role, MessageRole::Assistant);
1013        assert_eq!(normalized[3].tool_call_id.as_deref(), Some("call_2"));
1014        assert!(normalized[3].content.as_text().contains("bounded placeholder"));
1015    }
1016
1017    #[test]
1018    fn request_normalization_is_idempotent_and_does_not_mutate_durable_history() {
1019        let messages = vec![
1020            make_tool_call("call_1", "read_file"),
1021            Message::system("intervening".to_string()),
1022            make_tool_response("call_1", "result"),
1023        ];
1024        let durable_before = messages.clone();
1025
1026        assert!(request_history_needs_normalization(&messages));
1027        let normalized = normalize_history_for_request(&messages);
1028        assert!(!request_history_needs_normalization(&normalized));
1029        assert_eq!(normalize_history_for_request(&normalized), normalized);
1030        assert_eq!(messages, durable_before);
1031    }
1032
1033    /// Test: recover_history_from_crash handles both missing and orphan outputs
1034    #[test]
1035    fn test_recover_from_crash() {
1036        let mut messages = vec![
1037            make_tool_call("crashed_call", "dangerous_op"),
1038            make_tool_response("old_call", "stale result"),
1039        ];
1040
1041        recover_history_from_crash(&mut messages);
1042
1043        let report = validate_history_invariants(&messages);
1044        assert!(report.is_valid());
1045        assert!(
1046            messages
1047                .iter()
1048                .any(|msg| { msg.tool_call_id.as_ref().is_some_and(|id| id == "crashed_call") })
1049        );
1050        assert!(
1051            !messages
1052                .iter()
1053                .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "old_call"))
1054        );
1055    }
1056
1057    /// Test: HistoryValidationReport summary messages
1058    #[test]
1059    fn test_validation_report_summary() {
1060        let valid = HistoryValidationReport::default();
1061        assert_eq!(valid.summary(), "History invariants are valid");
1062        assert!(valid.is_valid());
1063
1064        let invalid = HistoryValidationReport {
1065            missing_outputs: vec![
1066                MissingOutput {
1067                    call_id: ToolCallId::new("call_1"),
1068                    tool_name: "tool_a".into(),
1069                },
1070                MissingOutput {
1071                    call_id: ToolCallId::new("call_2"),
1072                    tool_name: "tool_b".into(),
1073                },
1074            ],
1075            orphan_outputs: vec![ToolCallId::new("orphan_1")],
1076        };
1077        assert_eq!(invalid.summary(), "2 missing outputs, 1 orphan outputs");
1078        assert!(!invalid.is_valid());
1079    }
1080
1081    /// Test: Multiple tool calls with selective missing outputs
1082    #[test]
1083    fn test_multiple_calls_partial_outputs() {
1084        let _messages: Vec<Message> = (1..=3)
1085            .flat_map(|i| {
1086                vec![
1087                    make_tool_call(&format!("call_{i}"), &format!("tool_{i}")),
1088                    if i != 2 {
1089                        make_tool_response(&format!("call_{i}"), &format!("result_{i}"))
1090                    } else {
1091                        // Simulate a gap: we don't add a response for call_2 here directly,
1092                        // but we need to build messages differently.
1093                        // Instead, build manually below.
1094                        Message::tool_response("placeholder".into(), "".into())
1095                    },
1096                ]
1097            })
1098            .collect();
1099        // Redo: explicit construction
1100        let mut messages = vec![
1101            make_tool_call("call_1", "tool_1"),
1102            make_tool_response("call_1", "result_1"),
1103            make_tool_call("call_2", "tool_2"),
1104            make_tool_call("call_3", "tool_3"),
1105            make_tool_response("call_3", "result_3"),
1106        ];
1107
1108        let report = validate_history_invariants(&messages);
1109        assert!(!report.is_valid());
1110        assert_eq!(report.missing_outputs.len(), 1);
1111        assert_eq!(report.missing_outputs[0].call_id.as_str(), "call_2");
1112
1113        normalize_history(&mut messages);
1114        assert!(validate_history_invariants(&messages).is_valid());
1115    }
1116
1117    /// Test: OutputStatus enum conversion
1118    #[test]
1119    fn test_output_status_as_str() {
1120        assert_eq!(OutputStatus::Success.as_str(), "success");
1121        assert_eq!(OutputStatus::Failed.as_str(), "failed");
1122        assert_eq!(OutputStatus::Canceled.as_str(), "canceled");
1123        assert_eq!(OutputStatus::Timeout.as_str(), "timeout");
1124    }
1125
1126    /// Test: find_safe_split_point maintains call/output pairs
1127    #[test]
1128    fn test_find_safe_split_point() {
1129        let messages = vec![
1130            Message::user("User 1".into()),           // 0
1131            make_tool_call("call_a", "tool_a"),       // 1
1132            make_tool_response("call_a", "Result A"), // 2
1133            make_tool_call("call_b", "tool_b"),       // 3
1134            make_tool_response("call_b", "Result B"), // 4
1135        ];
1136        let conversation_len = 5;
1137
1138        // Split at 3 means keeping 3,4. But response at 2 needs call at 1 -> must split at 2.
1139        let safe = safe_history_split_point(&messages, conversation_len, 3);
1140        assert_eq!(safe, 2, "Should move split to include Call A");
1141
1142        // Split at 4 is safe: call_b (3) and response_b (4) are both kept.
1143        let safe2 = safe_history_split_point(&messages, conversation_len, 4);
1144        assert_eq!(safe2, 4, "Should stay at 4 as it is safe");
1145    }
1146
1147    #[test]
1148    fn test_summarize_list_formatting() {
1149        assert_eq!(summarize_list(&[]), "none");
1150        assert_eq!(summarize_list(&["a".into()]), "a");
1151        assert_eq!(summarize_list(&["a".into(), "b".into()]), "a, b");
1152        let many: Vec<String> = (1..=7).map(|i| format!("item{i}")).collect();
1153        let result = summarize_list(&many);
1154        assert!(result.contains("item1, item2, item3, item4, item5"));
1155        assert!(result.contains("[+2 more]"));
1156    }
1157
1158    fn bulky_tool_history(count: usize, body_chars: usize) -> Vec<Message> {
1159        let mut messages = vec![Message::user("start".to_string())];
1160        for i in 0..count {
1161            let call_id = format!("call_{i}");
1162            messages.push(make_tool_call(&call_id, "read_file"));
1163            messages.push(make_tool_response(&call_id, &"x".repeat(body_chars)));
1164        }
1165        messages.push(Message::assistant("done".to_string()));
1166        messages
1167    }
1168
1169    fn is_cleared_stub(message: &Message) -> bool {
1170        message.content.as_text().contains("\"cleared\":\"tool_result\"")
1171    }
1172
1173    #[test]
1174    fn clear_old_tool_results_noop_below_trigger() {
1175        let messages = bulky_tool_history(3, 200);
1176        let cleared = clear_old_tool_results(&messages, u64::MAX, 1, 1, false);
1177        assert_eq!(cleared.len(), messages.len());
1178        assert!(
1179            cleared
1180                .iter()
1181                .filter(|m| m.role == MessageRole::Tool)
1182                .all(|m| !is_cleared_stub(m))
1183        );
1184    }
1185
1186    #[test]
1187    fn clear_old_tool_results_keeps_newest_and_stubs_all_older() {
1188        // Four large tool results; keep 1. Every non-kept result is stubbed so
1189        // re-shaping full durable history cannot leave a growing tail.
1190        let messages = bulky_tool_history(4, 4_000);
1191        let cleared = clear_old_tool_results(&messages, 1, 1, 1, false);
1192        let tool_msgs: Vec<&Message> = cleared.iter().filter(|m| m.role == MessageRole::Tool).collect();
1193        assert_eq!(tool_msgs.len(), 4);
1194        assert!(is_cleared_stub(tool_msgs[0]), "oldest result is cleared");
1195        assert!(is_cleared_stub(tool_msgs[1]));
1196        assert!(is_cleared_stub(tool_msgs[2]));
1197        assert!(!is_cleared_stub(tool_msgs[3]), "newest result stays intact");
1198        // Protocol pairing survives.
1199        for (original, rewritten) in messages.iter().zip(cleared.iter()) {
1200            assert_eq!(original.role, rewritten.role);
1201            assert_eq!(original.tool_call_id, rewritten.tool_call_id);
1202        }
1203    }
1204
1205    #[test]
1206    fn clear_old_tool_results_respects_keep_tool_uses() {
1207        let messages = bulky_tool_history(5, 3_000);
1208        let cleared = clear_old_tool_results(&messages, 1, 3, 1, false);
1209        let tool_msgs: Vec<&Message> = cleared.iter().filter(|m| m.role == MessageRole::Tool).collect();
1210        assert_eq!(tool_msgs.len(), 5);
1211        assert!(!is_cleared_stub(tool_msgs[2]));
1212        assert!(!is_cleared_stub(tool_msgs[3]));
1213        assert!(!is_cleared_stub(tool_msgs[4]));
1214    }
1215
1216    #[test]
1217    fn clear_old_tool_results_optional_clear_tool_inputs() {
1218        let mut messages = bulky_tool_history(3, 3_000);
1219        // Attach a recognizable argument payload on the first call.
1220        if let Some(call) = messages
1221            .get_mut(1)
1222            .and_then(|m| m.tool_calls.as_mut())
1223            .and_then(|calls| calls.first_mut())
1224            .and_then(|call| call.function.as_mut())
1225        {
1226            call.arguments = "{\"path\":\"secret-path\"}".to_string();
1227        }
1228
1229        let cleared = clear_old_tool_results(&messages, 1, 1, 1, true);
1230        let first_call_args = cleared
1231            .get(1)
1232            .and_then(|m| m.tool_calls.as_ref())
1233            .and_then(|calls| calls.first())
1234            .and_then(|call| call.function.as_ref())
1235            .map(|function| function.arguments.as_str())
1236            .unwrap_or_default();
1237        assert!(!first_call_args.contains("secret-path"), "cleared call arguments must drop the original payload");
1238        assert!(
1239            first_call_args.contains("\"cleared\":\"tool_input\""),
1240            "cleared arguments must be a JSON placeholder, got {first_call_args:?}"
1241        );
1242        assert!(
1243            serde_json::from_str::<serde_json::Value>(first_call_args).is_ok(),
1244            "cleared arguments must stay valid JSON for the wire"
1245        );
1246        // Newest retained call keeps its arguments.
1247        let last_call_args = cleared
1248            .get(5)
1249            .and_then(|m| m.tool_calls.as_ref())
1250            .and_then(|calls| calls.first())
1251            .and_then(|call| call.function.as_ref())
1252            .map(|function| function.arguments.as_str())
1253            .unwrap_or_default();
1254        assert_eq!(last_call_args, "{}");
1255    }
1256
1257    #[test]
1258    fn clear_old_tool_results_default_config_clears_paired_inputs() {
1259        // Regression: `clear_tool_inputs` now defaults to true, so the common
1260        // request-shaping path must drop stale apply_patch/write_file bodies
1261        // together with the stubbed results.
1262        let default_clear_tool_inputs =
1263            vtcode_config::core::agent::ToolResultClearingConfig::default().clear_tool_inputs;
1264        assert!(default_clear_tool_inputs, "config default must clear tool inputs");
1265
1266        let mut messages = bulky_tool_history(3, 3_000);
1267        if let Some(call) = messages
1268            .get_mut(1)
1269            .and_then(|m| m.tool_calls.as_mut())
1270            .and_then(|calls| calls.first_mut())
1271            .and_then(|call| call.function.as_mut())
1272        {
1273            call.arguments = "{\"input\":\"*** Begin Patch\\n*** End Patch\"}".to_string();
1274        }
1275
1276        let cleared = clear_old_tool_results(&messages, 1, 1, 1, default_clear_tool_inputs);
1277        let cleared_args = cleared
1278            .get(1)
1279            .and_then(|m| m.tool_calls.as_ref())
1280            .and_then(|calls| calls.first())
1281            .and_then(|call| call.function.as_ref())
1282            .map(|function| function.arguments.as_str())
1283            .unwrap_or_default();
1284        assert!(
1285            !cleared_args.contains("Begin Patch"),
1286            "default-on clearing must drop patch bodies, got {cleared_args:?}"
1287        );
1288        assert_eq!(cleared_args, CLEARED_TOOL_INPUT_PLACEHOLDER);
1289
1290        let kept_args = cleared
1291            .get(5)
1292            .and_then(|m| m.tool_calls.as_ref())
1293            .and_then(|calls| calls.first())
1294            .and_then(|call| call.function.as_ref())
1295            .map(|function| function.arguments.as_str())
1296            .unwrap_or_default();
1297        assert_eq!(kept_args, "{}", "kept call arguments must stay intact");
1298    }
1299
1300    #[test]
1301    fn clear_old_tool_results_is_idempotent_on_stubs() {
1302        let messages = bulky_tool_history(4, 3_000);
1303        let once = clear_old_tool_results(&messages, 1, 1, 1_000, false);
1304        let twice = clear_old_tool_results(&once, 1, 1, 1_000, false);
1305        let stubs_once = once.iter().filter(|m| is_cleared_stub(m)).count();
1306        let stubs_twice = twice.iter().filter(|m| is_cleared_stub(m)).count();
1307        assert_eq!(stubs_once, stubs_twice);
1308    }
1309
1310    #[test]
1311    fn clear_old_tool_results_leaves_durable_history_unchanged() {
1312        let messages = bulky_tool_history(3, 2_500);
1313        let snapshot: Vec<String> = messages.iter().map(|m| m.content.as_text().into_owned()).collect();
1314        let request_messages = clear_old_tool_results(&messages, 1, 1, 1, false);
1315        let after: Vec<String> = messages.iter().map(|m| m.content.as_text().into_owned()).collect();
1316        assert_eq!(snapshot, after, "durable history must not be mutated");
1317        assert!(
1318            request_messages
1319                .iter()
1320                .filter(|m| m.role == MessageRole::Tool)
1321                .any(is_cleared_stub),
1322            "request messages should carry stubs"
1323        );
1324    }
1325
1326    #[test]
1327    fn local_tool_result_clearing_gate_mirrors_native_edits() {
1328        use super::should_apply_local_tool_result_clearing as gate;
1329        assert!(gate("zai", false, true), "non-Anthropic uses local clearing");
1330        assert!(gate("openai", false, true), "OpenAI uses local clearing");
1331        assert!(gate("anthropic", false, true), "Anthropic without edits falls back to local");
1332        assert!(!gate("anthropic", true, true), "Anthropic with context_edits uses native clear_tool_uses only");
1333        assert!(!gate("zai", false, false), "disabled config never clears");
1334    }
1335
1336    #[test]
1337    fn clear_old_tool_results_stub_is_valid_json_for_non_ascii_tool_names() {
1338        let mut messages = bulky_tool_history(3, 2_500);
1339        // Index 2 is the first tool response (call_0).
1340        messages[2].origin_tool = Some("读取文件".to_string());
1341        messages[2].tool_call_id = Some("call_ünïcode".to_string());
1342        let cleared = clear_old_tool_results(&messages, 1, 1, 1, false);
1343        let stub = cleared
1344            .iter()
1345            .filter(|m| is_cleared_stub(m))
1346            .find(|m| m.content.as_text().contains("call_ünïcode"))
1347            .expect("stub for the renamed call");
1348        let parsed: serde_json::Value =
1349            serde_json::from_str(stub.content.as_text().as_ref()).expect("stub must be valid JSON");
1350        assert_eq!(parsed["tool"], "读取文件");
1351        assert_eq!(parsed["tool_call_id"], "call_ünïcode");
1352    }
1353
1354    #[test]
1355    fn clear_old_tool_results_clear_tool_inputs_covers_freeform_text() {
1356        let mut messages = bulky_tool_history(3, 2_500);
1357        // Freeform custom tool payload lives in `text`, not function.arguments.
1358        if let Some(call) = messages
1359            .get_mut(1)
1360            .and_then(|m| m.tool_calls.as_mut())
1361            .and_then(|calls| calls.first_mut())
1362        {
1363            call.text = Some("raw freeform payload".to_string());
1364            call.thought_signature = Some("sig".to_string());
1365        }
1366        let cleared = clear_old_tool_results(&messages, 1, 1, 1, true);
1367        let call = cleared
1368            .get(1)
1369            .and_then(|m| m.tool_calls.as_ref())
1370            .and_then(|calls| calls.first())
1371            .expect("call preserved");
1372        assert_eq!(call.text.as_deref(), Some(CLEARED_TOOL_INPUT_PLACEHOLDER));
1373        assert!(call.thought_signature.is_none());
1374    }
1375}
1376
1377#[cfg(test)]
1378mod tool_result_clearing_tests;