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 crate::text_index::TextIndex;
9use regex::Regex;
10
11#[derive(Default, Clone, Debug, PartialEq, Eq)]
12pub struct VisibleTextState {
13 raw_text: String,
14 last_visible_text: String,
15}
16
17impl VisibleTextState {
18 pub fn push(&mut self, delta: &str, partial: bool) -> (String, String) {
19 self.raw_text.push_str(delta);
20 let visible_text = sanitize_visible_assistant_text(&self.raw_text, partial);
21 let visible_delta = visible_text
22 .strip_prefix(&self.last_visible_text)
23 .unwrap_or(visible_text.as_str())
24 .to_string();
25 self.last_visible_text = visible_text.clone();
26 (visible_text, visible_delta)
27 }
28
29 pub fn clear(&mut self) {
30 self.raw_text.clear();
31 self.last_visible_text.clear();
32 }
33}
34
35fn internal_block_patterns() -> &'static [Regex] {
36 static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
37 PATTERNS.get_or_init(|| {
38 [
39 r"(?s)<think>.*?</think>",
40 r"(?s)<think>.*$",
41 r"(?s)<\|tool_call\|>.*?</\|tool_call\|>",
42 r"(?s)<tool_?call>.*?</tool_?call>",
46 r"(?s)<done>.*?</done>",
47 r"(?s)<tool_result[^>]*>.*?</tool_result>",
48 r"(?s)\[result of [^\]]+\].*?\[end of [^\]]+\]",
49 r"(?m)^\s*(##DONE##|DONE|PLAN_READY)\s*$",
50 r"(?s)\s*(##DONE##|PLAN_READY)\s*$",
51 ]
52 .into_iter()
53 .map(|pattern| Regex::new(pattern).expect("valid assistant sanitization regex"))
54 .collect()
55 })
56}
57
58fn assistant_prose_regex() -> &'static Regex {
59 static RE: OnceLock<Regex> = OnceLock::new();
60 RE.get_or_init(|| {
61 Regex::new(r"(?ms)^[ \t]*<assistant_?prose>\s*(.*?)\s*</assistant_?prose>")
62 .expect("valid assistant_prose regex")
63 })
64}
65
66fn user_response_regex() -> &'static Regex {
67 static RE: OnceLock<Regex> = OnceLock::new();
68 RE.get_or_init(|| {
69 Regex::new(r"(?ms)^[ \t]*<user_?response>\s*(.*?)\s*</user_?response>")
70 .expect("valid user_response regex")
71 })
72}
73
74fn is_protocol_tag_position(index: &TextIndex, text: &str, idx: usize) -> bool {
75 index.is_line_leading(text, idx) && !index.inside_markdown_fence(idx)
76}
77
78fn extract_user_response(text: &str) -> Option<(String, String)> {
86 let index = TextIndex::build(text);
87 let mut sections: Vec<String> = Vec::new();
88 let mut remainder = String::with_capacity(text.len());
89 let mut last = 0;
90 for caps in user_response_regex().captures_iter(text) {
91 let Some(whole) = caps.get(0) else {
92 continue;
93 };
94 if !is_protocol_tag_position(&index, text, whole.start()) {
95 continue;
96 }
97 let Some(section) = caps.get(1).map(|m| m.as_str().trim().to_string()) else {
98 continue;
99 };
100 if section.is_empty() {
101 continue;
102 }
103 sections.push(section);
104 remainder.push_str(&text[last..whole.start()]);
105 last = whole.end();
106 }
107 if sections.is_empty() {
108 return None;
109 }
110 remainder.push_str(&text[last..]);
111 Some((sections.join("\n\n"), remainder))
112}
113
114fn unwrap_assistant_prose(text: &str) -> String {
115 let index = TextIndex::build(text);
116 let mut out = String::with_capacity(text.len());
117 let mut last = 0;
118 for caps in assistant_prose_regex().captures_iter(text) {
119 let Some(block) = caps.get(0) else {
120 continue;
121 };
122 if !is_protocol_tag_position(&index, text, block.start()) {
123 continue;
124 }
125 out.push_str(&text[last..block.start()]);
126 if let Some(body) = caps.get(1) {
127 out.push_str(body.as_str().trim());
128 }
129 last = block.end();
130 }
131 out.push_str(&text[last..]);
132 out
133}
134
135fn extract_visible_prose(text: &str, report: Option<&mut String>) -> String {
140 if let Some((user_response, superseded)) = extract_user_response(text) {
141 if let Some(slot) = report {
142 *slot = superseded;
143 }
144 return user_response;
145 }
146 unwrap_assistant_prose(text)
147}
148
149fn report_stripped_prose(superseded: &str) {
156 if superseded.trim().is_empty() {
157 return;
158 }
159 crate::boundary::BoundaryFailure::new(
160 crate::boundary::BoundaryId::VisibleTextSanitize,
161 crate::boundary::BoundaryFailureKind::Truncated,
162 "a <user_response> block superseded assistant prose that no host will render",
163 )
164 .with_excerpt(superseded)
165 .report();
166}
167
168fn json_fence_regex() -> &'static Regex {
169 static JSON_FENCE: OnceLock<Regex> = OnceLock::new();
170 JSON_FENCE
171 .get_or_init(|| Regex::new(r"(?s)```json[^\n]*\n(.*?)```").expect("valid json fence regex"))
172}
173
174fn inline_planner_json_regex() -> &'static Regex {
175 static INLINE_PLANNER_JSON: OnceLock<Regex> = OnceLock::new();
176 INLINE_PLANNER_JSON.get_or_init(|| {
177 Regex::new(r#"(?s)\{\s*"mode"\s*:\s*"(?:fast_execute|plan_then_execute|ask_user)".*?\}"#)
178 .expect("valid inline planner json regex")
179 })
180}
181
182fn partial_inline_planner_json_regex() -> &'static Regex {
183 static PARTIAL_INLINE_PLANNER_JSON: OnceLock<Regex> = OnceLock::new();
184 PARTIAL_INLINE_PLANNER_JSON.get_or_init(|| {
185 Regex::new(r#"(?s)\{\s*"mode"\s*:\s*"(?:fast_execute|plan_then_execute|ask_user)".*$"#)
186 .expect("valid partial inline planner json regex")
187 })
188}
189
190fn looks_like_internal_planning_json(source: &str) -> bool {
191 let trimmed = source.trim();
192 if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
193 return false;
194 }
195
196 fn collect_keys(value: &serde_json::Value, keys: &mut BTreeSet<String>) {
197 match value {
198 serde_json::Value::Object(map) => {
199 for (key, child) in map {
200 keys.insert(key.clone());
201 collect_keys(child, keys);
202 }
203 }
204 serde_json::Value::Array(items) => {
205 for item in items {
206 collect_keys(item, keys);
207 }
208 }
209 _ => {}
210 }
211 }
212
213 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
214 let mut keys = BTreeSet::new();
215 collect_keys(&parsed, &mut keys);
216 let has_planner_mode = match &parsed {
217 serde_json::Value::Object(map) => map
218 .get("mode")
219 .and_then(|value| value.as_str())
220 .is_some_and(|mode| {
221 matches!(mode, "fast_execute" | "plan_then_execute" | "ask_user")
222 }),
223 _ => false,
224 };
225 let has_internal_keys = [
226 "plan",
227 "steps",
228 "tool_calls",
229 "tool_name",
230 "verification",
231 "execution_mode",
232 "required_outputs",
233 "files_to_edit",
234 "next_action",
235 "reasoning",
236 "direction",
237 "targets",
238 "tasks",
239 "unknowns",
240 ]
241 .into_iter()
242 .any(|key| keys.contains(key));
243 return has_planner_mode || has_internal_keys;
244 }
245
246 false
247}
248
249fn strip_internal_json_fences(text: &str) -> String {
250 json_fence_regex()
251 .replace_all(text, |caps: ®ex::Captures| {
252 let body = caps
253 .get(1)
254 .map(|match_| match_.as_str())
255 .unwrap_or_default();
256 if looks_like_internal_planning_json(body) {
257 String::new()
258 } else {
259 caps.get(0)
260 .map(|match_| match_.as_str().to_string())
261 .unwrap_or_default()
262 }
263 })
264 .to_string()
265}
266
267fn strip_unclosed_internal_blocks(text: &str) -> String {
268 let index = TextIndex::build(text);
269 if let Some(open_idx) = text.rfind("<|tool_call|>") {
270 let close_idx = text.rfind("</|tool_call|>");
271 if close_idx.is_none_or(|idx| idx < open_idx) {
272 return text[..open_idx].to_string();
273 }
274 }
275
276 if let Some(open_idx) = text.rfind(TEXT_TOOL_CALL_OPEN) {
277 let close_idx = text.rfind(TEXT_TOOL_CALL_CLOSE);
278 if is_protocol_tag_position(&index, text, open_idx)
279 && close_idx.is_none_or(|idx| idx < open_idx)
280 {
281 return text[..open_idx].to_string();
282 }
283 }
284
285 if let Some(open_idx) = text.rfind(TEXT_TOOL_CALL_OPEN_COMPACT) {
286 let close_idx = text.rfind(TEXT_TOOL_CALL_CLOSE_COMPACT);
287 if is_protocol_tag_position(&index, text, open_idx)
288 && close_idx.is_none_or(|idx| idx < open_idx)
289 {
290 return text[..open_idx].to_string();
291 }
292 }
293
294 if let Some(open_idx) = text.rfind("<done>") {
295 let close_idx = text.rfind("</done>");
296 if is_protocol_tag_position(&index, text, open_idx)
297 && close_idx.is_none_or(|idx| idx < open_idx)
298 {
299 return text[..open_idx].to_string();
300 }
301 }
302
303 if let Some(open_idx) = text.rfind("<user_response>") {
304 let close_idx = text.rfind("</user_response>");
305 if is_protocol_tag_position(&index, text, open_idx)
306 && close_idx.is_none_or(|idx| idx < open_idx)
307 {
308 return text[..open_idx].to_string();
309 }
310 }
311
312 if let Some(open_idx) = text.rfind("<userresponse>") {
313 let close_idx = text.rfind("</userresponse>");
314 if is_protocol_tag_position(&index, text, open_idx)
315 && close_idx.is_none_or(|idx| idx < open_idx)
316 {
317 return text[..open_idx].to_string();
318 }
319 }
320
321 if let Some(open_idx) = text.rfind("[result of ") {
322 let close_idx = text.rfind("[end of ");
323 if close_idx.is_none_or(|idx| idx < open_idx) {
324 return text[..open_idx].to_string();
325 }
326 }
327
328 if let Some(open_idx) = text.rfind("<tool_result") {
329 let close_idx = text.rfind("</tool_result>");
330 if is_protocol_tag_position(&index, text, open_idx)
331 && close_idx.is_none_or(|idx| idx < open_idx)
332 {
333 return text[..open_idx].to_string();
334 }
335 }
336
337 text.to_string()
338}
339
340fn strip_inline_internal_planning_json(text: &str, partial: bool) -> String {
341 let mut stripped = inline_planner_json_regex()
342 .replace_all(text, "")
343 .to_string();
344 if partial {
345 stripped = partial_inline_planner_json_regex()
346 .replace_all(&stripped, "")
347 .to_string();
348 }
349 stripped
350}
351
352fn protocol_residue_regex() -> &'static Regex {
353 static RE: OnceLock<Regex> = OnceLock::new();
373 RE.get_or_init(|| {
374 Regex::new(r"<?/?\|?(?:t?o?o?l?)_call\|?>|<?/?\|?[a-z]*_prose>")
375 .expect("valid protocol residue regex")
376 })
377}
378
379fn strip_protocol_residue(text: &str) -> String {
380 let index = TextIndex::build(text);
381 protocol_residue_regex()
385 .replace_all(text, |caps: ®ex::Captures| {
386 let matched = caps.get(0).expect("capture group 0 always present");
387 if index.inside_markdown_fence(matched.start()) {
388 matched.as_str().to_string()
389 } else {
390 String::new()
391 }
392 })
393 .to_string()
394}
395
396fn looks_like_internal_verdict_object(map: &serde_json::Map<String, serde_json::Value>) -> bool {
397 let Some(verdict) = map
398 .get("verdict")
399 .and_then(|value| value.as_str())
400 .map(str::trim)
401 .filter(|value| !value.is_empty())
402 else {
403 return false;
404 };
405
406 let verdict = verdict.to_ascii_lowercase();
407 let has_completion_explanation = map.contains_key("reasoning")
408 || map.contains_key("reason")
409 || map.contains_key("next_step")
410 || map.contains_key("nextStep");
411 let has_judge_metadata = map.contains_key("critique")
412 || map.contains_key("confidence")
413 || map.contains_key("category")
414 || map.contains_key("error");
415
416 let known_internal_verdict = matches!(verdict.as_str(), "done" | "continue")
417 && has_completion_explanation
418 || matches!(verdict.as_str(), "revise" | "pass" | "fail" | "unclear") && has_judge_metadata
419 || matches!(verdict.as_str(), "allow" | "warn" | "block") && has_judge_metadata;
420 if !known_internal_verdict {
421 return false;
422 }
423
424 map.keys().all(|key| {
425 matches!(
426 key.as_str(),
427 "verdict"
428 | "reasoning"
429 | "reason"
430 | "next_step"
431 | "nextStep"
432 | "critique"
433 | "confidence"
434 | "category"
435 | "error"
436 )
437 })
438}
439
440fn looks_like_bare_internal_verdict_json(source: &str) -> bool {
441 let trimmed = source.trim();
442 if !trimmed.starts_with('{') {
443 return false;
444 }
445
446 let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(trimmed)
447 else {
448 return false;
449 };
450
451 looks_like_internal_verdict_object(&map)
452}
453
454fn internal_verdict_json_prefix_len(source: &str) -> Option<usize> {
455 let trimmed = source.trim_start();
456 if !trimmed.starts_with('{') {
457 return None;
458 }
459 let leading_ws = source.len() - trimmed.len();
460 let mut stream = serde_json::Deserializer::from_str(trimmed).into_iter::<serde_json::Value>();
461 let parsed = stream.next()?.ok()?;
462 let serde_json::Value::Object(map) = parsed else {
463 return None;
464 };
465 if !looks_like_internal_verdict_object(&map) {
466 return None;
467 }
468 Some(leading_ws + stream.byte_offset())
469}
470
471fn strip_bare_internal_json(text: &str) -> String {
472 if looks_like_bare_internal_verdict_json(text) {
481 return String::new();
482 }
483 text.to_string()
484}
485
486fn strip_leading_done_marker_control(text: &str) -> String {
487 let trimmed = text.trim_start();
488 let leading_ws = text.len() - trimmed.len();
489 for marker in ["</done>", "<done>", "/done>", "done>"] {
490 if let Some(after_marker) = trimmed.strip_prefix(marker) {
491 let after_marker = after_marker.trim_start();
492 let visible_start = internal_verdict_json_prefix_len(after_marker).unwrap_or(0);
493 return text[..leading_ws].to_string() + after_marker[visible_start..].trim_start();
494 }
495 }
496 text.to_string()
497}
498
499fn strip_trailing_internal_json(text: &str) -> String {
500 let trimmed = text.trim_end();
501 for (idx, ch) in trimmed.char_indices().rev() {
502 if ch == '{' && looks_like_bare_internal_verdict_json(&trimmed[idx..]) {
503 return trimmed[..idx].trim_end().to_string();
504 }
505 }
506 text.to_string()
507}
508
509fn strip_partial_marker_suffix(text: &str) -> String {
510 const MARKERS: [&str; 13] = [
511 "<|tool_call|>",
512 TEXT_TOOL_CALL_OPEN,
513 TEXT_TOOL_CALL_OPEN_COMPACT,
514 "<assistant_prose>",
515 "<assistantprose>",
516 "<user_response>",
517 "<userresponse>",
518 "<done>",
519 "<tool_result",
520 "[result of ",
521 "##DONE##",
522 "DONE",
523 "PLAN_READY",
524 ];
525 let index = TextIndex::build(text);
526 for marker in MARKERS {
527 for len in (1..marker.len()).rev() {
528 let prefix = &marker[..len];
529 if let Some(stripped) = text.strip_suffix(prefix) {
530 if is_protocol_tag_position(&index, text, stripped.len()) {
531 return stripped.to_string();
532 }
533 }
534 }
535 }
536 text.to_string()
537}
538
539fn normalize_visible_whitespace(text: &str) -> String {
540 text.replace("\r\n", "\n")
541 .replace("\n\n\n", "\n\n")
542 .trim()
543 .to_string()
544}
545
546pub fn project_visible_assistant_text(text: &str) -> String {
563 let mut superseded = String::new();
564 let visible = sanitize_inner(text, false, Some(&mut superseded));
565 report_stripped_prose(&superseded);
566 visible
567}
568
569pub fn sanitize_visible_assistant_text(text: &str, partial: bool) -> String {
574 sanitize_inner(text, partial, None)
575}
576
577fn sanitize_inner(text: &str, partial: bool, superseded: Option<&mut String>) -> String {
578 let mut sanitized = text.to_string();
579 for pattern in internal_block_patterns() {
580 sanitized = pattern.replace_all(&sanitized, "").to_string();
581 }
582 sanitized = extract_visible_prose(&sanitized, superseded);
586 sanitized = strip_internal_json_fences(&sanitized);
587 sanitized = strip_inline_internal_planning_json(&sanitized, partial);
588 sanitized = strip_protocol_residue(&sanitized);
593 sanitized = strip_leading_done_marker_control(&sanitized);
594 sanitized = strip_trailing_internal_json(&sanitized);
595 sanitized = strip_bare_internal_json(sanitized.trim());
596 if partial {
597 sanitized = strip_unclosed_internal_blocks(&sanitized);
598 sanitized = strip_partial_marker_suffix(&sanitized);
599 }
600 normalize_visible_whitespace(&sanitized)
601}
602
603#[cfg(test)]
604mod tests {
605 use super::{
606 project_visible_assistant_text, sanitize_visible_assistant_text, VisibleTextState,
607 };
608 use crate::agent_events::AgentEvent;
609 use crate::boundary::tests::CapturedEvents;
610 use crate::boundary::{BoundaryFailureKind, BoundaryId};
611
612 const SUPERSEDED: &str = "Here is a long piece of narration the operator will never see.\n\
613 <user_response>Visible answer.</user_response>";
614
615 #[test]
620 fn prose_superseded_by_a_user_response_block_reaches_the_event_bus() {
621 let captured = CapturedEvents::install();
622 let raw = SUPERSEDED;
623 assert_eq!(project_visible_assistant_text(raw), "Visible answer.");
624
625 let events = captured.boundary_failures();
626 assert_eq!(events.len(), 1, "got: {events:?}");
627 match &events[0] {
628 AgentEvent::BoundaryFailure {
629 boundary,
630 kind,
631 owner,
632 excerpt,
633 ..
634 } => {
635 assert_eq!(*boundary, BoundaryId::VisibleTextSanitize);
636 assert_eq!(*kind, BoundaryFailureKind::Truncated);
637 assert_eq!(owner, "harness");
638 assert!(
639 excerpt
640 .as_deref()
641 .is_some_and(|text| text.contains("never see")),
642 "the event must carry the prose that died: {excerpt:?}",
643 );
644 }
645 other => panic!("expected a BoundaryFailure, got {other:?}"),
646 }
647 }
648
649 #[test]
653 fn a_streaming_partial_pass_stays_quiet() {
654 let captured = CapturedEvents::install();
655 let raw = "Narration in flight.\n<user_response>Visible answer.</user_response>";
656 sanitize_visible_assistant_text(raw, true);
657 assert!(captured.boundary_failures().is_empty());
658 }
659
660 #[test]
665 fn re_sanitizing_already_projected_text_never_reports() {
666 let captured = CapturedEvents::install();
667 for _ in 0..5 {
668 assert_eq!(
669 sanitize_visible_assistant_text(SUPERSEDED, false),
670 "Visible answer."
671 );
672 }
673 assert!(
674 captured.boundary_failures().is_empty(),
675 "re-derivation must not emit: {:?}",
676 captured.boundary_failures(),
677 );
678 }
679
680 #[test]
683 fn one_lost_paragraph_produces_exactly_one_event() {
684 let captured = CapturedEvents::install();
685 let visible = project_visible_assistant_text(SUPERSEDED);
686 for _ in 0..3 {
689 sanitize_visible_assistant_text(SUPERSEDED, false);
690 sanitize_visible_assistant_text(&visible, false);
691 }
692 assert_eq!(captured.boundary_failures().len(), 1);
693 }
694
695 #[test]
698 fn replaying_a_transcript_of_historical_turns_reports_nothing() {
699 let captured = CapturedEvents::install();
700 let history = [SUPERSEDED, SUPERSEDED, "plain narration, no wrapper"];
701 for turn in history.iter().rev() {
702 sanitize_visible_assistant_text(turn, false);
703 }
704 assert!(captured.boundary_failures().is_empty());
705 }
706
707 #[test]
708 fn a_user_response_with_nothing_else_around_it_stays_quiet() {
709 let captured = CapturedEvents::install();
710 let raw = "<user_response>Visible answer.</user_response>";
711 assert_eq!(project_visible_assistant_text(raw), "Visible answer.");
712 assert!(
713 captured.boundary_failures().is_empty(),
714 "stripping only the wrapper tags is not a loss",
715 );
716 }
717
718 #[test]
719 fn push_emits_incremental_visible_delta_for_plain_chunks() {
720 let mut state = VisibleTextState::default();
721 let (visible, delta) = state.push("Hello", true);
722 assert_eq!(visible, "Hello");
723 assert_eq!(delta, "Hello");
724
725 let (visible, delta) = state.push(" world", true);
726 assert_eq!(visible, "Hello world");
727 assert_eq!(delta, " world");
728 }
729
730 #[test]
731 fn push_hides_open_think_block_until_closed() {
732 let mut state = VisibleTextState::default();
733 let (visible, delta) = state.push("Hi <think>secret", true);
734 assert_eq!(visible, "Hi");
735 assert_eq!(delta, "Hi");
736
737 let (visible, delta) = state.push(" plan</think> bye", true);
738 assert_eq!(visible, "Hi bye");
739 assert_eq!(delta, " bye");
740 }
741
742 #[test]
743 fn push_emits_full_visible_text_when_sanitization_shrinks_output() {
744 let mut state = VisibleTextState::default();
745 let (visible, _) = state.push("ok", true);
746 assert_eq!(visible, "ok");
747
748 let (visible, delta) = state.push(" <think>", true);
749 assert_eq!(visible, "ok");
750 assert_eq!(delta, "");
752 }
753
754 #[test]
755 fn push_partial_marker_suffix_is_held_back_until_resolved() {
756 let mut state = VisibleTextState::default();
757 let (visible, delta) = state.push("Hello\n##DON", true);
758 assert_eq!(visible, "Hello");
759 assert_eq!(delta, "Hello");
760
761 let (visible, delta) = state.push("E##\nmore", true);
762 assert_eq!(visible, "Hello\n\nmore");
763 assert_eq!(delta, "\n\nmore");
764 }
765
766 #[test]
767 fn clear_resets_streaming_state() {
768 let mut state = VisibleTextState::default();
769 let _ = state.push("Hello world", true);
770 state.clear();
771 let (visible, delta) = state.push("fresh", true);
772 assert_eq!(visible, "fresh");
773 assert_eq!(delta, "fresh");
774 }
775
776 #[test]
777 fn sanitize_drops_inline_planner_json_only_with_planner_mode() {
778 let raw = r#"{"mode":"plan_then_execute","plan":[]}"#;
779 assert_eq!(sanitize_visible_assistant_text(raw, false), "");
780 let raw = r#"{"status":"ok","message":"hello"}"#;
781 assert_eq!(sanitize_visible_assistant_text(raw, false), raw);
782 }
783
784 #[test]
785 fn sanitize_strips_orphan_tool_call_residue_and_truncations() {
786 assert_eq!(sanitize_visible_assistant_text("_call>", false), "");
789 assert_eq!(sanitize_visible_assistant_text("l_call>l_call>", false), "");
790 assert_eq!(
791 sanitize_visible_assistant_text("Done.\n})\n</tool_call>_call>", false),
792 "Done.\n})"
793 );
794 assert_eq!(
795 sanitize_visible_assistant_text("Implemented.</assistant_prose>", false),
796 "Implemented."
797 );
798 assert_eq!(
800 sanitize_visible_assistant_text("Implemented.\nnt_prose>", false),
801 "Implemented."
802 );
803 let fenced = "```\n</tool_call>\n```\nDone.";
805 assert_eq!(sanitize_visible_assistant_text(fenced, false), fenced);
806 }
807
808 #[test]
809 fn sanitize_does_not_touch_ordinary_prose_or_inequalities() {
810 let raw = "Use a_call> only as— wait, compare x > y and y > z here.";
812 let out = sanitize_visible_assistant_text(raw, false);
814 assert!(out.contains("compare x > y and y > z here."), "got: {out}");
815 assert_eq!(
816 sanitize_visible_assistant_text("The phrase tool call is normal prose.", false),
817 "The phrase tool call is normal prose."
818 );
819 }
820
821 #[test]
822 fn sanitize_drops_bare_completion_judge_verdict_json() {
823 let raw = r#"{"verdict":"done","reasoning":"All tests pass.","next_step":""}"#;
824 assert_eq!(sanitize_visible_assistant_text(raw, false), "");
825 let padded = "\n {\"verdict\":\"continue\",\"reasoning\":\"does not compile\"} \n";
827 assert_eq!(sanitize_visible_assistant_text(padded, false), "");
828 let keep = r#"{"status":"ok","message":"hello"}"#;
830 assert_eq!(sanitize_visible_assistant_text(keep, false), keep);
831 let visible_answer =
835 r#"{"tasks":["ship"],"steps":["test"],"reasoning":"user-visible rationale"}"#;
836 assert_eq!(
837 sanitize_visible_assistant_text(visible_answer, false),
838 visible_answer
839 );
840 let visible_verdict = r#"{"verdict":"pass","summary":"public result"}"#;
841 assert_eq!(
842 sanitize_visible_assistant_text(visible_verdict, false),
843 visible_verdict
844 );
845 let visible_verdict_rationale = r#"{"verdict":"pass","reasoning":"public rationale"}"#;
846 assert_eq!(
847 sanitize_visible_assistant_text(visible_verdict_rationale, false),
848 visible_verdict_rationale
849 );
850 }
851
852 #[test]
853 fn sanitize_drops_appended_completion_judge_verdict_json() {
854 let raw = r#"What can I help with today?{"verdict":"done","reasoning":"greeting","next_step":""}"#;
855 assert_eq!(
856 sanitize_visible_assistant_text(raw, false),
857 "What can I help with today?"
858 );
859 let visible_json = r#"Visible answer {"status":"ok","message":"hello"}"#;
860 assert_eq!(
861 sanitize_visible_assistant_text(visible_json, false),
862 visible_json
863 );
864 }
865
866 #[test]
867 fn sanitize_drops_done_marker_prefixed_internal_control() {
868 let raw = r#"/done>{"verdict":"continue","reasoning":"needs final"}Visible answer."#;
869 assert_eq!(
870 sanitize_visible_assistant_text(raw, false),
871 "Visible answer."
872 );
873 assert_eq!(
874 sanitize_visible_assistant_text(r#"done>{"verdict":"done","reasoning":"done"}"#, false),
875 ""
876 );
877 let inline = "The literal /done> marker can be mentioned inline.";
878 assert_eq!(sanitize_visible_assistant_text(inline, false), inline);
879 }
880
881 #[test]
882 fn sanitize_prefers_user_response_blocks_over_other_prose() {
883 let raw = "Working...\n<assistant_prose>internal narration</assistant_prose>\n<user_response>Visible answer.</user_response>\n##DONE##";
884 assert_eq!(
885 sanitize_visible_assistant_text(raw, false),
886 "Visible answer."
887 );
888 }
889
890 #[test]
891 fn sanitize_strips_trailing_runtime_sentinel_after_answer_text() {
892 assert_eq!(
893 sanitize_visible_assistant_text("HARN_LOCAL_TOOL_OK##DONE##", false),
894 "HARN_LOCAL_TOOL_OK"
895 );
896 assert_eq!(
897 sanitize_visible_assistant_text("Done.\nPLAN_READY", false),
898 "Done."
899 );
900 }
901
902 #[test]
903 fn sanitize_accepts_compact_protocol_tag_aliases_without_hiding_plain_words() {
904 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>";
905 assert_eq!(
906 sanitize_visible_assistant_text(raw, false),
907 "Visible answer."
908 );
909
910 assert_eq!(
911 sanitize_visible_assistant_text("A tool call summary is fine.", false),
912 "A tool call summary is fine."
913 );
914 }
915
916 #[test]
917 fn sanitize_ignores_inline_user_response_placeholder() {
918 let raw = "Wrap final answers in `<user_response>...</user_response>`.\nAudit: real answer";
919 assert_eq!(sanitize_visible_assistant_text(raw, false), raw);
920 }
921
922 #[test]
923 fn sanitize_prefers_top_level_user_response_over_inline_placeholder() {
924 let raw =
925 "Remember `<user_response>...</user_response>` is the wrapper.\n<user_response>Visible answer.</user_response>";
926 assert_eq!(
927 sanitize_visible_assistant_text(raw, false),
928 "Visible answer."
929 );
930 }
931
932 #[test]
933 fn sanitize_ignores_user_response_inside_markdown_fence() {
934 let raw = "```xml\n<user_response>example only</user_response>\n```\nFinal plain answer.";
935 assert_eq!(sanitize_visible_assistant_text(raw, false), raw);
936 }
937
938 #[test]
939 fn sanitize_partial_keeps_inline_protocol_prefixes() {
940 let raw = "Mention `<user_resp";
941 assert_eq!(sanitize_visible_assistant_text(raw, true), raw);
942 }
943
944 #[test]
945 fn sanitize_partial_hides_top_level_protocol_prefixes() {
946 assert_eq!(sanitize_visible_assistant_text("<user_resp", true), "");
947 }
948}