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 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            // Tagged response protocol: hide tool-call bodies (executed as
43            // structured data, never surfaced as narration) and done
44            // blocks (runtime signal, not user-facing).
45            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/// The `<user_response>` sections of `text`, plus everything outside them.
79///
80/// The remainder is returned rather than discarded because `<user_response>`
81/// *supersedes* the rest of the turn: when the model wraps an answer, every
82/// other word it wrote stops reaching the host. That subtraction is the single
83/// largest silent loss in this file, so the caller needs the remainder in hand
84/// to report it (harn#5142).
85#[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
143/// Strip the wrapper tags around `<assistant_prose>` blocks so the
144/// surfaced visible text reads as plain narration. When a
145/// `<user_response>` block is present, it becomes the authoritative
146/// host-facing surface and supersedes generic assistant prose.
147fn 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
157/// Report prose the model wrote that no host will ever render (harn#5142).
158///
159/// By the time this runs the internal protocol blocks are already gone —
160/// thinking, tool calls, tool results, done markers — so whatever is left is
161/// narration, and dropping it is a real subtraction from what the model said
162/// rather than protocol hygiene.
163fn 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: &regex::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    // Orphan / truncated protocol-tag litter that the well-formed block
366    // patterns above cannot match: a closing tag with no surviving opener, and
367    // the right-anchored `</tool_call>` truncations (`tool_call>`, `ol_call>`,
368    // `l_call>`, `_call>`) plus `</assistant_prose>` / `_prose>` / `</done>` /
369    // `/done>` fragments that weak open-weight models (incl. the GLM default)
370    // emit mid-stream. These are control-token residue, never legitimate
371    // narration, so they are stripped unconditionally — including from the
372    // FINAL transcript, which the partial-only strippers below never see.
373    // Bounds are tight (anchored on `_call>` / explicit tag names) to avoid
374    // touching ordinary prose like "x > y" or words ending in "e".
375    // Scope is deliberately limited to the UNAMBIGUOUS corruption families that
376    // never occur in real prose: right-anchored `</tool_call>` truncations
377    // (`</tool_call>`, `tool_call>`, `ol_call>`, `l_call>`, `_call>`, with the
378    // `<|tool_call|>` channel variant) and the `<assistant_prose>` close-tag
379    // truncations (`</assistant_prose>`, `assistant_prose>`, `nt_prose>`,
380    // `_prose>`). We do NOT blanket-strip `<user_response>`/`<done>`/
381    // `<tool_result>` here — those are owned by the position/fence-aware logic
382    // above and have legitimate inline-mention forms (see the placeholder/fence
383    // tests), so touching them regresses those guarantees.
384    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    // Fence-aware, matching the rest of this module: a fenced code block may
394    // legitimately show `</tool_call>` as an example, so residue inside a
395    // markdown fence is preserved; only standalone litter is removed.
396    protocol_residue_regex()
397        .replace_all(text, |caps: &regex::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    // A finalized turn whose entire visible body is an internal control object
485    // — e.g. the completion judge's `{"verdict":...,"reasoning":...}` — must
486    // never surface as the agent's message. The fenced/inline planner strips
487    // above only catch ```json fences and `{"mode":...}`; a bare top-level
488    // verdict/reasoning blob slips through. Keep this narrower than
489    // `looks_like_internal_planning_json`: user-facing JSON-only answers can
490    // legitimately contain keys like `tasks`, `steps`, or `reasoning`, and the
491    // visible-text sanitizer must not blank those whole messages.
492    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
571/// Project a freshly produced assistant reply into the text a host renders,
572/// reporting what the projection removed (harn#5142).
573///
574/// This is the **only** entry point that reports, and it has exactly one
575/// caller: the LLM result projection, which sees a turn's reply once, at the
576/// moment it is produced. Every other consumer of visible text — the session
577/// finalize path walking a transcript for the last assistant message, the
578/// sub-agent result synthesizer, its transcript fallback walk — is
579/// *re-deriving* a projection over text that was already projected once. If
580/// those reported too, a single lost paragraph would be re-reported on every
581/// re-derivation, unbounded in transcript length, and a replay would emit
582/// boundary events for historical turns into the very record it replays from.
583///
584/// Keeping the reporting path out of [`sanitize_visible_assistant_text`] is
585/// what makes that impossible rather than merely discouraged: a re-derivation
586/// site cannot emit, because the function it calls has no emit path.
587pub 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
594/// Sanitize text that has already been projected once — a transcript entry, a
595/// recorded result, a streamed partial.
596///
597/// Never reports. See [`project_visible_assistant_text`] for why.
598pub 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    // After runtime tags are stripped, surface only the explicit
608    // user-facing response when one exists; otherwise unwrap
609    // <assistant_prose> into plain narration.
610    sanitized = extract_visible_prose(&sanitized, superseded);
611    sanitized = strip_internal_json_fences(&sanitized);
612    sanitized = strip_inline_internal_planning_json(&sanitized, partial);
613    // Unconditional: orphan/truncated control-token residue and bare internal
614    // control JSON leak into FINAL transcripts too, where the partial-only
615    // strippers below never run. Bare-JSON check runs on the trimmed body so a
616    // verdict blob surrounded by whitespace is still recognized.
617    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    /// The `visible_text_sanitize` boundary (harn#5142). A `<user_response>`
641    /// block supersedes the whole rest of the turn, so narration the model
642    /// wrote reaches no host. Protocol blocks are already stripped by the time
643    /// the check runs, so what is reported here is genuinely lost prose.
644    #[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    /// A partial pass runs once per streamed delta over a remainder the next
675    /// delta may still complete. Reporting there would emit noise proportional
676    /// to token count, and a funnel that cries wolf gets muted.
677    #[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    /// The load-bearing separation: only the first-time projection reports.
686    /// Every other consumer re-derives a projection over text that was already
687    /// projected, and re-derivation must be silent or one lost paragraph gets
688    /// re-reported on every pass.
689    #[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    /// A turn is projected once, so one loss produces exactly one event no
706    /// matter how many times the resulting text is later re-read.
707    #[test]
708    fn one_lost_paragraph_produces_exactly_one_event() {
709        let captured = CapturedEvents::install();
710        let visible = project_visible_assistant_text(SUPERSEDED);
711        // Everything downstream re-reads the same turn: the session finalize
712        // walk, the sub-agent synthesizer, its transcript fallback walk.
713        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    /// Replay walks history. If it reported, it would write boundary events for
721    /// old turns into the very record it replays from.
722    #[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        // No prefix change so delta is empty.
776        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        // Real leak: weak/GLM models emit truncated `</tool_call>` fragments as
812        // standalone visible text. None match the well-formed block patterns.
813        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        // `_prose>` close-tag truncation (no opening tag) is also litter.
824        assert_eq!(
825            sanitize_visible_assistant_text("Implemented.\nnt_prose>", false),
826            "Implemented."
827        );
828        // Fence-aware: a fenced example showing the tag is preserved verbatim.
829        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        // Guard against over-eager residue stripping.
836        let raw = "Use a_call> only as— wait, compare x > y and y > z here.";
837        // `a_call>` IS residue-shaped (`_call>` truncation); ensure the rest survives.
838        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        // A bare verdict blob surrounded by whitespace is still recognized.
851        let padded = "\n  {\"verdict\":\"continue\",\"reasoning\":\"does not compile\"}  \n";
852        assert_eq!(sanitize_visible_assistant_text(padded, false), "");
853        // Legitimate non-internal JSON is preserved (consistent with existing behavior).
854        let keep = r#"{"status":"ok","message":"hello"}"#;
855        assert_eq!(sanitize_visible_assistant_text(keep, false), keep);
856        // Guard against blanking legitimate JSON-only answers that happen to
857        // use broad planning-ish keys. The bare verdict sanitizer is scoped to
858        // small internal control envelopes, not arbitrary structured answers.
859        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}