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 old tool-result *request* messages outside a bounded recent
408/// evidence window and the newest `keep_tool_uses` originating 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-retained 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` larger than 1 KiB for cleared results are replaced with
423/// a JSON placeholder. Small arguments retain usable command/path identity.
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    // Two batches can be only two short searches. Clearing everything before
487    // them discards the source needed for the pending edit, provoking rereads
488    // that the navigation guards correctly bound. Reserve half of the
489    // already capacity-capped trigger for recent complete evidence batches.
490    // This window stays bounded when rebuilding from full durable history.
491    let evidence_budget = trigger_tokens / 2;
492    let mut batch_tokens: Vec<u64> = analysis
493        .batches
494        .iter()
495        .map(|batch| messages[batch.assistant_index].estimate_tokens() as u64)
496        .collect();
497    for (&result_index, &batch_index) in &analysis.result_batch_by_index {
498        batch_tokens[batch_index] =
499            batch_tokens[batch_index].saturating_add(messages[result_index].estimate_tokens() as u64);
500    }
501    let mut retained_tokens = 0u64;
502    for (batch_index, &tokens) in batch_tokens.iter().enumerate().rev() {
503        let next_tokens = retained_tokens.saturating_add(tokens);
504        if next_tokens > evidence_budget {
505            break;
506        }
507        retained_tokens = next_tokens;
508        retained_batches.insert(batch_index);
509    }
510    let mut cleared_result_count = 0usize;
511    let mut cleared_calls_by_assistant: HashMap<usize, HashSet<&str>> = HashMap::new();
512    // Stub every eligible non-kept tool result (oldest-first). `clear_at_least_tokens`
513    // is a floor on reclaimed tokens; stopping early would leave a permanently
514    // growing tail on the next request that re-shapes full durable history.
515    let mut out = messages.to_vec();
516    for &tool_index in &tool_indices[..tool_indices.len() - keep] {
517        let batch_index = analysis.result_batch_by_index.get(&tool_index);
518        if batch_index.is_some_and(|index| retained_batches.contains(index)) {
519            continue;
520        }
521        let original = &messages[tool_index];
522        let original_tokens = original.estimate_tokens() as u64;
523        let stub = build_cleared_tool_result_stub(original);
524        let stub_tokens = stub.estimate_tokens() as u64;
525        if stub_tokens >= original_tokens {
526            continue;
527        }
528        if let Some(&batch_index) = batch_index
529            && let Some(call_id) = original.tool_call_id.as_deref()
530        {
531            cleared_calls_by_assistant
532                .entry(analysis.batches[batch_index].assistant_index)
533                .or_default()
534                .insert(call_id);
535        }
536        out[tool_index] = stub;
537        cleared_result_count += 1;
538        cleared_tokens = cleared_tokens.saturating_add(original_tokens.saturating_sub(stub_tokens));
539    }
540
541    // The floor is advisory: every eligible result is already stubbed. A
542    // protected batch or small results can leave less reclaimable than the
543    // configured floor.
544    if cleared_tokens < clear_at_least_tokens {
545        tracing::debug!(
546            cleared_tokens,
547            clear_at_least_tokens,
548            "tool-result clearing reclaimed less than the configured floor"
549        );
550    }
551
552    if clear_tool_inputs {
553        for (assistant_index, original_message) in out.iter_mut().enumerate() {
554            let Some(cleared_call_ids) = cleared_calls_by_assistant.remove(&assistant_index) else {
555                continue;
556            };
557            let mut message = original_message.clone();
558            let Some(tool_calls) = message.tool_calls.as_mut() else {
559                continue;
560            };
561            for call in tool_calls.iter_mut() {
562                if cleared_call_ids.contains(call.id.as_str()) {
563                    if let Some(function) = call.function.as_mut()
564                        && function.arguments.len() > RETAINED_TOOL_INPUT_BYTES
565                    {
566                        // Valid JSON placeholder: providers send `arguments`
567                        // verbatim; prose would break OpenAI tool-call parsing.
568                        function.arguments = CLEARED_TOOL_INPUT_PLACEHOLDER.to_string();
569                    }
570                    // Freeform custom tool payloads ride in `text`, not
571                    // `function.arguments` (see ToolCall::custom).
572                    if call.text.as_ref().is_some_and(|text| text.len() > RETAINED_TOOL_INPUT_BYTES) {
573                        call.text = Some(CLEARED_TOOL_INPUT_PLACEHOLDER.to_string());
574                    }
575                    call.thought_signature = None;
576                }
577            }
578            // Tiny arguments can be cheaper than the JSON placeholder. Do
579            // not spend more tokens on inputs than the results reclaimed.
580            let input_growth =
581                (message.estimate_tokens() as u64).saturating_sub(original_message.estimate_tokens() as u64);
582            if input_growth <= cleared_tokens {
583                *original_message = message;
584                cleared_tokens = cleared_tokens.saturating_sub(input_growth);
585            }
586        }
587    }
588
589    tracing::debug!(
590        original_tokens = estimated_tokens,
591        projected_tokens = out
592            .iter()
593            .map(|message| message.estimate_tokens() as u64)
594            .fold(0u64, u64::saturating_add),
595        cleared_result_count,
596        protected_batch_count = retained_batches.len(),
597        "local tool-result clearing projection"
598    );
599    out
600}
601
602/// JSON placeholder for cleared tool-call inputs (must stay valid JSON).
603const CLEARED_TOOL_INPUT_PLACEHOLDER: &str = "{\"cleared\":\"tool_input\"}";
604// Keep ordinary command/path arguments intact instead of teaching providers
605// to emit synthetic arguments that cannot pass the tool's declared schema.
606const RETAINED_TOOL_INPUT_BYTES: usize = 1024;
607
608/// Whether request assembly should apply local [`clear_old_tool_results`].
609///
610/// Local clearing and Anthropic `clear_tool_uses` are mutually exclusive:
611/// native edits already reclaim tool bodies on the wire, so applying both
612/// would double-shape the same history.
613pub fn should_apply_local_tool_result_clearing(
614    provider_name: &str,
615    context_edits: bool,
616    clearing_enabled: bool,
617) -> bool {
618    clearing_enabled && !(provider_name.eq_ignore_ascii_case("anthropic") && context_edits)
619}
620
621fn build_cleared_tool_result_stub(original: &Message) -> Message {
622    let mut stub = original.clone();
623    // serde_json (not `{:?}`) so non-ASCII tool names / call ids stay valid JSON.
624    let body = serde_json::json!({
625        "cleared": "tool_result",
626        "reason": "tool_result_clearing",
627        "note": CLEARED_TOOL_RESULT_NOTE,
628        "tool": original.origin_tool.as_deref().unwrap_or("tool"),
629        "tool_call_id": original.tool_call_id.as_deref().unwrap_or(""),
630    })
631    .to_string();
632    stub.content = crate::llm::provider::MessageContent::Text(body);
633    stub
634}
635
636/// Find a split point that keeps tool-call outputs paired with their calls.
637pub fn safe_history_split_point(messages: &[Message], conversation_len: usize, preferred_split_at: usize) -> usize {
638    if preferred_split_at == 0 || preferred_split_at >= conversation_len {
639        return preferred_split_at;
640    }
641
642    let mut call_indices: HashMap<&str, usize> = HashMap::new();
643    for (i, msg) in messages.iter().enumerate() {
644        if let Some(tool_calls) = &msg.tool_calls {
645            for call in tool_calls {
646                call_indices.insert(&call.id, i);
647            }
648        }
649    }
650
651    let mut safe_split_at = preferred_split_at;
652    loop {
653        if safe_split_at == 0 {
654            break;
655        }
656
657        let has_orphan = ((safe_split_at + 1)..messages.len()).any(|i| {
658            messages
659                .get(i)
660                .and_then(|msg| msg.tool_call_id.as_ref())
661                .and_then(|id| call_indices.get(id.as_str()))
662                .is_some_and(|&call_idx| call_idx <= safe_split_at)
663        });
664
665        if !has_orphan {
666            break;
667        }
668
669        safe_split_at -= 1;
670    }
671
672    safe_split_at
673}
674
675/// Ensure all tool calls have corresponding outputs in the message list.
676pub fn ensure_call_outputs_present(messages: &mut Vec<Message>) {
677    let report = validate_history_invariants(messages);
678
679    // Create synthetic outputs for missing calls in reverse order to avoid index shifting
680    for missing in report.missing_outputs.iter().rev() {
681        let synthetic_message = Message::tool_response(
682            missing.call_id.as_str().to_string(),
683            "canceled: Tool execution was interrupted. This synthetic output was created \
684             during history normalization to maintain conversation invariants."
685                .to_string(),
686        );
687
688        tracing::warn!("Creating synthetic output for call {} due to missing execution result", missing.call_id);
689
690        // Find the position to insert: right after the corresponding call
691        let insert_pos = messages
692            .iter()
693            .position(|msg| {
694                msg.tool_calls
695                    .as_ref()
696                    .is_some_and(|calls| calls.iter().any(|call| call.id == missing.call_id.as_str()))
697            })
698            .map(|pos| pos + 1);
699
700        if let Some(pos) = insert_pos {
701            messages.insert(pos, synthetic_message);
702        } else {
703            // If we can't find the call, just append the synthetic output
704            messages.push(synthetic_message);
705        }
706    }
707}
708
709/// Remove outputs without corresponding calls (orphaned outputs) from the message list.
710pub fn remove_orphan_outputs(messages: &mut Vec<Message>) {
711    let report = validate_history_invariants(messages);
712
713    if report.orphan_outputs.is_empty() {
714        return;
715    }
716
717    let orphan_ids: HashSet<String> = report.orphan_outputs.iter().map(|id| id.as_str().to_string()).collect();
718
719    let initial_len = messages.len();
720
721    // Retain only messages that either:
722    // - Don't have a tool_call_id (not a tool response)
723    // - Have a tool_call_id that matches an existing call
724    messages.retain(|msg| {
725        if let Some(tool_call_id) = msg.tool_call_id.as_ref()
726            && orphan_ids.contains(tool_call_id)
727        {
728            tracing::warn!("Removing orphan output for call {}", tool_call_id);
729            return false;
730        }
731        true
732    });
733
734    if messages.len() != initial_len {
735        tracing::info!("Removed {} orphan outputs", initial_len - messages.len());
736    }
737}
738
739/// Normalize history to enforce call/output pairing invariants.
740pub fn normalize_history(messages: &mut Vec<Message>) {
741    ensure_call_outputs_present(messages);
742    remove_orphan_outputs(messages);
743
744    // Log if issues were found
745    let report = validate_history_invariants(messages);
746    if !report.is_valid() {
747        tracing::warn!("History validation: {}", report.summary());
748    } else {
749        tracing::debug!("History normalized successfully");
750    }
751}
752
753/// Recover from crashed or interrupted session by fixing history invariants.
754pub fn recover_history_from_crash(messages: &mut Vec<Message>) {
755    let report = validate_history_invariants(messages);
756
757    if !report.missing_outputs.is_empty() {
758        tracing::warn!("Found {} missing outputs during recovery", report.missing_outputs.len());
759        ensure_call_outputs_present(messages);
760    }
761
762    if !report.orphan_outputs.is_empty() {
763        tracing::warn!("Found {} orphan outputs during recovery", report.orphan_outputs.len());
764        remove_orphan_outputs(messages);
765    }
766
767    if report.is_valid() {
768        tracing::debug!("History invariants are valid");
769    }
770}
771
772// ============================================================================
773// Tests: Context Manager - Call/Output Pairing Invariants
774// ============================================================================
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use crate::llm::provider::{Message, MessageRole};
780    /// Helper: Create test messages
781    fn make_tool_call(call_id: &str, tool_name: &str) -> Message {
782        Message::assistant_with_tools(
783            "".to_string(),
784            vec![crate::llm::provider::ToolCall::function(
785                call_id.to_string(),
786                tool_name.to_string(),
787                "{}".to_string(),
788            )],
789        )
790    }
791
792    fn make_tool_response(call_id: &str, content: &str) -> Message {
793        Message::tool_response(call_id.to_string(), content.to_string())
794    }
795
796    /// Test: Valid history with matched calls and outputs
797    #[test]
798    fn test_validate_history_valid_matched_pairs() {
799        let mut messages = vec![
800            make_tool_call("call_1", "list_files"),
801            make_tool_response("call_1", "file1.rs\nfile2.rs"),
802        ];
803
804        let report = validate_history_invariants(&messages);
805        assert!(report.is_valid(), "Valid paired call/output should pass");
806        assert!(report.missing_outputs.is_empty());
807        assert!(report.orphan_outputs.is_empty());
808
809        // Normalize should be a no-op
810        normalize_history(&mut messages);
811        assert_eq!(messages.len(), 2);
812    }
813
814    /// Test: Missing output (tool call without response)
815    #[test]
816    fn test_validate_history_missing_output() {
817        let messages = vec![make_tool_call("call_1", "list_files")];
818
819        let report = validate_history_invariants(&messages);
820        assert!(!report.is_valid());
821        assert_eq!(report.missing_outputs.len(), 1);
822        assert_eq!(report.missing_outputs[0].call_id.as_str(), "call_1");
823        assert!(report.orphan_outputs.is_empty());
824    }
825
826    /// Test: Orphan output (response without corresponding call)
827    #[test]
828    fn test_validate_history_orphan_output() {
829        let messages = vec![make_tool_response("orphan_call", "Some result")];
830
831        let report = validate_history_invariants(&messages);
832        assert!(!report.is_valid());
833        assert!(report.missing_outputs.is_empty());
834        assert_eq!(report.orphan_outputs.len(), 1);
835        assert_eq!(report.orphan_outputs[0].as_str(), "orphan_call");
836    }
837
838    /// Test: ensure_call_outputs_present creates synthetic outputs
839    #[test]
840    fn test_ensure_call_outputs_present() {
841        let mut messages = vec![make_tool_call("call_1", "list_files")];
842        let initial_len = messages.len();
843
844        ensure_call_outputs_present(&mut messages);
845
846        assert_eq!(messages.len(), initial_len + 1);
847        let last_msg = &messages[initial_len];
848        assert_eq!(last_msg.tool_call_id, Some("call_1".to_string()));
849        assert!(last_msg.content.as_text().contains("canceled"));
850
851        let report = validate_history_invariants(&messages);
852        assert!(report.is_valid());
853    }
854
855    /// Test: remove_orphan_outputs filters out orphaned responses
856    #[test]
857    fn test_remove_orphan_outputs() {
858        let mut messages = vec![
859            make_tool_call("call_1", "list_files"),
860            make_tool_response("call_1", "valid result"),
861            make_tool_response("orphan_call", "orphan result"),
862        ];
863
864        let initial_len = messages.len();
865        remove_orphan_outputs(&mut messages);
866
867        assert_eq!(messages.len(), initial_len - 1);
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_call") })
877        );
878
879        let report = validate_history_invariants(&messages);
880        assert!(report.is_valid());
881    }
882
883    /// Test: normalize() applies both fixes (synthetic output + orphan removal)
884    #[test]
885    fn test_normalize_combined_fixes() {
886        let mut messages = vec![
887            make_tool_call("call_1", "read_file"),
888            make_tool_call("call_2", "write_file"),
889            make_tool_response("call_2", "written"),
890            make_tool_response("orphan", "orphan result"),
891        ];
892
893        normalize_history(&mut messages);
894
895        let report = validate_history_invariants(&messages);
896        assert!(report.is_valid());
897        assert!(
898            messages
899                .iter()
900                .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "call_1"))
901        );
902        assert!(
903            !messages
904                .iter()
905                .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "orphan"))
906        );
907    }
908
909    #[test]
910    fn request_normalization_shared_reuses_clean_history_arc() {
911        let messages = Arc::new(vec![Message::user("clean history".to_string())]);
912
913        let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
914
915        assert!(Arc::ptr_eq(&messages, &normalized));
916    }
917
918    #[test]
919    fn request_normalization_reused_ids_match_causally_preceding_batches() {
920        let messages = Arc::new(vec![
921            make_tool_response("reused", "early result"),
922            make_tool_call("reused", "read_file"),
923            make_tool_response("reused", "first result"),
924            make_tool_call("reused", "write_file"),
925            Message::user("interleaved".to_string()),
926            make_tool_response("reused", "second result"),
927            make_tool_response("reused", "duplicate result"),
928        ]);
929        let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
930        assert_eq!(
931            normalized.as_ref(),
932            &vec![
933                make_tool_call("reused", "read_file"),
934                make_tool_response("reused", "first result"),
935                make_tool_call("reused", "write_file"),
936                make_tool_response("reused", "second result"),
937                Message::user("interleaved".to_string()),
938            ]
939        );
940        assert_eq!(messages.len(), 7);
941        assert_eq!(messages[0].content.as_text(), "early result");
942    }
943
944    #[test]
945    fn request_normalization_shared_repairs_asymmetric_batch_without_mutating_source() {
946        let messages = Arc::new(vec![
947            Message::assistant_with_tools(
948                "three calls".to_string(),
949                ["call_a", "call_b", "call_c"]
950                    .into_iter()
951                    .map(|id| {
952                        crate::llm::provider::ToolCall::function(
953                            id.to_string(),
954                            "read_file".to_string(),
955                            "{}".to_string(),
956                        )
957                    })
958                    .collect(),
959            ),
960            Message::user("interleaved request".to_string()),
961            make_tool_response("call_b", "result B"),
962            make_tool_response("call_a", "result A"),
963            make_tool_response("call_b", "duplicate B"),
964        ]);
965        let source_before = messages.as_ref().clone();
966
967        let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
968
969        assert!(!Arc::ptr_eq(&messages, &normalized));
970        assert_eq!(normalized.len(), 5);
971        assert_eq!(normalized[0], source_before[0]);
972        assert_eq!(normalized[1], make_tool_response("call_b", "result B"));
973        assert_eq!(normalized[2], make_tool_response("call_a", "result A"));
974        assert_eq!(normalized[3].role, MessageRole::Tool);
975        assert_eq!(normalized[3].tool_call_id.as_deref(), Some("call_c"));
976        assert_eq!(
977            normalized[3].content.as_text(),
978            "canceled: no tool result was recorded; this bounded placeholder preserves the tool-call protocol."
979        );
980        assert_eq!(normalized[4], Message::user("interleaved request".to_string()));
981        assert_eq!(messages.as_ref(), &source_before);
982        let repeated = normalize_history_for_request_shared(Arc::clone(&normalized));
983        assert!(Arc::ptr_eq(&normalized, &repeated));
984    }
985
986    #[test]
987    fn request_normalization_groups_split_results_after_intervening_messages() {
988        let messages = vec![
989            Message::assistant_with_tools(
990                "".to_string(),
991                vec![
992                    crate::llm::provider::ToolCall::function(
993                        "call_1".to_string(),
994                        "read_file".to_string(),
995                        "{}".to_string(),
996                    ),
997                    crate::llm::provider::ToolCall::function(
998                        "call_2".to_string(),
999                        "read_file".to_string(),
1000                        "{}".to_string(),
1001                    ),
1002                ],
1003            ),
1004            Message::system("intervening system note".to_string()),
1005            make_tool_response("call_2", "result two"),
1006            Message::user("intervening user note".to_string()),
1007            make_tool_response("call_1", "result one"),
1008            Message::assistant("done".to_string()),
1009        ];
1010
1011        let normalized = normalize_history_for_request(&messages);
1012
1013        assert_eq!(normalized[0].role, MessageRole::Assistant);
1014        assert_eq!(normalized[1].tool_call_id.as_deref(), Some("call_2"));
1015        assert_eq!(normalized[1].content.as_text(), "result two");
1016        assert_eq!(normalized[2].tool_call_id.as_deref(), Some("call_1"));
1017        assert_eq!(normalized[2].content.as_text(), "result one");
1018        assert_eq!(normalized[3].role, MessageRole::System);
1019        assert_eq!(normalized[4].role, MessageRole::User);
1020        assert_eq!(normalized[5].role, MessageRole::Assistant);
1021    }
1022
1023    #[test]
1024    fn request_normalization_drops_early_orphan_duplicate_and_synthesizes_missing_results() {
1025        let messages = vec![
1026            make_tool_response("call_1", "causally early"),
1027            make_tool_response("orphan", "orphaned"),
1028            make_tool_call("call_1", "read_file"),
1029            make_tool_call("call_2", "read_file"),
1030            make_tool_response("call_1", "valid"),
1031            make_tool_response("call_1", "duplicate"),
1032        ];
1033
1034        let normalized = normalize_history_for_request(&messages);
1035
1036        assert_eq!(normalized.len(), 4);
1037        assert_eq!(normalized[0].role, MessageRole::Assistant);
1038        assert_eq!(normalized[0].tool_calls.as_ref().map(Vec::len), Some(1));
1039        assert_eq!(normalized[1].tool_call_id.as_deref(), Some("call_1"));
1040        assert_eq!(normalized[1].content.as_text(), "valid");
1041        assert_eq!(normalized[2].role, MessageRole::Assistant);
1042        assert_eq!(normalized[3].tool_call_id.as_deref(), Some("call_2"));
1043        assert!(normalized[3].content.as_text().contains("bounded placeholder"));
1044    }
1045
1046    #[test]
1047    fn request_normalization_is_idempotent_and_does_not_mutate_durable_history() {
1048        let messages = vec![
1049            make_tool_call("call_1", "read_file"),
1050            Message::system("intervening".to_string()),
1051            make_tool_response("call_1", "result"),
1052        ];
1053        let durable_before = messages.clone();
1054
1055        assert!(request_history_needs_normalization(&messages));
1056        let normalized = normalize_history_for_request(&messages);
1057        assert!(!request_history_needs_normalization(&normalized));
1058        assert_eq!(normalize_history_for_request(&normalized), normalized);
1059        assert_eq!(messages, durable_before);
1060    }
1061
1062    /// Test: recover_history_from_crash handles both missing and orphan outputs
1063    #[test]
1064    fn test_recover_from_crash() {
1065        let mut messages = vec![
1066            make_tool_call("crashed_call", "dangerous_op"),
1067            make_tool_response("old_call", "stale result"),
1068        ];
1069
1070        recover_history_from_crash(&mut messages);
1071
1072        let report = validate_history_invariants(&messages);
1073        assert!(report.is_valid());
1074        assert!(
1075            messages
1076                .iter()
1077                .any(|msg| { msg.tool_call_id.as_ref().is_some_and(|id| id == "crashed_call") })
1078        );
1079        assert!(
1080            !messages
1081                .iter()
1082                .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "old_call"))
1083        );
1084    }
1085
1086    /// Test: HistoryValidationReport summary messages
1087    #[test]
1088    fn test_validation_report_summary() {
1089        let valid = HistoryValidationReport::default();
1090        assert_eq!(valid.summary(), "History invariants are valid");
1091        assert!(valid.is_valid());
1092
1093        let invalid = HistoryValidationReport {
1094            missing_outputs: vec![
1095                MissingOutput {
1096                    call_id: ToolCallId::new("call_1"),
1097                    tool_name: "tool_a".into(),
1098                },
1099                MissingOutput {
1100                    call_id: ToolCallId::new("call_2"),
1101                    tool_name: "tool_b".into(),
1102                },
1103            ],
1104            orphan_outputs: vec![ToolCallId::new("orphan_1")],
1105        };
1106        assert_eq!(invalid.summary(), "2 missing outputs, 1 orphan outputs");
1107        assert!(!invalid.is_valid());
1108    }
1109
1110    /// Test: Multiple tool calls with selective missing outputs
1111    #[test]
1112    fn test_multiple_calls_partial_outputs() {
1113        let _messages: Vec<Message> = (1..=3)
1114            .flat_map(|i| {
1115                vec![
1116                    make_tool_call(&format!("call_{i}"), &format!("tool_{i}")),
1117                    if i != 2 {
1118                        make_tool_response(&format!("call_{i}"), &format!("result_{i}"))
1119                    } else {
1120                        // Simulate a gap: we don't add a response for call_2 here directly,
1121                        // but we need to build messages differently.
1122                        // Instead, build manually below.
1123                        Message::tool_response("placeholder".into(), "".into())
1124                    },
1125                ]
1126            })
1127            .collect();
1128        // Redo: explicit construction
1129        let mut messages = vec![
1130            make_tool_call("call_1", "tool_1"),
1131            make_tool_response("call_1", "result_1"),
1132            make_tool_call("call_2", "tool_2"),
1133            make_tool_call("call_3", "tool_3"),
1134            make_tool_response("call_3", "result_3"),
1135        ];
1136
1137        let report = validate_history_invariants(&messages);
1138        assert!(!report.is_valid());
1139        assert_eq!(report.missing_outputs.len(), 1);
1140        assert_eq!(report.missing_outputs[0].call_id.as_str(), "call_2");
1141
1142        normalize_history(&mut messages);
1143        assert!(validate_history_invariants(&messages).is_valid());
1144    }
1145
1146    /// Test: OutputStatus enum conversion
1147    #[test]
1148    fn test_output_status_as_str() {
1149        assert_eq!(OutputStatus::Success.as_str(), "success");
1150        assert_eq!(OutputStatus::Failed.as_str(), "failed");
1151        assert_eq!(OutputStatus::Canceled.as_str(), "canceled");
1152        assert_eq!(OutputStatus::Timeout.as_str(), "timeout");
1153    }
1154
1155    /// Test: find_safe_split_point maintains call/output pairs
1156    #[test]
1157    fn test_find_safe_split_point() {
1158        let messages = vec![
1159            Message::user("User 1".into()),           // 0
1160            make_tool_call("call_a", "tool_a"),       // 1
1161            make_tool_response("call_a", "Result A"), // 2
1162            make_tool_call("call_b", "tool_b"),       // 3
1163            make_tool_response("call_b", "Result B"), // 4
1164        ];
1165        let conversation_len = 5;
1166
1167        // Split at 3 means keeping 3,4. But response at 2 needs call at 1 -> must split at 2.
1168        let safe = safe_history_split_point(&messages, conversation_len, 3);
1169        assert_eq!(safe, 2, "Should move split to include Call A");
1170
1171        // Split at 4 is safe: call_b (3) and response_b (4) are both kept.
1172        let safe2 = safe_history_split_point(&messages, conversation_len, 4);
1173        assert_eq!(safe2, 4, "Should stay at 4 as it is safe");
1174    }
1175
1176    #[test]
1177    fn test_summarize_list_formatting() {
1178        assert_eq!(summarize_list(&[]), "none");
1179        assert_eq!(summarize_list(&["a".into()]), "a");
1180        assert_eq!(summarize_list(&["a".into(), "b".into()]), "a, b");
1181        let many: Vec<String> = (1..=7).map(|i| format!("item{i}")).collect();
1182        let result = summarize_list(&many);
1183        assert!(result.contains("item1, item2, item3, item4, item5"));
1184        assert!(result.contains("[+2 more]"));
1185    }
1186
1187    fn bulky_tool_history(count: usize, body_chars: usize) -> Vec<Message> {
1188        let mut messages = vec![Message::user("start".to_string())];
1189        for i in 0..count {
1190            let call_id = format!("call_{i}");
1191            messages.push(make_tool_call(&call_id, "read_file"));
1192            messages.push(make_tool_response(&call_id, &"x".repeat(body_chars)));
1193        }
1194        messages.push(Message::assistant("done".to_string()));
1195        messages
1196    }
1197
1198    fn is_cleared_stub(message: &Message) -> bool {
1199        message.content.as_text().contains("\"cleared\":\"tool_result\"")
1200    }
1201
1202    #[test]
1203    fn clear_old_tool_results_noop_below_trigger() {
1204        let messages = bulky_tool_history(3, 200);
1205        let cleared = clear_old_tool_results(&messages, u64::MAX, 1, 1, false);
1206        assert_eq!(cleared.len(), messages.len());
1207        assert!(
1208            cleared
1209                .iter()
1210                .filter(|m| m.role == MessageRole::Tool)
1211                .all(|m| !is_cleared_stub(m))
1212        );
1213    }
1214
1215    #[test]
1216    fn clear_old_tool_results_keeps_newest_and_stubs_all_older() {
1217        // Four large tool results; keep 1. Every non-kept result is stubbed so
1218        // re-shaping full durable history cannot leave a growing tail.
1219        let messages = bulky_tool_history(4, 4_000);
1220        let cleared = clear_old_tool_results(&messages, 1, 1, 1, false);
1221        let tool_msgs: Vec<&Message> = cleared.iter().filter(|m| m.role == MessageRole::Tool).collect();
1222        assert_eq!(tool_msgs.len(), 4);
1223        assert!(is_cleared_stub(tool_msgs[0]), "oldest result is cleared");
1224        assert!(is_cleared_stub(tool_msgs[1]));
1225        assert!(is_cleared_stub(tool_msgs[2]));
1226        assert!(!is_cleared_stub(tool_msgs[3]), "newest result stays intact");
1227        // Protocol pairing survives.
1228        for (original, rewritten) in messages.iter().zip(cleared.iter()) {
1229            assert_eq!(original.role, rewritten.role);
1230            assert_eq!(original.tool_call_id, rewritten.tool_call_id);
1231        }
1232    }
1233
1234    #[test]
1235    fn clear_old_tool_results_respects_keep_tool_uses() {
1236        let messages = bulky_tool_history(5, 3_000);
1237        let cleared = clear_old_tool_results(&messages, 1, 3, 1, false);
1238        let tool_msgs: Vec<&Message> = cleared.iter().filter(|m| m.role == MessageRole::Tool).collect();
1239        assert_eq!(tool_msgs.len(), 5);
1240        assert!(!is_cleared_stub(tool_msgs[2]));
1241        assert!(!is_cleared_stub(tool_msgs[3]));
1242        assert!(!is_cleared_stub(tool_msgs[4]));
1243    }
1244
1245    #[test]
1246    fn clear_old_tool_results_optional_clear_tool_inputs() {
1247        let mut messages = bulky_tool_history(3, 3_000);
1248        // Attach a recognizable argument payload on the first call.
1249        if let Some(call) = messages
1250            .get_mut(1)
1251            .and_then(|m| m.tool_calls.as_mut())
1252            .and_then(|calls| calls.first_mut())
1253            .and_then(|call| call.function.as_mut())
1254        {
1255            call.arguments = serde_json::json!({"path": "secret-path", "padding": "x".repeat(1024)}).to_string();
1256        }
1257
1258        let cleared = clear_old_tool_results(&messages, 1, 1, 1, true);
1259        let first_call_args = cleared
1260            .get(1)
1261            .and_then(|m| m.tool_calls.as_ref())
1262            .and_then(|calls| calls.first())
1263            .and_then(|call| call.function.as_ref())
1264            .map(|function| function.arguments.as_str())
1265            .unwrap_or_default();
1266        assert!(!first_call_args.contains("secret-path"), "cleared call arguments must drop the original payload");
1267        assert!(
1268            first_call_args.contains("\"cleared\":\"tool_input\""),
1269            "cleared arguments must be a JSON placeholder, got {first_call_args:?}"
1270        );
1271        assert!(
1272            serde_json::from_str::<serde_json::Value>(first_call_args).is_ok(),
1273            "cleared arguments must stay valid JSON for the wire"
1274        );
1275        // Newest retained call keeps its arguments.
1276        let last_call_args = cleared
1277            .get(5)
1278            .and_then(|m| m.tool_calls.as_ref())
1279            .and_then(|calls| calls.first())
1280            .and_then(|call| call.function.as_ref())
1281            .map(|function| function.arguments.as_str())
1282            .unwrap_or_default();
1283        assert_eq!(last_call_args, "{}");
1284    }
1285
1286    #[test]
1287    fn clear_old_tool_results_default_config_clears_paired_inputs() {
1288        // Regression: `clear_tool_inputs` now defaults to true, so the common
1289        // request-shaping path must drop stale apply_patch/write_file bodies
1290        // together with the stubbed results.
1291        let default_clear_tool_inputs =
1292            vtcode_config::core::agent::ToolResultClearingConfig::default().clear_tool_inputs;
1293        assert!(default_clear_tool_inputs, "config default must clear tool inputs");
1294
1295        let mut messages = bulky_tool_history(3, 3_000);
1296        if let Some(call) = messages
1297            .get_mut(1)
1298            .and_then(|m| m.tool_calls.as_mut())
1299            .and_then(|calls| calls.first_mut())
1300            .and_then(|call| call.function.as_mut())
1301        {
1302            call.arguments =
1303                serde_json::json!({"input": format!("*** Begin Patch\n{}\n*** End Patch", "x".repeat(1024))})
1304                    .to_string();
1305        }
1306
1307        let cleared = clear_old_tool_results(&messages, 1, 1, 1, default_clear_tool_inputs);
1308        let cleared_args = cleared
1309            .get(1)
1310            .and_then(|m| m.tool_calls.as_ref())
1311            .and_then(|calls| calls.first())
1312            .and_then(|call| call.function.as_ref())
1313            .map(|function| function.arguments.as_str())
1314            .unwrap_or_default();
1315        assert!(
1316            !cleared_args.contains("Begin Patch"),
1317            "default-on clearing must drop patch bodies, got {cleared_args:?}"
1318        );
1319        assert_eq!(cleared_args, CLEARED_TOOL_INPUT_PLACEHOLDER);
1320
1321        let kept_args = cleared
1322            .get(5)
1323            .and_then(|m| m.tool_calls.as_ref())
1324            .and_then(|calls| calls.first())
1325            .and_then(|call| call.function.as_ref())
1326            .map(|function| function.arguments.as_str())
1327            .unwrap_or_default();
1328        assert_eq!(kept_args, "{}", "kept call arguments must stay intact");
1329    }
1330
1331    #[test]
1332    fn clear_old_tool_results_is_idempotent_on_stubs() {
1333        let messages = bulky_tool_history(4, 3_000);
1334        let once = clear_old_tool_results(&messages, 1, 1, 1_000, false);
1335        let twice = clear_old_tool_results(&once, 1, 1, 1_000, false);
1336        let stubs_once = once.iter().filter(|m| is_cleared_stub(m)).count();
1337        let stubs_twice = twice.iter().filter(|m| is_cleared_stub(m)).count();
1338        assert_eq!(stubs_once, stubs_twice);
1339    }
1340
1341    #[test]
1342    fn clear_old_tool_results_leaves_durable_history_unchanged() {
1343        let messages = bulky_tool_history(3, 2_500);
1344        let snapshot: Vec<String> = messages.iter().map(|m| m.content.as_text().into_owned()).collect();
1345        let request_messages = clear_old_tool_results(&messages, 1, 1, 1, false);
1346        let after: Vec<String> = messages.iter().map(|m| m.content.as_text().into_owned()).collect();
1347        assert_eq!(snapshot, after, "durable history must not be mutated");
1348        assert!(
1349            request_messages
1350                .iter()
1351                .filter(|m| m.role == MessageRole::Tool)
1352                .any(is_cleared_stub),
1353            "request messages should carry stubs"
1354        );
1355    }
1356
1357    #[test]
1358    fn local_tool_result_clearing_gate_mirrors_native_edits() {
1359        use super::should_apply_local_tool_result_clearing as gate;
1360        assert!(gate("zai", false, true), "non-Anthropic uses local clearing");
1361        assert!(gate("openai", false, true), "OpenAI uses local clearing");
1362        assert!(gate("anthropic", false, true), "Anthropic without edits falls back to local");
1363        assert!(!gate("anthropic", true, true), "Anthropic with context_edits uses native clear_tool_uses only");
1364        assert!(!gate("zai", false, false), "disabled config never clears");
1365    }
1366
1367    #[test]
1368    fn clear_old_tool_results_stub_is_valid_json_for_non_ascii_tool_names() {
1369        let mut messages = bulky_tool_history(3, 2_500);
1370        // Index 2 is the first tool response (call_0).
1371        messages[2].origin_tool = Some("读取文件".to_string());
1372        messages[2].tool_call_id = Some("call_ünïcode".to_string());
1373        let cleared = clear_old_tool_results(&messages, 1, 1, 1, false);
1374        let stub = cleared
1375            .iter()
1376            .filter(|m| is_cleared_stub(m))
1377            .find(|m| m.content.as_text().contains("call_ünïcode"))
1378            .expect("stub for the renamed call");
1379        let parsed: serde_json::Value =
1380            serde_json::from_str(stub.content.as_text().as_ref()).expect("stub must be valid JSON");
1381        assert_eq!(parsed["tool"], "读取文件");
1382        assert_eq!(parsed["tool_call_id"], "call_ünïcode");
1383    }
1384
1385    #[test]
1386    fn clear_old_tool_results_clear_tool_inputs_covers_freeform_text() {
1387        let mut messages = bulky_tool_history(3, 2_500);
1388        // Freeform custom tool payload lives in `text`, not function.arguments.
1389        if let Some(call) = messages
1390            .get_mut(1)
1391            .and_then(|m| m.tool_calls.as_mut())
1392            .and_then(|calls| calls.first_mut())
1393        {
1394            call.text = Some("raw freeform payload".repeat(100));
1395            call.thought_signature = Some("sig".to_string());
1396        }
1397        let cleared = clear_old_tool_results(&messages, 1, 1, 1, true);
1398        let call = cleared
1399            .get(1)
1400            .and_then(|m| m.tool_calls.as_ref())
1401            .and_then(|calls| calls.first())
1402            .expect("call preserved");
1403        assert_eq!(call.text.as_deref(), Some(CLEARED_TOOL_INPUT_PLACEHOLDER));
1404        assert!(call.thought_signature.is_none());
1405    }
1406}
1407
1408#[cfg(test)]
1409mod tool_result_clearing_tests;