pub mod message;
pub mod turn;
/// Number of recent messages kept at full fidelity during compression.
/// The compression path condenses everything BEFORE the last
/// `KEEP_MESSAGES` messages into a one-line-per-round summary.
///
/// Consumed by `build_compression_content` (producer) and by any
/// `CtxBuilder` impl that needs to preserve the same "keep recent"
/// semantics when formulating its compression plan.
pub(crate) const KEEP_MESSAGES: usize = 20;
use crate::tool::{ToolCall, ToolCallBuffer, ToolResult};
use message::{Message, MessageContent, Role};
use turn::{TurnStatus, TurnTracker};
/// Context budget statistics for logging/debugging.
#[derive(Debug, Clone, Default)]
pub struct ContextStats {
pub system_tokens: usize,
/// Tokens actually sent to the LLM (excluding system prompt).
pub sent_tokens: usize,
/// Tokens dropped (oldest turns removed to fit context window).
pub dropped_tokens: usize,
pub total_messages: usize,
}
#[derive(Debug)]
pub struct Conversation {
pub messages: Vec<Message>,
pub stream_buffer: Option<String>,
pub tool_call_buffer: Option<ToolCallBuffer>,
pub turn_tracker: TurnTracker,
/// Cold zone: FIFO queue of compressed history summaries (max 3).
/// Each entry is an LLM-generated summary of older turns.
pub cold_summaries: Vec<String>,
}
impl Default for Conversation {
fn default() -> Self {
Self {
messages: Vec::new(),
stream_buffer: None,
tool_call_buffer: None,
turn_tracker: TurnTracker::new(),
cold_summaries: Vec::new(),
}
}
}
impl Conversation {
pub fn new() -> Self {
Self::default()
}
/// Number of real (non-synthetic) user prompts. This is the maximum `N`
/// accepted by `/undo N`, and what bare `/undo` targets (the last prompt).
///
/// NOTE: `N` counts only real prompts so it matches the prompts the user
/// actually typed. The scrollback dividers drawn by `replay_session`
/// (`modals/session_picker.rs`) go before EVERY `Role::User` message,
/// including synthetic injections (compaction markers, plan-mode notes) —
/// so in the rare case of a standalone synthetic user message, the visible
/// divider count can run one ahead of `N` for later turns. This is the
/// intentional Phase A trade-off; tighter alignment is deferred to Phase B.
pub fn prompt_count(&self) -> usize {
self.messages
.iter()
.filter(|m| matches!(m.role, Role::User) && !m.synthetic)
.count()
}
/// Roll conversation memory back to just before the `nth` (1-based) real
/// user prompt, removing that prompt and every message after it, then
/// rebuilding the turn tracker. Synthetic user injections (compaction
/// markers, plan-mode notes) are ignored when counting but ARE removed
/// when they fall after the cut. Any leading non-user messages before the
/// first prompt are preserved. In-flight stream/tool buffers are cleared.
///
/// Returns the removed prompt's text (for the UI to restore into the input
/// box), or `None` if `nth == 0` or `nth > prompt_count()`.
pub fn undo_to_prompt(&mut self, nth: usize) -> Option<String> {
if nth == 0 {
return None;
}
let start_idx = self
.messages
.iter()
.enumerate()
.filter(|(_, m)| matches!(m.role, Role::User) && !m.synthetic)
.map(|(i, _)| i)
.nth(nth - 1)?;
let restored = self.messages[start_idx].text().unwrap_or("").to_string();
self.stream_buffer = None;
self.tool_call_buffer = None;
self.messages.truncate(start_idx);
self.turn_tracker = TurnTracker::rebuild(&self.messages);
Some(restored)
}
/// Load conversation history from disk. Never fails — returns empty on any error.
pub fn load(path: &std::path::Path) -> Self {
let data = match std::fs::read_to_string(path) {
Ok(d) => d,
Err(_) => return Self::default(),
};
// Try parsing, if corrupted just start fresh
let messages = match serde_json::from_str::<Vec<Message>>(&data) {
Ok(msgs) => msgs,
Err(_) => {
// Corrupted history — backup and start fresh
let backup = path.with_extension("json.bak");
let _ = std::fs::rename(path, &backup);
return Self::default();
}
};
let turn_tracker = TurnTracker::rebuild(&messages);
Self {
messages,
stream_buffer: None,
tool_call_buffer: None,
turn_tracker,
cold_summaries: Vec::new(),
}
}
/// Save conversation history to disk atomically (write to temp, then rename).
pub fn save(&self, path: &std::path::Path) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(data) = serde_json::to_string(&self.messages) {
let temp_path = path.with_extension("json.tmp");
if std::fs::write(&temp_path, &data).is_ok() {
let _ = std::fs::rename(&temp_path, path);
}
}
}
/// Path to history file.
pub fn history_path() -> std::path::PathBuf {
crate::config::Config::config_dir().join("history.json")
}
pub fn add_user_message(&mut self, content: &str) {
// Merge with last message if it's also User — prevents consecutive User messages
// which cause OpenAI-compatible APIs to return empty responses.
if let Some(last) = self.messages.last_mut() {
if matches!(last.role, Role::User) {
if let MessageContent::Text(ref mut text) = last.content {
text.push('\n');
text.push_str(content);
return;
}
}
}
let idx = self.messages.len();
self.messages.push(Message::new(Role::User, content));
self.turn_tracker.on_user_message(idx);
}
/// Add an agent-authored synthetic message through the `Role::User`
/// channel — `[Additional context from user]`, `Output limit hit`
/// self-prompts, `[Context was compressed]` state summaries, etc.
/// See `Message.synthetic` doc for the full rationale.
///
/// Same API-compat merge behavior as `add_user_message` (avoids two
/// consecutive `Role::User` messages which break OpenAI-compatible
/// providers), BUT the merge preserves the EXISTING message's
/// `synthetic` flag — appending synthetic content to a real user
/// prompt doesn't reclassify the real prompt as synthetic. Only a
/// freshly-pushed message carries `synthetic = true`.
pub fn add_synthetic_user_message(&mut self, content: &str) {
if let Some(last) = self.messages.last_mut() {
if matches!(last.role, Role::User) {
if let MessageContent::Text(ref mut text) = last.content {
text.push('\n');
text.push_str(content);
return;
}
}
}
let idx = self.messages.len();
self.messages.push(Message::synthetic_user(content));
self.turn_tracker.on_user_message(idx);
}
/// Cancel the current active turn: save all conversation content up to
/// the moment of cancel. The user cancelled because they want to
/// redirect the model, not because they want to lose context — the
/// LLM needs to see what it already did so it can adjust.
///
/// If the model issued tool calls that never got results, we append
/// `(cancelled)` ToolResult entries for them so the API doesn't
/// reject the message sequence with "messages illegal".
pub fn cancel_current_turn(&mut self) {
// Defensive: if no active turn, nothing to cancel.
let start_idx = match self.turn_tracker.active_turn() {
Some(turn) => turn.start_idx,
None => return,
};
// Finalize any in-flight stream buffer as an assistant message.
self.finalize_stream();
// Clear any partial tool-call buffer.
self.tool_call_buffer = None;
// Find tool calls that lack results and append (cancelled)
// results for them — keeps the API happy.
self.backfill_cancelled_tool_results();
// Update turn tracker
let msg_count = self.messages.len() - start_idx;
if let Some(current) = self.turn_tracker.turns.last_mut() {
current.msg_count = msg_count;
current.status = TurnStatus::Completed;
}
}
/// For any `AssistantWithToolCalls` in the current turn whose tool
/// calls lack a matching `ToolResult`, append a `(cancelled)` result.
/// This prevents "messages illegal" API errors from unpaired calls.
fn backfill_cancelled_tool_results(&mut self) {
// Collect call_ids that already have results (both inline and ref variants).
let mut seen_result_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
for msg in &self.messages {
if let Some(call_id) = msg.tool_result_call_id() {
seen_result_ids.insert(call_id.to_string());
}
}
let mut missing: Vec<(String, String)> = Vec::new();
for msg in &self.messages {
if let MessageContent::AssistantWithToolCalls { tool_calls, .. } = &msg.content {
for tc in tool_calls {
if !seen_result_ids.contains(&tc.id) {
missing.push((tc.id.clone(), tc.name.clone()));
}
}
}
}
for (call_id, _name) in missing {
let idx = self.messages.len();
self.messages.push(Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id,
output: "(cancelled)".into(),
success: false,
}),
synthetic: false,
});
self.turn_tracker.on_message_added(idx);
}
}
/// Cancel the current active turn AND remove the user message.
/// Used on Error exits where leaving an orphan user message (no
/// assistant reply) would cause weak models to return 0 tokens on
/// the next turn — two consecutive User messages with no intervening
/// Assistant confuses OpenAI-compatible APIs.
pub fn cancel_current_turn_including_user(&mut self) {
if let Some(turn) = self.turn_tracker.active_turn() {
let start_idx = turn.start_idx;
// Clear in-flight buffers before truncating messages.
self.stream_buffer = None;
self.tool_call_buffer = None;
// Remove all messages from this turn (user message + any assistant/tool messages)
self.messages.truncate(start_idx);
// Remove the turn from tracker
self.turn_tracker.turns.pop();
}
}
pub fn push_delta(&mut self, delta: &str) {
match &mut self.stream_buffer {
Some(buf) => buf.push_str(delta),
None => self.stream_buffer = Some(delta.to_string()),
}
}
/// Clear the stream buffer without finalizing (used when text output
/// is actually a malformed tool call that will be re-processed).
pub fn clear_stream_buffer(&mut self) {
self.stream_buffer = None;
}
pub fn finalize_stream(&mut self) {
if let Some(content) = self.stream_buffer.take() {
let Some(content) = clean_assistant_text(&content) else {
return;
};
let idx = self.messages.len();
self.messages.push(Message::new(Role::Assistant, content));
self.turn_tracker.on_message_added(idx);
}
}
pub fn add_assistant_tool_calls(
&mut self,
text: Option<&str>,
tool_calls: Vec<ToolCall>,
reasoning: Option<&str>,
) {
self.add_assistant_tool_calls_with_thinking(text, tool_calls, reasoning, Vec::new());
}
/// Like `add_assistant_tool_calls` but additionally stores Anthropic
/// extended-thinking content blocks (text + signature pairs). The
/// blocks must be echoed verbatim on subsequent requests when the
/// upstream is Anthropic-style and thinking is enabled — otherwise
/// the next request gets `400 The content[].thinking in the thinking
/// mode must be passed back to the API`. Other provider paths
/// (OpenAI / Ollama) ignore this field via `..` destructuring, so
/// leaving it populated is harmless across cross-provider switches.
pub fn add_assistant_tool_calls_with_thinking(
&mut self,
text: Option<&str>,
tool_calls: Vec<ToolCall>,
reasoning: Option<&str>,
thinking_blocks: Vec<crate::conversation::message::ThinkingBlock>,
) {
let idx = self.messages.len();
self.messages.push(Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: text.map(|s| s.to_string()),
tool_calls,
reasoning_content: reasoning.map(|s| s.to_string()),
thinking_blocks,
},
synthetic: false,
});
self.turn_tracker.on_message_added(idx);
}
pub fn add_tool_result(&mut self, result: ToolResult) {
let idx = self.messages.len();
self.messages.push(Message {
role: Role::Tool,
content: MessageContent::ToolResult(result),
synthetic: false,
});
self.turn_tracker.on_message_added(idx);
}
pub fn finalize_stream_with_tool_call(&mut self, tool_call: ToolCall, reasoning: Option<&str>) {
let text = self
.stream_buffer
.take()
.and_then(|s| clean_assistant_text(&s));
self.add_assistant_tool_calls(text.as_deref(), vec![tool_call], reasoning);
}
/// Finalize the current stream buffer with multiple tool calls at once (multi-tool support).
/// `reasoning` carries thinking-model reasoning_content accumulated during the stream;
/// it's stored on the message so the send-side policy can echo it back when the
/// provider demands (see `ReasoningPolicy`).
pub fn finalize_stream_with_tool_calls(
&mut self,
tool_calls: &[ToolCall],
reasoning: Option<&str>,
) {
self.finalize_stream_with_tool_calls_and_thinking(tool_calls, reasoning, Vec::new());
}
/// Variant that additionally records Anthropic extended-thinking
/// blocks for echo-back. See `add_assistant_tool_calls_with_thinking`.
pub fn finalize_stream_with_tool_calls_and_thinking(
&mut self,
tool_calls: &[ToolCall],
reasoning: Option<&str>,
thinking_blocks: Vec<crate::conversation::message::ThinkingBlock>,
) {
let text = self
.stream_buffer
.take()
.and_then(|s| clean_assistant_text(&s));
self.add_assistant_tool_calls_with_thinking(
text.as_deref(),
tool_calls.to_vec(),
reasoning,
thinking_blocks,
);
}
pub fn to_provider_messages(&self, system_prompt: &str) -> Vec<Message> {
let mut msgs = Vec::with_capacity(self.messages.len() + 1);
msgs.push(Message::new(Role::System, system_prompt));
msgs.extend(self.messages.iter().cloned());
msgs
}
/// Like to_provider_messages but only sends the last `window` messages.
/// Ensures the window starts at a valid boundary — never in the middle
/// of a tool_call/tool_result pair (which causes API "messages illegal" errors).
pub fn to_provider_messages_windowed(
&self,
system_prompt: &str,
window: usize,
) -> Vec<Message> {
let mut start = self.messages.len().saturating_sub(window);
// Scan forward to find a valid start position:
// - Skip ToolResult messages at the start (they need a preceding AssistantWithToolCalls)
// - Skip AssistantWithToolCalls without their following ToolResults
while start < self.messages.len() {
match &self.messages[start].content {
MessageContent::ToolResult(_) | MessageContent::ToolResultRef(_) => {
// Orphan tool result — skip it
start += 1;
}
_ => break,
}
}
// Also ensure we start on a User or System message if possible
// (safest boundary for the API)
let original_start = start;
while start < self.messages.len() {
if matches!(self.messages[start].role, Role::User | Role::System) {
break;
}
start += 1;
// Don't go too far — if we can't find a user message within 5, use original
if start > original_start + 5 {
start = original_start;
break;
}
}
let mut msgs = Vec::with_capacity(self.messages.len() - start + 1);
msgs.push(Message::new(Role::System, system_prompt));
msgs.extend(self.messages[start..].iter().cloned());
msgs
}
/// Apply compression: store summary in cold zone, remove old messages.
/// `remove_count` = number of messages from the front to remove.
/// (Changed from turn-based to message-based to support single-user-message
/// sessions where turn_tracker has only 1-2 turns but 30+ messages.)
///
/// ── CRITICAL INVARIANT ──
/// After compression:
/// - All surviving turns must have: start_idx < new_messages.len()
/// - All surviving turns must have: end_idx() <= new_messages.len()
/// - All surviving turns must have: msg_count > 0
/// These invariants prevent underflow in on_user_message(msg_idx).
pub fn apply_compression(&mut self, remove_count: usize, summary: String) {
if remove_count == 0 || summary.is_empty() {
return;
}
// Add to cold zone (FIFO, max 3)
self.cold_summaries.push(summary);
while self.cold_summaries.len() > 3 {
self.cold_summaries.remove(0);
}
let remove_end = remove_count.min(self.messages.len());
// ── Original-prompt preservation (af3d1ac7 follow-up) ──
// The first non-synthetic User message anchors the session;
// /resume renders the timeline as "User asked X → assistant did
// Y …". If compression drains across that message, /resume
// opens on the agent's tool_call instead of the human prompt
// and the model has no anchor for what it was supposed to do.
//
// af3d1ac7 protected this in `hard_truncate_to_target` (tier 3
// fallback) only. Task-boundary cleanup and `/compact` go
// through this `apply_compression`, which used to do a blind
// `drain(..remove_end)` — and was therefore the DOMINANT path
// that still ate the original prompt. Mirrors the sacred-set
// treatment in `agent/mod.rs:3189-3232`.
let first_real_user_idx = self
.messages
.iter()
.position(|m| m.role == message::Role::User && !m.synthetic);
if let Some(fr_idx) = first_real_user_idx {
if fr_idx < remove_end {
// Sacred message lives inside the drain window. Carve
// it out: drain everything in the window EXCEPT the
// sacred message. Drain high-to-low so the lower index
// stays valid through the first removal.
if fr_idx + 1 < remove_end {
self.messages.drain(fr_idx + 1..remove_end);
}
if fr_idx > 0 {
self.messages.drain(0..fr_idx);
}
// Turn re-index after a non-contiguous drain is
// brittle in the in-place form below; rebuild from
// scratch — TurnTracker::rebuild walks the messages
// and yields the same shape `hard_truncate_to_target`
// uses for the equivalent sacred-set drain.
self.turn_tracker =
turn::TurnTracker::rebuild(&self.messages);
return;
}
}
// No sacred message inside the drain window — drain contiguously
// and re-index turns in place (the well-tested fast path).
self.messages.drain(..remove_end);
let new_msg_len = self.messages.len();
// Re-index turn tracker: rebuild with strict validation and invariant enforcement.
// This replaces the previous retain logic which had edge cases causing underflow.
let mut surviving_turns = Vec::new();
for turn in self.turn_tracker.turns.drain(..) {
let turn_end = turn.end_idx();
// Skip turns entirely within the drained range (before remove_end)
if turn_end <= remove_end {
continue;
}
// Calculate new indices for surviving turns
let new_start = if turn.start_idx < remove_end {
// Turn partially overlaps the drain: restart at index 0
0
} else {
// Turn is entirely after remove_end: shift backwards
turn.start_idx - remove_end
};
// Calculate new message count
let new_count = if turn.start_idx < remove_end {
// Partial overlap: count only messages after remove_end
turn_end - remove_end
} else {
// No overlap: count unchanged
turn.msg_count
};
// INVARIANT ENFORCEMENT:
// Clamp indices to valid range in case of edge cases or corrupted state
let new_count = new_count.min(new_msg_len.saturating_sub(new_start));
// Only include turns with at least one message
if new_count > 0 && new_start < new_msg_len {
surviving_turns.push(turn::Turn {
start_idx: new_start,
msg_count: new_count,
status: turn.status,
summary: turn.summary,
});
}
}
self.turn_tracker.turns = surviving_turns;
}
}
/// Strip trailing duplicate content from model output.
/// Strip leaked reasoning that wasn't wrapped in <think> tags.
/// MiniMax and some models output their internal reasoning as plain text
/// before the actual response, separated by blank lines. Pattern:
/// "要求.../需要.../这个问题..." (reasoning) \n\n "actual reply"
/// We detect this by checking if the first paragraph looks like self-analysis
/// and strip it, keeping only the final response.
fn strip_leaked_reasoning(text: &str) -> String {
let trimmed = text.trim();
// Only process short text-only responses (not code/tool output)
if trimmed.len() > 1000 || trimmed.contains("```") {
return text.to_string();
}
// Split into paragraphs (separated by blank lines)
let paragraphs: Vec<&str> = trimmed
.split("\n\n")
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect();
if paragraphs.len() < 2 {
return text.to_string();
}
// Check if first paragraph is reasoning (self-analysis patterns)
let first = paragraphs[0];
let reasoning_markers = [
"要求",
"需要",
"这个问题",
"用户",
"根据规则",
"我应该",
"让我",
"分析",
"涉及到",
"敏感",
"回避",
"I need to",
"I should",
"Let me",
"The user",
];
let is_reasoning = reasoning_markers
.iter()
.any(|m| first.starts_with(m) || first.contains(m));
if is_reasoning {
// Keep only the last paragraph(s) — the actual response
// Find the first paragraph that doesn't look like reasoning
let mut start = paragraphs.len() - 1;
for (i, p) in paragraphs.iter().enumerate().skip(1) {
let still_reasoning = reasoning_markers
.iter()
.any(|m| p.starts_with(m) || p.contains(m));
if !still_reasoning {
start = i;
break;
}
}
return paragraphs[start..].join("\n\n");
}
text.to_string()
}
/// Weak models sometimes repeat their summary verbatim at the end.
/// Strategy: find a repeated heading/marker line and truncate at the second occurrence.
fn dedup_trailing_repeat(text: &str) -> String {
let text = text.trim_end();
if text.len() < 100 {
return text.to_string();
}
let lines: Vec<&str> = text.lines().collect();
if lines.len() < 6 {
return text.to_string();
}
// Look for repeated marker lines: headings (**, ##) or key phrases.
// If a distinctive line appears twice, the second occurrence starts the duplicate.
// Only check lines in the first half as potential repeat starts.
let half = lines.len() / 2;
for i in 0..half {
let line = lines[i].trim();
// Must be a "distinctive" line (heading, bold marker, numbered item header)
if line.len() < 8 {
continue;
}
let is_marker = line.starts_with("**")
|| line.starts_with("##")
|| line.starts_with("1.")
|| line.starts_with("1、");
if !is_marker {
continue;
}
// Look for this same line in the second half
for j in half..lines.len() {
let other = lines[j].trim();
if other == line {
// Found repeat marker. Verify: at least 3 lines after j should ~match lines after i.
let match_count = lines[i..]
.iter()
.zip(lines[j..].iter())
.filter(|(a, b)| a.trim() == b.trim())
.count();
let remaining = lines.len() - j;
// If >60% of remaining lines match, it's a duplicate
if remaining >= 3 && match_count * 100 / remaining >= 60 {
return lines[..j].join("\n");
}
}
}
}
text.to_string()
}
/// Apply the full assistant-text cleaning chain. Returns `None` when the
/// content should be dropped instead of committed to history (empty after
/// stripping, or corrupted bytes from provider stream failure). Used by
/// every `finalize_stream*` entry point so all three paths share the
/// same drop policy.
fn clean_assistant_text(raw: &str) -> Option<String> {
// Strip thinking-model artifacts. `<think>` and `<|im_*|>` are model-
// template tokens that occasionally leak into the visible content
// (provider didn't filter them, or they crossed a chunk boundary).
let stripped = raw
.replace("<think>", "")
.replace("</think>", "")
.replace("<|im_start|>", "")
.replace("<|im_end|>", "");
// Strip orphan Qwen/GLM XML tool-call residue. `ToolCallStreamFilter`
// (turn/runner.rs) suppresses well-formed `<tool_call>...</tool_call>`
// blocks during streaming, but only when the markers are PAIRED. When
// the model dribbles out unpaired closes (`</tool_call>`,
// `</arg_value>`, etc.) — observed on glm-5.1 going off the rails on
// reasoning-heavy questions, e.g. 2026-05-05 atomgr 14:31:48 — those
// residual tags pass straight through the filter and land in the
// assistant text. Strip them here so they don't poison the next
// turn's context (the model would see its own broken markup as
// prior conversation and double down).
let stripped = strip_orphan_tool_call_xml(&stripped);
// Strip leaked reasoning: MiniMax/DeepSeek sometimes output reasoning
// as plain text (no `<think>` tag) followed by the actual response.
// Detect by looking for the pattern: `要求/需要/让我/用户...`
// (analysis) → blank line → actual reply.
let stripped = strip_leaked_reasoning(&stripped);
let stripped = dedup_trailing_repeat(&stripped);
if stripped.trim().is_empty() {
return None;
}
if looks_corrupted(&stripped).is_some() {
// Letting corrupted bytes land in history poisons every
// subsequent turn — the model sees its own garbage as prior
// context and either echoes more garbage or derails. Drop
// silently: writing to stderr leaks into the TUI render area
// (atomcode-tuix doesn't redirect/capture stderr), polluting
// the input box. The turn loop sees an empty assistant turn
// and the user can `/retry` or switch models.
return None;
}
Some(stripped)
}
/// Strip orphan Qwen/GLM XML tool-call markup from a finalised assistant
/// message. Companion to `ToolCallStreamFilter` (turn/runner.rs) which
/// suppresses well-formed `<tool_call>...</tool_call>` blocks at stream
/// time but ONLY when the open and close are paired. When a model
/// dribbles out unpaired close tags (`</tool_call>`, `</arg_value>`,
/// etc.) without a preceding `<tool_call>` opener, the stream filter
/// stays in `inside=false` state and lets them through as plain text.
///
/// This function runs at finalize time on the cumulative assistant
/// content. It removes:
/// - `<tool_name>X</tool_name>` and `<arg_key>X</arg_key>` and
/// `<arg_value>X</arg_value>` paired sub-elements (with their
/// contents, since those contents are tool-call payloads, not
/// prose)
/// - any `<tool_call>` and `</tool_call>` tokens left after the
/// paired-element sweep (could be orphan opens, orphan closes, or
/// the wrappers around already-stripped sub-elements)
///
/// Conservative bail-out: if the input contains no closing tags from
/// this set, return the input unchanged. Real prose and code virtually
/// never contain `</tool_call>` etc. as literal text, so the false-
/// positive risk on legitimate content is near-zero.
fn strip_orphan_tool_call_xml(text: &str) -> String {
if !text.contains("</tool_call>")
&& !text.contains("</tool_name>")
&& !text.contains("</arg_key>")
&& !text.contains("</arg_value>")
{
return text.to_string();
}
let mut out = text.to_string();
// Strip paired sub-elements first, since their inner content is
// tool-call payload (file paths, args, etc.) and would otherwise
// become orphan prose after the wrapper tags are removed.
for tag in &["tool_name", "arg_key", "arg_value"] {
let open = format!("<{}>", tag);
let close = format!("</{}>", tag);
loop {
let Some(o) = out.find(&open) else { break };
let after_open = o + open.len();
let Some(c_rel) = out[after_open..].find(&close) else {
// Unmatched open — drop the bare open token and keep
// looking. Don't take any subsequent text since we
// can't tell where the intended payload ends.
out.replace_range(o..after_open, "");
continue;
};
let c_end = after_open + c_rel + close.len();
out.replace_range(o..c_end, "");
}
// Sweep any remaining bare close tokens (orphan closes with no
// preceding open).
out = out.replace(&close, "");
}
// Finally remove the outer `<tool_call>` / `</tool_call>` wrappers,
// including any orphan ones. Done last so the inner cleanup above
// still anchors on the wrapper boundaries when they were paired.
out = out.replace("<tool_call>", "").replace("</tool_call>", "");
out
}
/// Detect output that almost-certainly came from a corrupted provider stream
/// (binary bytes decoded as UTF-8, mojibake from wrong encoding, KV-cache
/// poisoning after timeout/retry, etc.) and should NOT be committed to
/// conversation history.
///
/// Returns `Some(reason)` when the text is corrupted; `None` when it looks
/// like real model output. Conservative by design: only fires on
/// unambiguously non-textual signals. False positives here would silently
/// drop legitimate responses, which is far worse than letting one garbage
/// turn through.
///
/// Trigger context (2026-05-02 datalog evidence): `deepseek-v4-flash` at
/// ~28K ctx after a successful file write hung 155s on the next turn,
/// then the framework's stream-timeout retry returned `P<ďĎĎĎĎ` (UTF-8
/// bytes 0x50 0x3C 0xC4 0x8F 0xC4 0x8E ×4 — Latin Extended-A mojibake of
/// what was almost certainly raw binary in the provider's response
/// buffer). Once that string lands in conversation history the next turn
/// sees its own garbage as prior context and the session is unrecoverable.
pub fn looks_corrupted(text: &str) -> Option<&'static str> {
let total_chars = text.chars().count();
if total_chars < 4 {
// Too short to judge confidently. The single-char `P` we've also
// observed slips through — caller's empty-check + any explicit
// /undo gate is the recovery path for that.
return None;
}
// Signal 1: U+FFFD replacement char density. The decoder marks bytes
// that didn't form valid UTF-8 with this; a single one in a long reply
// can be incidental, but >5% means decode failed broadly.
let replacement = text.chars().filter(|&c| c == '\u{FFFD}').count();
if replacement * 20 > total_chars {
return Some("replacement_char_density");
}
// Signal 2: C0 control bytes other than \t \n \r. Real model output
// never contains these; provider bug or transport corruption.
let bad_ctrl = text.chars().filter(|&c| {
let cp = c as u32;
cp < 0x20 && cp != 0x09 && cp != 0x0A && cp != 0x0D
}).count();
if bad_ctrl > 0 {
return Some("c0_control_bytes");
}
// Signal 3: Latin Extended-A density (U+0100-U+017F). The 2026-05-02
// `P<ďĎĎĎĎ` fixture is 7 chars with 5 in this range (71%). A real
// Czech/Slovak/Polish text mixes these with ASCII at low ratio
// (typically <15%); >40% density is mojibake of UTF-8 bytes
// 0xC4 0x8E etc. East Asian text is in U+4E00+ ranges and never
// triggers this signal. 40% threshold also rejects legitimate short
// Czech words like `čaj` (33%) while catching the fixture.
let latin_ext_a = text.chars().filter(|&c| {
let cp = c as u32;
(0x0100..=0x017F).contains(&cp)
}).count();
if latin_ext_a * 10 > total_chars * 4 {
return Some("latin_extended_a_mojibake");
}
// Signal 4: a single non-ASCII char repeating 5+ times in a row.
// Tokenizer/cache failure modes often emit one stuck token over and
// over. ASCII repetition is allowed (`====` separators, `....`
// ellipses, indentation runs). Run counter tallies `c == prev`
// events, so 5 consecutive identical chars produce run==4.
//
// Typographic chars (box drawing `─┌┐│═`, block elements `█▒░`,
// dashes `——`, ellipsis `…`, bullets `••`, middle dots `··`) are
// legitimate formatting — markdown tables and horizontal rules
// routinely repeat them dozens of times. Skipping these prevents
// false positives on perfectly valid model output (2026-05-03
// session: a markdown table with `─` × 30+ tripped this).
let mut prev = '\0';
let mut run = 0;
for c in text.chars() {
if c == prev && c as u32 > 0x7F && !is_typographic_repeat_safe(c) {
run += 1;
if run >= 4 {
return Some("stuck_non_ascii_repeat");
}
} else {
run = 0;
prev = c;
}
}
None
}
/// Code points where consecutive repetition is normal typography (markdown
/// tables, horizontal rules, ASCII-art-style art, em-dash sequences) and
/// should NOT trip the stuck-token corruption signal.
fn is_typographic_repeat_safe(c: char) -> bool {
let cp = c as u32;
(0x2500..=0x257F).contains(&cp) // Box Drawing (─│┌┐└┘├┤┬┴┼═║╔╗╚╝╠╣╦╩╬ etc.)
|| (0x2580..=0x259F).contains(&cp) // Block Elements (█▒░▀▄ etc.)
|| (0x2010..=0x2015).contains(&cp) // hyphens, en-dash, em-dash, horizontal bar
|| cp == 0x2026 // … ellipsis
|| cp == 0x2022 // • bullet
|| cp == 0x25E6 // ◦ white bullet
|| cp == 0x00B7 // · middle dot
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::message::Role;
#[test]
fn test_new_conversation_is_empty() {
let conv = Conversation::new();
assert!(conv.messages.is_empty());
assert!(conv.stream_buffer.is_none());
}
#[test]
fn strip_orphan_xml_no_op_on_plain_prose() {
// Real prose without any tool-call markup is returned byte-identical.
let text = "答案是可以 ping 通 10.0.0.1,因为服务端用了 TUN 设备。";
assert_eq!(strip_orphan_tool_call_xml(text), text);
}
#[test]
fn strip_orphan_xml_no_op_on_rust_generics() {
// Code with `<>` syntax (Rust generics, HTML, etc.) doesn't match
// any of our specific tool-call tag names, so the early bail-out
// keeps it untouched.
let text = "let x: Vec<HashMap<String, Arc<dyn Trait>>> = vec![];\n\
println!(\"<not_a_tag>\");";
assert_eq!(strip_orphan_tool_call_xml(text), text);
}
#[test]
fn strip_orphan_xml_handles_dribbled_close() {
// Reproduces 2026-05-05 atomgr 14:31:48: model emits a paired
// tool_call (suppressed by the stream filter), then dribbles out
// a SECOND set of arg_key/arg_value/close tags WITHOUT a leading
// <tool_call>. The stream filter in `inside=false` state passes
// those orphan markers straight through. The sanitiser must
// strip them at finalize time so they don't poison the assistant
// message stored in history.
let text = "actual_host, e\n);\npanic!(...);\n}</arg_value>\
<arg_key>limit</arg_key><arg_value>100</arg_value>\
<arg_key>offset</arg_key><arg_value>350</arg_value></tool_call>";
let cleaned = strip_orphan_tool_call_xml(text);
assert!(!cleaned.contains("</tool_call>"), "got: {}", cleaned);
assert!(!cleaned.contains("<arg_key>"), "got: {}", cleaned);
assert!(!cleaned.contains("</arg_value>"), "got: {}", cleaned);
// Real prose at the head survives.
assert!(cleaned.contains("actual_host, e"));
assert!(cleaned.contains("panic!"));
}
#[test]
fn strip_orphan_xml_consumes_paired_inner_payloads() {
// Inner payloads (file paths, args) are NOT prose — they're
// tool-call inputs that happened to leak into text. Strip the
// payload along with the wrapper, otherwise they'd survive as
// unattributed text fragments in history.
let text = "Sure, let me check\n<tool_name>read_file</tool_name>\
<arg_key>path</arg_key><arg_value>/tmp/x.rs</arg_value>";
let cleaned = strip_orphan_tool_call_xml(text);
assert!(!cleaned.contains("read_file"), "got: {}", cleaned);
assert!(!cleaned.contains("/tmp/x.rs"), "got: {}", cleaned);
assert!(cleaned.contains("Sure, let me check"));
}
#[test]
fn strip_orphan_xml_through_clean_assistant_text() {
// End-to-end: clean_assistant_text applies the sanitiser as part
// of its pipeline. A message that is ONLY orphan markup must end
// up as None (empty after stripping → drop the message) so it
// doesn't poison the next turn's prior context.
let only_residue = "<arg_key>limit</arg_key>\
<arg_value>100</arg_value></tool_call>";
assert_eq!(clean_assistant_text(only_residue), None);
}
#[test]
fn strip_orphan_xml_leaves_lone_open_alone_when_no_closes_present() {
// Conservative bail: when the input has NO close tags from our
// set, we leave it untouched. This protects prose that
// legitimately discusses the XML format (e.g. documentation
// strings mentioning the `<tool_name>` element by name) from
// being mangled. The failure mode that motivated the sanitiser
// is dribbled CLOSE tags; orphan opens-only is not seen in real
// datalogs, so the conservative bail is correct.
let text = "the field is called `<tool_name>` and contains the function name";
assert_eq!(strip_orphan_tool_call_xml(text), text);
}
#[test]
fn test_add_user_message() {
let mut conv = Conversation::new();
conv.add_user_message("hello");
assert_eq!(conv.messages.len(), 1);
assert!(matches!(conv.messages[0].role, Role::User));
assert_eq!(conv.messages[0].text().unwrap(), "hello");
}
#[test]
fn test_push_delta_creates_buffer() {
let mut conv = Conversation::new();
conv.push_delta("Hello");
assert_eq!(conv.stream_buffer, Some("Hello".to_string()));
conv.push_delta(" world");
assert_eq!(conv.stream_buffer, Some("Hello world".to_string()));
}
#[test]
fn test_finalize_stream() {
let mut conv = Conversation::new();
conv.push_delta("Hello world");
conv.finalize_stream();
assert!(conv.stream_buffer.is_none());
assert_eq!(conv.messages.len(), 1);
assert!(matches!(conv.messages[0].role, Role::Assistant));
assert_eq!(conv.messages[0].text().unwrap(), "Hello world");
}
#[test]
fn test_finalize_empty_buffer_is_noop() {
let mut conv = Conversation::new();
conv.finalize_stream();
assert!(conv.messages.is_empty());
}
#[test]
fn test_to_provider_messages_prepends_system() {
let mut conv = Conversation::new();
conv.add_user_message("hi");
let msgs = conv.to_provider_messages("You are helpful.");
assert_eq!(msgs.len(), 2);
assert!(matches!(msgs[0].role, Role::System));
assert_eq!(msgs[0].text().unwrap(), "You are helpful.");
assert!(matches!(msgs[1].role, Role::User));
}
#[test]
fn test_add_assistant_tool_calls() {
use crate::tool::ToolCall;
let mut conv = Conversation::new();
conv.add_user_message("hello");
let call = ToolCall {
id: "call_1".to_string(),
name: "read_file".to_string(),
arguments: r#"{"file_path":"/tmp/test"}"#.to_string(),
};
conv.add_assistant_tool_calls(Some("Let me read that file."), vec![call], None);
assert_eq!(conv.messages.len(), 2);
match &conv.messages[1].content {
MessageContent::AssistantWithToolCalls {
text, tool_calls, ..
} => {
assert_eq!(text.as_deref(), Some("Let me read that file."));
assert_eq!(tool_calls.len(), 1);
}
_ => panic!("Expected AssistantWithToolCalls"),
}
}
#[test]
fn test_add_tool_result() {
use crate::tool::ToolResult;
let mut conv = Conversation::new();
let result = ToolResult {
call_id: "call_1".to_string(),
output: "file contents".to_string(),
success: true,
};
conv.add_tool_result(result);
assert_eq!(conv.messages.len(), 1);
assert!(matches!(conv.messages[0].role, Role::Tool));
}
#[test]
fn test_finalize_stream_with_tool_call() {
use crate::tool::ToolCall;
let mut conv = Conversation::new();
conv.push_delta("Let me check...");
let call = ToolCall {
id: "call_1".to_string(),
name: "read_file".to_string(),
arguments: "{}".to_string(),
};
conv.finalize_stream_with_tool_call(call, None);
assert!(conv.stream_buffer.is_none());
assert_eq!(conv.messages.len(), 1);
match &conv.messages[0].content {
MessageContent::AssistantWithToolCalls {
text, tool_calls, ..
} => {
assert_eq!(text.as_deref(), Some("Let me check..."));
assert_eq!(tool_calls.len(), 1);
}
_ => panic!("Expected AssistantWithToolCalls"),
}
}
#[test]
fn test_cold_zone_fifo_max_3() {
let mut conv = Conversation::new();
conv.cold_summaries.push("summary 1".to_string());
conv.cold_summaries.push("summary 2".to_string());
conv.cold_summaries.push("summary 3".to_string());
// Create some turns so apply_compression has something to remove
for i in 0..4 {
conv.add_user_message(&format!("t{}", i));
conv.messages.push(Message::new(Role::Assistant, "ok"));
conv.turn_tracker.on_message_added(conv.messages.len() - 1);
}
conv.apply_compression(2, "summary 4".to_string());
// FIFO: oldest dropped, newest kept
assert_eq!(conv.cold_summaries.len(), 3);
assert_eq!(conv.cold_summaries[0], "summary 2");
assert_eq!(conv.cold_summaries[2], "summary 4");
}
#[test]
fn test_compression_then_add_user_message_no_underflow() {
let mut conv = Conversation::new();
// Build 2 turns (4 messages total)
// Turn 1: User + Assistant response
conv.add_user_message("task 1");
assert_eq!(conv.turn_tracker.turns.len(), 1);
conv.push_delta("response 1");
conv.finalize_stream();
conv.turn_tracker.complete_current(); // Mark as completed
// Turn 2: User + Assistant response
conv.add_user_message("task 2");
assert_eq!(conv.turn_tracker.turns.len(), 2);
conv.push_delta("response 2");
conv.finalize_stream();
conv.turn_tracker.complete_current(); // Mark as completed
// Verify state before compression
assert_eq!(conv.messages.len(), 4);
assert_eq!(
conv.turn_tracker.turns[0].status,
turn::TurnStatus::Completed
);
assert_eq!(
conv.turn_tracker.turns[1].status,
turn::TurnStatus::Completed
);
assert_eq!(conv.turn_tracker.turns[0].msg_count, 2);
assert_eq!(conv.turn_tracker.turns[1].msg_count, 2);
// Compress: remove first 2 messages (covers first complete turn).
// Post-fix contract: the FIRST non-synthetic User message ("task
// 1" at msg 0) is sacred — apply_compression carves it out of
// the drain window. So instead of [user2, asst2] (2 msgs), we
// get [user1, user2, asst2] (3 msgs) with user1 still anchoring
// the timeline for /resume.
conv.apply_compression(2, "Turn 1 summary".to_string());
// Verify compression result. user1 is preserved; asst1 dropped.
assert_eq!(conv.messages.len(), 3);
assert_eq!(conv.messages[0].role, Role::User);
// turn_tracker is rebuilt from messages on carve-out: 2 user
// messages → 2 turns. turn0 has just user1 (asst1 was dropped);
// turn1 has user2 + asst2.
assert_eq!(conv.turn_tracker.turns.len(), 2);
assert_eq!(conv.turn_tracker.turns[0].start_idx, 0);
assert_eq!(conv.turn_tracker.turns[0].msg_count, 1);
assert_eq!(conv.turn_tracker.turns[1].start_idx, 1);
assert_eq!(conv.turn_tracker.turns[1].msg_count, 2);
// CRITICAL: Add a new user message. This should NOT panic with underflow.
// Before the fix, this could panic if Turn indices were corrupted.
conv.add_user_message("task 3");
// Verify final state — 4 msgs, 3 turns, no panic.
assert_eq!(conv.messages.len(), 4);
assert_eq!(conv.turn_tracker.turns.len(), 3);
assert_eq!(
conv.turn_tracker.turns[0].status,
turn::TurnStatus::Completed
);
assert_eq!(conv.turn_tracker.turns[2].status, turn::TurnStatus::Active);
assert_eq!(conv.turn_tracker.turns[2].start_idx, 3);
}
/// Test partial turn compression (a turn spans the compression boundary).
/// This is more complex: when a turn is partially within the removed range,
/// its indices must be recalculated correctly.
#[test]
fn test_compression_partial_turn_overlap() {
let mut conv = Conversation::new();
// Build 2 turns:
// Turn 1: msg 0 (user), msg 1 (assistant)
// Turn 2: msg 2 (user), msg 3 (assistant), msg 4 (tool result)
conv.add_user_message("task 1");
conv.push_delta("response 1");
conv.finalize_stream();
conv.turn_tracker.complete_current();
conv.add_user_message("task 2");
conv.push_delta("response 2");
conv.finalize_stream();
use crate::tool::ToolResult;
conv.add_tool_result(ToolResult {
call_id: "call_1".to_string(),
output: "result".to_string(),
success: true,
});
conv.turn_tracker.complete_current();
assert_eq!(conv.messages.len(), 5);
assert_eq!(conv.turn_tracker.turns.len(), 2);
assert_eq!(conv.turn_tracker.turns[0].msg_count, 2);
assert_eq!(conv.turn_tracker.turns[1].msg_count, 3);
// Compress: remove first 3 messages.
// Post-fix contract: user1 (msg 0) is sacred — carved out of
// the drain window. Result: [user1, asst2, tool_result] = 3
// msgs. user2's continuation (asst2 + tool_result) survives;
// user2 itself was the one we dropped between user1 (kept) and
// remove_end.
conv.apply_compression(3, "Old history".to_string());
// Verify compression result
assert_eq!(conv.messages.len(), 3);
assert_eq!(conv.messages[0].role, Role::User); // preserved user1
// Rebuild yields one Active turn (only user1, no following User
// message to close it before EOF).
assert_eq!(conv.turn_tracker.turns.len(), 1);
let surviving = &conv.turn_tracker.turns[0];
assert_eq!(surviving.start_idx, 0);
assert_eq!(surviving.msg_count, 3);
// Add a new user message: should not panic
conv.add_user_message("task 3");
// Verify invariants hold — 2 turns now, no panic.
assert_eq!(conv.messages.len(), 4);
assert_eq!(conv.turn_tracker.turns.len(), 2);
assert_eq!(conv.turn_tracker.turns[0].start_idx, 0);
assert_eq!(conv.turn_tracker.turns[1].start_idx, 3);
}
/// Test aggressive compression that removes almost everything.
/// Ensure Turns are corrected and no crashes occur.
#[test]
fn test_compression_removes_most_messages() {
let mut conv = Conversation::new();
// Build 3 turns (6 messages): 2 + 2 + 2
for i in 1..=3 {
conv.add_user_message(&format!("task {}", i));
conv.push_delta(&format!("response {}", i));
conv.finalize_stream();
conv.turn_tracker.complete_current();
}
assert_eq!(conv.messages.len(), 6);
assert_eq!(conv.turn_tracker.turns.len(), 3);
// Aggressively compress: drain everything except the last
// assistant message. Post-fix contract: user1 (msg 0) is the
// original prompt and gets carved out of the drain window, so
// we end up with [user1, last_asst] = 2 messages, not 1.
conv.apply_compression(5, "Entire history summarized".to_string());
// user1 + last asst survive.
assert_eq!(conv.messages.len(), 2);
assert_eq!(conv.messages[0].role, Role::User); // preserved user1
// Rebuild: one Active turn containing both messages.
assert_eq!(conv.turn_tracker.turns.len(), 1);
assert_eq!(conv.turn_tracker.turns[0].start_idx, 0);
assert_eq!(conv.turn_tracker.turns[0].msg_count, 2);
// Add a new user message: should not crash
conv.add_user_message("new task");
assert_eq!(conv.messages.len(), 3);
assert_eq!(conv.turn_tracker.turns.len(), 2);
assert_eq!(conv.turn_tracker.turns[1].start_idx, 2);
}
/// Test edge case: compression amount exceeds total messages.
/// apply_compression should clamp safely.
#[test]
fn test_compression_exceeds_message_count() {
let mut conv = Conversation::new();
conv.add_user_message("hello");
conv.push_delta("response");
conv.finalize_stream();
assert_eq!(conv.messages.len(), 2);
// Try to remove 100 messages (more than exist).
// Post-fix contract: even with remove_count >> message count,
// the first non-synthetic User message is preserved. The drain
// clamps to messages.len() and carves out msg 0.
conv.apply_compression(100, "Summary".to_string());
// user1 survives, asst dropped.
assert_eq!(conv.messages.len(), 1);
assert_eq!(conv.messages[0].role, Role::User);
assert_eq!(conv.turn_tracker.turns.len(), 1);
// Add a new user message after compression. `add_user_message`
// merges into the last User msg when one already exists (API
// compat: providers reject two consecutive User msgs). So the
// count stays at 1, just the body grows.
conv.add_user_message("new message");
assert_eq!(conv.messages.len(), 1);
assert_eq!(conv.turn_tracker.turns.len(), 1);
}
// ── Original-prompt preservation across compression (P0-A) ──
//
// af3d1ac7 protected `hard_truncate_to_target` (tier 3 fallback);
// these tests pin the same invariant for `apply_compression`, which
// is the dominant path hit by task-boundary cleanup + `/compact`
// and was where the original-prompt-drained bug still triggered.
/// Contract: after `apply_compression` with `remove_count` that
/// would otherwise consume the first non-synthetic User message,
/// that message survives at the new index 0. /resume re-renders
/// the timeline starting from the human's original prompt, NOT
/// from a stray tool_call.
#[test]
fn apply_compression_preserves_first_real_user_message() {
let mut conv = Conversation::new();
// Build 4 turns × ~2 messages = 8 messages.
for i in 1..=4 {
conv.add_user_message(&format!("task {}", i));
conv.push_delta(&format!("response {}", i));
conv.finalize_stream();
conv.turn_tracker.complete_current();
}
assert_eq!(conv.messages.len(), 8);
let original_prompt = match &conv.messages[0].content {
MessageContent::Text(s) => s.clone(),
_ => panic!("msg 0 must be User text"),
};
assert_eq!(original_prompt, "task 1");
// Drain the first 6 messages — old behavior would consume the
// original prompt at index 0 and shift "task 4" to position 0.
conv.apply_compression(6, "Cold-zone summary".to_string());
// msg[0] must STILL be the original prompt, not a tool_call /
// assistant continuation.
assert_eq!(conv.messages[0].role, Role::User);
match &conv.messages[0].content {
MessageContent::Text(s) => assert_eq!(s, "task 1"),
other => panic!("msg 0 must remain the original Text prompt; got {:?}", other),
}
}
/// Negative control: when the first non-synthetic User message is
/// OUTSIDE the drain window (because there are leading synthetic
/// context injects), the fast in-place re-index path runs as
/// before — no needless carve-out.
#[test]
fn apply_compression_no_carve_out_when_real_user_after_drain_window() {
let mut conv = Conversation::new();
// We want a layout: [synthetic, asst, real_user, asst]. Put an
// Assistant between the synthetic and the real user so that
// add_user_message doesn't merge them (the merge fires when
// the previous message is also User, regardless of `synthetic`).
conv.messages
.push(Message::synthetic_user("[synthetic context]")); // idx 0
conv.messages
.push(Message::new(Role::Assistant, "ack")); // idx 1
conv.add_user_message("real prompt"); // idx 2
conv.push_delta("response"); // streamed into idx 3
conv.finalize_stream();
conv.turn_tracker.complete_current();
assert_eq!(conv.messages.len(), 4);
// Drain first 2 messages (the synthetic context + ack asst).
// first_real_user_idx (2) == remove_end (2), so the sacred
// anchor is AT the boundary, not INSIDE the drain window —
// carve-out doesn't trigger and the fast in-place path runs.
conv.apply_compression(2, "trim leading synthetic".to_string());
assert_eq!(conv.messages.len(), 2);
assert_eq!(conv.messages[0].role, Role::User);
assert!(!conv.messages[0].synthetic);
match &conv.messages[0].content {
MessageContent::Text(s) => assert_eq!(s, "real prompt"),
other => panic!("expected the real prompt at idx 0; got {:?}", other),
}
}
/// Synthetic User messages don't count as sacred. They were injected
/// by the agent (`[Context was compressed]`, `[Output limit hit]`,
/// etc.) and have no value as conversation anchors. Compression must
/// be free to drain across them.
#[test]
fn apply_compression_drains_synthetic_user_messages() {
let mut conv = Conversation::new();
conv.messages
.push(Message::synthetic_user("[Context was compressed]"));
conv.messages
.push(Message::synthetic_user("[Output limit hit]"));
conv.messages
.push(Message::new(Role::Assistant, "old response"));
conv.add_user_message("real prompt");
conv.push_delta("real response");
conv.finalize_stream();
conv.turn_tracker.complete_current();
assert_eq!(conv.messages.len(), 5);
// Drain the first 3 (all synthetic / pre-real-prompt).
// No carve-out needed: first_real_user_idx (3) >= remove_end (3).
conv.apply_compression(3, "summary".to_string());
// Real prompt is now at idx 0.
assert_eq!(conv.messages.len(), 2);
assert_eq!(conv.messages[0].role, Role::User);
assert!(!conv.messages[0].synthetic);
match &conv.messages[0].content {
MessageContent::Text(s) => assert_eq!(s, "real prompt"),
other => panic!("real prompt must be at idx 0; got {:?}", other),
}
}
// ── looks_corrupted: garbage detection ──
/// 2026-05-02 datalog `atomgr/2026-05-02_10-37-51.md` line 402:
/// deepseek-v4-flash returned `P<ďĎĎĎĎ` after a 155s stream timeout
/// + retry — UTF-8 decoding of `0x50 0x3C 0xC4 0x8F 0xC4 0x8E ×4`,
/// almost certainly raw binary in the provider's response buffer.
/// Without this guard the string lands in conversation history and
/// poisons every subsequent turn.
#[test]
fn looks_corrupted_catches_real_datalog_fixture() {
assert_eq!(
looks_corrupted("P<ďĎĎĎĎ"),
Some("latin_extended_a_mojibake")
);
}
#[test]
fn looks_corrupted_catches_replacement_char_density() {
let s: String = (0..10).map(|_| '\u{FFFD}').collect();
assert_eq!(looks_corrupted(&s), Some("replacement_char_density"));
}
#[test]
fn looks_corrupted_catches_c0_control_bytes() {
// \x01 \x02 \x03 = SOH STX ETX, never appear in real text
assert_eq!(
looks_corrupted("hello\x01world"),
Some("c0_control_bytes")
);
}
#[test]
fn looks_corrupted_catches_stuck_repeat() {
// Five consecutive non-ASCII chars from a tokenizer/cache failure
let s = format!("hi {}", "中".repeat(5));
assert_eq!(looks_corrupted(&s), Some("stuck_non_ascii_repeat"));
}
#[test]
fn looks_corrupted_passes_normal_chinese() {
// CJK is U+4E00+, well outside Latin Extended-A
assert_eq!(looks_corrupted("你好,让我帮你写代码"), None);
}
#[test]
fn looks_corrupted_passes_normal_english() {
assert_eq!(
looks_corrupted("Let me read the file and figure out what changed."),
None
);
}
#[test]
fn looks_corrupted_passes_short_czech() {
// Real Czech word `čaj` (tea) — 33% latin-ext-a but legitimate
assert_eq!(looks_corrupted("čaj"), None);
// 4 chars at 25% — below 40% threshold
assert_eq!(looks_corrupted("čajov"), None);
}
#[test]
fn looks_corrupted_passes_ascii_separators() {
// `=====` and `....` patterns are legitimate, ASCII repetition
// is allowed even past the 5-char run threshold
assert_eq!(looks_corrupted("====================="), None);
assert_eq!(looks_corrupted("Done. ......"), None);
}
/// 2026-05-03 session: a markdown table from `deepseek-v4-flash` running
/// on atomgr tripped Signal 4 because `─` (U+2500) repeated dozens of
/// times across table borders. The whitelist prevents this false positive
/// while still catching CJK / latin-ext-a stuck-token corruption.
#[test]
fn looks_corrupted_passes_markdown_table_borders() {
// Box drawing — markdown table from real datalog
let table = "┌───────────────────────┬──────────────────────────────────┐\n\
│ 文件 │ 动作 │\n\
├───────────────────────┼──────────────────────────────────┤\n\
│ src/main.rs │ CLI 改为子命令 │\n\
└───────────────────────┴──────────────────────────────────┘";
assert_eq!(looks_corrupted(table), None);
}
#[test]
fn looks_corrupted_passes_horizontal_rules_and_typography() {
// Horizontal rules using box drawing, double, em-dash, ellipsis
assert_eq!(looks_corrupted(&"─".repeat(80)), None);
assert_eq!(looks_corrupted(&"═".repeat(40)), None);
assert_eq!(looks_corrupted(&"━".repeat(40)), None);
assert_eq!(looks_corrupted(&"—".repeat(20)), None); // em-dash
assert_eq!(looks_corrupted(&"…".repeat(20)), None); // ellipsis
assert_eq!(looks_corrupted(&"•".repeat(10)), None); // bullet
// Block elements
assert_eq!(looks_corrupted(&"█".repeat(20)), None);
}
#[test]
fn looks_corrupted_still_catches_real_cjk_corruption() {
// CJK repetition is NOT in the whitelist — still flagged. This
// is the actual stuck-token failure mode.
assert_eq!(
looks_corrupted(&format!("hi {}", "中".repeat(5))),
Some("stuck_non_ascii_repeat")
);
}
#[test]
fn looks_corrupted_too_short_returns_none() {
// Below 4 chars: trim_empty handles the truly-empty case;
// single chars like the 2nd datalog `P` slip through and rely
// on /retry / model switch.
assert_eq!(looks_corrupted("P"), None);
assert_eq!(looks_corrupted("ok"), None);
}
#[test]
fn finalize_stream_drops_corrupted_output() {
let mut conv = Conversation::new();
conv.push_delta("P<ďĎĎĎĎ");
conv.finalize_stream();
// Corrupted text never reaches messages — history is preserved
// clean and the next turn doesn't see the garbage as context.
assert!(
conv.messages.is_empty(),
"corrupted assistant output must not be committed to history"
);
assert!(
conv.stream_buffer.is_none(),
"stream buffer must be drained even on drop"
);
}
// ── cancel_current_turn: preserves completed content (issue #260) ──
#[test]
fn cancel_preserves_completed_assistant_text() {
// Cancel after model has responded with text (no tool calls)
let mut conv = Conversation::new();
conv.add_user_message("创建 index.html");
conv.push_delta("好的,我来帮你创建");
conv.finalize_stream();
assert_eq!(conv.messages.len(), 2);
conv.cancel_current_turn();
// User message + assistant text are both preserved
assert_eq!(conv.messages.len(), 2);
assert!(matches!(conv.messages[0].role, Role::User));
assert!(matches!(conv.messages[1].role, Role::Assistant));
assert_eq!(conv.messages[0].text().unwrap(), "创建 index.html");
assert_eq!(conv.messages[1].text().unwrap(), "好的,我来帮你创建");
// Turn is completed
assert_eq!(conv.turn_tracker.turns.len(), 1);
assert_eq!(conv.turn_tracker.turns[0].status, TurnStatus::Completed);
assert_eq!(conv.turn_tracker.turns[0].msg_count, 2);
}
#[test]
fn cancel_backfills_missing_tool_results() {
// Cancel while model has issued tool calls but no results yet
let mut conv = Conversation::new();
conv.add_user_message("创建 index.html");
conv.add_assistant_tool_calls(
Some("creating file"),
vec![ToolCall {
id: "call_1".into(),
name: "write_file".into(),
arguments: "{}".into(),
}],
None,
);
// user + assistant_with_tool_calls = 2, no result yet
assert_eq!(conv.messages.len(), 2);
conv.cancel_current_turn();
// All messages preserved + (cancelled) result appended
assert_eq!(conv.messages.len(), 3);
assert!(matches!(conv.messages[0].role, Role::User));
assert!(matches!(conv.messages[1].role, Role::Assistant));
assert!(matches!(conv.messages[2].role, Role::Tool));
if let MessageContent::ToolResult(r) = &conv.messages[2].content {
assert!(!r.success);
assert_eq!(r.output, "(cancelled)");
assert_eq!(r.call_id, "call_1");
} else {
panic!("expected ToolResult");
}
}
#[test]
fn cancel_preserves_completed_tool_pairs_and_backfills_incomplete() {
// Model did read_file (complete), then started edit_file (no result)
let mut conv = Conversation::new();
conv.add_user_message("读取 main.rs 然后修改它");
conv.add_assistant_tool_calls(
None,
vec![ToolCall {
id: "call_1".into(),
name: "read_file".into(),
arguments: r#"{"file_path":"main.rs"}"#.into(),
}],
None,
);
conv.add_tool_result(ToolResult {
call_id: "call_1".into(),
output: "fn main() {}".into(),
success: true,
});
conv.add_assistant_tool_calls(
Some("editing file"),
vec![ToolCall {
id: "call_2".into(),
name: "edit_file".into(),
arguments: r#"{"file_path":"main.rs"}"#.into(),
}],
None,
);
// user + atc1 + result1 + atc2 = 4
assert_eq!(conv.messages.len(), 4);
conv.cancel_current_turn();
// All 4 preserved + 1 backfilled result for call_2 = 5
assert_eq!(conv.messages.len(), 5);
assert!(matches!(conv.messages[0].role, Role::User));
assert!(matches!(conv.messages[1].role, Role::Assistant)); // atc1
assert!(matches!(conv.messages[2].role, Role::Tool)); // result1
assert!(matches!(conv.messages[3].role, Role::Assistant)); // atc2
assert!(matches!(conv.messages[4].role, Role::Tool)); // backfilled result2
if let MessageContent::ToolResult(r) = &conv.messages[4].content {
assert_eq!(r.call_id, "call_2");
assert!(!r.success);
}
}
#[test]
fn cancel_preserves_previous_turns() {
let mut conv = Conversation::new();
conv.add_user_message("你好");
conv.push_delta("你好!有什么可以帮你?");
conv.finalize_stream();
conv.turn_tracker.complete_current();
conv.add_user_message("创建 index.html");
conv.push_delta("好的,我来创建...");
conv.finalize_stream();
assert_eq!(conv.messages.len(), 4);
conv.cancel_current_turn();
assert_eq!(conv.messages.len(), 4);
assert_eq!(conv.turn_tracker.turns.len(), 2);
assert_eq!(conv.turn_tracker.turns[0].status, TurnStatus::Completed);
assert_eq!(conv.turn_tracker.turns[1].status, TurnStatus::Completed);
}
#[test]
fn cancel_then_follow_up_sees_completed_work() {
let mut conv = Conversation::new();
conv.add_user_message("创建 index.html");
conv.add_assistant_tool_calls(
Some("creating file"),
vec![ToolCall {
id: "call_1".into(),
name: "write_file".into(),
arguments: r#"{"file_path":"index.html","content":"hello"}"#.into(),
}],
None,
);
conv.add_tool_result(ToolResult {
call_id: "call_1".into(),
output: "File written successfully".into(),
success: true,
});
conv.cancel_current_turn();
conv.add_user_message("不要删那行,改成 XXX");
let msgs = conv.to_provider_messages("You are helpful.");
let all_text: String = msgs.iter().map(|m| m.text().unwrap_or("")).collect();
assert!(
all_text.contains("write_file") || all_text.contains("index.html"),
"LLM must see what it already did"
);
assert!(all_text.contains("不要删那行"), "LLM must see the corrective prompt");
}
#[test]
fn cancel_finalizes_stream_buffer() {
let mut conv = Conversation::new();
conv.add_user_message("你好");
conv.push_delta("你好!我是");
assert!(conv.stream_buffer.is_some());
conv.cancel_current_turn();
assert!(conv.stream_buffer.is_none());
assert_eq!(conv.messages.len(), 2);
assert!(matches!(conv.messages[1].role, Role::Assistant));
}
#[test]
fn cancel_including_user_removes_everything() {
let mut conv = Conversation::new();
conv.add_user_message("hello");
conv.push_delta("partial response");
conv.finalize_stream();
conv.cancel_current_turn_including_user();
assert!(conv.messages.is_empty());
assert!(conv.turn_tracker.turns.is_empty());
}
#[test]
fn cancel_including_user_preserves_previous_turns() {
let mut conv = Conversation::new();
conv.add_user_message("你好");
conv.push_delta("你好!");
conv.finalize_stream();
conv.turn_tracker.complete_current();
conv.add_user_message("创建文件");
conv.push_delta("好的...");
conv.finalize_stream();
conv.cancel_current_turn_including_user();
assert_eq!(conv.messages.len(), 2);
assert_eq!(conv.turn_tracker.turns.len(), 1);
}
#[test]
fn cancel_on_empty_conversation_is_noop() {
let mut conv = Conversation::new();
conv.cancel_current_turn();
assert!(conv.messages.is_empty());
}
#[test]
fn cancel_backfills_multi_tool_calls_partial_results() {
let mut conv = Conversation::new();
conv.add_user_message("读取 a.rs 和 b.rs");
conv.add_assistant_tool_calls(
None,
vec![
ToolCall {
id: "call_1".into(),
name: "read_file".into(),
arguments: r#"{"file_path":"a.rs"}"#.into(),
},
ToolCall {
id: "call_2".into(),
name: "read_file".into(),
arguments: r#"{"file_path":"b.rs"}"#.into(),
},
],
None,
);
conv.add_tool_result(ToolResult {
call_id: "call_1".into(),
output: "a content".into(),
success: true,
});
// call_2 has no result yet
conv.cancel_current_turn();
// All preserved + 1 backfilled result for call_2
assert_eq!(conv.messages.len(), 4); // user + atc + result1 + result2(cancelled)
if let MessageContent::ToolResult(r) = &conv.messages[3].content {
assert_eq!(r.call_id, "call_2");
assert!(!r.success);
assert_eq!(r.output, "(cancelled)");
}
}
/// When a tool result is stored as ToolResultRef (disk-cached large
/// output), backfill must recognise it as "has result" and NOT append
/// a duplicate (cancelled) entry.
#[test]
fn cancel_backfill_recognises_tool_result_ref() {
use crate::tool::result_store::ToolResultRef;
let mut conv = Conversation::new();
conv.add_user_message("读取 big_file.rs");
conv.add_assistant_tool_calls(
Some("reading"),
vec![ToolCall {
id: "call_1".into(),
name: "read_file".into(),
arguments: r#"{"file_path":"big_file.rs"}"#.into(),
}],
None,
);
// Result stored as ToolResultRef (large output on disk)
let idx = conv.messages.len();
conv.messages.push(Message {
role: Role::Tool,
content: MessageContent::ToolResultRef(ToolResultRef {
call_id: "call_1".into(),
hash: "abc123".into(),
summary: "500 lines of Rust code".into(),
byte_size: 20_000,
success: true,
}),
synthetic: false,
});
conv.turn_tracker.on_message_added(idx);
// Another tool call with NO result yet
conv.add_assistant_tool_calls(
None,
vec![ToolCall {
id: "call_2".into(),
name: "edit_file".into(),
arguments: r#"{"file_path":"big_file.rs"}"#.into(),
}],
None,
);
conv.cancel_current_turn();
// call_1 (ToolResultRef) must NOT get a duplicate backfilled result.
// Only call_2 (no result) should get a backfilled (cancelled).
assert_eq!(conv.messages.len(), 5); // user + atc1 + ref_result1 + atc2 + backfilled_result2
// Verify the backfilled result is for call_2 only
if let MessageContent::ToolResult(r) = &conv.messages[4].content {
assert_eq!(r.call_id, "call_2");
assert!(!r.success);
assert_eq!(r.output, "(cancelled)");
} else {
panic!("expected ToolResult for call_2");
}
}
/// Double-cancel is a no-op: calling cancel_current_turn twice should
/// not panic or corrupt state.
#[test]
fn cancel_double_cancel_is_noop() {
let mut conv = Conversation::new();
conv.add_user_message("hello");
conv.push_delta("world");
conv.finalize_stream();
conv.cancel_current_turn();
assert_eq!(conv.messages.len(), 2);
// Second cancel — turn already Completed, should be a no-op
conv.cancel_current_turn();
assert_eq!(conv.messages.len(), 2);
}
// ── Review round 2: additional test coverage ──
/// After cancel_current_turn_including_user, calling cancel_current_turn
/// on the now-absent active turn is a safe no-op (turn was popped).
#[test]
fn cancel_after_including_user_is_noop() {
let mut conv = Conversation::new();
conv.add_user_message("hello");
conv.push_delta("partial");
conv.finalize_stream();
conv.cancel_current_turn_including_user();
assert!(conv.messages.is_empty());
// No active turn — cancel should be harmless
conv.cancel_current_turn();
assert!(conv.messages.is_empty());
assert!(conv.turn_tracker.turns.is_empty());
}
/// cancel_current_turn_including_user clears stream_buffer so it
/// doesn't leak into the next turn.
#[test]
fn cancel_including_user_clears_stream_buffer() {
let mut conv = Conversation::new();
conv.add_user_message("hello");
conv.push_delta("partial response still streaming");
assert!(conv.stream_buffer.is_some());
conv.cancel_current_turn_including_user();
assert!(conv.stream_buffer.is_none(), "stream_buffer must be cleared");
assert!(conv.messages.is_empty());
}
/// cancel_current_turn_including_user clears tool_call_buffer so it
/// doesn't leak into the next turn.
#[test]
fn cancel_including_user_clears_tool_call_buffer() {
use crate::tool::ToolCallBuffer;
let mut conv = Conversation::new();
conv.add_user_message("hello");
// Simulate a partial tool call buffer
conv.tool_call_buffer = Some(ToolCallBuffer {
id: "call_partial".into(),
name: "bash".into(),
arguments: r#"{"command":"ls"}"#.into(),
hint_sent: false,
});
assert!(conv.tool_call_buffer.is_some());
conv.cancel_current_turn_including_user();
assert!(conv.tool_call_buffer.is_none(), "tool_call_buffer must be cleared");
}
/// cancel_current_turn_including_user on a completed turn (no active turn)
/// is a no-op — messages and turns are untouched.
#[test]
fn cancel_including_user_on_completed_turn_is_noop() {
let mut conv = Conversation::new();
conv.add_user_message("hello");
conv.push_delta("world");
conv.finalize_stream();
conv.turn_tracker.complete_current();
assert_eq!(conv.messages.len(), 2);
assert_eq!(conv.turn_tracker.turns.len(), 1);
// Turn is Completed, not Active — cancel_including_user does nothing
conv.cancel_current_turn_including_user();
assert_eq!(conv.messages.len(), 2, "completed turn must not be removed");
assert_eq!(conv.turn_tracker.turns.len(), 1);
}
/// After cancel_including_user, the conversation is clean enough to
/// start a new turn and produce valid provider messages.
#[test]
fn cancel_including_user_then_new_turn_produces_valid_messages() {
let mut conv = Conversation::new();
conv.add_user_message("bad prompt");
conv.push_delta("bad response");
conv.finalize_stream();
conv.cancel_current_turn_including_user();
// Start a fresh turn
conv.add_user_message("good prompt");
conv.push_delta("good response");
conv.finalize_stream();
conv.turn_tracker.complete_current();
let msgs = conv.to_provider_messages("system");
// System + User + Assistant = 3
assert_eq!(msgs.len(), 3);
assert!(matches!(msgs[0].role, Role::System));
assert!(matches!(msgs[1].role, Role::User));
assert!(matches!(msgs[2].role, Role::Assistant));
}
/// backfill: all results are ToolResultRef (no inline ToolResult at all).
/// None of them should be mistakenly backfilled as (cancelled).
#[test]
fn cancel_backfill_all_tool_result_refs() {
use crate::tool::result_store::ToolResultRef;
let mut conv = Conversation::new();
conv.add_user_message("读取大文件");
conv.add_assistant_tool_calls(
None,
vec![
ToolCall {
id: "call_1".into(),
name: "read_file".into(),
arguments: r#"{"file_path":"a.rs"}"#.into(),
},
ToolCall {
id: "call_2".into(),
name: "read_file".into(),
arguments: r#"{"file_path":"b.rs"}"#.into(),
},
],
None,
);
// Both results as ToolResultRef
for (call_id, summary) in [("call_1", "a.rs content"), ("call_2", "b.rs content")] {
let idx = conv.messages.len();
conv.messages.push(Message {
role: Role::Tool,
content: MessageContent::ToolResultRef(ToolResultRef {
call_id: call_id.into(),
hash: format!("hash_{}", call_id),
summary: summary.into(),
byte_size: 10_000,
success: true,
}),
synthetic: false,
});
conv.turn_tracker.on_message_added(idx);
}
conv.cancel_current_turn();
// No backfill needed: both calls have results (as refs).
// user + atc + ref1 + ref2 = 4
assert_eq!(conv.messages.len(), 4);
}
/// backfill: mix of ToolResult and ToolResultRef in the same turn.
/// Only the truly unpaired call gets backfilled.
#[test]
fn cancel_backfill_mixed_result_types() {
use crate::tool::result_store::ToolResultRef;
let mut conv = Conversation::new();
conv.add_user_message("读取文件并编辑");
conv.add_assistant_tool_calls(
None,
vec![
ToolCall {
id: "call_1".into(),
name: "read_file".into(),
arguments: r#"{"file_path":"x.rs"}"#.into(),
},
ToolCall {
id: "call_2".into(),
name: "bash".into(),
arguments: r#"{"command":"make"}"#.into(),
},
ToolCall {
id: "call_3".into(),
name: "edit_file".into(),
arguments: r#"{"file_path":"x.rs"}"#.into(),
},
],
None,
);
// call_1: inline ToolResult
conv.add_tool_result(ToolResult {
call_id: "call_1".into(),
output: "file content".into(),
success: true,
});
// call_2: ToolResultRef
let idx = conv.messages.len();
conv.messages.push(Message {
role: Role::Tool,
content: MessageContent::ToolResultRef(ToolResultRef {
call_id: "call_2".into(),
hash: "hash_call_2".into(),
summary: "make output".into(),
byte_size: 50_000,
success: true,
}),
synthetic: false,
});
conv.turn_tracker.on_message_added(idx);
// call_3: no result yet
conv.cancel_current_turn();
// user + atc + result1 + ref2 + backfilled_result3 = 5
assert_eq!(conv.messages.len(), 5);
// Only call_3 should be backfilled
if let MessageContent::ToolResult(r) = &conv.messages[4].content {
assert_eq!(r.call_id, "call_3");
assert!(!r.success);
assert_eq!(r.output, "(cancelled)");
} else {
panic!("expected ToolResult for call_3");
}
}
/// End-to-end: after cancel with backfilled results, the message
/// sequence sent to the provider is API-legal (no orphan tool results,
/// every ATC has matching results, sequence starts with System).
#[test]
fn cancel_then_provider_messages_are_api_legal() {
let mut conv = Conversation::new();
conv.add_user_message("读取 main.rs 然后修改它");
conv.add_assistant_tool_calls(
Some("reading file"),
vec![ToolCall {
id: "call_1".into(),
name: "read_file".into(),
arguments: r#"{"file_path":"main.rs"}"#.into(),
}],
None,
);
conv.add_tool_result(ToolResult {
call_id: "call_1".into(),
output: "fn main() {}".into(),
success: true,
});
conv.add_assistant_tool_calls(
Some("editing file"),
vec![ToolCall {
id: "call_2".into(),
name: "edit_file".into(),
arguments: r#"{"file_path":"main.rs"}"#.into(),
}],
None,
);
// Cancel before call_2 got a result
conv.cancel_current_turn();
// Verify API legality: System, User, ATC, ToolResult, ATC, ToolResult(cancelled)
let msgs = conv.to_provider_messages("You are helpful.");
assert!(matches!(msgs[0].role, Role::System));
assert!(matches!(msgs[1].role, Role::User));
// msgs[2] should be AssistantWithToolCalls (read_file)
assert!(matches!(msgs[2].role, Role::Assistant));
// msgs[3] should be ToolResult (call_1)
assert!(matches!(msgs[3].role, Role::Tool));
// msgs[4] should be AssistantWithToolCalls (edit_file)
assert!(matches!(msgs[4].role, Role::Assistant));
// msgs[5] should be ToolResult (call_2 cancelled)
assert!(matches!(msgs[5].role, Role::Tool));
// Verify every ATC's tool calls have matching results
let mut expected_call_ids: Vec<String> = Vec::new();
for msg in &msgs {
if let MessageContent::AssistantWithToolCalls { tool_calls, .. } = &msg.content {
for tc in tool_calls {
expected_call_ids.push(tc.id.clone());
}
}
}
let mut got_call_ids: Vec<String> = Vec::new();
for msg in &msgs {
if let Some(id) = msg.tool_result_call_id() {
got_call_ids.push(id.to_string());
}
}
assert_eq!(
expected_call_ids, got_call_ids,
"every tool call must have a matching result"
);
}
/// End-to-end: after cancel, user sends a follow-up message, and the
/// full provider message sequence is API-legal across both turns.
#[test]
fn cancel_then_follow_up_full_sequence_api_legal() {
let mut conv = Conversation::new();
// Turn 1 (completed normally)
conv.add_user_message("你好");
conv.push_delta("你好!");
conv.finalize_stream();
conv.turn_tracker.complete_current();
// Turn 2 (cancelled mid-tool)
conv.add_user_message("读取 main.rs");
conv.add_assistant_tool_calls(
None,
vec![ToolCall {
id: "call_1".into(),
name: "read_file".into(),
arguments: "{}".into(),
}],
None,
);
conv.cancel_current_turn();
// Turn 3 (follow-up after cancel)
conv.add_user_message("不要修改那行");
conv.push_delta("好的,我只添加新代码");
conv.finalize_stream();
conv.turn_tracker.complete_current();
let msgs = conv.to_provider_messages("system");
// Verify no consecutive User messages
for i in 1..msgs.len() {
if matches!(msgs[i].role, Role::User) {
assert!(
!matches!(msgs[i - 1].role, Role::User),
"consecutive User messages at index {}-{} are illegal",
i - 1,
i
);
}
}
// Verify every ATC has matching results
let mut expected: Vec<String> = Vec::new();
for msg in &msgs {
if let MessageContent::AssistantWithToolCalls { tool_calls, .. } = &msg.content {
for tc in tool_calls {
expected.push(tc.id.clone());
}
}
}
let mut got: Vec<String> = Vec::new();
for msg in &msgs {
if let Some(id) = msg.tool_result_call_id() {
got.push(id.to_string());
}
}
assert_eq!(expected, got, "all tool calls must have matching results");
}
/// cancel_current_turn properly updates turn tracker: turn transitions
/// from Active to Completed with correct msg_count after backfill.
#[test]
fn cancel_updates_turn_tracker_correctly() {
let mut conv = Conversation::new();
conv.add_user_message("hello");
conv.add_assistant_tool_calls(
None,
vec![ToolCall {
id: "call_1".into(),
name: "bash".into(),
arguments: "{}".into(),
}],
None,
);
// Before cancel: 2 messages, turn is Active
assert_eq!(conv.turn_tracker.turns.len(), 1);
assert_eq!(conv.turn_tracker.turns[0].status, TurnStatus::Active);
assert_eq!(conv.turn_tracker.turns[0].msg_count, 2);
conv.cancel_current_turn();
// After cancel: 3 messages (user + atc + backfilled), turn is Completed
assert_eq!(conv.messages.len(), 3);
assert_eq!(conv.turn_tracker.turns[0].status, TurnStatus::Completed);
assert_eq!(
conv.turn_tracker.turns[0].msg_count, 3,
"msg_count must include the backfilled result"
);
}
/// cancel_current_turn_including_user properly removes the turn from
/// the tracker (not just marks it).
#[test]
fn cancel_including_user_removes_turn_not_just_marks() {
let mut conv = Conversation::new();
// Previous turn
conv.add_user_message("hello");
conv.push_delta("hi");
conv.finalize_stream();
conv.turn_tracker.complete_current();
// Active turn
conv.add_user_message("bad");
conv.push_delta("oops");
conv.finalize_stream();
assert_eq!(conv.turn_tracker.turns.len(), 2);
conv.cancel_current_turn_including_user();
// Only the previous turn survives
assert_eq!(conv.turn_tracker.turns.len(), 1);
assert_eq!(conv.turn_tracker.turns[0].status, TurnStatus::Completed);
}
// ── add_synthetic_user_message tests ──────────────────────────────
/// `add_synthetic_user_message` pushes a Message with `synthetic =
/// true`, distinguishable from real user messages. Compaction relies
/// on this distinction to find the original prompt.
#[test]
fn add_synthetic_user_message_marks_message_synthetic() {
let mut conv = Conversation::new();
// First add an assistant message so add_synthetic_user_message
// doesn't try to merge into a User predecessor.
conv.messages.push(Message::new(Role::Assistant, "ack"));
conv.add_synthetic_user_message("[Context was compressed.] state");
assert_eq!(conv.messages.len(), 2);
let last = conv.messages.last().unwrap();
assert!(
last.synthetic,
"synthetic injection must carry synthetic=true"
);
assert!(matches!(last.role, Role::User));
}
/// Critical: `add_synthetic_user_message` invoked when the previous
/// message is a real user message MUST merge into it (API-compat
/// against consecutive User messages) AND keep the existing
/// `synthetic=false` flag. Otherwise a real prompt would be
/// reclassified as synthetic just because synthetic content was
/// appended — exactly the failure mode `synthetic` is meant to
/// prevent.
#[test]
fn synthetic_merge_into_real_user_preserves_real_flag() {
let mut conv = Conversation::new();
conv.add_user_message("real original prompt");
conv.add_synthetic_user_message("[Additional context]: synthetic appended");
assert_eq!(conv.messages.len(), 1, "merge collapses into one message");
let m = &conv.messages[0];
assert!(
!m.synthetic,
"merged message keeps existing `synthetic=false` — origin is real"
);
assert!(
m.text().unwrap().contains("real original prompt"),
"real text preserved"
);
assert!(
m.text().unwrap().contains("[Additional context]"),
"synthetic body still appended"
);
}
/// Symmetric merge case: synthetic-into-synthetic. When the last
/// message is already synthetic and we add another synthetic, the
/// merge collapses them and the flag stays true (no real authorship
/// was introduced).
#[test]
fn synthetic_merge_into_synthetic_keeps_flag_true() {
let mut conv = Conversation::new();
// Need a non-User predecessor so the FIRST synthetic creates a
// new message, not merges.
conv.messages.push(Message::new(Role::Assistant, "ack"));
conv.add_synthetic_user_message("[Output limit hit]");
conv.add_synthetic_user_message("[Context was compressed]");
assert_eq!(conv.messages.len(), 2);
assert!(
conv.messages[1].synthetic,
"two synthetics merged stays synthetic"
);
}
}
#[cfg(test)]
mod undo_tests {
use super::*;
use crate::conversation::message::{Message, MessageContent, Role};
fn convo(msgs: Vec<Message>) -> Conversation {
let mut c = Conversation::new();
c.turn_tracker = TurnTracker::rebuild(&msgs);
c.messages = msgs;
c
}
fn user(s: &str) -> Message { Message::new(Role::User, s) }
fn asst(s: &str) -> Message { Message::new(Role::Assistant, s) }
fn synthetic_user(s: &str) -> Message {
Message { role: Role::User, content: MessageContent::Text(s.into()), synthetic: true }
}
#[test]
fn prompt_count_ignores_synthetic() {
let c = convo(vec![user("p1"), asst("a1"), synthetic_user("[ctx]"), user("p2"), asst("a2")]);
assert_eq!(c.prompt_count(), 2);
}
#[test]
fn undo_last_prompt_truncates_and_returns_text() {
let mut c = convo(vec![user("p1"), asst("a1"), user("p2"), asst("a2")]);
let restored = c.undo_to_prompt(2);
assert_eq!(restored.as_deref(), Some("p2"));
assert_eq!(c.messages.len(), 2);
assert_eq!(c.messages[0].text(), Some("p1"));
assert_eq!(c.turn_tracker.turns.len(), 1);
}
#[test]
fn undo_earlier_prompt_removes_following_turns() {
let mut c = convo(vec![user("p1"), asst("a1"), user("p2"), asst("a2"), user("p3"), asst("a3")]);
let restored = c.undo_to_prompt(2);
assert_eq!(restored.as_deref(), Some("p2"));
assert_eq!(c.messages.len(), 2);
assert_eq!(c.prompt_count(), 1);
}
#[test]
fn undo_counts_real_prompts_only() {
// real prompts: p1@0, p2@3. nth=2 must cut at idx 3, keeping the synthetic.
let mut c = convo(vec![user("p1"), asst("a1"), synthetic_user("[ctx]"), user("p2"), asst("a2")]);
let restored = c.undo_to_prompt(2);
assert_eq!(restored.as_deref(), Some("p2"));
assert_eq!(c.messages.len(), 3);
}
#[test]
fn undo_first_prompt_keeps_leading_non_user() {
let mut c = convo(vec![asst("sys"), user("p1"), asst("a1")]);
let restored = c.undo_to_prompt(1);
assert_eq!(restored.as_deref(), Some("p1"));
assert_eq!(c.messages.len(), 1);
assert_eq!(c.messages[0].text(), Some("sys"));
}
#[test]
fn undo_out_of_range_returns_none_and_no_mutation() {
let mut c = convo(vec![user("p1"), asst("a1")]);
assert!(c.undo_to_prompt(0).is_none());
assert!(c.undo_to_prompt(2).is_none());
assert_eq!(c.messages.len(), 2);
}
#[test]
fn undo_clears_inflight_buffers() {
let mut c = convo(vec![user("p1"), asst("a1"), user("p2")]);
c.stream_buffer = Some("partial".into());
c.undo_to_prompt(2);
assert!(c.stream_buffer.is_none());
assert!(c.tool_call_buffer.is_none());
}
}