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