Skip to main content

atomcode_core/conversation/
mod.rs

1pub mod message;
2pub mod turn;
3
4/// Number of recent messages kept at full fidelity during compression.
5/// The compression path condenses everything BEFORE the last
6/// `KEEP_MESSAGES` messages into a one-line-per-round summary.
7///
8/// Consumed by `build_compression_content` (producer) and by any
9/// `CtxBuilder` impl that needs to preserve the same "keep recent"
10/// semantics when formulating its compression plan.
11pub(crate) const KEEP_MESSAGES: usize = 20;
12
13use crate::tool::{ToolCall, ToolCallBuffer, ToolResult};
14use message::{Message, MessageContent, Role};
15use turn::{TurnStatus, TurnTracker};
16
17/// Context budget statistics for logging/debugging.
18#[derive(Debug, Clone, Default)]
19pub struct ContextStats {
20    pub system_tokens: usize,
21    /// Tokens actually sent to the LLM (excluding system prompt).
22    pub sent_tokens: usize,
23    /// Tokens dropped (oldest turns removed to fit context window).
24    pub dropped_tokens: usize,
25    pub total_messages: usize,
26}
27
28#[derive(Debug)]
29pub struct Conversation {
30    pub messages: Vec<Message>,
31    pub stream_buffer: Option<String>,
32    pub tool_call_buffer: Option<ToolCallBuffer>,
33    pub turn_tracker: TurnTracker,
34    /// Cold zone: FIFO queue of compressed history summaries (max 3).
35    /// Each entry is an LLM-generated summary of older turns.
36    pub cold_summaries: Vec<String>,
37}
38
39impl Default for Conversation {
40    fn default() -> Self {
41        Self {
42            messages: Vec::new(),
43            stream_buffer: None,
44            tool_call_buffer: None,
45            turn_tracker: TurnTracker::new(),
46            cold_summaries: Vec::new(),
47        }
48    }
49}
50
51impl Conversation {
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Load conversation history from disk. Never fails — returns empty on any error.
57    pub fn load(path: &std::path::Path) -> Self {
58        let data = match std::fs::read_to_string(path) {
59            Ok(d) => d,
60            Err(_) => return Self::default(),
61        };
62
63        // Try parsing, if corrupted just start fresh
64        let messages = match serde_json::from_str::<Vec<Message>>(&data) {
65            Ok(msgs) => msgs,
66            Err(_) => {
67                // Corrupted history — backup and start fresh
68                let backup = path.with_extension("json.bak");
69                let _ = std::fs::rename(path, &backup);
70                return Self::default();
71            }
72        };
73
74        let turn_tracker = TurnTracker::rebuild(&messages);
75        Self {
76            messages,
77            stream_buffer: None,
78            tool_call_buffer: None,
79            turn_tracker,
80            cold_summaries: Vec::new(),
81        }
82    }
83
84    /// Save conversation history to disk atomically (write to temp, then rename).
85    pub fn save(&self, path: &std::path::Path) {
86        if let Some(parent) = path.parent() {
87            let _ = std::fs::create_dir_all(parent);
88        }
89        if let Ok(data) = serde_json::to_string(&self.messages) {
90            let temp_path = path.with_extension("json.tmp");
91            if std::fs::write(&temp_path, &data).is_ok() {
92                let _ = std::fs::rename(&temp_path, path);
93            }
94        }
95    }
96
97    /// Path to history file.
98    pub fn history_path() -> std::path::PathBuf {
99        crate::config::Config::config_dir().join("history.json")
100    }
101
102    pub fn add_user_message(&mut self, content: &str) {
103        // Merge with last message if it's also User — prevents consecutive User messages
104        // which cause OpenAI-compatible APIs to return empty responses.
105        if let Some(last) = self.messages.last_mut() {
106            if matches!(last.role, Role::User) {
107                if let MessageContent::Text(ref mut text) = last.content {
108                    text.push('\n');
109                    text.push_str(content);
110                    return;
111                }
112            }
113        }
114        let idx = self.messages.len();
115        self.messages.push(Message::new(Role::User, content));
116        self.turn_tracker.on_user_message(idx);
117    }
118
119    /// Cancel the current active turn: save all conversation content up to
120    /// the moment of cancel. The user cancelled because they want to
121    /// redirect the model, not because they want to lose context — the
122    /// LLM needs to see what it already did so it can adjust.
123    ///
124    /// If the model issued tool calls that never got results, we append
125    /// `(cancelled)` ToolResult entries for them so the API doesn't
126    /// reject the message sequence with "messages illegal".
127    pub fn cancel_current_turn(&mut self) {
128        // Defensive: if no active turn, nothing to cancel.
129        let start_idx = match self.turn_tracker.active_turn() {
130            Some(turn) => turn.start_idx,
131            None => return,
132        };
133
134        // Finalize any in-flight stream buffer as an assistant message.
135        self.finalize_stream();
136        // Clear any partial tool-call buffer.
137        self.tool_call_buffer = None;
138
139        // Find tool calls that lack results and append (cancelled)
140        // results for them — keeps the API happy.
141        self.backfill_cancelled_tool_results();
142
143        // Update turn tracker
144        let msg_count = self.messages.len() - start_idx;
145        if let Some(current) = self.turn_tracker.turns.last_mut() {
146            current.msg_count = msg_count;
147            current.status = TurnStatus::Completed;
148        }
149    }
150
151    /// For any `AssistantWithToolCalls` in the current turn whose tool
152    /// calls lack a matching `ToolResult`, append a `(cancelled)` result.
153    /// This prevents "messages illegal" API errors from unpaired calls.
154    fn backfill_cancelled_tool_results(&mut self) {
155        // Collect call_ids that already have results (both inline and ref variants).
156        let mut seen_result_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
157        for msg in &self.messages {
158            if let Some(call_id) = msg.tool_result_call_id() {
159                seen_result_ids.insert(call_id.to_string());
160            }
161        }
162
163        let mut missing: Vec<(String, String)> = Vec::new();
164        for msg in &self.messages {
165            if let MessageContent::AssistantWithToolCalls { tool_calls, .. } = &msg.content {
166                for tc in tool_calls {
167                    if !seen_result_ids.contains(&tc.id) {
168                        missing.push((tc.id.clone(), tc.name.clone()));
169                    }
170                }
171            }
172        }
173
174        for (call_id, _name) in missing {
175            let idx = self.messages.len();
176            self.messages.push(Message {
177                role: Role::Tool,
178                content: MessageContent::ToolResult(ToolResult {
179                    call_id,
180                    output: "(cancelled)".into(),
181                    success: false,
182                }),
183            });
184            self.turn_tracker.on_message_added(idx);
185        }
186    }
187
188    /// Cancel the current active turn AND remove the user message.
189    /// Used on Error exits where leaving an orphan user message (no
190    /// assistant reply) would cause weak models to return 0 tokens on
191    /// the next turn — two consecutive User messages with no intervening
192    /// Assistant confuses OpenAI-compatible APIs.
193    pub fn cancel_current_turn_including_user(&mut self) {
194        if let Some(turn) = self.turn_tracker.active_turn() {
195            let start_idx = turn.start_idx;
196            // Clear in-flight buffers before truncating messages.
197            self.stream_buffer = None;
198            self.tool_call_buffer = None;
199            // Remove all messages from this turn (user message + any assistant/tool messages)
200            self.messages.truncate(start_idx);
201            // Remove the turn from tracker
202            self.turn_tracker.turns.pop();
203        }
204    }
205
206    pub fn push_delta(&mut self, delta: &str) {
207        match &mut self.stream_buffer {
208            Some(buf) => buf.push_str(delta),
209            None => self.stream_buffer = Some(delta.to_string()),
210        }
211    }
212
213    /// Clear the stream buffer without finalizing (used when text output
214    /// is actually a malformed tool call that will be re-processed).
215    pub fn clear_stream_buffer(&mut self) {
216        self.stream_buffer = None;
217    }
218
219    pub fn finalize_stream(&mut self) {
220        if let Some(content) = self.stream_buffer.take() {
221            let Some(content) = clean_assistant_text(&content) else {
222                return;
223            };
224            let idx = self.messages.len();
225            self.messages.push(Message::new(Role::Assistant, content));
226            self.turn_tracker.on_message_added(idx);
227        }
228    }
229
230    pub fn add_assistant_tool_calls(
231        &mut self,
232        text: Option<&str>,
233        tool_calls: Vec<ToolCall>,
234        reasoning: Option<&str>,
235    ) {
236        self.add_assistant_tool_calls_with_thinking(text, tool_calls, reasoning, Vec::new());
237    }
238
239    /// Like `add_assistant_tool_calls` but additionally stores Anthropic
240    /// extended-thinking content blocks (text + signature pairs). The
241    /// blocks must be echoed verbatim on subsequent requests when the
242    /// upstream is Anthropic-style and thinking is enabled — otherwise
243    /// the next request gets `400 The content[].thinking in the thinking
244    /// mode must be passed back to the API`. Other provider paths
245    /// (OpenAI / Ollama) ignore this field via `..` destructuring, so
246    /// leaving it populated is harmless across cross-provider switches.
247    pub fn add_assistant_tool_calls_with_thinking(
248        &mut self,
249        text: Option<&str>,
250        tool_calls: Vec<ToolCall>,
251        reasoning: Option<&str>,
252        thinking_blocks: Vec<crate::conversation::message::ThinkingBlock>,
253    ) {
254        let idx = self.messages.len();
255        self.messages.push(Message {
256            role: Role::Assistant,
257            content: MessageContent::AssistantWithToolCalls {
258                text: text.map(|s| s.to_string()),
259                tool_calls,
260                reasoning_content: reasoning.map(|s| s.to_string()),
261                thinking_blocks,
262            },
263        });
264        self.turn_tracker.on_message_added(idx);
265    }
266
267    pub fn add_tool_result(&mut self, result: ToolResult) {
268        let idx = self.messages.len();
269        self.messages.push(Message {
270            role: Role::Tool,
271            content: MessageContent::ToolResult(result),
272        });
273        self.turn_tracker.on_message_added(idx);
274    }
275
276    pub fn finalize_stream_with_tool_call(&mut self, tool_call: ToolCall, reasoning: Option<&str>) {
277        let text = self
278            .stream_buffer
279            .take()
280            .and_then(|s| clean_assistant_text(&s));
281        self.add_assistant_tool_calls(text.as_deref(), vec![tool_call], reasoning);
282    }
283
284    /// Finalize the current stream buffer with multiple tool calls at once (multi-tool support).
285    /// `reasoning` carries thinking-model reasoning_content accumulated during the stream;
286    /// it's stored on the message so the send-side policy can echo it back when the
287    /// provider demands (see `ReasoningPolicy`).
288    pub fn finalize_stream_with_tool_calls(
289        &mut self,
290        tool_calls: &[ToolCall],
291        reasoning: Option<&str>,
292    ) {
293        self.finalize_stream_with_tool_calls_and_thinking(tool_calls, reasoning, Vec::new());
294    }
295
296    /// Variant that additionally records Anthropic extended-thinking
297    /// blocks for echo-back. See `add_assistant_tool_calls_with_thinking`.
298    pub fn finalize_stream_with_tool_calls_and_thinking(
299        &mut self,
300        tool_calls: &[ToolCall],
301        reasoning: Option<&str>,
302        thinking_blocks: Vec<crate::conversation::message::ThinkingBlock>,
303    ) {
304        let text = self
305            .stream_buffer
306            .take()
307            .and_then(|s| clean_assistant_text(&s));
308        self.add_assistant_tool_calls_with_thinking(
309            text.as_deref(),
310            tool_calls.to_vec(),
311            reasoning,
312            thinking_blocks,
313        );
314    }
315
316    pub fn to_provider_messages(&self, system_prompt: &str) -> Vec<Message> {
317        let mut msgs = Vec::with_capacity(self.messages.len() + 1);
318        msgs.push(Message::new(Role::System, system_prompt));
319        msgs.extend(self.messages.iter().cloned());
320        msgs
321    }
322
323    /// Like to_provider_messages but only sends the last `window` messages.
324    /// Ensures the window starts at a valid boundary — never in the middle
325    /// of a tool_call/tool_result pair (which causes API "messages illegal" errors).
326    pub fn to_provider_messages_windowed(
327        &self,
328        system_prompt: &str,
329        window: usize,
330    ) -> Vec<Message> {
331        let mut start = self.messages.len().saturating_sub(window);
332
333        // Scan forward to find a valid start position:
334        // - Skip ToolResult messages at the start (they need a preceding AssistantWithToolCalls)
335        // - Skip AssistantWithToolCalls without their following ToolResults
336        while start < self.messages.len() {
337            match &self.messages[start].content {
338                MessageContent::ToolResult(_) | MessageContent::ToolResultRef(_) => {
339                    // Orphan tool result — skip it
340                    start += 1;
341                }
342                _ => break,
343            }
344        }
345
346        // Also ensure we start on a User or System message if possible
347        // (safest boundary for the API)
348        let original_start = start;
349        while start < self.messages.len() {
350            if matches!(self.messages[start].role, Role::User | Role::System) {
351                break;
352            }
353            start += 1;
354            // Don't go too far — if we can't find a user message within 5, use original
355            if start > original_start + 5 {
356                start = original_start;
357                break;
358            }
359        }
360
361        let mut msgs = Vec::with_capacity(self.messages.len() - start + 1);
362        msgs.push(Message::new(Role::System, system_prompt));
363        msgs.extend(self.messages[start..].iter().cloned());
364        msgs
365    }
366
367    /// Apply compression: store summary in cold zone, remove old messages.
368    /// `remove_count` = number of messages from the front to remove.
369    /// (Changed from turn-based to message-based to support single-user-message
370    /// sessions where turn_tracker has only 1-2 turns but 30+ messages.)
371    ///
372    /// ── CRITICAL INVARIANT ──
373    /// After compression:
374    /// - All surviving turns must have: start_idx < new_messages.len()
375    /// - All surviving turns must have: end_idx() <= new_messages.len()
376    /// - All surviving turns must have: msg_count > 0
377    /// These invariants prevent underflow in on_user_message(msg_idx).
378    pub fn apply_compression(&mut self, remove_count: usize, summary: String) {
379        if remove_count == 0 || summary.is_empty() {
380            return;
381        }
382
383        // Add to cold zone (FIFO, max 3)
384        self.cold_summaries.push(summary);
385        while self.cold_summaries.len() > 3 {
386            self.cold_summaries.remove(0);
387        }
388
389        // Remove old messages from the front
390        let remove_end = remove_count.min(self.messages.len());
391        self.messages.drain(..remove_end);
392
393        let new_msg_len = self.messages.len();
394
395        // Re-index turn tracker: rebuild with strict validation and invariant enforcement.
396        // This replaces the previous retain logic which had edge cases causing underflow.
397        let mut surviving_turns = Vec::new();
398
399        for turn in self.turn_tracker.turns.drain(..) {
400            let turn_end = turn.end_idx();
401
402            // Skip turns entirely within the drained range (before remove_end)
403            if turn_end <= remove_end {
404                continue;
405            }
406
407            // Calculate new indices for surviving turns
408            let new_start = if turn.start_idx < remove_end {
409                // Turn partially overlaps the drain: restart at index 0
410                0
411            } else {
412                // Turn is entirely after remove_end: shift backwards
413                turn.start_idx - remove_end
414            };
415
416            // Calculate new message count
417            let new_count = if turn.start_idx < remove_end {
418                // Partial overlap: count only messages after remove_end
419                turn_end - remove_end
420            } else {
421                // No overlap: count unchanged
422                turn.msg_count
423            };
424
425            // INVARIANT ENFORCEMENT:
426            // Clamp indices to valid range in case of edge cases or corrupted state
427            let new_count = new_count.min(new_msg_len.saturating_sub(new_start));
428
429            // Only include turns with at least one message
430            if new_count > 0 && new_start < new_msg_len {
431                surviving_turns.push(turn::Turn {
432                    start_idx: new_start,
433                    msg_count: new_count,
434                    status: turn.status,
435                    summary: turn.summary,
436                });
437            }
438        }
439
440        self.turn_tracker.turns = surviving_turns;
441    }
442}
443
444/// Strip trailing duplicate content from model output.
445/// Strip leaked reasoning that wasn't wrapped in <think> tags.
446/// MiniMax and some models output their internal reasoning as plain text
447/// before the actual response, separated by blank lines. Pattern:
448///   "要求.../需要.../这个问题..." (reasoning) \n\n "actual reply"
449/// We detect this by checking if the first paragraph looks like self-analysis
450/// and strip it, keeping only the final response.
451fn strip_leaked_reasoning(text: &str) -> String {
452    let trimmed = text.trim();
453    // Only process short text-only responses (not code/tool output)
454    if trimmed.len() > 1000 || trimmed.contains("```") {
455        return text.to_string();
456    }
457
458    // Split into paragraphs (separated by blank lines)
459    let paragraphs: Vec<&str> = trimmed
460        .split("\n\n")
461        .map(|p| p.trim())
462        .filter(|p| !p.is_empty())
463        .collect();
464
465    if paragraphs.len() < 2 {
466        return text.to_string();
467    }
468
469    // Check if first paragraph is reasoning (self-analysis patterns)
470    let first = paragraphs[0];
471    let reasoning_markers = [
472        "要求",
473        "需要",
474        "这个问题",
475        "用户",
476        "根据规则",
477        "我应该",
478        "让我",
479        "分析",
480        "涉及到",
481        "敏感",
482        "回避",
483        "I need to",
484        "I should",
485        "Let me",
486        "The user",
487    ];
488    let is_reasoning = reasoning_markers
489        .iter()
490        .any(|m| first.starts_with(m) || first.contains(m));
491
492    if is_reasoning {
493        // Keep only the last paragraph(s) — the actual response
494        // Find the first paragraph that doesn't look like reasoning
495        let mut start = paragraphs.len() - 1;
496        for (i, p) in paragraphs.iter().enumerate().skip(1) {
497            let still_reasoning = reasoning_markers
498                .iter()
499                .any(|m| p.starts_with(m) || p.contains(m));
500            if !still_reasoning {
501                start = i;
502                break;
503            }
504        }
505        return paragraphs[start..].join("\n\n");
506    }
507
508    text.to_string()
509}
510
511/// Weak models sometimes repeat their summary verbatim at the end.
512/// Strategy: find a repeated heading/marker line and truncate at the second occurrence.
513fn dedup_trailing_repeat(text: &str) -> String {
514    let text = text.trim_end();
515    if text.len() < 100 {
516        return text.to_string();
517    }
518
519    let lines: Vec<&str> = text.lines().collect();
520    if lines.len() < 6 {
521        return text.to_string();
522    }
523
524    // Look for repeated marker lines: headings (**, ##) or key phrases.
525    // If a distinctive line appears twice, the second occurrence starts the duplicate.
526    // Only check lines in the first half as potential repeat starts.
527    let half = lines.len() / 2;
528    for i in 0..half {
529        let line = lines[i].trim();
530        // Must be a "distinctive" line (heading, bold marker, numbered item header)
531        if line.len() < 8 {
532            continue;
533        }
534        let is_marker = line.starts_with("**")
535            || line.starts_with("##")
536            || line.starts_with("1.")
537            || line.starts_with("1、");
538        if !is_marker {
539            continue;
540        }
541
542        // Look for this same line in the second half
543        for j in half..lines.len() {
544            let other = lines[j].trim();
545            if other == line {
546                // Found repeat marker. Verify: at least 3 lines after j should ~match lines after i.
547                let match_count = lines[i..]
548                    .iter()
549                    .zip(lines[j..].iter())
550                    .filter(|(a, b)| a.trim() == b.trim())
551                    .count();
552                let remaining = lines.len() - j;
553                // If >60% of remaining lines match, it's a duplicate
554                if remaining >= 3 && match_count * 100 / remaining >= 60 {
555                    return lines[..j].join("\n");
556                }
557            }
558        }
559    }
560
561    text.to_string()
562}
563
564/// Apply the full assistant-text cleaning chain. Returns `None` when the
565/// content should be dropped instead of committed to history (empty after
566/// stripping, or corrupted bytes from provider stream failure). Used by
567/// every `finalize_stream*` entry point so all three paths share the
568/// same drop policy.
569fn clean_assistant_text(raw: &str) -> Option<String> {
570    // Strip thinking-model artifacts. `<think>` and `<|im_*|>` are model-
571    // template tokens that occasionally leak into the visible content
572    // (provider didn't filter them, or they crossed a chunk boundary).
573    let stripped = raw
574        .replace("<think>", "")
575        .replace("</think>", "")
576        .replace("<|im_start|>", "")
577        .replace("<|im_end|>", "");
578    // Strip orphan Qwen/GLM XML tool-call residue. `ToolCallStreamFilter`
579    // (turn/runner.rs) suppresses well-formed `<tool_call>...</tool_call>`
580    // blocks during streaming, but only when the markers are PAIRED. When
581    // the model dribbles out unpaired closes (`</tool_call>`,
582    // `</arg_value>`, etc.) — observed on glm-5.1 going off the rails on
583    // reasoning-heavy questions, e.g. 2026-05-05 atomgr 14:31:48 — those
584    // residual tags pass straight through the filter and land in the
585    // assistant text. Strip them here so they don't poison the next
586    // turn's context (the model would see its own broken markup as
587    // prior conversation and double down).
588    let stripped = strip_orphan_tool_call_xml(&stripped);
589    // Strip leaked reasoning: MiniMax/DeepSeek sometimes output reasoning
590    // as plain text (no `<think>` tag) followed by the actual response.
591    // Detect by looking for the pattern: `要求/需要/让我/用户...`
592    // (analysis) → blank line → actual reply.
593    let stripped = strip_leaked_reasoning(&stripped);
594    let stripped = dedup_trailing_repeat(&stripped);
595    if stripped.trim().is_empty() {
596        return None;
597    }
598    if looks_corrupted(&stripped).is_some() {
599        // Letting corrupted bytes land in history poisons every
600        // subsequent turn — the model sees its own garbage as prior
601        // context and either echoes more garbage or derails. Drop
602        // silently: writing to stderr leaks into the TUI render area
603        // (atomcode-tuix doesn't redirect/capture stderr), polluting
604        // the input box. The turn loop sees an empty assistant turn
605        // and the user can `/retry` or switch models.
606        return None;
607    }
608    Some(stripped)
609}
610
611/// Strip orphan Qwen/GLM XML tool-call markup from a finalised assistant
612/// message. Companion to `ToolCallStreamFilter` (turn/runner.rs) which
613/// suppresses well-formed `<tool_call>...</tool_call>` blocks at stream
614/// time but ONLY when the open and close are paired. When a model
615/// dribbles out unpaired close tags (`</tool_call>`, `</arg_value>`,
616/// etc.) without a preceding `<tool_call>` opener, the stream filter
617/// stays in `inside=false` state and lets them through as plain text.
618///
619/// This function runs at finalize time on the cumulative assistant
620/// content. It removes:
621///   - `<tool_name>X</tool_name>` and `<arg_key>X</arg_key>` and
622///     `<arg_value>X</arg_value>` paired sub-elements (with their
623///     contents, since those contents are tool-call payloads, not
624///     prose)
625///   - any `<tool_call>` and `</tool_call>` tokens left after the
626///     paired-element sweep (could be orphan opens, orphan closes, or
627///     the wrappers around already-stripped sub-elements)
628///
629/// Conservative bail-out: if the input contains no closing tags from
630/// this set, return the input unchanged. Real prose and code virtually
631/// never contain `</tool_call>` etc. as literal text, so the false-
632/// positive risk on legitimate content is near-zero.
633fn strip_orphan_tool_call_xml(text: &str) -> String {
634    if !text.contains("</tool_call>")
635        && !text.contains("</tool_name>")
636        && !text.contains("</arg_key>")
637        && !text.contains("</arg_value>")
638    {
639        return text.to_string();
640    }
641
642    let mut out = text.to_string();
643
644    // Strip paired sub-elements first, since their inner content is
645    // tool-call payload (file paths, args, etc.) and would otherwise
646    // become orphan prose after the wrapper tags are removed.
647    for tag in &["tool_name", "arg_key", "arg_value"] {
648        let open = format!("<{}>", tag);
649        let close = format!("</{}>", tag);
650        loop {
651            let Some(o) = out.find(&open) else { break };
652            let after_open = o + open.len();
653            let Some(c_rel) = out[after_open..].find(&close) else {
654                // Unmatched open — drop the bare open token and keep
655                // looking. Don't take any subsequent text since we
656                // can't tell where the intended payload ends.
657                out.replace_range(o..after_open, "");
658                continue;
659            };
660            let c_end = after_open + c_rel + close.len();
661            out.replace_range(o..c_end, "");
662        }
663        // Sweep any remaining bare close tokens (orphan closes with no
664        // preceding open).
665        out = out.replace(&close, "");
666    }
667
668    // Finally remove the outer `<tool_call>` / `</tool_call>` wrappers,
669    // including any orphan ones. Done last so the inner cleanup above
670    // still anchors on the wrapper boundaries when they were paired.
671    out = out.replace("<tool_call>", "").replace("</tool_call>", "");
672
673    out
674}
675
676/// Detect output that almost-certainly came from a corrupted provider stream
677/// (binary bytes decoded as UTF-8, mojibake from wrong encoding, KV-cache
678/// poisoning after timeout/retry, etc.) and should NOT be committed to
679/// conversation history.
680///
681/// Returns `Some(reason)` when the text is corrupted; `None` when it looks
682/// like real model output. Conservative by design: only fires on
683/// unambiguously non-textual signals. False positives here would silently
684/// drop legitimate responses, which is far worse than letting one garbage
685/// turn through.
686///
687/// Trigger context (2026-05-02 datalog evidence): `deepseek-v4-flash` at
688/// ~28K ctx after a successful file write hung 155s on the next turn,
689/// then the framework's stream-timeout retry returned `P<ďĎĎĎĎ` (UTF-8
690/// bytes 0x50 0x3C 0xC4 0x8F 0xC4 0x8E ×4 — Latin Extended-A mojibake of
691/// what was almost certainly raw binary in the provider's response
692/// buffer). Once that string lands in conversation history the next turn
693/// sees its own garbage as prior context and the session is unrecoverable.
694pub fn looks_corrupted(text: &str) -> Option<&'static str> {
695    let total_chars = text.chars().count();
696    if total_chars < 4 {
697        // Too short to judge confidently. The single-char `P` we've also
698        // observed slips through — caller's empty-check + any explicit
699        // /undo gate is the recovery path for that.
700        return None;
701    }
702
703    // Signal 1: U+FFFD replacement char density. The decoder marks bytes
704    // that didn't form valid UTF-8 with this; a single one in a long reply
705    // can be incidental, but >5% means decode failed broadly.
706    let replacement = text.chars().filter(|&c| c == '\u{FFFD}').count();
707    if replacement * 20 > total_chars {
708        return Some("replacement_char_density");
709    }
710
711    // Signal 2: C0 control bytes other than \t \n \r. Real model output
712    // never contains these; provider bug or transport corruption.
713    let bad_ctrl = text.chars().filter(|&c| {
714        let cp = c as u32;
715        cp < 0x20 && cp != 0x09 && cp != 0x0A && cp != 0x0D
716    }).count();
717    if bad_ctrl > 0 {
718        return Some("c0_control_bytes");
719    }
720
721    // Signal 3: Latin Extended-A density (U+0100-U+017F). The 2026-05-02
722    // `P<ďĎĎĎĎ` fixture is 7 chars with 5 in this range (71%). A real
723    // Czech/Slovak/Polish text mixes these with ASCII at low ratio
724    // (typically <15%); >40% density is mojibake of UTF-8 bytes
725    // 0xC4 0x8E etc. East Asian text is in U+4E00+ ranges and never
726    // triggers this signal. 40% threshold also rejects legitimate short
727    // Czech words like `čaj` (33%) while catching the fixture.
728    let latin_ext_a = text.chars().filter(|&c| {
729        let cp = c as u32;
730        (0x0100..=0x017F).contains(&cp)
731    }).count();
732    if latin_ext_a * 10 > total_chars * 4 {
733        return Some("latin_extended_a_mojibake");
734    }
735
736    // Signal 4: a single non-ASCII char repeating 5+ times in a row.
737    // Tokenizer/cache failure modes often emit one stuck token over and
738    // over. ASCII repetition is allowed (`====` separators, `....`
739    // ellipses, indentation runs). Run counter tallies `c == prev`
740    // events, so 5 consecutive identical chars produce run==4.
741    //
742    // Typographic chars (box drawing `─┌┐│═`, block elements `█▒░`,
743    // dashes `——`, ellipsis `…`, bullets `••`, middle dots `··`) are
744    // legitimate formatting — markdown tables and horizontal rules
745    // routinely repeat them dozens of times. Skipping these prevents
746    // false positives on perfectly valid model output (2026-05-03
747    // session: a markdown table with `─` × 30+ tripped this).
748    let mut prev = '\0';
749    let mut run = 0;
750    for c in text.chars() {
751        if c == prev && c as u32 > 0x7F && !is_typographic_repeat_safe(c) {
752            run += 1;
753            if run >= 4 {
754                return Some("stuck_non_ascii_repeat");
755            }
756        } else {
757            run = 0;
758            prev = c;
759        }
760    }
761
762    None
763}
764
765/// Code points where consecutive repetition is normal typography (markdown
766/// tables, horizontal rules, ASCII-art-style art, em-dash sequences) and
767/// should NOT trip the stuck-token corruption signal.
768fn is_typographic_repeat_safe(c: char) -> bool {
769    let cp = c as u32;
770    (0x2500..=0x257F).contains(&cp)        // Box Drawing (─│┌┐└┘├┤┬┴┼═║╔╗╚╝╠╣╦╩╬ etc.)
771        || (0x2580..=0x259F).contains(&cp) // Block Elements (█▒░▀▄ etc.)
772        || (0x2010..=0x2015).contains(&cp) // hyphens, en-dash, em-dash, horizontal bar
773        || cp == 0x2026                    // …  ellipsis
774        || cp == 0x2022                    // •  bullet
775        || cp == 0x25E6                    // ◦  white bullet
776        || cp == 0x00B7                    // ·  middle dot
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782    use crate::conversation::message::Role;
783
784    #[test]
785    fn test_new_conversation_is_empty() {
786        let conv = Conversation::new();
787        assert!(conv.messages.is_empty());
788        assert!(conv.stream_buffer.is_none());
789    }
790
791    #[test]
792    fn strip_orphan_xml_no_op_on_plain_prose() {
793        // Real prose without any tool-call markup is returned byte-identical.
794        let text = "答案是可以 ping 通 10.0.0.1,因为服务端用了 TUN 设备。";
795        assert_eq!(strip_orphan_tool_call_xml(text), text);
796    }
797
798    #[test]
799    fn strip_orphan_xml_no_op_on_rust_generics() {
800        // Code with `<>` syntax (Rust generics, HTML, etc.) doesn't match
801        // any of our specific tool-call tag names, so the early bail-out
802        // keeps it untouched.
803        let text = "let x: Vec<HashMap<String, Arc<dyn Trait>>> = vec![];\n\
804                    println!(\"<not_a_tag>\");";
805        assert_eq!(strip_orphan_tool_call_xml(text), text);
806    }
807
808    #[test]
809    fn strip_orphan_xml_handles_dribbled_close() {
810        // Reproduces 2026-05-05 atomgr 14:31:48: model emits a paired
811        // tool_call (suppressed by the stream filter), then dribbles out
812        // a SECOND set of arg_key/arg_value/close tags WITHOUT a leading
813        // <tool_call>. The stream filter in `inside=false` state passes
814        // those orphan markers straight through. The sanitiser must
815        // strip them at finalize time so they don't poison the assistant
816        // message stored in history.
817        let text = "actual_host, e\n);\npanic!(...);\n}</arg_value>\
818                    <arg_key>limit</arg_key><arg_value>100</arg_value>\
819                    <arg_key>offset</arg_key><arg_value>350</arg_value></tool_call>";
820        let cleaned = strip_orphan_tool_call_xml(text);
821        assert!(!cleaned.contains("</tool_call>"), "got: {}", cleaned);
822        assert!(!cleaned.contains("<arg_key>"), "got: {}", cleaned);
823        assert!(!cleaned.contains("</arg_value>"), "got: {}", cleaned);
824        // Real prose at the head survives.
825        assert!(cleaned.contains("actual_host, e"));
826        assert!(cleaned.contains("panic!"));
827    }
828
829    #[test]
830    fn strip_orphan_xml_consumes_paired_inner_payloads() {
831        // Inner payloads (file paths, args) are NOT prose — they're
832        // tool-call inputs that happened to leak into text. Strip the
833        // payload along with the wrapper, otherwise they'd survive as
834        // unattributed text fragments in history.
835        let text = "Sure, let me check\n<tool_name>read_file</tool_name>\
836                    <arg_key>path</arg_key><arg_value>/tmp/x.rs</arg_value>";
837        let cleaned = strip_orphan_tool_call_xml(text);
838        assert!(!cleaned.contains("read_file"), "got: {}", cleaned);
839        assert!(!cleaned.contains("/tmp/x.rs"), "got: {}", cleaned);
840        assert!(cleaned.contains("Sure, let me check"));
841    }
842
843    #[test]
844    fn strip_orphan_xml_through_clean_assistant_text() {
845        // End-to-end: clean_assistant_text applies the sanitiser as part
846        // of its pipeline. A message that is ONLY orphan markup must end
847        // up as None (empty after stripping → drop the message) so it
848        // doesn't poison the next turn's prior context.
849        let only_residue = "<arg_key>limit</arg_key>\
850                            <arg_value>100</arg_value></tool_call>";
851        assert_eq!(clean_assistant_text(only_residue), None);
852    }
853
854    #[test]
855    fn strip_orphan_xml_leaves_lone_open_alone_when_no_closes_present() {
856        // Conservative bail: when the input has NO close tags from our
857        // set, we leave it untouched. This protects prose that
858        // legitimately discusses the XML format (e.g. documentation
859        // strings mentioning the `<tool_name>` element by name) from
860        // being mangled. The failure mode that motivated the sanitiser
861        // is dribbled CLOSE tags; orphan opens-only is not seen in real
862        // datalogs, so the conservative bail is correct.
863        let text = "the field is called `<tool_name>` and contains the function name";
864        assert_eq!(strip_orphan_tool_call_xml(text), text);
865    }
866
867    #[test]
868    fn test_add_user_message() {
869        let mut conv = Conversation::new();
870        conv.add_user_message("hello");
871        assert_eq!(conv.messages.len(), 1);
872        assert!(matches!(conv.messages[0].role, Role::User));
873        assert_eq!(conv.messages[0].text().unwrap(), "hello");
874    }
875
876    #[test]
877    fn test_push_delta_creates_buffer() {
878        let mut conv = Conversation::new();
879        conv.push_delta("Hello");
880        assert_eq!(conv.stream_buffer, Some("Hello".to_string()));
881        conv.push_delta(" world");
882        assert_eq!(conv.stream_buffer, Some("Hello world".to_string()));
883    }
884
885    #[test]
886    fn test_finalize_stream() {
887        let mut conv = Conversation::new();
888        conv.push_delta("Hello world");
889        conv.finalize_stream();
890        assert!(conv.stream_buffer.is_none());
891        assert_eq!(conv.messages.len(), 1);
892        assert!(matches!(conv.messages[0].role, Role::Assistant));
893        assert_eq!(conv.messages[0].text().unwrap(), "Hello world");
894    }
895
896    #[test]
897    fn test_finalize_empty_buffer_is_noop() {
898        let mut conv = Conversation::new();
899        conv.finalize_stream();
900        assert!(conv.messages.is_empty());
901    }
902
903    #[test]
904    fn test_to_provider_messages_prepends_system() {
905        let mut conv = Conversation::new();
906        conv.add_user_message("hi");
907        let msgs = conv.to_provider_messages("You are helpful.");
908        assert_eq!(msgs.len(), 2);
909        assert!(matches!(msgs[0].role, Role::System));
910        assert_eq!(msgs[0].text().unwrap(), "You are helpful.");
911        assert!(matches!(msgs[1].role, Role::User));
912    }
913
914    #[test]
915    fn test_add_assistant_tool_calls() {
916        use crate::tool::ToolCall;
917        let mut conv = Conversation::new();
918        conv.add_user_message("hello");
919        let call = ToolCall {
920            id: "call_1".to_string(),
921            name: "read_file".to_string(),
922            arguments: r#"{"file_path":"/tmp/test"}"#.to_string(),
923        };
924        conv.add_assistant_tool_calls(Some("Let me read that file."), vec![call], None);
925        assert_eq!(conv.messages.len(), 2);
926        match &conv.messages[1].content {
927            MessageContent::AssistantWithToolCalls {
928                text, tool_calls, ..
929            } => {
930                assert_eq!(text.as_deref(), Some("Let me read that file."));
931                assert_eq!(tool_calls.len(), 1);
932            }
933            _ => panic!("Expected AssistantWithToolCalls"),
934        }
935    }
936
937    #[test]
938    fn test_add_tool_result() {
939        use crate::tool::ToolResult;
940        let mut conv = Conversation::new();
941        let result = ToolResult {
942            call_id: "call_1".to_string(),
943            output: "file contents".to_string(),
944            success: true,
945        };
946        conv.add_tool_result(result);
947        assert_eq!(conv.messages.len(), 1);
948        assert!(matches!(conv.messages[0].role, Role::Tool));
949    }
950
951    #[test]
952    fn test_finalize_stream_with_tool_call() {
953        use crate::tool::ToolCall;
954        let mut conv = Conversation::new();
955        conv.push_delta("Let me check...");
956        let call = ToolCall {
957            id: "call_1".to_string(),
958            name: "read_file".to_string(),
959            arguments: "{}".to_string(),
960        };
961        conv.finalize_stream_with_tool_call(call, None);
962        assert!(conv.stream_buffer.is_none());
963        assert_eq!(conv.messages.len(), 1);
964        match &conv.messages[0].content {
965            MessageContent::AssistantWithToolCalls {
966                text, tool_calls, ..
967            } => {
968                assert_eq!(text.as_deref(), Some("Let me check..."));
969                assert_eq!(tool_calls.len(), 1);
970            }
971            _ => panic!("Expected AssistantWithToolCalls"),
972        }
973    }
974
975    #[test]
976    fn test_cold_zone_fifo_max_3() {
977        let mut conv = Conversation::new();
978        conv.cold_summaries.push("summary 1".to_string());
979        conv.cold_summaries.push("summary 2".to_string());
980        conv.cold_summaries.push("summary 3".to_string());
981
982        // Create some turns so apply_compression has something to remove
983        for i in 0..4 {
984            conv.add_user_message(&format!("t{}", i));
985            conv.messages.push(Message::new(Role::Assistant, "ok"));
986            conv.turn_tracker.on_message_added(conv.messages.len() - 1);
987        }
988
989        conv.apply_compression(2, "summary 4".to_string());
990
991        // FIFO: oldest dropped, newest kept
992        assert_eq!(conv.cold_summaries.len(), 3);
993        assert_eq!(conv.cold_summaries[0], "summary 2");
994        assert_eq!(conv.cold_summaries[2], "summary 4");
995    }
996
997    #[test]
998    fn test_compression_then_add_user_message_no_underflow() {
999        let mut conv = Conversation::new();
1000
1001        // Build 2 turns (4 messages total)
1002        // Turn 1: User + Assistant response
1003        conv.add_user_message("task 1");
1004        assert_eq!(conv.turn_tracker.turns.len(), 1);
1005        conv.push_delta("response 1");
1006        conv.finalize_stream();
1007        conv.turn_tracker.complete_current(); // Mark as completed
1008
1009        // Turn 2: User + Assistant response
1010        conv.add_user_message("task 2");
1011        assert_eq!(conv.turn_tracker.turns.len(), 2);
1012        conv.push_delta("response 2");
1013        conv.finalize_stream();
1014        conv.turn_tracker.complete_current(); // Mark as completed
1015
1016        // Verify state before compression
1017        assert_eq!(conv.messages.len(), 4);
1018        assert_eq!(
1019            conv.turn_tracker.turns[0].status,
1020            turn::TurnStatus::Completed
1021        );
1022        assert_eq!(
1023            conv.turn_tracker.turns[1].status,
1024            turn::TurnStatus::Completed
1025        );
1026        assert_eq!(conv.turn_tracker.turns[0].msg_count, 2);
1027        assert_eq!(conv.turn_tracker.turns[1].msg_count, 2);
1028
1029        // Compress: remove first 2 messages (covers first complete turn)
1030        conv.apply_compression(2, "Turn 1 summary".to_string());
1031
1032        // Verify compression result
1033        assert_eq!(conv.messages.len(), 2);
1034        assert_eq!(conv.turn_tracker.turns.len(), 1);
1035        assert_eq!(conv.turn_tracker.turns[0].start_idx, 0);
1036        assert_eq!(conv.turn_tracker.turns[0].msg_count, 2);
1037
1038        // CRITICAL: Add a new user message. This should NOT panic with underflow.
1039        // Before the fix, this could panic if Turn indices were corrupted.
1040        conv.add_user_message("task 3");
1041
1042        // Verify final state
1043        assert_eq!(conv.messages.len(), 3);
1044        assert_eq!(conv.turn_tracker.turns.len(), 2);
1045        assert_eq!(
1046            conv.turn_tracker.turns[0].status,
1047            turn::TurnStatus::Completed
1048        );
1049        assert_eq!(conv.turn_tracker.turns[0].msg_count, 2);
1050        assert_eq!(conv.turn_tracker.turns[1].status, turn::TurnStatus::Active);
1051        assert_eq!(conv.turn_tracker.turns[1].start_idx, 2);
1052    }
1053
1054    /// Test partial turn compression (a turn spans the compression boundary).
1055    /// This is more complex: when a turn is partially within the removed range,
1056    /// its indices must be recalculated correctly.
1057    #[test]
1058    fn test_compression_partial_turn_overlap() {
1059        let mut conv = Conversation::new();
1060
1061        // Build 2 turns:
1062        // Turn 1: msg 0 (user), msg 1 (assistant)
1063        // Turn 2: msg 2 (user), msg 3 (assistant), msg 4 (tool result)
1064        conv.add_user_message("task 1");
1065        conv.push_delta("response 1");
1066        conv.finalize_stream();
1067        conv.turn_tracker.complete_current();
1068
1069        conv.add_user_message("task 2");
1070        conv.push_delta("response 2");
1071        conv.finalize_stream();
1072        use crate::tool::ToolResult;
1073        conv.add_tool_result(ToolResult {
1074            call_id: "call_1".to_string(),
1075            output: "result".to_string(),
1076            success: true,
1077        });
1078        conv.turn_tracker.complete_current();
1079
1080        assert_eq!(conv.messages.len(), 5);
1081        assert_eq!(conv.turn_tracker.turns.len(), 2);
1082        assert_eq!(conv.turn_tracker.turns[0].msg_count, 2);
1083        assert_eq!(conv.turn_tracker.turns[1].msg_count, 3);
1084
1085        // Compress: remove first 3 messages
1086        // This removes Turn 1 entirely and partially overlaps Turn 2
1087        // (Turn 2 starts at 2, ends at 5, so 1 message survives at index 0)
1088        conv.apply_compression(3, "Old history".to_string());
1089
1090        // Verify compression result
1091        assert_eq!(conv.messages.len(), 2);
1092        assert_eq!(conv.turn_tracker.turns.len(), 1);
1093        let surviving_turn = &conv.turn_tracker.turns[0];
1094        assert_eq!(surviving_turn.start_idx, 0);
1095        assert_eq!(surviving_turn.msg_count, 2); // (5 - 3) messages remain
1096        assert_eq!(surviving_turn.end_idx(), 2);
1097
1098        // Add a new user message: should not panic
1099        conv.add_user_message("task 3");
1100
1101        // Verify invariants hold
1102        assert_eq!(conv.messages.len(), 3);
1103        assert_eq!(conv.turn_tracker.turns.len(), 2);
1104        assert_eq!(conv.turn_tracker.turns[0].msg_count, 2);
1105        assert_eq!(conv.turn_tracker.turns[1].start_idx, 2);
1106    }
1107
1108    /// Test aggressive compression that removes almost everything.
1109    /// Ensure Turns are corrected and no crashes occur.
1110    #[test]
1111    fn test_compression_removes_most_messages() {
1112        let mut conv = Conversation::new();
1113
1114        // Build 3 turns (6 messages): 2 + 2 + 2
1115        for i in 1..=3 {
1116            conv.add_user_message(&format!("task {}", i));
1117            conv.push_delta(&format!("response {}", i));
1118            conv.finalize_stream();
1119            conv.turn_tracker.complete_current();
1120        }
1121        assert_eq!(conv.messages.len(), 6);
1122        assert_eq!(conv.turn_tracker.turns.len(), 3);
1123
1124        // Aggressively compress: keep only the last message
1125        conv.apply_compression(5, "Entire history summarized".to_string());
1126
1127        // Only the last assistant message (msg 5) should remain
1128        assert_eq!(conv.messages.len(), 1);
1129        assert_eq!(conv.turn_tracker.turns.len(), 1);
1130        assert_eq!(conv.turn_tracker.turns[0].start_idx, 0);
1131        assert_eq!(conv.turn_tracker.turns[0].msg_count, 1);
1132
1133        // Add a new user message: should not crash
1134        conv.add_user_message("new task");
1135
1136        assert_eq!(conv.messages.len(), 2);
1137        assert_eq!(conv.turn_tracker.turns.len(), 2);
1138        assert_eq!(conv.turn_tracker.turns[1].start_idx, 1);
1139    }
1140
1141    /// Test edge case: compression amount exceeds total messages.
1142    /// apply_compression should clamp safely.
1143    #[test]
1144    fn test_compression_exceeds_message_count() {
1145        let mut conv = Conversation::new();
1146
1147        conv.add_user_message("hello");
1148        conv.push_delta("response");
1149        conv.finalize_stream();
1150
1151        assert_eq!(conv.messages.len(), 2);
1152
1153        // Try to remove 100 messages (more than exist)
1154        conv.apply_compression(100, "Summary".to_string());
1155
1156        // Should remove all messages
1157        assert_eq!(conv.messages.is_empty(), true);
1158        assert_eq!(conv.turn_tracker.turns.is_empty(), true);
1159
1160        // Add a new user message after clearing: should work
1161        conv.add_user_message("new message");
1162        assert_eq!(conv.messages.len(), 1);
1163        assert_eq!(conv.turn_tracker.turns.len(), 1);
1164    }
1165
1166    // ── looks_corrupted: garbage detection ──
1167
1168    /// 2026-05-02 datalog `atomgr/2026-05-02_10-37-51.md` line 402:
1169    /// deepseek-v4-flash returned `P<ďĎĎĎĎ` after a 155s stream timeout
1170    /// + retry — UTF-8 decoding of `0x50 0x3C 0xC4 0x8F 0xC4 0x8E ×4`,
1171    /// almost certainly raw binary in the provider's response buffer.
1172    /// Without this guard the string lands in conversation history and
1173    /// poisons every subsequent turn.
1174    #[test]
1175    fn looks_corrupted_catches_real_datalog_fixture() {
1176        assert_eq!(
1177            looks_corrupted("P<ďĎĎĎĎ"),
1178            Some("latin_extended_a_mojibake")
1179        );
1180    }
1181
1182    #[test]
1183    fn looks_corrupted_catches_replacement_char_density() {
1184        let s: String = (0..10).map(|_| '\u{FFFD}').collect();
1185        assert_eq!(looks_corrupted(&s), Some("replacement_char_density"));
1186    }
1187
1188    #[test]
1189    fn looks_corrupted_catches_c0_control_bytes() {
1190        // \x01 \x02 \x03 = SOH STX ETX, never appear in real text
1191        assert_eq!(
1192            looks_corrupted("hello\x01world"),
1193            Some("c0_control_bytes")
1194        );
1195    }
1196
1197    #[test]
1198    fn looks_corrupted_catches_stuck_repeat() {
1199        // Five consecutive non-ASCII chars from a tokenizer/cache failure
1200        let s = format!("hi {}", "中".repeat(5));
1201        assert_eq!(looks_corrupted(&s), Some("stuck_non_ascii_repeat"));
1202    }
1203
1204    #[test]
1205    fn looks_corrupted_passes_normal_chinese() {
1206        // CJK is U+4E00+, well outside Latin Extended-A
1207        assert_eq!(looks_corrupted("你好,让我帮你写代码"), None);
1208    }
1209
1210    #[test]
1211    fn looks_corrupted_passes_normal_english() {
1212        assert_eq!(
1213            looks_corrupted("Let me read the file and figure out what changed."),
1214            None
1215        );
1216    }
1217
1218    #[test]
1219    fn looks_corrupted_passes_short_czech() {
1220        // Real Czech word `čaj` (tea) — 33% latin-ext-a but legitimate
1221        assert_eq!(looks_corrupted("čaj"), None);
1222        // 4 chars at 25% — below 40% threshold
1223        assert_eq!(looks_corrupted("čajov"), None);
1224    }
1225
1226    #[test]
1227    fn looks_corrupted_passes_ascii_separators() {
1228        // `=====` and `....` patterns are legitimate, ASCII repetition
1229        // is allowed even past the 5-char run threshold
1230        assert_eq!(looks_corrupted("====================="), None);
1231        assert_eq!(looks_corrupted("Done. ......"), None);
1232    }
1233
1234    /// 2026-05-03 session: a markdown table from `deepseek-v4-flash` running
1235    /// on atomgr tripped Signal 4 because `─` (U+2500) repeated dozens of
1236    /// times across table borders. The whitelist prevents this false positive
1237    /// while still catching CJK / latin-ext-a stuck-token corruption.
1238    #[test]
1239    fn looks_corrupted_passes_markdown_table_borders() {
1240        // Box drawing — markdown table from real datalog
1241        let table = "┌───────────────────────┬──────────────────────────────────┐\n\
1242                     │ 文件                  │ 动作                             │\n\
1243                     ├───────────────────────┼──────────────────────────────────┤\n\
1244                     │ src/main.rs           │ CLI 改为子命令                   │\n\
1245                     └───────────────────────┴──────────────────────────────────┘";
1246        assert_eq!(looks_corrupted(table), None);
1247    }
1248
1249    #[test]
1250    fn looks_corrupted_passes_horizontal_rules_and_typography() {
1251        // Horizontal rules using box drawing, double, em-dash, ellipsis
1252        assert_eq!(looks_corrupted(&"─".repeat(80)), None);
1253        assert_eq!(looks_corrupted(&"═".repeat(40)), None);
1254        assert_eq!(looks_corrupted(&"━".repeat(40)), None);
1255        assert_eq!(looks_corrupted(&"—".repeat(20)), None); // em-dash
1256        assert_eq!(looks_corrupted(&"…".repeat(20)), None); // ellipsis
1257        assert_eq!(looks_corrupted(&"•".repeat(10)), None); // bullet
1258        // Block elements
1259        assert_eq!(looks_corrupted(&"█".repeat(20)), None);
1260    }
1261
1262    #[test]
1263    fn looks_corrupted_still_catches_real_cjk_corruption() {
1264        // CJK repetition is NOT in the whitelist — still flagged. This
1265        // is the actual stuck-token failure mode.
1266        assert_eq!(
1267            looks_corrupted(&format!("hi {}", "中".repeat(5))),
1268            Some("stuck_non_ascii_repeat")
1269        );
1270    }
1271
1272    #[test]
1273    fn looks_corrupted_too_short_returns_none() {
1274        // Below 4 chars: trim_empty handles the truly-empty case;
1275        // single chars like the 2nd datalog `P` slip through and rely
1276        // on /retry / model switch.
1277        assert_eq!(looks_corrupted("P"), None);
1278        assert_eq!(looks_corrupted("ok"), None);
1279    }
1280
1281    #[test]
1282    fn finalize_stream_drops_corrupted_output() {
1283        let mut conv = Conversation::new();
1284        conv.push_delta("P<ďĎĎĎĎ");
1285        conv.finalize_stream();
1286        // Corrupted text never reaches messages — history is preserved
1287        // clean and the next turn doesn't see the garbage as context.
1288        assert!(
1289            conv.messages.is_empty(),
1290            "corrupted assistant output must not be committed to history"
1291        );
1292        assert!(
1293            conv.stream_buffer.is_none(),
1294            "stream buffer must be drained even on drop"
1295        );
1296    }
1297
1298    // ── cancel_current_turn: preserves completed content (issue #260) ──
1299
1300    #[test]
1301    fn cancel_preserves_completed_assistant_text() {
1302        // Cancel after model has responded with text (no tool calls)
1303        let mut conv = Conversation::new();
1304        conv.add_user_message("创建 index.html");
1305        conv.push_delta("好的,我来帮你创建");
1306        conv.finalize_stream();
1307
1308        assert_eq!(conv.messages.len(), 2);
1309        conv.cancel_current_turn();
1310
1311        // User message + assistant text are both preserved
1312        assert_eq!(conv.messages.len(), 2);
1313        assert!(matches!(conv.messages[0].role, Role::User));
1314        assert!(matches!(conv.messages[1].role, Role::Assistant));
1315        assert_eq!(conv.messages[0].text().unwrap(), "创建 index.html");
1316        assert_eq!(conv.messages[1].text().unwrap(), "好的,我来帮你创建");
1317
1318        // Turn is completed
1319        assert_eq!(conv.turn_tracker.turns.len(), 1);
1320        assert_eq!(conv.turn_tracker.turns[0].status, TurnStatus::Completed);
1321        assert_eq!(conv.turn_tracker.turns[0].msg_count, 2);
1322    }
1323
1324    #[test]
1325    fn cancel_backfills_missing_tool_results() {
1326        // Cancel while model has issued tool calls but no results yet
1327        let mut conv = Conversation::new();
1328        conv.add_user_message("创建 index.html");
1329        conv.add_assistant_tool_calls(
1330            Some("creating file"),
1331            vec![ToolCall {
1332                id: "call_1".into(),
1333                name: "write_file".into(),
1334                arguments: "{}".into(),
1335            }],
1336            None,
1337        );
1338
1339        // user + assistant_with_tool_calls = 2, no result yet
1340        assert_eq!(conv.messages.len(), 2);
1341
1342        conv.cancel_current_turn();
1343
1344        // All messages preserved + (cancelled) result appended
1345        assert_eq!(conv.messages.len(), 3);
1346        assert!(matches!(conv.messages[0].role, Role::User));
1347        assert!(matches!(conv.messages[1].role, Role::Assistant));
1348        assert!(matches!(conv.messages[2].role, Role::Tool));
1349        if let MessageContent::ToolResult(r) = &conv.messages[2].content {
1350            assert!(!r.success);
1351            assert_eq!(r.output, "(cancelled)");
1352            assert_eq!(r.call_id, "call_1");
1353        } else {
1354            panic!("expected ToolResult");
1355        }
1356    }
1357
1358    #[test]
1359    fn cancel_preserves_completed_tool_pairs_and_backfills_incomplete() {
1360        // Model did read_file (complete), then started edit_file (no result)
1361        let mut conv = Conversation::new();
1362        conv.add_user_message("读取 main.rs 然后修改它");
1363
1364        conv.add_assistant_tool_calls(
1365            None,
1366            vec![ToolCall {
1367                id: "call_1".into(),
1368                name: "read_file".into(),
1369                arguments: r#"{"file_path":"main.rs"}"#.into(),
1370            }],
1371            None,
1372        );
1373        conv.add_tool_result(ToolResult {
1374            call_id: "call_1".into(),
1375            output: "fn main() {}".into(),
1376            success: true,
1377        });
1378
1379        conv.add_assistant_tool_calls(
1380            Some("editing file"),
1381            vec![ToolCall {
1382                id: "call_2".into(),
1383                name: "edit_file".into(),
1384                arguments: r#"{"file_path":"main.rs"}"#.into(),
1385            }],
1386            None,
1387        );
1388
1389        // user + atc1 + result1 + atc2 = 4
1390        assert_eq!(conv.messages.len(), 4);
1391
1392        conv.cancel_current_turn();
1393
1394        // All 4 preserved + 1 backfilled result for call_2 = 5
1395        assert_eq!(conv.messages.len(), 5);
1396        assert!(matches!(conv.messages[0].role, Role::User));
1397        assert!(matches!(conv.messages[1].role, Role::Assistant)); // atc1
1398        assert!(matches!(conv.messages[2].role, Role::Tool)); // result1
1399        assert!(matches!(conv.messages[3].role, Role::Assistant)); // atc2
1400        assert!(matches!(conv.messages[4].role, Role::Tool)); // backfilled result2
1401        if let MessageContent::ToolResult(r) = &conv.messages[4].content {
1402            assert_eq!(r.call_id, "call_2");
1403            assert!(!r.success);
1404        }
1405    }
1406
1407    #[test]
1408    fn cancel_preserves_previous_turns() {
1409        let mut conv = Conversation::new();
1410        conv.add_user_message("你好");
1411        conv.push_delta("你好!有什么可以帮你?");
1412        conv.finalize_stream();
1413        conv.turn_tracker.complete_current();
1414
1415        conv.add_user_message("创建 index.html");
1416        conv.push_delta("好的,我来创建...");
1417        conv.finalize_stream();
1418
1419        assert_eq!(conv.messages.len(), 4);
1420        conv.cancel_current_turn();
1421
1422        assert_eq!(conv.messages.len(), 4);
1423        assert_eq!(conv.turn_tracker.turns.len(), 2);
1424        assert_eq!(conv.turn_tracker.turns[0].status, TurnStatus::Completed);
1425        assert_eq!(conv.turn_tracker.turns[1].status, TurnStatus::Completed);
1426    }
1427
1428    #[test]
1429    fn cancel_then_follow_up_sees_completed_work() {
1430        let mut conv = Conversation::new();
1431        conv.add_user_message("创建 index.html");
1432        conv.add_assistant_tool_calls(
1433            Some("creating file"),
1434            vec![ToolCall {
1435                id: "call_1".into(),
1436                name: "write_file".into(),
1437                arguments: r#"{"file_path":"index.html","content":"hello"}"#.into(),
1438            }],
1439            None,
1440        );
1441        conv.add_tool_result(ToolResult {
1442            call_id: "call_1".into(),
1443            output: "File written successfully".into(),
1444            success: true,
1445        });
1446
1447        conv.cancel_current_turn();
1448        conv.add_user_message("不要删那行,改成 XXX");
1449
1450        let msgs = conv.to_provider_messages("You are helpful.");
1451        let all_text: String = msgs.iter().map(|m| m.text().unwrap_or("")).collect();
1452        assert!(
1453            all_text.contains("write_file") || all_text.contains("index.html"),
1454            "LLM must see what it already did"
1455        );
1456        assert!(all_text.contains("不要删那行"), "LLM must see the corrective prompt");
1457    }
1458
1459    #[test]
1460    fn cancel_finalizes_stream_buffer() {
1461        let mut conv = Conversation::new();
1462        conv.add_user_message("你好");
1463        conv.push_delta("你好!我是");
1464
1465        assert!(conv.stream_buffer.is_some());
1466        conv.cancel_current_turn();
1467
1468        assert!(conv.stream_buffer.is_none());
1469        assert_eq!(conv.messages.len(), 2);
1470        assert!(matches!(conv.messages[1].role, Role::Assistant));
1471    }
1472
1473    #[test]
1474    fn cancel_including_user_removes_everything() {
1475        let mut conv = Conversation::new();
1476        conv.add_user_message("hello");
1477        conv.push_delta("partial response");
1478        conv.finalize_stream();
1479
1480        conv.cancel_current_turn_including_user();
1481
1482        assert!(conv.messages.is_empty());
1483        assert!(conv.turn_tracker.turns.is_empty());
1484    }
1485
1486    #[test]
1487    fn cancel_including_user_preserves_previous_turns() {
1488        let mut conv = Conversation::new();
1489        conv.add_user_message("你好");
1490        conv.push_delta("你好!");
1491        conv.finalize_stream();
1492        conv.turn_tracker.complete_current();
1493
1494        conv.add_user_message("创建文件");
1495        conv.push_delta("好的...");
1496        conv.finalize_stream();
1497
1498        conv.cancel_current_turn_including_user();
1499
1500        assert_eq!(conv.messages.len(), 2);
1501        assert_eq!(conv.turn_tracker.turns.len(), 1);
1502    }
1503
1504    #[test]
1505    fn cancel_on_empty_conversation_is_noop() {
1506        let mut conv = Conversation::new();
1507        conv.cancel_current_turn();
1508        assert!(conv.messages.is_empty());
1509    }
1510
1511    #[test]
1512    fn cancel_backfills_multi_tool_calls_partial_results() {
1513        let mut conv = Conversation::new();
1514        conv.add_user_message("读取 a.rs 和 b.rs");
1515
1516        conv.add_assistant_tool_calls(
1517            None,
1518            vec![
1519                ToolCall {
1520                    id: "call_1".into(),
1521                    name: "read_file".into(),
1522                    arguments: r#"{"file_path":"a.rs"}"#.into(),
1523                },
1524                ToolCall {
1525                    id: "call_2".into(),
1526                    name: "read_file".into(),
1527                    arguments: r#"{"file_path":"b.rs"}"#.into(),
1528                },
1529            ],
1530            None,
1531        );
1532        conv.add_tool_result(ToolResult {
1533            call_id: "call_1".into(),
1534            output: "a content".into(),
1535            success: true,
1536        });
1537        // call_2 has no result yet
1538
1539        conv.cancel_current_turn();
1540
1541        // All preserved + 1 backfilled result for call_2
1542        assert_eq!(conv.messages.len(), 4); // user + atc + result1 + result2(cancelled)
1543        if let MessageContent::ToolResult(r) = &conv.messages[3].content {
1544            assert_eq!(r.call_id, "call_2");
1545            assert!(!r.success);
1546            assert_eq!(r.output, "(cancelled)");
1547        }
1548    }
1549
1550    /// When a tool result is stored as ToolResultRef (disk-cached large
1551    /// output), backfill must recognise it as "has result" and NOT append
1552    /// a duplicate (cancelled) entry.
1553    #[test]
1554    fn cancel_backfill_recognises_tool_result_ref() {
1555        use crate::tool::result_store::ToolResultRef;
1556
1557        let mut conv = Conversation::new();
1558        conv.add_user_message("读取 big_file.rs");
1559
1560        conv.add_assistant_tool_calls(
1561            Some("reading"),
1562            vec![ToolCall {
1563                id: "call_1".into(),
1564                name: "read_file".into(),
1565                arguments: r#"{"file_path":"big_file.rs"}"#.into(),
1566            }],
1567            None,
1568        );
1569
1570        // Result stored as ToolResultRef (large output on disk)
1571        let idx = conv.messages.len();
1572        conv.messages.push(Message {
1573            role: Role::Tool,
1574            content: MessageContent::ToolResultRef(ToolResultRef {
1575                call_id: "call_1".into(),
1576                hash: "abc123".into(),
1577                summary: "500 lines of Rust code".into(),
1578                byte_size: 20_000,
1579                success: true,
1580            }),
1581        });
1582        conv.turn_tracker.on_message_added(idx);
1583
1584        // Another tool call with NO result yet
1585        conv.add_assistant_tool_calls(
1586            None,
1587            vec![ToolCall {
1588                id: "call_2".into(),
1589                name: "edit_file".into(),
1590                arguments: r#"{"file_path":"big_file.rs"}"#.into(),
1591            }],
1592            None,
1593        );
1594
1595        conv.cancel_current_turn();
1596
1597        // call_1 (ToolResultRef) must NOT get a duplicate backfilled result.
1598        // Only call_2 (no result) should get a backfilled (cancelled).
1599        assert_eq!(conv.messages.len(), 5); // user + atc1 + ref_result1 + atc2 + backfilled_result2
1600
1601        // Verify the backfilled result is for call_2 only
1602        if let MessageContent::ToolResult(r) = &conv.messages[4].content {
1603            assert_eq!(r.call_id, "call_2");
1604            assert!(!r.success);
1605            assert_eq!(r.output, "(cancelled)");
1606        } else {
1607            panic!("expected ToolResult for call_2");
1608        }
1609    }
1610
1611    /// Double-cancel is a no-op: calling cancel_current_turn twice should
1612    /// not panic or corrupt state.
1613    #[test]
1614    fn cancel_double_cancel_is_noop() {
1615        let mut conv = Conversation::new();
1616        conv.add_user_message("hello");
1617        conv.push_delta("world");
1618        conv.finalize_stream();
1619
1620        conv.cancel_current_turn();
1621        assert_eq!(conv.messages.len(), 2);
1622
1623        // Second cancel — turn already Completed, should be a no-op
1624        conv.cancel_current_turn();
1625        assert_eq!(conv.messages.len(), 2);
1626    }
1627
1628    // ── Review round 2: additional test coverage ──
1629
1630    /// After cancel_current_turn_including_user, calling cancel_current_turn
1631    /// on the now-absent active turn is a safe no-op (turn was popped).
1632    #[test]
1633    fn cancel_after_including_user_is_noop() {
1634        let mut conv = Conversation::new();
1635        conv.add_user_message("hello");
1636        conv.push_delta("partial");
1637        conv.finalize_stream();
1638
1639        conv.cancel_current_turn_including_user();
1640        assert!(conv.messages.is_empty());
1641
1642        // No active turn — cancel should be harmless
1643        conv.cancel_current_turn();
1644        assert!(conv.messages.is_empty());
1645        assert!(conv.turn_tracker.turns.is_empty());
1646    }
1647
1648    /// cancel_current_turn_including_user clears stream_buffer so it
1649    /// doesn't leak into the next turn.
1650    #[test]
1651    fn cancel_including_user_clears_stream_buffer() {
1652        let mut conv = Conversation::new();
1653        conv.add_user_message("hello");
1654        conv.push_delta("partial response still streaming");
1655
1656        assert!(conv.stream_buffer.is_some());
1657
1658        conv.cancel_current_turn_including_user();
1659
1660        assert!(conv.stream_buffer.is_none(), "stream_buffer must be cleared");
1661        assert!(conv.messages.is_empty());
1662    }
1663
1664    /// cancel_current_turn_including_user clears tool_call_buffer so it
1665    /// doesn't leak into the next turn.
1666    #[test]
1667    fn cancel_including_user_clears_tool_call_buffer() {
1668        use crate::tool::ToolCallBuffer;
1669        let mut conv = Conversation::new();
1670        conv.add_user_message("hello");
1671
1672        // Simulate a partial tool call buffer
1673        conv.tool_call_buffer = Some(ToolCallBuffer {
1674            id: "call_partial".into(),
1675            name: "bash".into(),
1676            arguments: r#"{"command":"ls"}"#.into(),
1677            hint_sent: false,
1678        });
1679
1680        assert!(conv.tool_call_buffer.is_some());
1681
1682        conv.cancel_current_turn_including_user();
1683
1684        assert!(conv.tool_call_buffer.is_none(), "tool_call_buffer must be cleared");
1685    }
1686
1687    /// cancel_current_turn_including_user on a completed turn (no active turn)
1688    /// is a no-op — messages and turns are untouched.
1689    #[test]
1690    fn cancel_including_user_on_completed_turn_is_noop() {
1691        let mut conv = Conversation::new();
1692        conv.add_user_message("hello");
1693        conv.push_delta("world");
1694        conv.finalize_stream();
1695        conv.turn_tracker.complete_current();
1696
1697        assert_eq!(conv.messages.len(), 2);
1698        assert_eq!(conv.turn_tracker.turns.len(), 1);
1699
1700        // Turn is Completed, not Active — cancel_including_user does nothing
1701        conv.cancel_current_turn_including_user();
1702
1703        assert_eq!(conv.messages.len(), 2, "completed turn must not be removed");
1704        assert_eq!(conv.turn_tracker.turns.len(), 1);
1705    }
1706
1707    /// After cancel_including_user, the conversation is clean enough to
1708    /// start a new turn and produce valid provider messages.
1709    #[test]
1710    fn cancel_including_user_then_new_turn_produces_valid_messages() {
1711        let mut conv = Conversation::new();
1712        conv.add_user_message("bad prompt");
1713        conv.push_delta("bad response");
1714        conv.finalize_stream();
1715
1716        conv.cancel_current_turn_including_user();
1717
1718        // Start a fresh turn
1719        conv.add_user_message("good prompt");
1720        conv.push_delta("good response");
1721        conv.finalize_stream();
1722        conv.turn_tracker.complete_current();
1723
1724        let msgs = conv.to_provider_messages("system");
1725        // System + User + Assistant = 3
1726        assert_eq!(msgs.len(), 3);
1727        assert!(matches!(msgs[0].role, Role::System));
1728        assert!(matches!(msgs[1].role, Role::User));
1729        assert!(matches!(msgs[2].role, Role::Assistant));
1730    }
1731
1732    /// backfill: all results are ToolResultRef (no inline ToolResult at all).
1733    /// None of them should be mistakenly backfilled as (cancelled).
1734    #[test]
1735    fn cancel_backfill_all_tool_result_refs() {
1736        use crate::tool::result_store::ToolResultRef;
1737
1738        let mut conv = Conversation::new();
1739        conv.add_user_message("读取大文件");
1740
1741        conv.add_assistant_tool_calls(
1742            None,
1743            vec![
1744                ToolCall {
1745                    id: "call_1".into(),
1746                    name: "read_file".into(),
1747                    arguments: r#"{"file_path":"a.rs"}"#.into(),
1748                },
1749                ToolCall {
1750                    id: "call_2".into(),
1751                    name: "read_file".into(),
1752                    arguments: r#"{"file_path":"b.rs"}"#.into(),
1753                },
1754            ],
1755            None,
1756        );
1757
1758        // Both results as ToolResultRef
1759        for (call_id, summary) in [("call_1", "a.rs content"), ("call_2", "b.rs content")] {
1760            let idx = conv.messages.len();
1761            conv.messages.push(Message {
1762                role: Role::Tool,
1763                content: MessageContent::ToolResultRef(ToolResultRef {
1764                    call_id: call_id.into(),
1765                    hash: format!("hash_{}", call_id),
1766                    summary: summary.into(),
1767                    byte_size: 10_000,
1768                    success: true,
1769                }),
1770            });
1771            conv.turn_tracker.on_message_added(idx);
1772        }
1773
1774        conv.cancel_current_turn();
1775
1776        // No backfill needed: both calls have results (as refs).
1777        // user + atc + ref1 + ref2 = 4
1778        assert_eq!(conv.messages.len(), 4);
1779    }
1780
1781    /// backfill: mix of ToolResult and ToolResultRef in the same turn.
1782    /// Only the truly unpaired call gets backfilled.
1783    #[test]
1784    fn cancel_backfill_mixed_result_types() {
1785        use crate::tool::result_store::ToolResultRef;
1786
1787        let mut conv = Conversation::new();
1788        conv.add_user_message("读取文件并编辑");
1789
1790        conv.add_assistant_tool_calls(
1791            None,
1792            vec![
1793                ToolCall {
1794                    id: "call_1".into(),
1795                    name: "read_file".into(),
1796                    arguments: r#"{"file_path":"x.rs"}"#.into(),
1797                },
1798                ToolCall {
1799                    id: "call_2".into(),
1800                    name: "bash".into(),
1801                    arguments: r#"{"command":"make"}"#.into(),
1802                },
1803                ToolCall {
1804                    id: "call_3".into(),
1805                    name: "edit_file".into(),
1806                    arguments: r#"{"file_path":"x.rs"}"#.into(),
1807                },
1808            ],
1809            None,
1810        );
1811
1812        // call_1: inline ToolResult
1813        conv.add_tool_result(ToolResult {
1814            call_id: "call_1".into(),
1815            output: "file content".into(),
1816            success: true,
1817        });
1818
1819        // call_2: ToolResultRef
1820        let idx = conv.messages.len();
1821        conv.messages.push(Message {
1822            role: Role::Tool,
1823            content: MessageContent::ToolResultRef(ToolResultRef {
1824                call_id: "call_2".into(),
1825                hash: "hash_call_2".into(),
1826                summary: "make output".into(),
1827                byte_size: 50_000,
1828                success: true,
1829            }),
1830        });
1831        conv.turn_tracker.on_message_added(idx);
1832
1833        // call_3: no result yet
1834
1835        conv.cancel_current_turn();
1836
1837        // user + atc + result1 + ref2 + backfilled_result3 = 5
1838        assert_eq!(conv.messages.len(), 5);
1839
1840        // Only call_3 should be backfilled
1841        if let MessageContent::ToolResult(r) = &conv.messages[4].content {
1842            assert_eq!(r.call_id, "call_3");
1843            assert!(!r.success);
1844            assert_eq!(r.output, "(cancelled)");
1845        } else {
1846            panic!("expected ToolResult for call_3");
1847        }
1848    }
1849
1850    /// End-to-end: after cancel with backfilled results, the message
1851    /// sequence sent to the provider is API-legal (no orphan tool results,
1852    /// every ATC has matching results, sequence starts with System).
1853    #[test]
1854    fn cancel_then_provider_messages_are_api_legal() {
1855        let mut conv = Conversation::new();
1856        conv.add_user_message("读取 main.rs 然后修改它");
1857
1858        conv.add_assistant_tool_calls(
1859            Some("reading file"),
1860            vec![ToolCall {
1861                id: "call_1".into(),
1862                name: "read_file".into(),
1863                arguments: r#"{"file_path":"main.rs"}"#.into(),
1864            }],
1865            None,
1866        );
1867        conv.add_tool_result(ToolResult {
1868            call_id: "call_1".into(),
1869            output: "fn main() {}".into(),
1870            success: true,
1871        });
1872
1873        conv.add_assistant_tool_calls(
1874            Some("editing file"),
1875            vec![ToolCall {
1876                id: "call_2".into(),
1877                name: "edit_file".into(),
1878                arguments: r#"{"file_path":"main.rs"}"#.into(),
1879            }],
1880            None,
1881        );
1882
1883        // Cancel before call_2 got a result
1884        conv.cancel_current_turn();
1885
1886        // Verify API legality: System, User, ATC, ToolResult, ATC, ToolResult(cancelled)
1887        let msgs = conv.to_provider_messages("You are helpful.");
1888        assert!(matches!(msgs[0].role, Role::System));
1889        assert!(matches!(msgs[1].role, Role::User));
1890        // msgs[2] should be AssistantWithToolCalls (read_file)
1891        assert!(matches!(msgs[2].role, Role::Assistant));
1892        // msgs[3] should be ToolResult (call_1)
1893        assert!(matches!(msgs[3].role, Role::Tool));
1894        // msgs[4] should be AssistantWithToolCalls (edit_file)
1895        assert!(matches!(msgs[4].role, Role::Assistant));
1896        // msgs[5] should be ToolResult (call_2 cancelled)
1897        assert!(matches!(msgs[5].role, Role::Tool));
1898
1899        // Verify every ATC's tool calls have matching results
1900        let mut expected_call_ids: Vec<String> = Vec::new();
1901        for msg in &msgs {
1902            if let MessageContent::AssistantWithToolCalls { tool_calls, .. } = &msg.content {
1903                for tc in tool_calls {
1904                    expected_call_ids.push(tc.id.clone());
1905                }
1906            }
1907        }
1908        let mut got_call_ids: Vec<String> = Vec::new();
1909        for msg in &msgs {
1910            if let Some(id) = msg.tool_result_call_id() {
1911                got_call_ids.push(id.to_string());
1912            }
1913        }
1914        assert_eq!(
1915            expected_call_ids, got_call_ids,
1916            "every tool call must have a matching result"
1917        );
1918    }
1919
1920    /// End-to-end: after cancel, user sends a follow-up message, and the
1921    /// full provider message sequence is API-legal across both turns.
1922    #[test]
1923    fn cancel_then_follow_up_full_sequence_api_legal() {
1924        let mut conv = Conversation::new();
1925
1926        // Turn 1 (completed normally)
1927        conv.add_user_message("你好");
1928        conv.push_delta("你好!");
1929        conv.finalize_stream();
1930        conv.turn_tracker.complete_current();
1931
1932        // Turn 2 (cancelled mid-tool)
1933        conv.add_user_message("读取 main.rs");
1934        conv.add_assistant_tool_calls(
1935            None,
1936            vec![ToolCall {
1937                id: "call_1".into(),
1938                name: "read_file".into(),
1939                arguments: "{}".into(),
1940            }],
1941            None,
1942        );
1943        conv.cancel_current_turn();
1944
1945        // Turn 3 (follow-up after cancel)
1946        conv.add_user_message("不要修改那行");
1947        conv.push_delta("好的,我只添加新代码");
1948        conv.finalize_stream();
1949        conv.turn_tracker.complete_current();
1950
1951        let msgs = conv.to_provider_messages("system");
1952
1953        // Verify no consecutive User messages
1954        for i in 1..msgs.len() {
1955            if matches!(msgs[i].role, Role::User) {
1956                assert!(
1957                    !matches!(msgs[i - 1].role, Role::User),
1958                    "consecutive User messages at index {}-{} are illegal",
1959                    i - 1,
1960                    i
1961                );
1962            }
1963        }
1964
1965        // Verify every ATC has matching results
1966        let mut expected: Vec<String> = Vec::new();
1967        for msg in &msgs {
1968            if let MessageContent::AssistantWithToolCalls { tool_calls, .. } = &msg.content {
1969                for tc in tool_calls {
1970                    expected.push(tc.id.clone());
1971                }
1972            }
1973        }
1974        let mut got: Vec<String> = Vec::new();
1975        for msg in &msgs {
1976            if let Some(id) = msg.tool_result_call_id() {
1977                got.push(id.to_string());
1978            }
1979        }
1980        assert_eq!(expected, got, "all tool calls must have matching results");
1981    }
1982
1983    /// cancel_current_turn properly updates turn tracker: turn transitions
1984    /// from Active to Completed with correct msg_count after backfill.
1985    #[test]
1986    fn cancel_updates_turn_tracker_correctly() {
1987        let mut conv = Conversation::new();
1988        conv.add_user_message("hello");
1989        conv.add_assistant_tool_calls(
1990            None,
1991            vec![ToolCall {
1992                id: "call_1".into(),
1993                name: "bash".into(),
1994                arguments: "{}".into(),
1995            }],
1996            None,
1997        );
1998        // Before cancel: 2 messages, turn is Active
1999        assert_eq!(conv.turn_tracker.turns.len(), 1);
2000        assert_eq!(conv.turn_tracker.turns[0].status, TurnStatus::Active);
2001        assert_eq!(conv.turn_tracker.turns[0].msg_count, 2);
2002
2003        conv.cancel_current_turn();
2004
2005        // After cancel: 3 messages (user + atc + backfilled), turn is Completed
2006        assert_eq!(conv.messages.len(), 3);
2007        assert_eq!(conv.turn_tracker.turns[0].status, TurnStatus::Completed);
2008        assert_eq!(
2009            conv.turn_tracker.turns[0].msg_count, 3,
2010            "msg_count must include the backfilled result"
2011        );
2012    }
2013
2014    /// cancel_current_turn_including_user properly removes the turn from
2015    /// the tracker (not just marks it).
2016    #[test]
2017    fn cancel_including_user_removes_turn_not_just_marks() {
2018        let mut conv = Conversation::new();
2019
2020        // Previous turn
2021        conv.add_user_message("hello");
2022        conv.push_delta("hi");
2023        conv.finalize_stream();
2024        conv.turn_tracker.complete_current();
2025
2026        // Active turn
2027        conv.add_user_message("bad");
2028        conv.push_delta("oops");
2029        conv.finalize_stream();
2030
2031        assert_eq!(conv.turn_tracker.turns.len(), 2);
2032
2033        conv.cancel_current_turn_including_user();
2034
2035        // Only the previous turn survives
2036        assert_eq!(conv.turn_tracker.turns.len(), 1);
2037        assert_eq!(conv.turn_tracker.turns[0].status, TurnStatus::Completed);
2038    }
2039}