Skip to main content

harn_vm/
visible_text.rs

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            // Tagged response protocol: hide tool-call bodies (executed as
42            // structured data, never surfaced as narration) and done
43            // blocks (runtime signal, not user-facing).
44            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
134/// Strip the wrapper tags around `<assistant_prose>` blocks so the
135/// surfaced visible text reads as plain narration. When a
136/// `<user_response>` block is present, it becomes the authoritative
137/// host-facing surface and supersedes generic assistant prose.
138fn 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: &regex::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    // Orphan / truncated protocol-tag litter that the well-formed block
318    // patterns above cannot match: a closing tag with no surviving opener, and
319    // the right-anchored `</tool_call>` truncations (`tool_call>`, `ol_call>`,
320    // `l_call>`, `_call>`) plus `</assistant_prose>` / `_prose>` / `</done>` /
321    // `/done>` fragments that weak open-weight models (incl. the GLM default)
322    // emit mid-stream. These are control-token residue, never legitimate
323    // narration, so they are stripped unconditionally — including from the
324    // FINAL transcript, which the partial-only strippers below never see.
325    // Bounds are tight (anchored on `_call>` / explicit tag names) to avoid
326    // touching ordinary prose like "x > y" or words ending in "e".
327    // Scope is deliberately limited to the UNAMBIGUOUS corruption families that
328    // never occur in real prose: right-anchored `</tool_call>` truncations
329    // (`</tool_call>`, `tool_call>`, `ol_call>`, `l_call>`, `_call>`, with the
330    // `<|tool_call|>` channel variant) and the `<assistant_prose>` close-tag
331    // truncations (`</assistant_prose>`, `assistant_prose>`, `nt_prose>`,
332    // `_prose>`). We do NOT blanket-strip `<user_response>`/`<done>`/
333    // `<tool_result>` here — those are owned by the position/fence-aware logic
334    // above and have legitimate inline-mention forms (see the placeholder/fence
335    // tests), so touching them regresses those guarantees.
336    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    // Fence-aware, matching the rest of this module: a fenced code block may
345    // legitimately show `</tool_call>` as an example, so residue inside a
346    // markdown fence is preserved; only standalone litter is removed.
347    protocol_residue_regex()
348        .replace_all(text, |caps: &regex::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    // A finalized turn whose entire visible body is an internal control object
436    // — e.g. the completion judge's `{"verdict":...,"reasoning":...}` — must
437    // never surface as the agent's message. The fenced/inline planner strips
438    // above only catch ```json fences and `{"mode":...}`; a bare top-level
439    // verdict/reasoning blob slips through. Keep this narrower than
440    // `looks_like_internal_planning_json`: user-facing JSON-only answers can
441    // legitimately contain keys like `tasks`, `steps`, or `reasoning`, and the
442    // visible-text sanitizer must not blank those whole messages.
443    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    // After runtime tags are stripped, surface only the explicit
514    // user-facing response when one exists; otherwise unwrap
515    // <assistant_prose> into plain narration.
516    sanitized = extract_visible_prose(&sanitized);
517    sanitized = strip_internal_json_fences(&sanitized);
518    sanitized = strip_inline_internal_planning_json(&sanitized, partial);
519    // Unconditional: orphan/truncated control-token residue and bare internal
520    // control JSON leak into FINAL transcripts too, where the partial-only
521    // strippers below never run. Bare-JSON check runs on the trimmed body so a
522    // verdict blob surrounded by whitespace is still recognized.
523    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        // No prefix change so delta is empty.
571        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        // Real leak: weak/GLM models emit truncated `</tool_call>` fragments as
607        // standalone visible text. None match the well-formed block patterns.
608        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        // `_prose>` close-tag truncation (no opening tag) is also litter.
619        assert_eq!(
620            sanitize_visible_assistant_text("Implemented.\nnt_prose>", false),
621            "Implemented."
622        );
623        // Fence-aware: a fenced example showing the tag is preserved verbatim.
624        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        // Guard against over-eager residue stripping.
631        let raw = "Use a_call> only as— wait, compare x > y and y > z here.";
632        // `a_call>` IS residue-shaped (`_call>` truncation); ensure the rest survives.
633        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        // A bare verdict blob surrounded by whitespace is still recognized.
646        let padded = "\n  {\"verdict\":\"continue\",\"reasoning\":\"does not compile\"}  \n";
647        assert_eq!(sanitize_visible_assistant_text(padded, false), "");
648        // Legitimate non-internal JSON is preserved (consistent with existing behavior).
649        let keep = r#"{"status":"ok","message":"hello"}"#;
650        assert_eq!(sanitize_visible_assistant_text(keep, false), keep);
651        // Guard against blanking legitimate JSON-only answers that happen to
652        // use broad planning-ish keys. The bare verdict sanitizer is scoped to
653        // small internal control envelopes, not arbitrary structured answers.
654        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}