1pub mod message;
2pub mod turn;
3
4pub(crate) const KEEP_MESSAGES: usize = 20;
12
13use crate::tool::{ToolCall, ToolCallBuffer, ToolResult};
14use message::{Message, MessageContent, Role};
15use turn::{TurnStatus, TurnTracker};
16
17#[derive(Debug, Clone, Default)]
19pub struct ContextStats {
20 pub system_tokens: usize,
21 pub sent_tokens: usize,
23 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 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 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 let messages = match serde_json::from_str::<Vec<Message>>(&data) {
65 Ok(msgs) => msgs,
66 Err(_) => {
67 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 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 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 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 pub fn cancel_current_turn(&mut self) {
128 let start_idx = match self.turn_tracker.active_turn() {
130 Some(turn) => turn.start_idx,
131 None => return,
132 };
133
134 self.finalize_stream();
136 self.tool_call_buffer = None;
138
139 self.backfill_cancelled_tool_results();
142
143 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 fn backfill_cancelled_tool_results(&mut self) {
155 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 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 self.stream_buffer = None;
198 self.tool_call_buffer = None;
199 self.messages.truncate(start_idx);
201 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 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 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 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 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 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 while start < self.messages.len() {
337 match &self.messages[start].content {
338 MessageContent::ToolResult(_) | MessageContent::ToolResultRef(_) => {
339 start += 1;
341 }
342 _ => break,
343 }
344 }
345
346 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 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 pub fn apply_compression(&mut self, remove_count: usize, summary: String) {
379 if remove_count == 0 || summary.is_empty() {
380 return;
381 }
382
383 self.cold_summaries.push(summary);
385 while self.cold_summaries.len() > 3 {
386 self.cold_summaries.remove(0);
387 }
388
389 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 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 if turn_end <= remove_end {
404 continue;
405 }
406
407 let new_start = if turn.start_idx < remove_end {
409 0
411 } else {
412 turn.start_idx - remove_end
414 };
415
416 let new_count = if turn.start_idx < remove_end {
418 turn_end - remove_end
420 } else {
421 turn.msg_count
423 };
424
425 let new_count = new_count.min(new_msg_len.saturating_sub(new_start));
428
429 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
444fn strip_leaked_reasoning(text: &str) -> String {
452 let trimmed = text.trim();
453 if trimmed.len() > 1000 || trimmed.contains("```") {
455 return text.to_string();
456 }
457
458 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 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 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
511fn 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 let half = lines.len() / 2;
528 for i in 0..half {
529 let line = lines[i].trim();
530 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 for j in half..lines.len() {
544 let other = lines[j].trim();
545 if other == line {
546 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 remaining >= 3 && match_count * 100 / remaining >= 60 {
555 return lines[..j].join("\n");
556 }
557 }
558 }
559 }
560
561 text.to_string()
562}
563
564fn clean_assistant_text(raw: &str) -> Option<String> {
570 let stripped = raw
574 .replace("<think>", "")
575 .replace("</think>", "")
576 .replace("<|im_start|>", "")
577 .replace("<|im_end|>", "");
578 let stripped = strip_orphan_tool_call_xml(&stripped);
589 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 return None;
607 }
608 Some(stripped)
609}
610
611fn 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 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 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 out = out.replace(&close, "");
666 }
667
668 out = out.replace("<tool_call>", "").replace("</tool_call>", "");
672
673 out
674}
675
676pub fn looks_corrupted(text: &str) -> Option<&'static str> {
695 let total_chars = text.chars().count();
696 if total_chars < 4 {
697 return None;
701 }
702
703 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 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 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 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
765fn is_typographic_repeat_safe(c: char) -> bool {
769 let cp = c as u32;
770 (0x2500..=0x257F).contains(&cp) || (0x2580..=0x259F).contains(&cp) || (0x2010..=0x2015).contains(&cp) || cp == 0x2026 || cp == 0x2022 || cp == 0x25E6 || cp == 0x00B7 }
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 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 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 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 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 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 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 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 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 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 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(); 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(); 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 conv.apply_compression(2, "Turn 1 summary".to_string());
1031
1032 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 conv.add_user_message("task 3");
1041
1042 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]
1058 fn test_compression_partial_turn_overlap() {
1059 let mut conv = Conversation::new();
1060
1061 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 conv.apply_compression(3, "Old history".to_string());
1089
1090 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); assert_eq!(surviving_turn.end_idx(), 2);
1097
1098 conv.add_user_message("task 3");
1100
1101 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]
1111 fn test_compression_removes_most_messages() {
1112 let mut conv = Conversation::new();
1113
1114 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 conv.apply_compression(5, "Entire history summarized".to_string());
1126
1127 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 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]
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 conv.apply_compression(100, "Summary".to_string());
1155
1156 assert_eq!(conv.messages.is_empty(), true);
1158 assert_eq!(conv.turn_tracker.turns.is_empty(), true);
1159
1160 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 #[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 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 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 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 assert_eq!(looks_corrupted("čaj"), None);
1222 assert_eq!(looks_corrupted("čajov"), None);
1224 }
1225
1226 #[test]
1227 fn looks_corrupted_passes_ascii_separators() {
1228 assert_eq!(looks_corrupted("====================="), None);
1231 assert_eq!(looks_corrupted("Done. ......"), None);
1232 }
1233
1234 #[test]
1239 fn looks_corrupted_passes_markdown_table_borders() {
1240 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 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); assert_eq!(looks_corrupted(&"…".repeat(20)), None); assert_eq!(looks_corrupted(&"•".repeat(10)), None); assert_eq!(looks_corrupted(&"█".repeat(20)), None);
1260 }
1261
1262 #[test]
1263 fn looks_corrupted_still_catches_real_cjk_corruption() {
1264 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 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 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 #[test]
1301 fn cancel_preserves_completed_assistant_text() {
1302 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 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 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 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 assert_eq!(conv.messages.len(), 2);
1341
1342 conv.cancel_current_turn();
1343
1344 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 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 assert_eq!(conv.messages.len(), 4);
1391
1392 conv.cancel_current_turn();
1393
1394 assert_eq!(conv.messages.len(), 5);
1396 assert!(matches!(conv.messages[0].role, Role::User));
1397 assert!(matches!(conv.messages[1].role, Role::Assistant)); assert!(matches!(conv.messages[2].role, Role::Tool)); assert!(matches!(conv.messages[3].role, Role::Assistant)); assert!(matches!(conv.messages[4].role, Role::Tool)); 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 conv.cancel_current_turn();
1540
1541 assert_eq!(conv.messages.len(), 4); 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 #[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 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 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 assert_eq!(conv.messages.len(), 5); 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 #[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 conv.cancel_current_turn();
1625 assert_eq!(conv.messages.len(), 2);
1626 }
1627
1628 #[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 conv.cancel_current_turn();
1644 assert!(conv.messages.is_empty());
1645 assert!(conv.turn_tracker.turns.is_empty());
1646 }
1647
1648 #[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 #[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 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 #[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 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 #[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 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 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 #[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 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 assert_eq!(conv.messages.len(), 4);
1779 }
1780
1781 #[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 conv.add_tool_result(ToolResult {
1814 call_id: "call_1".into(),
1815 output: "file content".into(),
1816 success: true,
1817 });
1818
1819 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 conv.cancel_current_turn();
1836
1837 assert_eq!(conv.messages.len(), 5);
1839
1840 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 #[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 conv.cancel_current_turn();
1885
1886 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 assert!(matches!(msgs[2].role, Role::Assistant));
1892 assert!(matches!(msgs[3].role, Role::Tool));
1894 assert!(matches!(msgs[4].role, Role::Assistant));
1896 assert!(matches!(msgs[5].role, Role::Tool));
1898
1899 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 #[test]
1923 fn cancel_then_follow_up_full_sequence_api_legal() {
1924 let mut conv = Conversation::new();
1925
1926 conv.add_user_message("你好");
1928 conv.push_delta("你好!");
1929 conv.finalize_stream();
1930 conv.turn_tracker.complete_current();
1931
1932 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 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 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 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 #[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 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 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 #[test]
2017 fn cancel_including_user_removes_turn_not_just_marks() {
2018 let mut conv = Conversation::new();
2019
2020 conv.add_user_message("hello");
2022 conv.push_delta("hi");
2023 conv.finalize_stream();
2024 conv.turn_tracker.complete_current();
2025
2026 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 assert_eq!(conv.turn_tracker.turns.len(), 1);
2037 assert_eq!(conv.turn_tracker.turns[0].status, TurnStatus::Completed);
2038 }
2039}