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/// Request-only local stand-in for Anthropic `clear_tool_uses_20250919`.
372///
373/// Providers without `context_management.edits` never get native tool-result
374/// clearing, so long histories keep paying full price for every old tool body
375/// on each request. Once estimated history tokens exceed `trigger_tokens`, this
376/// rewrites every tool-result *request* message except the newest
377/// `keep_tool_uses` to a bounded stub. `clear_at_least_tokens` is a floor on
378/// reclaimed tokens (not a ceiling): clearing must stub all non-kept results so
379/// re-running on full durable history cannot leave a permanently growing tail.
380///
381/// Contract (same as [`normalize_history_for_request`]):
382/// - never mutates durable session history or `ThreadEvent`s;
383/// - preserves `role`, `tool_call_id`, `origin_tool`, and message order so
384///   provider tool-pairing validation still passes;
385/// - idempotent: re-running on already-stubbed output is a no-op below the
386///   trigger and cannot re-clear stubs usefully.
387///
388/// When `clear_tool_inputs` is set, assistant `tool_calls[].function.arguments`
389/// and freeform `text` for cleared results are replaced with a JSON placeholder
390/// (never prose — providers send those fields verbatim on the wire).
391pub fn clear_old_tool_results(
392    messages: &[Message],
393    trigger_tokens: u64,
394    keep_tool_uses: u32,
395    clear_at_least_tokens: u64,
396    clear_tool_inputs: bool,
397) -> Vec<Message> {
398    if trigger_tokens == 0 || messages.is_empty() {
399        return messages.to_vec();
400    }
401
402    let estimated_tokens: u64 = messages
403        .iter()
404        .map(|message| message.estimate_tokens() as u64)
405        .fold(0u64, u64::saturating_add);
406    if estimated_tokens < trigger_tokens {
407        return messages.to_vec();
408    }
409
410    let tool_indices: Vec<usize> = messages
411        .iter()
412        .enumerate()
413        .filter(|(_, message)| message.role == crate::llm::provider::MessageRole::Tool)
414        .map(|(index, _)| index)
415        .collect();
416    let keep = keep_tool_uses as usize;
417    if tool_indices.len() <= keep {
418        return messages.to_vec();
419    }
420
421    let mut cleared_tokens = 0u64;
422    let mut cleared_call_ids: HashSet<String> = HashSet::new();
423    // Stub every non-kept tool result (oldest-first). `clear_at_least_tokens`
424    // is a floor on reclaimed tokens; stopping early would leave a permanently
425    // growing tail on the next request that re-shapes full durable history.
426    let mut out = messages.to_vec();
427    for &tool_index in &tool_indices[..tool_indices.len() - keep] {
428        let original = &messages[tool_index];
429        let original_tokens = original.estimate_tokens() as u64;
430        let stub = build_cleared_tool_result_stub(original);
431        let stub_tokens = stub.estimate_tokens() as u64;
432        if let Some(call_id) = original.tool_call_id.clone() {
433            cleared_call_ids.insert(call_id);
434        }
435        out[tool_index] = stub;
436        cleared_tokens = cleared_tokens.saturating_add(original_tokens.saturating_sub(stub_tokens));
437    }
438
439    // Floor is advisory: all non-kept results are already stubbed, so the
440    // floor is met whenever anything was reclaimable. Surface a miss only if
441    // the keep window left less than the configured floor (tiny histories).
442    if cleared_tokens < clear_at_least_tokens {
443        tracing::debug!(
444            cleared_tokens,
445            clear_at_least_tokens,
446            "tool-result clearing reclaimed less than the configured floor"
447        );
448    }
449
450    if clear_tool_inputs && !cleared_call_ids.is_empty() {
451        for message in &mut out {
452            if message.role != crate::llm::provider::MessageRole::Assistant {
453                continue;
454            }
455            let Some(tool_calls) = message.tool_calls.as_mut() else {
456                continue;
457            };
458            for call in tool_calls.iter_mut() {
459                if cleared_call_ids.contains(&call.id) {
460                    if let Some(function) = call.function.as_mut() {
461                        // Valid JSON placeholder: providers send `arguments`
462                        // verbatim; prose would break OpenAI tool-call parsing.
463                        function.arguments = CLEARED_TOOL_INPUT_PLACEHOLDER.to_string();
464                    }
465                    // Freeform custom tool payloads ride in `text`, not
466                    // `function.arguments` (see ToolCall::custom).
467                    if call.text.is_some() {
468                        call.text = Some(CLEARED_TOOL_INPUT_PLACEHOLDER.to_string());
469                    }
470                    call.thought_signature = None;
471                }
472            }
473        }
474    }
475
476    out
477}
478
479/// JSON placeholder for cleared tool-call inputs (must stay valid JSON).
480const CLEARED_TOOL_INPUT_PLACEHOLDER: &str = "{\"cleared\":\"tool_input\"}";
481
482/// Whether request assembly should apply local [`clear_old_tool_results`].
483///
484/// Local clearing and Anthropic `clear_tool_uses` are mutually exclusive:
485/// native edits already reclaim tool bodies on the wire, so applying both
486/// would double-shape the same history.
487pub fn should_apply_local_tool_result_clearing(
488    provider_name: &str,
489    context_edits: bool,
490    clearing_enabled: bool,
491) -> bool {
492    clearing_enabled && !(provider_name.eq_ignore_ascii_case("anthropic") && context_edits)
493}
494
495fn build_cleared_tool_result_stub(original: &Message) -> Message {
496    let mut stub = original.clone();
497    // serde_json (not `{:?}`) so non-ASCII tool names / call ids stay valid JSON.
498    let body = serde_json::json!({
499        "cleared": "tool_result",
500        "reason": "tool_result_clearing",
501        "note": CLEARED_TOOL_RESULT_NOTE,
502        "tool": original.origin_tool.as_deref().unwrap_or("tool"),
503        "tool_call_id": original.tool_call_id.as_deref().unwrap_or(""),
504    })
505    .to_string();
506    stub.content = crate::llm::provider::MessageContent::Text(body);
507    stub
508}
509
510/// Find a split point that keeps tool-call outputs paired with their calls.
511pub fn safe_history_split_point(messages: &[Message], conversation_len: usize, preferred_split_at: usize) -> usize {
512    if preferred_split_at == 0 || preferred_split_at >= conversation_len {
513        return preferred_split_at;
514    }
515
516    let mut call_indices: HashMap<&str, usize> = HashMap::new();
517    for (i, msg) in messages.iter().enumerate() {
518        if let Some(tool_calls) = &msg.tool_calls {
519            for call in tool_calls {
520                call_indices.insert(&call.id, i);
521            }
522        }
523    }
524
525    let mut safe_split_at = preferred_split_at;
526    loop {
527        if safe_split_at == 0 {
528            break;
529        }
530
531        let has_orphan = ((safe_split_at + 1)..messages.len()).any(|i| {
532            messages
533                .get(i)
534                .and_then(|msg| msg.tool_call_id.as_ref())
535                .and_then(|id| call_indices.get(id.as_str()))
536                .is_some_and(|&call_idx| call_idx <= safe_split_at)
537        });
538
539        if !has_orphan {
540            break;
541        }
542
543        safe_split_at -= 1;
544    }
545
546    safe_split_at
547}
548
549/// Ensure all tool calls have corresponding outputs in the message list.
550pub fn ensure_call_outputs_present(messages: &mut Vec<Message>) {
551    let report = validate_history_invariants(messages);
552
553    // Create synthetic outputs for missing calls in reverse order to avoid index shifting
554    for missing in report.missing_outputs.iter().rev() {
555        let synthetic_message = Message::tool_response(
556            missing.call_id.as_str().to_string(),
557            "canceled: Tool execution was interrupted. This synthetic output was created \
558             during history normalization to maintain conversation invariants."
559                .to_string(),
560        );
561
562        tracing::warn!("Creating synthetic output for call {} due to missing execution result", missing.call_id);
563
564        // Find the position to insert: right after the corresponding call
565        let insert_pos = messages
566            .iter()
567            .position(|msg| {
568                msg.tool_calls
569                    .as_ref()
570                    .is_some_and(|calls| calls.iter().any(|call| call.id == missing.call_id.as_str()))
571            })
572            .map(|pos| pos + 1);
573
574        if let Some(pos) = insert_pos {
575            messages.insert(pos, synthetic_message);
576        } else {
577            // If we can't find the call, just append the synthetic output
578            messages.push(synthetic_message);
579        }
580    }
581}
582
583/// Remove outputs without corresponding calls (orphaned outputs) from the message list.
584pub fn remove_orphan_outputs(messages: &mut Vec<Message>) {
585    let report = validate_history_invariants(messages);
586
587    if report.orphan_outputs.is_empty() {
588        return;
589    }
590
591    let orphan_ids: HashSet<String> = report.orphan_outputs.iter().map(|id| id.as_str().to_string()).collect();
592
593    let initial_len = messages.len();
594
595    // Retain only messages that either:
596    // - Don't have a tool_call_id (not a tool response)
597    // - Have a tool_call_id that matches an existing call
598    messages.retain(|msg| {
599        if let Some(tool_call_id) = msg.tool_call_id.as_ref()
600            && orphan_ids.contains(tool_call_id)
601        {
602            tracing::warn!("Removing orphan output for call {}", tool_call_id);
603            return false;
604        }
605        true
606    });
607
608    if messages.len() != initial_len {
609        tracing::info!("Removed {} orphan outputs", initial_len - messages.len());
610    }
611}
612
613/// Normalize history to enforce call/output pairing invariants.
614pub fn normalize_history(messages: &mut Vec<Message>) {
615    ensure_call_outputs_present(messages);
616    remove_orphan_outputs(messages);
617
618    // Log if issues were found
619    let report = validate_history_invariants(messages);
620    if !report.is_valid() {
621        tracing::warn!("History validation: {}", report.summary());
622    } else {
623        tracing::debug!("History normalized successfully");
624    }
625}
626
627/// Recover from crashed or interrupted session by fixing history invariants.
628pub fn recover_history_from_crash(messages: &mut Vec<Message>) {
629    let report = validate_history_invariants(messages);
630
631    if !report.missing_outputs.is_empty() {
632        tracing::warn!("Found {} missing outputs during recovery", report.missing_outputs.len());
633        ensure_call_outputs_present(messages);
634    }
635
636    if !report.orphan_outputs.is_empty() {
637        tracing::warn!("Found {} orphan outputs during recovery", report.orphan_outputs.len());
638        remove_orphan_outputs(messages);
639    }
640
641    if report.is_valid() {
642        tracing::debug!("History invariants are valid");
643    }
644}
645
646// ============================================================================
647// Tests: Context Manager - Call/Output Pairing Invariants
648// ============================================================================
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use crate::llm::provider::{Message, MessageRole};
654    /// Helper: Create test messages
655    fn make_tool_call(call_id: &str, tool_name: &str) -> Message {
656        Message::assistant_with_tools(
657            "".to_string(),
658            vec![crate::llm::provider::ToolCall::function(
659                call_id.to_string(),
660                tool_name.to_string(),
661                "{}".to_string(),
662            )],
663        )
664    }
665
666    fn make_tool_response(call_id: &str, content: &str) -> Message {
667        Message::tool_response(call_id.to_string(), content.to_string())
668    }
669
670    /// Test: Valid history with matched calls and outputs
671    #[test]
672    fn test_validate_history_valid_matched_pairs() {
673        let mut messages = vec![
674            make_tool_call("call_1", "list_files"),
675            make_tool_response("call_1", "file1.rs\nfile2.rs"),
676        ];
677
678        let report = validate_history_invariants(&messages);
679        assert!(report.is_valid(), "Valid paired call/output should pass");
680        assert!(report.missing_outputs.is_empty());
681        assert!(report.orphan_outputs.is_empty());
682
683        // Normalize should be a no-op
684        normalize_history(&mut messages);
685        assert_eq!(messages.len(), 2);
686    }
687
688    /// Test: Missing output (tool call without response)
689    #[test]
690    fn test_validate_history_missing_output() {
691        let messages = vec![make_tool_call("call_1", "list_files")];
692
693        let report = validate_history_invariants(&messages);
694        assert!(!report.is_valid());
695        assert_eq!(report.missing_outputs.len(), 1);
696        assert_eq!(report.missing_outputs[0].call_id.as_str(), "call_1");
697        assert!(report.orphan_outputs.is_empty());
698    }
699
700    /// Test: Orphan output (response without corresponding call)
701    #[test]
702    fn test_validate_history_orphan_output() {
703        let messages = vec![make_tool_response("orphan_call", "Some result")];
704
705        let report = validate_history_invariants(&messages);
706        assert!(!report.is_valid());
707        assert!(report.missing_outputs.is_empty());
708        assert_eq!(report.orphan_outputs.len(), 1);
709        assert_eq!(report.orphan_outputs[0].as_str(), "orphan_call");
710    }
711
712    /// Test: ensure_call_outputs_present creates synthetic outputs
713    #[test]
714    fn test_ensure_call_outputs_present() {
715        let mut messages = vec![make_tool_call("call_1", "list_files")];
716        let initial_len = messages.len();
717
718        ensure_call_outputs_present(&mut messages);
719
720        assert_eq!(messages.len(), initial_len + 1);
721        let last_msg = &messages[initial_len];
722        assert_eq!(last_msg.tool_call_id, Some("call_1".to_string()));
723        assert!(last_msg.content.as_text().contains("canceled"));
724
725        let report = validate_history_invariants(&messages);
726        assert!(report.is_valid());
727    }
728
729    /// Test: remove_orphan_outputs filters out orphaned responses
730    #[test]
731    fn test_remove_orphan_outputs() {
732        let mut messages = vec![
733            make_tool_call("call_1", "list_files"),
734            make_tool_response("call_1", "valid result"),
735            make_tool_response("orphan_call", "orphan result"),
736        ];
737
738        let initial_len = messages.len();
739        remove_orphan_outputs(&mut messages);
740
741        assert_eq!(messages.len(), initial_len - 1);
742        assert!(
743            messages
744                .iter()
745                .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "call_1"))
746        );
747        assert!(
748            !messages
749                .iter()
750                .any(|msg| { msg.tool_call_id.as_ref().is_some_and(|id| id == "orphan_call") })
751        );
752
753        let report = validate_history_invariants(&messages);
754        assert!(report.is_valid());
755    }
756
757    /// Test: normalize() applies both fixes (synthetic output + orphan removal)
758    #[test]
759    fn test_normalize_combined_fixes() {
760        let mut messages = vec![
761            make_tool_call("call_1", "read_file"),
762            make_tool_call("call_2", "write_file"),
763            make_tool_response("call_2", "written"),
764            make_tool_response("orphan", "orphan result"),
765        ];
766
767        normalize_history(&mut messages);
768
769        let report = validate_history_invariants(&messages);
770        assert!(report.is_valid());
771        assert!(
772            messages
773                .iter()
774                .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "call_1"))
775        );
776        assert!(
777            !messages
778                .iter()
779                .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "orphan"))
780        );
781    }
782
783    #[test]
784    fn request_normalization_shared_reuses_clean_history_arc() {
785        let messages = Arc::new(vec![Message::user("clean history".to_string())]);
786
787        let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
788
789        assert!(Arc::ptr_eq(&messages, &normalized));
790    }
791
792    #[test]
793    fn request_normalization_reused_ids_match_causally_preceding_batches() {
794        let messages = Arc::new(vec![
795            make_tool_response("reused", "early result"),
796            make_tool_call("reused", "read_file"),
797            make_tool_response("reused", "first result"),
798            make_tool_call("reused", "write_file"),
799            Message::user("interleaved".to_string()),
800            make_tool_response("reused", "second result"),
801            make_tool_response("reused", "duplicate result"),
802        ]);
803        let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
804        assert_eq!(
805            normalized.as_ref(),
806            &vec![
807                make_tool_call("reused", "read_file"),
808                make_tool_response("reused", "first result"),
809                make_tool_call("reused", "write_file"),
810                make_tool_response("reused", "second result"),
811                Message::user("interleaved".to_string()),
812            ]
813        );
814        assert_eq!(messages.len(), 7);
815        assert_eq!(messages[0].content.as_text(), "early result");
816    }
817
818    #[test]
819    fn request_normalization_shared_repairs_asymmetric_batch_without_mutating_source() {
820        let messages = Arc::new(vec![
821            Message::assistant_with_tools(
822                "three calls".to_string(),
823                ["call_a", "call_b", "call_c"]
824                    .into_iter()
825                    .map(|id| {
826                        crate::llm::provider::ToolCall::function(
827                            id.to_string(),
828                            "read_file".to_string(),
829                            "{}".to_string(),
830                        )
831                    })
832                    .collect(),
833            ),
834            Message::user("interleaved request".to_string()),
835            make_tool_response("call_b", "result B"),
836            make_tool_response("call_a", "result A"),
837            make_tool_response("call_b", "duplicate B"),
838        ]);
839        let source_before = messages.as_ref().clone();
840
841        let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
842
843        assert!(!Arc::ptr_eq(&messages, &normalized));
844        assert_eq!(normalized.len(), 5);
845        assert_eq!(normalized[0], source_before[0]);
846        assert_eq!(normalized[1], make_tool_response("call_b", "result B"));
847        assert_eq!(normalized[2], make_tool_response("call_a", "result A"));
848        assert_eq!(normalized[3].role, MessageRole::Tool);
849        assert_eq!(normalized[3].tool_call_id.as_deref(), Some("call_c"));
850        assert_eq!(
851            normalized[3].content.as_text(),
852            "canceled: no tool result was recorded; this bounded placeholder preserves the tool-call protocol."
853        );
854        assert_eq!(normalized[4], Message::user("interleaved request".to_string()));
855        assert_eq!(messages.as_ref(), &source_before);
856        let repeated = normalize_history_for_request_shared(Arc::clone(&normalized));
857        assert!(Arc::ptr_eq(&normalized, &repeated));
858    }
859
860    #[test]
861    fn request_normalization_groups_split_results_after_intervening_messages() {
862        let messages = vec![
863            Message::assistant_with_tools(
864                "".to_string(),
865                vec![
866                    crate::llm::provider::ToolCall::function(
867                        "call_1".to_string(),
868                        "read_file".to_string(),
869                        "{}".to_string(),
870                    ),
871                    crate::llm::provider::ToolCall::function(
872                        "call_2".to_string(),
873                        "read_file".to_string(),
874                        "{}".to_string(),
875                    ),
876                ],
877            ),
878            Message::system("intervening system note".to_string()),
879            make_tool_response("call_2", "result two"),
880            Message::user("intervening user note".to_string()),
881            make_tool_response("call_1", "result one"),
882            Message::assistant("done".to_string()),
883        ];
884
885        let normalized = normalize_history_for_request(&messages);
886
887        assert_eq!(normalized[0].role, MessageRole::Assistant);
888        assert_eq!(normalized[1].tool_call_id.as_deref(), Some("call_2"));
889        assert_eq!(normalized[1].content.as_text(), "result two");
890        assert_eq!(normalized[2].tool_call_id.as_deref(), Some("call_1"));
891        assert_eq!(normalized[2].content.as_text(), "result one");
892        assert_eq!(normalized[3].role, MessageRole::System);
893        assert_eq!(normalized[4].role, MessageRole::User);
894        assert_eq!(normalized[5].role, MessageRole::Assistant);
895    }
896
897    #[test]
898    fn request_normalization_drops_early_orphan_duplicate_and_synthesizes_missing_results() {
899        let messages = vec![
900            make_tool_response("call_1", "causally early"),
901            make_tool_response("orphan", "orphaned"),
902            make_tool_call("call_1", "read_file"),
903            make_tool_call("call_2", "read_file"),
904            make_tool_response("call_1", "valid"),
905            make_tool_response("call_1", "duplicate"),
906        ];
907
908        let normalized = normalize_history_for_request(&messages);
909
910        assert_eq!(normalized.len(), 4);
911        assert_eq!(normalized[0].role, MessageRole::Assistant);
912        assert_eq!(normalized[0].tool_calls.as_ref().map(Vec::len), Some(1));
913        assert_eq!(normalized[1].tool_call_id.as_deref(), Some("call_1"));
914        assert_eq!(normalized[1].content.as_text(), "valid");
915        assert_eq!(normalized[2].role, MessageRole::Assistant);
916        assert_eq!(normalized[3].tool_call_id.as_deref(), Some("call_2"));
917        assert!(normalized[3].content.as_text().contains("bounded placeholder"));
918    }
919
920    #[test]
921    fn request_normalization_is_idempotent_and_does_not_mutate_durable_history() {
922        let messages = vec![
923            make_tool_call("call_1", "read_file"),
924            Message::system("intervening".to_string()),
925            make_tool_response("call_1", "result"),
926        ];
927        let durable_before = messages.clone();
928
929        assert!(request_history_needs_normalization(&messages));
930        let normalized = normalize_history_for_request(&messages);
931        assert!(!request_history_needs_normalization(&normalized));
932        assert_eq!(normalize_history_for_request(&normalized), normalized);
933        assert_eq!(messages, durable_before);
934    }
935
936    /// Test: recover_history_from_crash handles both missing and orphan outputs
937    #[test]
938    fn test_recover_from_crash() {
939        let mut messages = vec![
940            make_tool_call("crashed_call", "dangerous_op"),
941            make_tool_response("old_call", "stale result"),
942        ];
943
944        recover_history_from_crash(&mut messages);
945
946        let report = validate_history_invariants(&messages);
947        assert!(report.is_valid());
948        assert!(
949            messages
950                .iter()
951                .any(|msg| { msg.tool_call_id.as_ref().is_some_and(|id| id == "crashed_call") })
952        );
953        assert!(
954            !messages
955                .iter()
956                .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "old_call"))
957        );
958    }
959
960    /// Test: HistoryValidationReport summary messages
961    #[test]
962    fn test_validation_report_summary() {
963        let valid = HistoryValidationReport::default();
964        assert_eq!(valid.summary(), "History invariants are valid");
965        assert!(valid.is_valid());
966
967        let invalid = HistoryValidationReport {
968            missing_outputs: vec![
969                MissingOutput {
970                    call_id: ToolCallId::new("call_1"),
971                    tool_name: "tool_a".into(),
972                },
973                MissingOutput {
974                    call_id: ToolCallId::new("call_2"),
975                    tool_name: "tool_b".into(),
976                },
977            ],
978            orphan_outputs: vec![ToolCallId::new("orphan_1")],
979        };
980        assert_eq!(invalid.summary(), "2 missing outputs, 1 orphan outputs");
981        assert!(!invalid.is_valid());
982    }
983
984    /// Test: Multiple tool calls with selective missing outputs
985    #[test]
986    fn test_multiple_calls_partial_outputs() {
987        let _messages: Vec<Message> = (1..=3)
988            .flat_map(|i| {
989                vec![
990                    make_tool_call(&format!("call_{i}"), &format!("tool_{i}")),
991                    if i != 2 {
992                        make_tool_response(&format!("call_{i}"), &format!("result_{i}"))
993                    } else {
994                        // Simulate a gap: we don't add a response for call_2 here directly,
995                        // but we need to build messages differently.
996                        // Instead, build manually below.
997                        Message::tool_response("placeholder".into(), "".into())
998                    },
999                ]
1000            })
1001            .collect();
1002        // Redo: explicit construction
1003        let mut messages = vec![
1004            make_tool_call("call_1", "tool_1"),
1005            make_tool_response("call_1", "result_1"),
1006            make_tool_call("call_2", "tool_2"),
1007            make_tool_call("call_3", "tool_3"),
1008            make_tool_response("call_3", "result_3"),
1009        ];
1010
1011        let report = validate_history_invariants(&messages);
1012        assert!(!report.is_valid());
1013        assert_eq!(report.missing_outputs.len(), 1);
1014        assert_eq!(report.missing_outputs[0].call_id.as_str(), "call_2");
1015
1016        normalize_history(&mut messages);
1017        assert!(validate_history_invariants(&messages).is_valid());
1018    }
1019
1020    /// Test: OutputStatus enum conversion
1021    #[test]
1022    fn test_output_status_as_str() {
1023        assert_eq!(OutputStatus::Success.as_str(), "success");
1024        assert_eq!(OutputStatus::Failed.as_str(), "failed");
1025        assert_eq!(OutputStatus::Canceled.as_str(), "canceled");
1026        assert_eq!(OutputStatus::Timeout.as_str(), "timeout");
1027    }
1028
1029    /// Test: find_safe_split_point maintains call/output pairs
1030    #[test]
1031    fn test_find_safe_split_point() {
1032        let messages = vec![
1033            Message::user("User 1".into()),           // 0
1034            make_tool_call("call_a", "tool_a"),       // 1
1035            make_tool_response("call_a", "Result A"), // 2
1036            make_tool_call("call_b", "tool_b"),       // 3
1037            make_tool_response("call_b", "Result B"), // 4
1038        ];
1039        let conversation_len = 5;
1040
1041        // Split at 3 means keeping 3,4. But response at 2 needs call at 1 -> must split at 2.
1042        let safe = safe_history_split_point(&messages, conversation_len, 3);
1043        assert_eq!(safe, 2, "Should move split to include Call A");
1044
1045        // Split at 4 is safe: call_b (3) and response_b (4) are both kept.
1046        let safe2 = safe_history_split_point(&messages, conversation_len, 4);
1047        assert_eq!(safe2, 4, "Should stay at 4 as it is safe");
1048    }
1049
1050    #[test]
1051    fn test_summarize_list_formatting() {
1052        assert_eq!(summarize_list(&[]), "none");
1053        assert_eq!(summarize_list(&["a".into()]), "a");
1054        assert_eq!(summarize_list(&["a".into(), "b".into()]), "a, b");
1055        let many: Vec<String> = (1..=7).map(|i| format!("item{i}")).collect();
1056        let result = summarize_list(&many);
1057        assert!(result.contains("item1, item2, item3, item4, item5"));
1058        assert!(result.contains("[+2 more]"));
1059    }
1060
1061    fn bulky_tool_history(count: usize, body_chars: usize) -> Vec<Message> {
1062        let mut messages = vec![Message::user("start".to_string())];
1063        for i in 0..count {
1064            let call_id = format!("call_{i}");
1065            messages.push(make_tool_call(&call_id, "read_file"));
1066            messages.push(make_tool_response(&call_id, &"x".repeat(body_chars)));
1067        }
1068        messages.push(Message::assistant("done".to_string()));
1069        messages
1070    }
1071
1072    fn is_cleared_stub(message: &Message) -> bool {
1073        message.content.as_text().contains("\"cleared\":\"tool_result\"")
1074    }
1075
1076    #[test]
1077    fn clear_old_tool_results_noop_below_trigger() {
1078        let messages = bulky_tool_history(3, 200);
1079        let cleared = clear_old_tool_results(&messages, u64::MAX, 1, 1, false);
1080        assert_eq!(cleared.len(), messages.len());
1081        assert!(
1082            cleared
1083                .iter()
1084                .filter(|m| m.role == MessageRole::Tool)
1085                .all(|m| !is_cleared_stub(m))
1086        );
1087    }
1088
1089    #[test]
1090    fn clear_old_tool_results_keeps_newest_and_stubs_all_older() {
1091        // Four large tool results; keep 1. Every non-kept result is stubbed so
1092        // re-shaping full durable history cannot leave a growing tail.
1093        let messages = bulky_tool_history(4, 4_000);
1094        let cleared = clear_old_tool_results(&messages, 1, 1, 1, false);
1095        let tool_msgs: Vec<&Message> = cleared.iter().filter(|m| m.role == MessageRole::Tool).collect();
1096        assert_eq!(tool_msgs.len(), 4);
1097        assert!(is_cleared_stub(tool_msgs[0]), "oldest result is cleared");
1098        assert!(is_cleared_stub(tool_msgs[1]));
1099        assert!(is_cleared_stub(tool_msgs[2]));
1100        assert!(!is_cleared_stub(tool_msgs[3]), "newest result stays intact");
1101        // Protocol pairing survives.
1102        for (original, rewritten) in messages.iter().zip(cleared.iter()) {
1103            assert_eq!(original.role, rewritten.role);
1104            assert_eq!(original.tool_call_id, rewritten.tool_call_id);
1105        }
1106    }
1107
1108    #[test]
1109    fn clear_old_tool_results_respects_keep_tool_uses() {
1110        let messages = bulky_tool_history(5, 3_000);
1111        let cleared = clear_old_tool_results(&messages, 1, 3, 1, false);
1112        let tool_msgs: Vec<&Message> = cleared.iter().filter(|m| m.role == MessageRole::Tool).collect();
1113        assert_eq!(tool_msgs.len(), 5);
1114        assert!(!is_cleared_stub(tool_msgs[2]));
1115        assert!(!is_cleared_stub(tool_msgs[3]));
1116        assert!(!is_cleared_stub(tool_msgs[4]));
1117    }
1118
1119    #[test]
1120    fn clear_old_tool_results_optional_clear_tool_inputs() {
1121        let mut messages = bulky_tool_history(3, 3_000);
1122        // Attach a recognizable argument payload on the first call.
1123        if let Some(call) = messages
1124            .get_mut(1)
1125            .and_then(|m| m.tool_calls.as_mut())
1126            .and_then(|calls| calls.first_mut())
1127            .and_then(|call| call.function.as_mut())
1128        {
1129            call.arguments = "{\"path\":\"secret-path\"}".to_string();
1130        }
1131
1132        let cleared = clear_old_tool_results(&messages, 1, 1, 1, true);
1133        let first_call_args = cleared
1134            .get(1)
1135            .and_then(|m| m.tool_calls.as_ref())
1136            .and_then(|calls| calls.first())
1137            .and_then(|call| call.function.as_ref())
1138            .map(|function| function.arguments.as_str())
1139            .unwrap_or_default();
1140        assert!(!first_call_args.contains("secret-path"), "cleared call arguments must drop the original payload");
1141        assert!(
1142            first_call_args.contains("\"cleared\":\"tool_input\""),
1143            "cleared arguments must be a JSON placeholder, got {first_call_args:?}"
1144        );
1145        assert!(
1146            serde_json::from_str::<serde_json::Value>(first_call_args).is_ok(),
1147            "cleared arguments must stay valid JSON for the wire"
1148        );
1149        // Newest retained call keeps its arguments.
1150        let last_call_args = cleared
1151            .get(5)
1152            .and_then(|m| m.tool_calls.as_ref())
1153            .and_then(|calls| calls.first())
1154            .and_then(|call| call.function.as_ref())
1155            .map(|function| function.arguments.as_str())
1156            .unwrap_or_default();
1157        assert_eq!(last_call_args, "{}");
1158    }
1159
1160    #[test]
1161    fn clear_old_tool_results_default_config_clears_paired_inputs() {
1162        // Regression: `clear_tool_inputs` now defaults to true, so the common
1163        // request-shaping path must drop stale apply_patch/write_file bodies
1164        // together with the stubbed results.
1165        let default_clear_tool_inputs =
1166            vtcode_config::core::agent::ToolResultClearingConfig::default().clear_tool_inputs;
1167        assert!(default_clear_tool_inputs, "config default must clear tool inputs");
1168
1169        let mut messages = bulky_tool_history(3, 3_000);
1170        if let Some(call) = messages
1171            .get_mut(1)
1172            .and_then(|m| m.tool_calls.as_mut())
1173            .and_then(|calls| calls.first_mut())
1174            .and_then(|call| call.function.as_mut())
1175        {
1176            call.arguments = "{\"input\":\"*** Begin Patch\\n*** End Patch\"}".to_string();
1177        }
1178
1179        let cleared = clear_old_tool_results(&messages, 1, 1, 1, default_clear_tool_inputs);
1180        let cleared_args = cleared
1181            .get(1)
1182            .and_then(|m| m.tool_calls.as_ref())
1183            .and_then(|calls| calls.first())
1184            .and_then(|call| call.function.as_ref())
1185            .map(|function| function.arguments.as_str())
1186            .unwrap_or_default();
1187        assert!(
1188            !cleared_args.contains("Begin Patch"),
1189            "default-on clearing must drop patch bodies, got {cleared_args:?}"
1190        );
1191        assert_eq!(cleared_args, CLEARED_TOOL_INPUT_PLACEHOLDER);
1192
1193        let kept_args = cleared
1194            .get(5)
1195            .and_then(|m| m.tool_calls.as_ref())
1196            .and_then(|calls| calls.first())
1197            .and_then(|call| call.function.as_ref())
1198            .map(|function| function.arguments.as_str())
1199            .unwrap_or_default();
1200        assert_eq!(kept_args, "{}", "kept call arguments must stay intact");
1201    }
1202
1203    #[test]
1204    fn clear_old_tool_results_is_idempotent_on_stubs() {
1205        let messages = bulky_tool_history(4, 3_000);
1206        let once = clear_old_tool_results(&messages, 1, 1, 1_000, false);
1207        let twice = clear_old_tool_results(&once, 1, 1, 1_000, false);
1208        let stubs_once = once.iter().filter(|m| is_cleared_stub(m)).count();
1209        let stubs_twice = twice.iter().filter(|m| is_cleared_stub(m)).count();
1210        assert_eq!(stubs_once, stubs_twice);
1211    }
1212
1213    #[test]
1214    fn clear_old_tool_results_leaves_durable_history_unchanged() {
1215        let messages = bulky_tool_history(3, 2_500);
1216        let snapshot: Vec<String> = messages.iter().map(|m| m.content.as_text().into_owned()).collect();
1217        let request_messages = clear_old_tool_results(&messages, 1, 1, 1, false);
1218        let after: Vec<String> = messages.iter().map(|m| m.content.as_text().into_owned()).collect();
1219        assert_eq!(snapshot, after, "durable history must not be mutated");
1220        assert!(
1221            request_messages
1222                .iter()
1223                .filter(|m| m.role == MessageRole::Tool)
1224                .any(is_cleared_stub),
1225            "request messages should carry stubs"
1226        );
1227    }
1228
1229    #[test]
1230    fn local_tool_result_clearing_gate_mirrors_native_edits() {
1231        use super::should_apply_local_tool_result_clearing as gate;
1232        assert!(gate("zai", false, true), "non-Anthropic uses local clearing");
1233        assert!(gate("openai", false, true), "OpenAI uses local clearing");
1234        assert!(gate("anthropic", false, true), "Anthropic without edits falls back to local");
1235        assert!(!gate("anthropic", true, true), "Anthropic with context_edits uses native clear_tool_uses only");
1236        assert!(!gate("zai", false, false), "disabled config never clears");
1237    }
1238
1239    #[test]
1240    fn clear_old_tool_results_stub_is_valid_json_for_non_ascii_tool_names() {
1241        let mut messages = bulky_tool_history(3, 2_500);
1242        // Index 2 is the first tool response (call_0).
1243        messages[2].origin_tool = Some("读取文件".to_string());
1244        messages[2].tool_call_id = Some("call_ünïcode".to_string());
1245        let cleared = clear_old_tool_results(&messages, 1, 1, 1, false);
1246        let stub = cleared
1247            .iter()
1248            .filter(|m| is_cleared_stub(m))
1249            .find(|m| m.content.as_text().contains("call_ünïcode"))
1250            .expect("stub for the renamed call");
1251        let parsed: serde_json::Value =
1252            serde_json::from_str(stub.content.as_text().as_ref()).expect("stub must be valid JSON");
1253        assert_eq!(parsed["tool"], "读取文件");
1254        assert_eq!(parsed["tool_call_id"], "call_ünïcode");
1255    }
1256
1257    #[test]
1258    fn clear_old_tool_results_clear_tool_inputs_covers_freeform_text() {
1259        let mut messages = bulky_tool_history(3, 2_500);
1260        // Freeform custom tool payload lives in `text`, not function.arguments.
1261        if let Some(call) = messages
1262            .get_mut(1)
1263            .and_then(|m| m.tool_calls.as_mut())
1264            .and_then(|calls| calls.first_mut())
1265        {
1266            call.text = Some("raw freeform payload".to_string());
1267            call.thought_signature = Some("sig".to_string());
1268        }
1269        let cleared = clear_old_tool_results(&messages, 1, 1, 1, true);
1270        let call = cleared
1271            .get(1)
1272            .and_then(|m| m.tool_calls.as_ref())
1273            .and_then(|calls| calls.first())
1274            .expect("call preserved");
1275        assert_eq!(call.text.as_deref(), Some(CLEARED_TOOL_INPUT_PLACEHOLDER));
1276        assert!(call.thought_signature.is_none());
1277    }
1278}