1use std::collections::BTreeSet;
2use std::sync::OnceLock;
3
4use crate::llm::tools::{
5 TEXT_TOOL_CALL_CLOSE, TEXT_TOOL_CALL_CLOSE_COMPACT, TEXT_TOOL_CALL_OPEN,
6 TEXT_TOOL_CALL_OPEN_COMPACT,
7};
8use regex::Regex;
9
10#[derive(Default, Clone, Debug, PartialEq, Eq)]
11pub struct VisibleTextState {
12 raw_text: String,
13 last_visible_text: String,
14}
15
16impl VisibleTextState {
17 pub fn push(&mut self, delta: &str, partial: bool) -> (String, String) {
18 self.raw_text.push_str(delta);
19 let visible_text = sanitize_visible_assistant_text(&self.raw_text, partial);
20 let visible_delta = visible_text
21 .strip_prefix(&self.last_visible_text)
22 .unwrap_or(visible_text.as_str())
23 .to_string();
24 self.last_visible_text = visible_text.clone();
25 (visible_text, visible_delta)
26 }
27
28 pub fn clear(&mut self) {
29 self.raw_text.clear();
30 self.last_visible_text.clear();
31 }
32}
33
34fn internal_block_patterns() -> &'static [Regex] {
35 static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
36 PATTERNS.get_or_init(|| {
37 [
38 r"(?s)<think>.*?</think>",
39 r"(?s)<think>.*$",
40 r"(?s)<\|tool_call\|>.*?</\|tool_call\|>",
41 r"(?s)<tool_?call>.*?</tool_?call>",
45 r"(?s)<done>.*?</done>",
46 r"(?s)<tool_result[^>]*>.*?</tool_result>",
47 r"(?s)\[result of [^\]]+\].*?\[end of [^\]]+\]",
48 r"(?m)^\s*(##DONE##|DONE|PLAN_READY)\s*$",
49 r"(?s)\s*(##DONE##|PLAN_READY)\s*$",
50 ]
51 .into_iter()
52 .map(|pattern| Regex::new(pattern).expect("valid assistant sanitization regex"))
53 .collect()
54 })
55}
56
57fn assistant_prose_regex() -> &'static Regex {
58 static RE: OnceLock<Regex> = OnceLock::new();
59 RE.get_or_init(|| {
60 Regex::new(r"(?ms)^[ \t]*<assistant_?prose>\s*(.*?)\s*</assistant_?prose>")
61 .expect("valid assistant_prose regex")
62 })
63}
64
65fn user_response_regex() -> &'static Regex {
66 static RE: OnceLock<Regex> = OnceLock::new();
67 RE.get_or_init(|| {
68 Regex::new(r"(?ms)^[ \t]*<user_?response>\s*(.*?)\s*</user_?response>")
69 .expect("valid user_response regex")
70 })
71}
72
73fn inside_markdown_fence(text: &str, idx: usize) -> bool {
74 let mut count = 0;
75 let mut cursor = 0;
76 while cursor < idx {
77 let Some(pos) = text[cursor..idx].find("```") else {
78 break;
79 };
80 count += 1;
81 cursor += pos + 3;
82 }
83 count % 2 == 1
84}
85
86fn is_top_level_tag_position(text: &str, idx: usize) -> bool {
87 let line_start = text[..idx].rfind('\n').map(|pos| pos + 1).unwrap_or(0);
88 text[line_start..idx]
89 .chars()
90 .all(|ch| matches!(ch, ' ' | '\t' | '\r'))
91}
92
93fn is_protocol_tag_position(text: &str, idx: usize) -> bool {
94 is_top_level_tag_position(text, idx) && !inside_markdown_fence(text, idx)
95}
96
97fn extract_user_response(text: &str) -> Option<String> {
98 let sections: Vec<String> = user_response_regex()
99 .captures_iter(text)
100 .filter(|caps| {
101 caps.get(0)
102 .is_some_and(|m| is_protocol_tag_position(text, m.start()))
103 })
104 .filter_map(|caps| caps.get(1).map(|m| m.as_str().trim().to_string()))
105 .filter(|section| !section.is_empty())
106 .collect();
107 if sections.is_empty() {
108 None
109 } else {
110 Some(sections.join("\n\n"))
111 }
112}
113
114fn unwrap_assistant_prose(text: &str) -> String {
115 let mut out = String::with_capacity(text.len());
116 let mut last = 0;
117 for caps in assistant_prose_regex().captures_iter(text) {
118 let Some(block) = caps.get(0) else {
119 continue;
120 };
121 if !is_protocol_tag_position(text, block.start()) {
122 continue;
123 }
124 out.push_str(&text[last..block.start()]);
125 if let Some(body) = caps.get(1) {
126 out.push_str(body.as_str().trim());
127 }
128 last = block.end();
129 }
130 out.push_str(&text[last..]);
131 out
132}
133
134fn extract_visible_prose(text: &str) -> String {
139 if let Some(user_response) = extract_user_response(text) {
140 return user_response;
141 }
142 unwrap_assistant_prose(text)
143}
144
145fn json_fence_regex() -> &'static Regex {
146 static JSON_FENCE: OnceLock<Regex> = OnceLock::new();
147 JSON_FENCE
148 .get_or_init(|| Regex::new(r"(?s)```json[^\n]*\n(.*?)```").expect("valid json fence regex"))
149}
150
151fn inline_planner_json_regex() -> &'static Regex {
152 static INLINE_PLANNER_JSON: OnceLock<Regex> = OnceLock::new();
153 INLINE_PLANNER_JSON.get_or_init(|| {
154 Regex::new(r#"(?s)\{\s*"mode"\s*:\s*"(?:fast_execute|plan_then_execute|ask_user)".*?\}"#)
155 .expect("valid inline planner json regex")
156 })
157}
158
159fn partial_inline_planner_json_regex() -> &'static Regex {
160 static PARTIAL_INLINE_PLANNER_JSON: OnceLock<Regex> = OnceLock::new();
161 PARTIAL_INLINE_PLANNER_JSON.get_or_init(|| {
162 Regex::new(r#"(?s)\{\s*"mode"\s*:\s*"(?:fast_execute|plan_then_execute|ask_user)".*$"#)
163 .expect("valid partial inline planner json regex")
164 })
165}
166
167fn looks_like_internal_planning_json(source: &str) -> bool {
168 let trimmed = source.trim();
169 if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
170 return false;
171 }
172
173 fn collect_keys(value: &serde_json::Value, keys: &mut BTreeSet<String>) {
174 match value {
175 serde_json::Value::Object(map) => {
176 for (key, child) in map {
177 keys.insert(key.clone());
178 collect_keys(child, keys);
179 }
180 }
181 serde_json::Value::Array(items) => {
182 for item in items {
183 collect_keys(item, keys);
184 }
185 }
186 _ => {}
187 }
188 }
189
190 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
191 let mut keys = BTreeSet::new();
192 collect_keys(&parsed, &mut keys);
193 let has_planner_mode = match &parsed {
194 serde_json::Value::Object(map) => map
195 .get("mode")
196 .and_then(|value| value.as_str())
197 .is_some_and(|mode| {
198 matches!(mode, "fast_execute" | "plan_then_execute" | "ask_user")
199 }),
200 _ => false,
201 };
202 let has_internal_keys = [
203 "plan",
204 "steps",
205 "tool_calls",
206 "tool_name",
207 "verification",
208 "execution_mode",
209 "required_outputs",
210 "files_to_edit",
211 "next_action",
212 "reasoning",
213 "direction",
214 "targets",
215 "tasks",
216 "unknowns",
217 ]
218 .into_iter()
219 .any(|key| keys.contains(key));
220 return has_planner_mode || has_internal_keys;
221 }
222
223 false
224}
225
226fn strip_internal_json_fences(text: &str) -> String {
227 json_fence_regex()
228 .replace_all(text, |caps: ®ex::Captures| {
229 let body = caps
230 .get(1)
231 .map(|match_| match_.as_str())
232 .unwrap_or_default();
233 if looks_like_internal_planning_json(body) {
234 String::new()
235 } else {
236 caps.get(0)
237 .map(|match_| match_.as_str().to_string())
238 .unwrap_or_default()
239 }
240 })
241 .to_string()
242}
243
244fn strip_unclosed_internal_blocks(text: &str) -> String {
245 if let Some(open_idx) = text.rfind("<|tool_call|>") {
246 let close_idx = text.rfind("</|tool_call|>");
247 if close_idx.is_none_or(|idx| idx < open_idx) {
248 return text[..open_idx].to_string();
249 }
250 }
251
252 if let Some(open_idx) = text.rfind(TEXT_TOOL_CALL_OPEN) {
253 let close_idx = text.rfind(TEXT_TOOL_CALL_CLOSE);
254 if is_protocol_tag_position(text, open_idx) && close_idx.is_none_or(|idx| idx < open_idx) {
255 return text[..open_idx].to_string();
256 }
257 }
258
259 if let Some(open_idx) = text.rfind(TEXT_TOOL_CALL_OPEN_COMPACT) {
260 let close_idx = text.rfind(TEXT_TOOL_CALL_CLOSE_COMPACT);
261 if is_protocol_tag_position(text, open_idx) && close_idx.is_none_or(|idx| idx < open_idx) {
262 return text[..open_idx].to_string();
263 }
264 }
265
266 if let Some(open_idx) = text.rfind("<done>") {
267 let close_idx = text.rfind("</done>");
268 if is_protocol_tag_position(text, open_idx) && close_idx.is_none_or(|idx| idx < open_idx) {
269 return text[..open_idx].to_string();
270 }
271 }
272
273 if let Some(open_idx) = text.rfind("<user_response>") {
274 let close_idx = text.rfind("</user_response>");
275 if is_protocol_tag_position(text, open_idx) && close_idx.is_none_or(|idx| idx < open_idx) {
276 return text[..open_idx].to_string();
277 }
278 }
279
280 if let Some(open_idx) = text.rfind("<userresponse>") {
281 let close_idx = text.rfind("</userresponse>");
282 if is_protocol_tag_position(text, open_idx) && close_idx.is_none_or(|idx| idx < open_idx) {
283 return text[..open_idx].to_string();
284 }
285 }
286
287 if let Some(open_idx) = text.rfind("[result of ") {
288 let close_idx = text.rfind("[end of ");
289 if close_idx.is_none_or(|idx| idx < open_idx) {
290 return text[..open_idx].to_string();
291 }
292 }
293
294 if let Some(open_idx) = text.rfind("<tool_result") {
295 let close_idx = text.rfind("</tool_result>");
296 if is_protocol_tag_position(text, open_idx) && close_idx.is_none_or(|idx| idx < open_idx) {
297 return text[..open_idx].to_string();
298 }
299 }
300
301 text.to_string()
302}
303
304fn strip_inline_internal_planning_json(text: &str, partial: bool) -> String {
305 let mut stripped = inline_planner_json_regex()
306 .replace_all(text, "")
307 .to_string();
308 if partial {
309 stripped = partial_inline_planner_json_regex()
310 .replace_all(&stripped, "")
311 .to_string();
312 }
313 stripped
314}
315
316fn protocol_residue_regex() -> &'static Regex {
317 static RE: OnceLock<Regex> = OnceLock::new();
337 RE.get_or_init(|| {
338 Regex::new(r"<?/?\|?(?:t?o?o?l?)_call\|?>|<?/?\|?[a-z]*_prose>")
339 .expect("valid protocol residue regex")
340 })
341}
342
343fn strip_protocol_residue(text: &str) -> String {
344 protocol_residue_regex()
348 .replace_all(text, |caps: ®ex::Captures| {
349 let matched = caps.get(0).expect("capture group 0 always present");
350 if inside_markdown_fence(text, matched.start()) {
351 matched.as_str().to_string()
352 } else {
353 String::new()
354 }
355 })
356 .to_string()
357}
358
359fn looks_like_internal_verdict_object(map: &serde_json::Map<String, serde_json::Value>) -> bool {
360 let Some(verdict) = map
361 .get("verdict")
362 .and_then(|value| value.as_str())
363 .map(str::trim)
364 .filter(|value| !value.is_empty())
365 else {
366 return false;
367 };
368
369 let verdict = verdict.to_ascii_lowercase();
370 let has_completion_explanation = map.contains_key("reasoning")
371 || map.contains_key("reason")
372 || map.contains_key("next_step")
373 || map.contains_key("nextStep");
374 let has_judge_metadata = map.contains_key("critique")
375 || map.contains_key("confidence")
376 || map.contains_key("category")
377 || map.contains_key("error");
378
379 let known_internal_verdict = matches!(verdict.as_str(), "done" | "continue")
380 && has_completion_explanation
381 || matches!(verdict.as_str(), "revise" | "pass" | "fail" | "unclear") && has_judge_metadata
382 || matches!(verdict.as_str(), "allow" | "warn" | "block") && has_judge_metadata;
383 if !known_internal_verdict {
384 return false;
385 }
386
387 map.keys().all(|key| {
388 matches!(
389 key.as_str(),
390 "verdict"
391 | "reasoning"
392 | "reason"
393 | "next_step"
394 | "nextStep"
395 | "critique"
396 | "confidence"
397 | "category"
398 | "error"
399 )
400 })
401}
402
403fn looks_like_bare_internal_verdict_json(source: &str) -> bool {
404 let trimmed = source.trim();
405 if !trimmed.starts_with('{') {
406 return false;
407 }
408
409 let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(trimmed)
410 else {
411 return false;
412 };
413
414 looks_like_internal_verdict_object(&map)
415}
416
417fn internal_verdict_json_prefix_len(source: &str) -> Option<usize> {
418 let trimmed = source.trim_start();
419 if !trimmed.starts_with('{') {
420 return None;
421 }
422 let leading_ws = source.len() - trimmed.len();
423 let mut stream = serde_json::Deserializer::from_str(trimmed).into_iter::<serde_json::Value>();
424 let parsed = stream.next()?.ok()?;
425 let serde_json::Value::Object(map) = parsed else {
426 return None;
427 };
428 if !looks_like_internal_verdict_object(&map) {
429 return None;
430 }
431 Some(leading_ws + stream.byte_offset())
432}
433
434fn strip_bare_internal_json(text: &str) -> String {
435 if looks_like_bare_internal_verdict_json(text) {
444 return String::new();
445 }
446 text.to_string()
447}
448
449fn strip_leading_done_marker_control(text: &str) -> String {
450 let trimmed = text.trim_start();
451 let leading_ws = text.len() - trimmed.len();
452 for marker in ["</done>", "<done>", "/done>", "done>"] {
453 if let Some(after_marker) = trimmed.strip_prefix(marker) {
454 let after_marker = after_marker.trim_start();
455 let visible_start = internal_verdict_json_prefix_len(after_marker).unwrap_or(0);
456 return text[..leading_ws].to_string() + after_marker[visible_start..].trim_start();
457 }
458 }
459 text.to_string()
460}
461
462fn strip_trailing_internal_json(text: &str) -> String {
463 let trimmed = text.trim_end();
464 for (idx, ch) in trimmed.char_indices().rev() {
465 if ch == '{' && looks_like_bare_internal_verdict_json(&trimmed[idx..]) {
466 return trimmed[..idx].trim_end().to_string();
467 }
468 }
469 text.to_string()
470}
471
472fn strip_partial_marker_suffix(text: &str) -> String {
473 const MARKERS: [&str; 13] = [
474 "<|tool_call|>",
475 TEXT_TOOL_CALL_OPEN,
476 TEXT_TOOL_CALL_OPEN_COMPACT,
477 "<assistant_prose>",
478 "<assistantprose>",
479 "<user_response>",
480 "<userresponse>",
481 "<done>",
482 "<tool_result",
483 "[result of ",
484 "##DONE##",
485 "DONE",
486 "PLAN_READY",
487 ];
488 for marker in MARKERS {
489 for len in (1..marker.len()).rev() {
490 let prefix = &marker[..len];
491 if let Some(stripped) = text.strip_suffix(prefix) {
492 if is_protocol_tag_position(text, stripped.len()) {
493 return stripped.to_string();
494 }
495 }
496 }
497 }
498 text.to_string()
499}
500
501fn normalize_visible_whitespace(text: &str) -> String {
502 text.replace("\r\n", "\n")
503 .replace("\n\n\n", "\n\n")
504 .trim()
505 .to_string()
506}
507
508pub fn sanitize_visible_assistant_text(text: &str, partial: bool) -> String {
509 let mut sanitized = text.to_string();
510 for pattern in internal_block_patterns() {
511 sanitized = pattern.replace_all(&sanitized, "").to_string();
512 }
513 sanitized = extract_visible_prose(&sanitized);
517 sanitized = strip_internal_json_fences(&sanitized);
518 sanitized = strip_inline_internal_planning_json(&sanitized, partial);
519 sanitized = strip_protocol_residue(&sanitized);
524 sanitized = strip_leading_done_marker_control(&sanitized);
525 sanitized = strip_trailing_internal_json(&sanitized);
526 sanitized = strip_bare_internal_json(sanitized.trim());
527 if partial {
528 sanitized = strip_unclosed_internal_blocks(&sanitized);
529 sanitized = strip_partial_marker_suffix(&sanitized);
530 }
531 normalize_visible_whitespace(&sanitized)
532}
533
534#[cfg(test)]
535mod tests {
536 use super::{sanitize_visible_assistant_text, VisibleTextState};
537
538 #[test]
539 fn push_emits_incremental_visible_delta_for_plain_chunks() {
540 let mut state = VisibleTextState::default();
541 let (visible, delta) = state.push("Hello", true);
542 assert_eq!(visible, "Hello");
543 assert_eq!(delta, "Hello");
544
545 let (visible, delta) = state.push(" world", true);
546 assert_eq!(visible, "Hello world");
547 assert_eq!(delta, " world");
548 }
549
550 #[test]
551 fn push_hides_open_think_block_until_closed() {
552 let mut state = VisibleTextState::default();
553 let (visible, delta) = state.push("Hi <think>secret", true);
554 assert_eq!(visible, "Hi");
555 assert_eq!(delta, "Hi");
556
557 let (visible, delta) = state.push(" plan</think> bye", true);
558 assert_eq!(visible, "Hi bye");
559 assert_eq!(delta, " bye");
560 }
561
562 #[test]
563 fn push_emits_full_visible_text_when_sanitization_shrinks_output() {
564 let mut state = VisibleTextState::default();
565 let (visible, _) = state.push("ok", true);
566 assert_eq!(visible, "ok");
567
568 let (visible, delta) = state.push(" <think>", true);
569 assert_eq!(visible, "ok");
570 assert_eq!(delta, "");
572 }
573
574 #[test]
575 fn push_partial_marker_suffix_is_held_back_until_resolved() {
576 let mut state = VisibleTextState::default();
577 let (visible, delta) = state.push("Hello\n##DON", true);
578 assert_eq!(visible, "Hello");
579 assert_eq!(delta, "Hello");
580
581 let (visible, delta) = state.push("E##\nmore", true);
582 assert_eq!(visible, "Hello\n\nmore");
583 assert_eq!(delta, "\n\nmore");
584 }
585
586 #[test]
587 fn clear_resets_streaming_state() {
588 let mut state = VisibleTextState::default();
589 let _ = state.push("Hello world", true);
590 state.clear();
591 let (visible, delta) = state.push("fresh", true);
592 assert_eq!(visible, "fresh");
593 assert_eq!(delta, "fresh");
594 }
595
596 #[test]
597 fn sanitize_drops_inline_planner_json_only_with_planner_mode() {
598 let raw = r#"{"mode":"plan_then_execute","plan":[]}"#;
599 assert_eq!(sanitize_visible_assistant_text(raw, false), "");
600 let raw = r#"{"status":"ok","message":"hello"}"#;
601 assert_eq!(sanitize_visible_assistant_text(raw, false), raw);
602 }
603
604 #[test]
605 fn sanitize_strips_orphan_tool_call_residue_and_truncations() {
606 assert_eq!(sanitize_visible_assistant_text("_call>", false), "");
609 assert_eq!(sanitize_visible_assistant_text("l_call>l_call>", false), "");
610 assert_eq!(
611 sanitize_visible_assistant_text("Done.\n})\n</tool_call>_call>", false),
612 "Done.\n})"
613 );
614 assert_eq!(
615 sanitize_visible_assistant_text("Implemented.</assistant_prose>", false),
616 "Implemented."
617 );
618 assert_eq!(
620 sanitize_visible_assistant_text("Implemented.\nnt_prose>", false),
621 "Implemented."
622 );
623 let fenced = "```\n</tool_call>\n```\nDone.";
625 assert_eq!(sanitize_visible_assistant_text(fenced, false), fenced);
626 }
627
628 #[test]
629 fn sanitize_does_not_touch_ordinary_prose_or_inequalities() {
630 let raw = "Use a_call> only as— wait, compare x > y and y > z here.";
632 let out = sanitize_visible_assistant_text(raw, false);
634 assert!(out.contains("compare x > y and y > z here."), "got: {out}");
635 assert_eq!(
636 sanitize_visible_assistant_text("The phrase tool call is normal prose.", false),
637 "The phrase tool call is normal prose."
638 );
639 }
640
641 #[test]
642 fn sanitize_drops_bare_completion_judge_verdict_json() {
643 let raw = r#"{"verdict":"done","reasoning":"All tests pass.","next_step":""}"#;
644 assert_eq!(sanitize_visible_assistant_text(raw, false), "");
645 let padded = "\n {\"verdict\":\"continue\",\"reasoning\":\"does not compile\"} \n";
647 assert_eq!(sanitize_visible_assistant_text(padded, false), "");
648 let keep = r#"{"status":"ok","message":"hello"}"#;
650 assert_eq!(sanitize_visible_assistant_text(keep, false), keep);
651 let visible_answer =
655 r#"{"tasks":["ship"],"steps":["test"],"reasoning":"user-visible rationale"}"#;
656 assert_eq!(
657 sanitize_visible_assistant_text(visible_answer, false),
658 visible_answer
659 );
660 let visible_verdict = r#"{"verdict":"pass","summary":"public result"}"#;
661 assert_eq!(
662 sanitize_visible_assistant_text(visible_verdict, false),
663 visible_verdict
664 );
665 let visible_verdict_rationale = r#"{"verdict":"pass","reasoning":"public rationale"}"#;
666 assert_eq!(
667 sanitize_visible_assistant_text(visible_verdict_rationale, false),
668 visible_verdict_rationale
669 );
670 }
671
672 #[test]
673 fn sanitize_drops_appended_completion_judge_verdict_json() {
674 let raw = r#"What can I help with today?{"verdict":"done","reasoning":"greeting","next_step":""}"#;
675 assert_eq!(
676 sanitize_visible_assistant_text(raw, false),
677 "What can I help with today?"
678 );
679 let visible_json = r#"Visible answer {"status":"ok","message":"hello"}"#;
680 assert_eq!(
681 sanitize_visible_assistant_text(visible_json, false),
682 visible_json
683 );
684 }
685
686 #[test]
687 fn sanitize_drops_done_marker_prefixed_internal_control() {
688 let raw = r#"/done>{"verdict":"continue","reasoning":"needs final"}Visible answer."#;
689 assert_eq!(
690 sanitize_visible_assistant_text(raw, false),
691 "Visible answer."
692 );
693 assert_eq!(
694 sanitize_visible_assistant_text(r#"done>{"verdict":"done","reasoning":"done"}"#, false),
695 ""
696 );
697 let inline = "The literal /done> marker can be mentioned inline.";
698 assert_eq!(sanitize_visible_assistant_text(inline, false), inline);
699 }
700
701 #[test]
702 fn sanitize_prefers_user_response_blocks_over_other_prose() {
703 let raw = "Working...\n<assistant_prose>internal narration</assistant_prose>\n<user_response>Visible answer.</user_response>\n##DONE##";
704 assert_eq!(
705 sanitize_visible_assistant_text(raw, false),
706 "Visible answer."
707 );
708 }
709
710 #[test]
711 fn sanitize_strips_trailing_runtime_sentinel_after_answer_text() {
712 assert_eq!(
713 sanitize_visible_assistant_text("HARN_LOCAL_TOOL_OK##DONE##", false),
714 "HARN_LOCAL_TOOL_OK"
715 );
716 assert_eq!(
717 sanitize_visible_assistant_text("Done.\nPLAN_READY", false),
718 "Done."
719 );
720 }
721
722 #[test]
723 fn sanitize_accepts_compact_protocol_tag_aliases_without_hiding_plain_words() {
724 let raw = "The phrase tool call is normal prose.\n<assistantprose>hidden</assistantprose>\n<toolcall>\nrun({ command: \"git status\" })\n</toolcall>\n<userresponse>Visible answer.</userresponse>\n<done>##DONE##</done>";
725 assert_eq!(
726 sanitize_visible_assistant_text(raw, false),
727 "Visible answer."
728 );
729
730 assert_eq!(
731 sanitize_visible_assistant_text("A tool call summary is fine.", false),
732 "A tool call summary is fine."
733 );
734 }
735
736 #[test]
737 fn sanitize_ignores_inline_user_response_placeholder() {
738 let raw = "Wrap final answers in `<user_response>...</user_response>`.\nAudit: real answer";
739 assert_eq!(sanitize_visible_assistant_text(raw, false), raw);
740 }
741
742 #[test]
743 fn sanitize_prefers_top_level_user_response_over_inline_placeholder() {
744 let raw =
745 "Remember `<user_response>...</user_response>` is the wrapper.\n<user_response>Visible answer.</user_response>";
746 assert_eq!(
747 sanitize_visible_assistant_text(raw, false),
748 "Visible answer."
749 );
750 }
751
752 #[test]
753 fn sanitize_ignores_user_response_inside_markdown_fence() {
754 let raw = "```xml\n<user_response>example only</user_response>\n```\nFinal plain answer.";
755 assert_eq!(sanitize_visible_assistant_text(raw, false), raw);
756 }
757
758 #[test]
759 fn sanitize_partial_keeps_inline_protocol_prefixes() {
760 let raw = "Mention `<user_resp";
761 assert_eq!(sanitize_visible_assistant_text(raw, true), raw);
762 }
763
764 #[test]
765 fn sanitize_partial_hides_top_level_protocol_prefixes() {
766 assert_eq!(sanitize_visible_assistant_text("<user_resp", true), "");
767 }
768}