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
78#[expect(
86 clippy::string_slice,
87 reason = "last/whole.start()/whole.end() are regex match offsets on text"
88)]
89fn extract_user_response(text: &str) -> Option<(String, String)> {
90 let index = TextIndex::build(text);
91 let mut sections: Vec<String> = Vec::new();
92 let mut remainder = String::with_capacity(text.len());
93 let mut last = 0;
94 for caps in user_response_regex().captures_iter(text) {
95 let Some(whole) = caps.get(0) else {
96 continue;
97 };
98 if !is_protocol_tag_position(&index, text, whole.start()) {
99 continue;
100 }
101 let Some(section) = caps.get(1).map(|m| m.as_str().trim().to_string()) else {
102 continue;
103 };
104 if section.is_empty() {
105 continue;
106 }
107 sections.push(section);
108 remainder.push_str(&text[last..whole.start()]);
109 last = whole.end();
110 }
111 if sections.is_empty() {
112 return None;
113 }
114 remainder.push_str(&text[last..]);
115 Some((sections.join("\n\n"), remainder))
116}
117
118#[expect(
119 clippy::string_slice,
120 reason = "last/block.start()/block.end() are regex match offsets on text"
121)]
122fn unwrap_assistant_prose(text: &str) -> String {
123 let index = TextIndex::build(text);
124 let mut out = String::with_capacity(text.len());
125 let mut last = 0;
126 for caps in assistant_prose_regex().captures_iter(text) {
127 let Some(block) = caps.get(0) else {
128 continue;
129 };
130 if !is_protocol_tag_position(&index, text, block.start()) {
131 continue;
132 }
133 out.push_str(&text[last..block.start()]);
134 if let Some(body) = caps.get(1) {
135 out.push_str(body.as_str().trim());
136 }
137 last = block.end();
138 }
139 out.push_str(&text[last..]);
140 out
141}
142
143fn extract_visible_prose(text: &str, report: Option<&mut String>) -> String {
148 if let Some((user_response, superseded)) = extract_user_response(text) {
149 if let Some(slot) = report {
150 *slot = superseded;
151 }
152 return user_response;
153 }
154 unwrap_assistant_prose(text)
155}
156
157fn report_stripped_prose(superseded: &str) {
164 if superseded.trim().is_empty() {
165 return;
166 }
167 crate::boundary::BoundaryFailure::new(
168 crate::boundary::BoundaryId::VisibleTextSanitize,
169 crate::boundary::BoundaryFailureKind::Truncated,
170 "a <user_response> block superseded assistant prose that no host will render",
171 )
172 .with_excerpt(superseded)
173 .report();
174}
175
176fn json_fence_regex() -> &'static Regex {
177 static JSON_FENCE: OnceLock<Regex> = OnceLock::new();
178 JSON_FENCE
179 .get_or_init(|| Regex::new(r"(?s)```json[^\n]*\n(.*?)```").expect("valid json fence regex"))
180}
181
182fn inline_planner_json_regex() -> &'static Regex {
183 static INLINE_PLANNER_JSON: OnceLock<Regex> = OnceLock::new();
184 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 inline planner json regex")
187 })
188}
189
190fn partial_inline_planner_json_regex() -> &'static Regex {
191 static PARTIAL_INLINE_PLANNER_JSON: OnceLock<Regex> = OnceLock::new();
192 PARTIAL_INLINE_PLANNER_JSON.get_or_init(|| {
193 Regex::new(r#"(?s)\{\s*"mode"\s*:\s*"(?:fast_execute|plan_then_execute|ask_user)".*$"#)
194 .expect("valid partial inline planner json regex")
195 })
196}
197
198fn looks_like_internal_planning_json(source: &str) -> bool {
199 let trimmed = source.trim();
200 if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
201 return false;
202 }
203
204 fn collect_keys(value: &serde_json::Value, keys: &mut BTreeSet<String>) {
205 match value {
206 serde_json::Value::Object(map) => {
207 for (key, child) in map {
208 keys.insert(key.clone());
209 collect_keys(child, keys);
210 }
211 }
212 serde_json::Value::Array(items) => {
213 for item in items {
214 collect_keys(item, keys);
215 }
216 }
217 _ => {}
218 }
219 }
220
221 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
222 let mut keys = BTreeSet::new();
223 collect_keys(&parsed, &mut keys);
224 let has_planner_mode = match &parsed {
225 serde_json::Value::Object(map) => map
226 .get("mode")
227 .and_then(|value| value.as_str())
228 .is_some_and(|mode| {
229 matches!(mode, "fast_execute" | "plan_then_execute" | "ask_user")
230 }),
231 _ => false,
232 };
233 let has_internal_keys = [
234 "plan",
235 "steps",
236 "tool_calls",
237 "tool_name",
238 "verification",
239 "execution_mode",
240 "required_outputs",
241 "files_to_edit",
242 "next_action",
243 "reasoning",
244 "direction",
245 "targets",
246 "tasks",
247 "unknowns",
248 ]
249 .into_iter()
250 .any(|key| keys.contains(key));
251 return has_planner_mode || has_internal_keys;
252 }
253
254 false
255}
256
257fn strip_internal_json_fences(text: &str) -> String {
258 json_fence_regex()
259 .replace_all(text, |caps: ®ex::Captures| {
260 let body = caps
261 .get(1)
262 .map(|match_| match_.as_str())
263 .unwrap_or_default();
264 if looks_like_internal_planning_json(body) {
265 String::new()
266 } else {
267 caps.get(0)
268 .map(|match_| match_.as_str().to_string())
269 .unwrap_or_default()
270 }
271 })
272 .to_string()
273}
274
275#[expect(
276 clippy::string_slice,
277 reason = "every open_idx is an rfind offset on text, so a char boundary"
278)]
279fn strip_unclosed_internal_blocks(text: &str) -> String {
280 let index = TextIndex::build(text);
281 if let Some(open_idx) = text.rfind("<|tool_call|>") {
282 let close_idx = text.rfind("</|tool_call|>");
283 if close_idx.is_none_or(|idx| idx < open_idx) {
284 return text[..open_idx].to_string();
285 }
286 }
287
288 if let Some(open_idx) = text.rfind(TEXT_TOOL_CALL_OPEN) {
289 let close_idx = text.rfind(TEXT_TOOL_CALL_CLOSE);
290 if is_protocol_tag_position(&index, text, open_idx)
291 && close_idx.is_none_or(|idx| idx < open_idx)
292 {
293 return text[..open_idx].to_string();
294 }
295 }
296
297 if let Some(open_idx) = text.rfind(TEXT_TOOL_CALL_OPEN_COMPACT) {
298 let close_idx = text.rfind(TEXT_TOOL_CALL_CLOSE_COMPACT);
299 if is_protocol_tag_position(&index, text, open_idx)
300 && close_idx.is_none_or(|idx| idx < open_idx)
301 {
302 return text[..open_idx].to_string();
303 }
304 }
305
306 if let Some(open_idx) = text.rfind("<done>") {
307 let close_idx = text.rfind("</done>");
308 if is_protocol_tag_position(&index, text, open_idx)
309 && close_idx.is_none_or(|idx| idx < open_idx)
310 {
311 return text[..open_idx].to_string();
312 }
313 }
314
315 if let Some(open_idx) = text.rfind("<user_response>") {
316 let close_idx = text.rfind("</user_response>");
317 if is_protocol_tag_position(&index, text, open_idx)
318 && close_idx.is_none_or(|idx| idx < open_idx)
319 {
320 return text[..open_idx].to_string();
321 }
322 }
323
324 if let Some(open_idx) = text.rfind("<userresponse>") {
325 let close_idx = text.rfind("</userresponse>");
326 if is_protocol_tag_position(&index, text, open_idx)
327 && close_idx.is_none_or(|idx| idx < open_idx)
328 {
329 return text[..open_idx].to_string();
330 }
331 }
332
333 if let Some(open_idx) = text.rfind("[result of ") {
334 let close_idx = text.rfind("[end of ");
335 if close_idx.is_none_or(|idx| idx < open_idx) {
336 return text[..open_idx].to_string();
337 }
338 }
339
340 if let Some(open_idx) = text.rfind("<tool_result") {
341 let close_idx = text.rfind("</tool_result>");
342 if is_protocol_tag_position(&index, text, open_idx)
343 && close_idx.is_none_or(|idx| idx < open_idx)
344 {
345 return text[..open_idx].to_string();
346 }
347 }
348
349 text.to_string()
350}
351
352fn strip_inline_internal_planning_json(text: &str, partial: bool) -> String {
353 let mut stripped = inline_planner_json_regex()
354 .replace_all(text, "")
355 .to_string();
356 if partial {
357 stripped = partial_inline_planner_json_regex()
358 .replace_all(&stripped, "")
359 .to_string();
360 }
361 stripped
362}
363
364fn protocol_residue_regex() -> &'static Regex {
365 static RE: OnceLock<Regex> = OnceLock::new();
385 RE.get_or_init(|| {
386 Regex::new(r"<?/?\|?(?:t?o?o?l?)_call\|?>|<?/?\|?[a-z]*_prose>")
387 .expect("valid protocol residue regex")
388 })
389}
390
391fn strip_protocol_residue(text: &str) -> String {
392 let index = TextIndex::build(text);
393 protocol_residue_regex()
397 .replace_all(text, |caps: ®ex::Captures| {
398 let matched = caps.get(0).expect("capture group 0 always present");
399 if index.inside_markdown_fence(matched.start()) {
400 matched.as_str().to_string()
401 } else {
402 String::new()
403 }
404 })
405 .to_string()
406}
407
408fn looks_like_internal_verdict_object(map: &serde_json::Map<String, serde_json::Value>) -> bool {
409 let Some(verdict) = map
410 .get("verdict")
411 .and_then(|value| value.as_str())
412 .map(str::trim)
413 .filter(|value| !value.is_empty())
414 else {
415 return false;
416 };
417
418 let verdict = verdict.to_ascii_lowercase();
419 let has_completion_explanation = map.contains_key("reasoning")
420 || map.contains_key("reason")
421 || map.contains_key("next_step")
422 || map.contains_key("nextStep");
423 let has_judge_metadata = map.contains_key("critique")
424 || map.contains_key("confidence")
425 || map.contains_key("category")
426 || map.contains_key("error");
427
428 let known_internal_verdict = matches!(verdict.as_str(), "done" | "continue")
429 && has_completion_explanation
430 || matches!(verdict.as_str(), "revise" | "pass" | "fail" | "unclear") && has_judge_metadata
431 || matches!(verdict.as_str(), "allow" | "warn" | "block") && has_judge_metadata;
432 if !known_internal_verdict {
433 return false;
434 }
435
436 map.keys().all(|key| {
437 matches!(
438 key.as_str(),
439 "verdict"
440 | "reasoning"
441 | "reason"
442 | "next_step"
443 | "nextStep"
444 | "critique"
445 | "confidence"
446 | "category"
447 | "error"
448 )
449 })
450}
451
452fn looks_like_bare_internal_verdict_json(source: &str) -> bool {
453 let trimmed = source.trim();
454 if !trimmed.starts_with('{') {
455 return false;
456 }
457
458 let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(trimmed)
459 else {
460 return false;
461 };
462
463 looks_like_internal_verdict_object(&map)
464}
465
466fn internal_verdict_json_prefix_len(source: &str) -> Option<usize> {
467 let trimmed = source.trim_start();
468 if !trimmed.starts_with('{') {
469 return None;
470 }
471 let leading_ws = source.len() - trimmed.len();
472 let mut stream = serde_json::Deserializer::from_str(trimmed).into_iter::<serde_json::Value>();
473 let parsed = stream.next()?.ok()?;
474 let serde_json::Value::Object(map) = parsed else {
475 return None;
476 };
477 if !looks_like_internal_verdict_object(&map) {
478 return None;
479 }
480 Some(leading_ws + stream.byte_offset())
481}
482
483fn strip_bare_internal_json(text: &str) -> String {
484 if looks_like_bare_internal_verdict_json(text) {
493 return String::new();
494 }
495 text.to_string()
496}
497
498#[expect(
499 clippy::string_slice,
500 reason = "leading_ws is a trim_start delta; visible_start is a serde_json byte offset \
501 landing after a complete JSON value"
502)]
503fn strip_leading_done_marker_control(text: &str) -> String {
504 let trimmed = text.trim_start();
505 let leading_ws = text.len() - trimmed.len();
506 for marker in ["</done>", "<done>", "/done>", "done>"] {
507 if let Some(after_marker) = trimmed.strip_prefix(marker) {
508 let after_marker = after_marker.trim_start();
509 let visible_start = internal_verdict_json_prefix_len(after_marker).unwrap_or(0);
510 return text[..leading_ws].to_string() + after_marker[visible_start..].trim_start();
511 }
512 }
513 text.to_string()
514}
515
516#[expect(
517 clippy::string_slice,
518 reason = "idx comes from char_indices of trimmed"
519)]
520fn strip_trailing_internal_json(text: &str) -> String {
521 let trimmed = text.trim_end();
522 for (idx, ch) in trimmed.char_indices().rev() {
523 if ch == '{' && looks_like_bare_internal_verdict_json(&trimmed[idx..]) {
524 return trimmed[..idx].trim_end().to_string();
525 }
526 }
527 text.to_string()
528}
529
530#[expect(
531 clippy::string_slice,
532 reason = "the markers are ASCII literals, so any prefix length is a char boundary"
533)]
534fn strip_partial_marker_suffix(text: &str) -> String {
535 const MARKERS: [&str; 13] = [
536 "<|tool_call|>",
537 TEXT_TOOL_CALL_OPEN,
538 TEXT_TOOL_CALL_OPEN_COMPACT,
539 "<assistant_prose>",
540 "<assistantprose>",
541 "<user_response>",
542 "<userresponse>",
543 "<done>",
544 "<tool_result",
545 "[result of ",
546 "##DONE##",
547 "DONE",
548 "PLAN_READY",
549 ];
550 let index = TextIndex::build(text);
551 for marker in MARKERS {
552 for len in (1..marker.len()).rev() {
553 let prefix = &marker[..len];
554 if let Some(stripped) = text.strip_suffix(prefix) {
555 if is_protocol_tag_position(&index, text, stripped.len()) {
556 return stripped.to_string();
557 }
558 }
559 }
560 }
561 text.to_string()
562}
563
564fn normalize_visible_whitespace(text: &str) -> String {
565 text.replace("\r\n", "\n")
566 .replace("\n\n\n", "\n\n")
567 .trim()
568 .to_string()
569}
570
571pub fn project_visible_assistant_text(text: &str) -> String {
588 let mut superseded = String::new();
589 let visible = sanitize_inner(text, false, Some(&mut superseded));
590 report_stripped_prose(&superseded);
591 visible
592}
593
594pub fn sanitize_visible_assistant_text(text: &str, partial: bool) -> String {
599 sanitize_inner(text, partial, None)
600}
601
602fn sanitize_inner(text: &str, partial: bool, superseded: Option<&mut String>) -> String {
603 let mut sanitized = text.to_string();
604 for pattern in internal_block_patterns() {
605 sanitized = pattern.replace_all(&sanitized, "").to_string();
606 }
607 sanitized = extract_visible_prose(&sanitized, superseded);
611 sanitized = strip_internal_json_fences(&sanitized);
612 sanitized = strip_inline_internal_planning_json(&sanitized, partial);
613 sanitized = strip_protocol_residue(&sanitized);
618 sanitized = strip_leading_done_marker_control(&sanitized);
619 sanitized = strip_trailing_internal_json(&sanitized);
620 sanitized = strip_bare_internal_json(sanitized.trim());
621 if partial {
622 sanitized = strip_unclosed_internal_blocks(&sanitized);
623 sanitized = strip_partial_marker_suffix(&sanitized);
624 }
625 normalize_visible_whitespace(&sanitized)
626}
627
628#[cfg(test)]
629mod tests {
630 use super::{
631 project_visible_assistant_text, sanitize_visible_assistant_text, VisibleTextState,
632 };
633 use crate::agent_events::AgentEvent;
634 use crate::boundary::tests::CapturedEvents;
635 use crate::boundary::{BoundaryFailureKind, BoundaryId};
636
637 const SUPERSEDED: &str = "Here is a long piece of narration the operator will never see.\n\
638 <user_response>Visible answer.</user_response>";
639
640 #[test]
645 fn prose_superseded_by_a_user_response_block_reaches_the_event_bus() {
646 let captured = CapturedEvents::install();
647 let raw = SUPERSEDED;
648 assert_eq!(project_visible_assistant_text(raw), "Visible answer.");
649
650 let events = captured.boundary_failures();
651 assert_eq!(events.len(), 1, "got: {events:?}");
652 match &events[0] {
653 AgentEvent::BoundaryFailure {
654 boundary,
655 kind,
656 owner,
657 excerpt,
658 ..
659 } => {
660 assert_eq!(*boundary, BoundaryId::VisibleTextSanitize);
661 assert_eq!(*kind, BoundaryFailureKind::Truncated);
662 assert_eq!(owner, "harness");
663 assert!(
664 excerpt
665 .as_deref()
666 .is_some_and(|text| text.contains("never see")),
667 "the event must carry the prose that died: {excerpt:?}",
668 );
669 }
670 other => panic!("expected a BoundaryFailure, got {other:?}"),
671 }
672 }
673
674 #[test]
678 fn a_streaming_partial_pass_stays_quiet() {
679 let captured = CapturedEvents::install();
680 let raw = "Narration in flight.\n<user_response>Visible answer.</user_response>";
681 sanitize_visible_assistant_text(raw, true);
682 assert!(captured.boundary_failures().is_empty());
683 }
684
685 #[test]
690 fn re_sanitizing_already_projected_text_never_reports() {
691 let captured = CapturedEvents::install();
692 for _ in 0..5 {
693 assert_eq!(
694 sanitize_visible_assistant_text(SUPERSEDED, false),
695 "Visible answer."
696 );
697 }
698 assert!(
699 captured.boundary_failures().is_empty(),
700 "re-derivation must not emit: {:?}",
701 captured.boundary_failures(),
702 );
703 }
704
705 #[test]
708 fn one_lost_paragraph_produces_exactly_one_event() {
709 let captured = CapturedEvents::install();
710 let visible = project_visible_assistant_text(SUPERSEDED);
711 for _ in 0..3 {
714 sanitize_visible_assistant_text(SUPERSEDED, false);
715 sanitize_visible_assistant_text(&visible, false);
716 }
717 assert_eq!(captured.boundary_failures().len(), 1);
718 }
719
720 #[test]
723 fn replaying_a_transcript_of_historical_turns_reports_nothing() {
724 let captured = CapturedEvents::install();
725 let history = [SUPERSEDED, SUPERSEDED, "plain narration, no wrapper"];
726 for turn in history.iter().rev() {
727 sanitize_visible_assistant_text(turn, false);
728 }
729 assert!(captured.boundary_failures().is_empty());
730 }
731
732 #[test]
733 fn a_user_response_with_nothing_else_around_it_stays_quiet() {
734 let captured = CapturedEvents::install();
735 let raw = "<user_response>Visible answer.</user_response>";
736 assert_eq!(project_visible_assistant_text(raw), "Visible answer.");
737 assert!(
738 captured.boundary_failures().is_empty(),
739 "stripping only the wrapper tags is not a loss",
740 );
741 }
742
743 #[test]
744 fn push_emits_incremental_visible_delta_for_plain_chunks() {
745 let mut state = VisibleTextState::default();
746 let (visible, delta) = state.push("Hello", true);
747 assert_eq!(visible, "Hello");
748 assert_eq!(delta, "Hello");
749
750 let (visible, delta) = state.push(" world", true);
751 assert_eq!(visible, "Hello world");
752 assert_eq!(delta, " world");
753 }
754
755 #[test]
756 fn push_hides_open_think_block_until_closed() {
757 let mut state = VisibleTextState::default();
758 let (visible, delta) = state.push("Hi <think>secret", true);
759 assert_eq!(visible, "Hi");
760 assert_eq!(delta, "Hi");
761
762 let (visible, delta) = state.push(" plan</think> bye", true);
763 assert_eq!(visible, "Hi bye");
764 assert_eq!(delta, " bye");
765 }
766
767 #[test]
768 fn push_emits_full_visible_text_when_sanitization_shrinks_output() {
769 let mut state = VisibleTextState::default();
770 let (visible, _) = state.push("ok", true);
771 assert_eq!(visible, "ok");
772
773 let (visible, delta) = state.push(" <think>", true);
774 assert_eq!(visible, "ok");
775 assert_eq!(delta, "");
777 }
778
779 #[test]
780 fn push_partial_marker_suffix_is_held_back_until_resolved() {
781 let mut state = VisibleTextState::default();
782 let (visible, delta) = state.push("Hello\n##DON", true);
783 assert_eq!(visible, "Hello");
784 assert_eq!(delta, "Hello");
785
786 let (visible, delta) = state.push("E##\nmore", true);
787 assert_eq!(visible, "Hello\n\nmore");
788 assert_eq!(delta, "\n\nmore");
789 }
790
791 #[test]
792 fn clear_resets_streaming_state() {
793 let mut state = VisibleTextState::default();
794 let _ = state.push("Hello world", true);
795 state.clear();
796 let (visible, delta) = state.push("fresh", true);
797 assert_eq!(visible, "fresh");
798 assert_eq!(delta, "fresh");
799 }
800
801 #[test]
802 fn sanitize_drops_inline_planner_json_only_with_planner_mode() {
803 let raw = r#"{"mode":"plan_then_execute","plan":[]}"#;
804 assert_eq!(sanitize_visible_assistant_text(raw, false), "");
805 let raw = r#"{"status":"ok","message":"hello"}"#;
806 assert_eq!(sanitize_visible_assistant_text(raw, false), raw);
807 }
808
809 #[test]
810 fn sanitize_strips_orphan_tool_call_residue_and_truncations() {
811 assert_eq!(sanitize_visible_assistant_text("_call>", false), "");
814 assert_eq!(sanitize_visible_assistant_text("l_call>l_call>", false), "");
815 assert_eq!(
816 sanitize_visible_assistant_text("Done.\n})\n</tool_call>_call>", false),
817 "Done.\n})"
818 );
819 assert_eq!(
820 sanitize_visible_assistant_text("Implemented.</assistant_prose>", false),
821 "Implemented."
822 );
823 assert_eq!(
825 sanitize_visible_assistant_text("Implemented.\nnt_prose>", false),
826 "Implemented."
827 );
828 let fenced = "```\n</tool_call>\n```\nDone.";
830 assert_eq!(sanitize_visible_assistant_text(fenced, false), fenced);
831 }
832
833 #[test]
834 fn sanitize_does_not_touch_ordinary_prose_or_inequalities() {
835 let raw = "Use a_call> only as— wait, compare x > y and y > z here.";
837 let out = sanitize_visible_assistant_text(raw, false);
839 assert!(out.contains("compare x > y and y > z here."), "got: {out}");
840 assert_eq!(
841 sanitize_visible_assistant_text("The phrase tool call is normal prose.", false),
842 "The phrase tool call is normal prose."
843 );
844 }
845
846 #[test]
847 fn sanitize_drops_bare_completion_judge_verdict_json() {
848 let raw = r#"{"verdict":"done","reasoning":"All tests pass.","next_step":""}"#;
849 assert_eq!(sanitize_visible_assistant_text(raw, false), "");
850 let padded = "\n {\"verdict\":\"continue\",\"reasoning\":\"does not compile\"} \n";
852 assert_eq!(sanitize_visible_assistant_text(padded, false), "");
853 let keep = r#"{"status":"ok","message":"hello"}"#;
855 assert_eq!(sanitize_visible_assistant_text(keep, false), keep);
856 let visible_answer =
860 r#"{"tasks":["ship"],"steps":["test"],"reasoning":"user-visible rationale"}"#;
861 assert_eq!(
862 sanitize_visible_assistant_text(visible_answer, false),
863 visible_answer
864 );
865 let visible_verdict = r#"{"verdict":"pass","summary":"public result"}"#;
866 assert_eq!(
867 sanitize_visible_assistant_text(visible_verdict, false),
868 visible_verdict
869 );
870 let visible_verdict_rationale = r#"{"verdict":"pass","reasoning":"public rationale"}"#;
871 assert_eq!(
872 sanitize_visible_assistant_text(visible_verdict_rationale, false),
873 visible_verdict_rationale
874 );
875 }
876
877 #[test]
878 fn sanitize_drops_appended_completion_judge_verdict_json() {
879 let raw = r#"What can I help with today?{"verdict":"done","reasoning":"greeting","next_step":""}"#;
880 assert_eq!(
881 sanitize_visible_assistant_text(raw, false),
882 "What can I help with today?"
883 );
884 let visible_json = r#"Visible answer {"status":"ok","message":"hello"}"#;
885 assert_eq!(
886 sanitize_visible_assistant_text(visible_json, false),
887 visible_json
888 );
889 }
890
891 #[test]
892 fn sanitize_drops_done_marker_prefixed_internal_control() {
893 let raw = r#"/done>{"verdict":"continue","reasoning":"needs final"}Visible answer."#;
894 assert_eq!(
895 sanitize_visible_assistant_text(raw, false),
896 "Visible answer."
897 );
898 assert_eq!(
899 sanitize_visible_assistant_text(r#"done>{"verdict":"done","reasoning":"done"}"#, false),
900 ""
901 );
902 let inline = "The literal /done> marker can be mentioned inline.";
903 assert_eq!(sanitize_visible_assistant_text(inline, false), inline);
904 }
905
906 #[test]
907 fn sanitize_prefers_user_response_blocks_over_other_prose() {
908 let raw = "Working...\n<assistant_prose>internal narration</assistant_prose>\n<user_response>Visible answer.</user_response>\n##DONE##";
909 assert_eq!(
910 sanitize_visible_assistant_text(raw, false),
911 "Visible answer."
912 );
913 }
914
915 #[test]
916 fn sanitize_strips_trailing_runtime_sentinel_after_answer_text() {
917 assert_eq!(
918 sanitize_visible_assistant_text("HARN_LOCAL_TOOL_OK##DONE##", false),
919 "HARN_LOCAL_TOOL_OK"
920 );
921 assert_eq!(
922 sanitize_visible_assistant_text("Done.\nPLAN_READY", false),
923 "Done."
924 );
925 }
926
927 #[test]
928 fn sanitize_accepts_compact_protocol_tag_aliases_without_hiding_plain_words() {
929 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>";
930 assert_eq!(
931 sanitize_visible_assistant_text(raw, false),
932 "Visible answer."
933 );
934
935 assert_eq!(
936 sanitize_visible_assistant_text("A tool call summary is fine.", false),
937 "A tool call summary is fine."
938 );
939 }
940
941 #[test]
942 fn sanitize_ignores_inline_user_response_placeholder() {
943 let raw = "Wrap final answers in `<user_response>...</user_response>`.\nAudit: real answer";
944 assert_eq!(sanitize_visible_assistant_text(raw, false), raw);
945 }
946
947 #[test]
948 fn sanitize_prefers_top_level_user_response_over_inline_placeholder() {
949 let raw =
950 "Remember `<user_response>...</user_response>` is the wrapper.\n<user_response>Visible answer.</user_response>";
951 assert_eq!(
952 sanitize_visible_assistant_text(raw, false),
953 "Visible answer."
954 );
955 }
956
957 #[test]
958 fn sanitize_ignores_user_response_inside_markdown_fence() {
959 let raw = "```xml\n<user_response>example only</user_response>\n```\nFinal plain answer.";
960 assert_eq!(sanitize_visible_assistant_text(raw, false), raw);
961 }
962
963 #[test]
964 fn sanitize_partial_keeps_inline_protocol_prefixes() {
965 let raw = "Mention `<user_resp";
966 assert_eq!(sanitize_visible_assistant_text(raw, true), raw);
967 }
968
969 #[test]
970 fn sanitize_partial_hides_top_level_protocol_prefixes() {
971 assert_eq!(sanitize_visible_assistant_text("<user_resp", true), "");
972 }
973}