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).
85fn extract_user_response(text: &str) -> Option<(String, String)> {
86    let index = TextIndex::build(text);
87    let mut sections: Vec<String> = Vec::new();
88    let mut remainder = String::with_capacity(text.len());
89    let mut last = 0;
90    for caps in user_response_regex().captures_iter(text) {
91        let Some(whole) = caps.get(0) else {
92            continue;
93        };
94        if !is_protocol_tag_position(&index, text, whole.start()) {
95            continue;
96        }
97        let Some(section) = caps.get(1).map(|m| m.as_str().trim().to_string()) else {
98            continue;
99        };
100        if section.is_empty() {
101            continue;
102        }
103        sections.push(section);
104        remainder.push_str(&text[last..whole.start()]);
105        last = whole.end();
106    }
107    if sections.is_empty() {
108        return None;
109    }
110    remainder.push_str(&text[last..]);
111    Some((sections.join("\n\n"), remainder))
112}
113
114fn unwrap_assistant_prose(text: &str) -> String {
115    let index = TextIndex::build(text);
116    let mut out = String::with_capacity(text.len());
117    let mut last = 0;
118    for caps in assistant_prose_regex().captures_iter(text) {
119        let Some(block) = caps.get(0) else {
120            continue;
121        };
122        if !is_protocol_tag_position(&index, text, block.start()) {
123            continue;
124        }
125        out.push_str(&text[last..block.start()]);
126        if let Some(body) = caps.get(1) {
127            out.push_str(body.as_str().trim());
128        }
129        last = block.end();
130    }
131    out.push_str(&text[last..]);
132    out
133}
134
135/// Strip the wrapper tags around `<assistant_prose>` blocks so the
136/// surfaced visible text reads as plain narration. When a
137/// `<user_response>` block is present, it becomes the authoritative
138/// host-facing surface and supersedes generic assistant prose.
139fn extract_visible_prose(text: &str, report: Option<&mut String>) -> String {
140    if let Some((user_response, superseded)) = extract_user_response(text) {
141        if let Some(slot) = report {
142            *slot = superseded;
143        }
144        return user_response;
145    }
146    unwrap_assistant_prose(text)
147}
148
149/// Report prose the model wrote that no host will ever render (harn#5142).
150///
151/// By the time this runs the internal protocol blocks are already gone —
152/// thinking, tool calls, tool results, done markers — so whatever is left is
153/// narration, and dropping it is a real subtraction from what the model said
154/// rather than protocol hygiene.
155fn report_stripped_prose(superseded: &str) {
156    if superseded.trim().is_empty() {
157        return;
158    }
159    crate::boundary::BoundaryFailure::new(
160        crate::boundary::BoundaryId::VisibleTextSanitize,
161        crate::boundary::BoundaryFailureKind::Truncated,
162        "a <user_response> block superseded assistant prose that no host will render",
163    )
164    .with_excerpt(superseded)
165    .report();
166}
167
168fn json_fence_regex() -> &'static Regex {
169    static JSON_FENCE: OnceLock<Regex> = OnceLock::new();
170    JSON_FENCE
171        .get_or_init(|| Regex::new(r"(?s)```json[^\n]*\n(.*?)```").expect("valid json fence regex"))
172}
173
174fn inline_planner_json_regex() -> &'static Regex {
175    static INLINE_PLANNER_JSON: OnceLock<Regex> = OnceLock::new();
176    INLINE_PLANNER_JSON.get_or_init(|| {
177        Regex::new(r#"(?s)\{\s*"mode"\s*:\s*"(?:fast_execute|plan_then_execute|ask_user)".*?\}"#)
178            .expect("valid inline planner json regex")
179    })
180}
181
182fn partial_inline_planner_json_regex() -> &'static Regex {
183    static PARTIAL_INLINE_PLANNER_JSON: OnceLock<Regex> = OnceLock::new();
184    PARTIAL_INLINE_PLANNER_JSON.get_or_init(|| {
185        Regex::new(r#"(?s)\{\s*"mode"\s*:\s*"(?:fast_execute|plan_then_execute|ask_user)".*$"#)
186            .expect("valid partial inline planner json regex")
187    })
188}
189
190fn looks_like_internal_planning_json(source: &str) -> bool {
191    let trimmed = source.trim();
192    if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
193        return false;
194    }
195
196    fn collect_keys(value: &serde_json::Value, keys: &mut BTreeSet<String>) {
197        match value {
198            serde_json::Value::Object(map) => {
199                for (key, child) in map {
200                    keys.insert(key.clone());
201                    collect_keys(child, keys);
202                }
203            }
204            serde_json::Value::Array(items) => {
205                for item in items {
206                    collect_keys(item, keys);
207                }
208            }
209            _ => {}
210        }
211    }
212
213    if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
214        let mut keys = BTreeSet::new();
215        collect_keys(&parsed, &mut keys);
216        let has_planner_mode = match &parsed {
217            serde_json::Value::Object(map) => map
218                .get("mode")
219                .and_then(|value| value.as_str())
220                .is_some_and(|mode| {
221                    matches!(mode, "fast_execute" | "plan_then_execute" | "ask_user")
222                }),
223            _ => false,
224        };
225        let has_internal_keys = [
226            "plan",
227            "steps",
228            "tool_calls",
229            "tool_name",
230            "verification",
231            "execution_mode",
232            "required_outputs",
233            "files_to_edit",
234            "next_action",
235            "reasoning",
236            "direction",
237            "targets",
238            "tasks",
239            "unknowns",
240        ]
241        .into_iter()
242        .any(|key| keys.contains(key));
243        return has_planner_mode || has_internal_keys;
244    }
245
246    false
247}
248
249fn strip_internal_json_fences(text: &str) -> String {
250    json_fence_regex()
251        .replace_all(text, |caps: &regex::Captures| {
252            let body = caps
253                .get(1)
254                .map(|match_| match_.as_str())
255                .unwrap_or_default();
256            if looks_like_internal_planning_json(body) {
257                String::new()
258            } else {
259                caps.get(0)
260                    .map(|match_| match_.as_str().to_string())
261                    .unwrap_or_default()
262            }
263        })
264        .to_string()
265}
266
267fn strip_unclosed_internal_blocks(text: &str) -> String {
268    let index = TextIndex::build(text);
269    if let Some(open_idx) = text.rfind("<|tool_call|>") {
270        let close_idx = text.rfind("</|tool_call|>");
271        if close_idx.is_none_or(|idx| idx < open_idx) {
272            return text[..open_idx].to_string();
273        }
274    }
275
276    if let Some(open_idx) = text.rfind(TEXT_TOOL_CALL_OPEN) {
277        let close_idx = text.rfind(TEXT_TOOL_CALL_CLOSE);
278        if is_protocol_tag_position(&index, text, open_idx)
279            && close_idx.is_none_or(|idx| idx < open_idx)
280        {
281            return text[..open_idx].to_string();
282        }
283    }
284
285    if let Some(open_idx) = text.rfind(TEXT_TOOL_CALL_OPEN_COMPACT) {
286        let close_idx = text.rfind(TEXT_TOOL_CALL_CLOSE_COMPACT);
287        if is_protocol_tag_position(&index, text, open_idx)
288            && close_idx.is_none_or(|idx| idx < open_idx)
289        {
290            return text[..open_idx].to_string();
291        }
292    }
293
294    if let Some(open_idx) = text.rfind("<done>") {
295        let close_idx = text.rfind("</done>");
296        if is_protocol_tag_position(&index, text, open_idx)
297            && close_idx.is_none_or(|idx| idx < open_idx)
298        {
299            return text[..open_idx].to_string();
300        }
301    }
302
303    if let Some(open_idx) = text.rfind("<user_response>") {
304        let close_idx = text.rfind("</user_response>");
305        if is_protocol_tag_position(&index, text, open_idx)
306            && close_idx.is_none_or(|idx| idx < open_idx)
307        {
308            return text[..open_idx].to_string();
309        }
310    }
311
312    if let Some(open_idx) = text.rfind("<userresponse>") {
313        let close_idx = text.rfind("</userresponse>");
314        if is_protocol_tag_position(&index, text, open_idx)
315            && close_idx.is_none_or(|idx| idx < open_idx)
316        {
317            return text[..open_idx].to_string();
318        }
319    }
320
321    if let Some(open_idx) = text.rfind("[result of ") {
322        let close_idx = text.rfind("[end of ");
323        if close_idx.is_none_or(|idx| idx < open_idx) {
324            return text[..open_idx].to_string();
325        }
326    }
327
328    if let Some(open_idx) = text.rfind("<tool_result") {
329        let close_idx = text.rfind("</tool_result>");
330        if is_protocol_tag_position(&index, text, open_idx)
331            && close_idx.is_none_or(|idx| idx < open_idx)
332        {
333            return text[..open_idx].to_string();
334        }
335    }
336
337    text.to_string()
338}
339
340fn strip_inline_internal_planning_json(text: &str, partial: bool) -> String {
341    let mut stripped = inline_planner_json_regex()
342        .replace_all(text, "")
343        .to_string();
344    if partial {
345        stripped = partial_inline_planner_json_regex()
346            .replace_all(&stripped, "")
347            .to_string();
348    }
349    stripped
350}
351
352fn protocol_residue_regex() -> &'static Regex {
353    // Orphan / truncated protocol-tag litter that the well-formed block
354    // patterns above cannot match: a closing tag with no surviving opener, and
355    // the right-anchored `</tool_call>` truncations (`tool_call>`, `ol_call>`,
356    // `l_call>`, `_call>`) plus `</assistant_prose>` / `_prose>` / `</done>` /
357    // `/done>` fragments that weak open-weight models (incl. the GLM default)
358    // emit mid-stream. These are control-token residue, never legitimate
359    // narration, so they are stripped unconditionally — including from the
360    // FINAL transcript, which the partial-only strippers below never see.
361    // Bounds are tight (anchored on `_call>` / explicit tag names) to avoid
362    // touching ordinary prose like "x > y" or words ending in "e".
363    // Scope is deliberately limited to the UNAMBIGUOUS corruption families that
364    // never occur in real prose: right-anchored `</tool_call>` truncations
365    // (`</tool_call>`, `tool_call>`, `ol_call>`, `l_call>`, `_call>`, with the
366    // `<|tool_call|>` channel variant) and the `<assistant_prose>` close-tag
367    // truncations (`</assistant_prose>`, `assistant_prose>`, `nt_prose>`,
368    // `_prose>`). We do NOT blanket-strip `<user_response>`/`<done>`/
369    // `<tool_result>` here — those are owned by the position/fence-aware logic
370    // above and have legitimate inline-mention forms (see the placeholder/fence
371    // tests), so touching them regresses those guarantees.
372    static RE: OnceLock<Regex> = OnceLock::new();
373    RE.get_or_init(|| {
374        Regex::new(r"<?/?\|?(?:t?o?o?l?)_call\|?>|<?/?\|?[a-z]*_prose>")
375            .expect("valid protocol residue regex")
376    })
377}
378
379fn strip_protocol_residue(text: &str) -> String {
380    let index = TextIndex::build(text);
381    // Fence-aware, matching the rest of this module: a fenced code block may
382    // legitimately show `</tool_call>` as an example, so residue inside a
383    // markdown fence is preserved; only standalone litter is removed.
384    protocol_residue_regex()
385        .replace_all(text, |caps: &regex::Captures| {
386            let matched = caps.get(0).expect("capture group 0 always present");
387            if index.inside_markdown_fence(matched.start()) {
388                matched.as_str().to_string()
389            } else {
390                String::new()
391            }
392        })
393        .to_string()
394}
395
396fn looks_like_internal_verdict_object(map: &serde_json::Map<String, serde_json::Value>) -> bool {
397    let Some(verdict) = map
398        .get("verdict")
399        .and_then(|value| value.as_str())
400        .map(str::trim)
401        .filter(|value| !value.is_empty())
402    else {
403        return false;
404    };
405
406    let verdict = verdict.to_ascii_lowercase();
407    let has_completion_explanation = map.contains_key("reasoning")
408        || map.contains_key("reason")
409        || map.contains_key("next_step")
410        || map.contains_key("nextStep");
411    let has_judge_metadata = map.contains_key("critique")
412        || map.contains_key("confidence")
413        || map.contains_key("category")
414        || map.contains_key("error");
415
416    let known_internal_verdict = matches!(verdict.as_str(), "done" | "continue")
417        && has_completion_explanation
418        || matches!(verdict.as_str(), "revise" | "pass" | "fail" | "unclear") && has_judge_metadata
419        || matches!(verdict.as_str(), "allow" | "warn" | "block") && has_judge_metadata;
420    if !known_internal_verdict {
421        return false;
422    }
423
424    map.keys().all(|key| {
425        matches!(
426            key.as_str(),
427            "verdict"
428                | "reasoning"
429                | "reason"
430                | "next_step"
431                | "nextStep"
432                | "critique"
433                | "confidence"
434                | "category"
435                | "error"
436        )
437    })
438}
439
440fn looks_like_bare_internal_verdict_json(source: &str) -> bool {
441    let trimmed = source.trim();
442    if !trimmed.starts_with('{') {
443        return false;
444    }
445
446    let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(trimmed)
447    else {
448        return false;
449    };
450
451    looks_like_internal_verdict_object(&map)
452}
453
454fn internal_verdict_json_prefix_len(source: &str) -> Option<usize> {
455    let trimmed = source.trim_start();
456    if !trimmed.starts_with('{') {
457        return None;
458    }
459    let leading_ws = source.len() - trimmed.len();
460    let mut stream = serde_json::Deserializer::from_str(trimmed).into_iter::<serde_json::Value>();
461    let parsed = stream.next()?.ok()?;
462    let serde_json::Value::Object(map) = parsed else {
463        return None;
464    };
465    if !looks_like_internal_verdict_object(&map) {
466        return None;
467    }
468    Some(leading_ws + stream.byte_offset())
469}
470
471fn strip_bare_internal_json(text: &str) -> String {
472    // A finalized turn whose entire visible body is an internal control object
473    // — e.g. the completion judge's `{"verdict":...,"reasoning":...}` — must
474    // never surface as the agent's message. The fenced/inline planner strips
475    // above only catch ```json fences and `{"mode":...}`; a bare top-level
476    // verdict/reasoning blob slips through. Keep this narrower than
477    // `looks_like_internal_planning_json`: user-facing JSON-only answers can
478    // legitimately contain keys like `tasks`, `steps`, or `reasoning`, and the
479    // visible-text sanitizer must not blank those whole messages.
480    if looks_like_bare_internal_verdict_json(text) {
481        return String::new();
482    }
483    text.to_string()
484}
485
486fn strip_leading_done_marker_control(text: &str) -> String {
487    let trimmed = text.trim_start();
488    let leading_ws = text.len() - trimmed.len();
489    for marker in ["</done>", "<done>", "/done>", "done>"] {
490        if let Some(after_marker) = trimmed.strip_prefix(marker) {
491            let after_marker = after_marker.trim_start();
492            let visible_start = internal_verdict_json_prefix_len(after_marker).unwrap_or(0);
493            return text[..leading_ws].to_string() + after_marker[visible_start..].trim_start();
494        }
495    }
496    text.to_string()
497}
498
499fn strip_trailing_internal_json(text: &str) -> String {
500    let trimmed = text.trim_end();
501    for (idx, ch) in trimmed.char_indices().rev() {
502        if ch == '{' && looks_like_bare_internal_verdict_json(&trimmed[idx..]) {
503            return trimmed[..idx].trim_end().to_string();
504        }
505    }
506    text.to_string()
507}
508
509fn strip_partial_marker_suffix(text: &str) -> String {
510    const MARKERS: [&str; 13] = [
511        "<|tool_call|>",
512        TEXT_TOOL_CALL_OPEN,
513        TEXT_TOOL_CALL_OPEN_COMPACT,
514        "<assistant_prose>",
515        "<assistantprose>",
516        "<user_response>",
517        "<userresponse>",
518        "<done>",
519        "<tool_result",
520        "[result of ",
521        "##DONE##",
522        "DONE",
523        "PLAN_READY",
524    ];
525    let index = TextIndex::build(text);
526    for marker in MARKERS {
527        for len in (1..marker.len()).rev() {
528            let prefix = &marker[..len];
529            if let Some(stripped) = text.strip_suffix(prefix) {
530                if is_protocol_tag_position(&index, text, stripped.len()) {
531                    return stripped.to_string();
532                }
533            }
534        }
535    }
536    text.to_string()
537}
538
539fn normalize_visible_whitespace(text: &str) -> String {
540    text.replace("\r\n", "\n")
541        .replace("\n\n\n", "\n\n")
542        .trim()
543        .to_string()
544}
545
546/// Project a freshly produced assistant reply into the text a host renders,
547/// reporting what the projection removed (harn#5142).
548///
549/// This is the **only** entry point that reports, and it has exactly one
550/// caller: the LLM result projection, which sees a turn's reply once, at the
551/// moment it is produced. Every other consumer of visible text — the session
552/// finalize path walking a transcript for the last assistant message, the
553/// sub-agent result synthesizer, its transcript fallback walk — is
554/// *re-deriving* a projection over text that was already projected once. If
555/// those reported too, a single lost paragraph would be re-reported on every
556/// re-derivation, unbounded in transcript length, and a replay would emit
557/// boundary events for historical turns into the very record it replays from.
558///
559/// Keeping the reporting path out of [`sanitize_visible_assistant_text`] is
560/// what makes that impossible rather than merely discouraged: a re-derivation
561/// site cannot emit, because the function it calls has no emit path.
562pub fn project_visible_assistant_text(text: &str) -> String {
563    let mut superseded = String::new();
564    let visible = sanitize_inner(text, false, Some(&mut superseded));
565    report_stripped_prose(&superseded);
566    visible
567}
568
569/// Sanitize text that has already been projected once — a transcript entry, a
570/// recorded result, a streamed partial.
571///
572/// Never reports. See [`project_visible_assistant_text`] for why.
573pub fn sanitize_visible_assistant_text(text: &str, partial: bool) -> String {
574    sanitize_inner(text, partial, None)
575}
576
577fn sanitize_inner(text: &str, partial: bool, superseded: Option<&mut String>) -> String {
578    let mut sanitized = text.to_string();
579    for pattern in internal_block_patterns() {
580        sanitized = pattern.replace_all(&sanitized, "").to_string();
581    }
582    // After runtime tags are stripped, surface only the explicit
583    // user-facing response when one exists; otherwise unwrap
584    // <assistant_prose> into plain narration.
585    sanitized = extract_visible_prose(&sanitized, superseded);
586    sanitized = strip_internal_json_fences(&sanitized);
587    sanitized = strip_inline_internal_planning_json(&sanitized, partial);
588    // Unconditional: orphan/truncated control-token residue and bare internal
589    // control JSON leak into FINAL transcripts too, where the partial-only
590    // strippers below never run. Bare-JSON check runs on the trimmed body so a
591    // verdict blob surrounded by whitespace is still recognized.
592    sanitized = strip_protocol_residue(&sanitized);
593    sanitized = strip_leading_done_marker_control(&sanitized);
594    sanitized = strip_trailing_internal_json(&sanitized);
595    sanitized = strip_bare_internal_json(sanitized.trim());
596    if partial {
597        sanitized = strip_unclosed_internal_blocks(&sanitized);
598        sanitized = strip_partial_marker_suffix(&sanitized);
599    }
600    normalize_visible_whitespace(&sanitized)
601}
602
603#[cfg(test)]
604mod tests {
605    use super::{
606        project_visible_assistant_text, sanitize_visible_assistant_text, VisibleTextState,
607    };
608    use crate::agent_events::AgentEvent;
609    use crate::boundary::tests::CapturedEvents;
610    use crate::boundary::{BoundaryFailureKind, BoundaryId};
611
612    const SUPERSEDED: &str = "Here is a long piece of narration the operator will never see.\n\
613                              <user_response>Visible answer.</user_response>";
614
615    /// The `visible_text_sanitize` boundary (harn#5142). A `<user_response>`
616    /// block supersedes the whole rest of the turn, so narration the model
617    /// wrote reaches no host. Protocol blocks are already stripped by the time
618    /// the check runs, so what is reported here is genuinely lost prose.
619    #[test]
620    fn prose_superseded_by_a_user_response_block_reaches_the_event_bus() {
621        let captured = CapturedEvents::install();
622        let raw = SUPERSEDED;
623        assert_eq!(project_visible_assistant_text(raw), "Visible answer.");
624
625        let events = captured.boundary_failures();
626        assert_eq!(events.len(), 1, "got: {events:?}");
627        match &events[0] {
628            AgentEvent::BoundaryFailure {
629                boundary,
630                kind,
631                owner,
632                excerpt,
633                ..
634            } => {
635                assert_eq!(*boundary, BoundaryId::VisibleTextSanitize);
636                assert_eq!(*kind, BoundaryFailureKind::Truncated);
637                assert_eq!(owner, "harness");
638                assert!(
639                    excerpt
640                        .as_deref()
641                        .is_some_and(|text| text.contains("never see")),
642                    "the event must carry the prose that died: {excerpt:?}",
643                );
644            }
645            other => panic!("expected a BoundaryFailure, got {other:?}"),
646        }
647    }
648
649    /// A partial pass runs once per streamed delta over a remainder the next
650    /// delta may still complete. Reporting there would emit noise proportional
651    /// to token count, and a funnel that cries wolf gets muted.
652    #[test]
653    fn a_streaming_partial_pass_stays_quiet() {
654        let captured = CapturedEvents::install();
655        let raw = "Narration in flight.\n<user_response>Visible answer.</user_response>";
656        sanitize_visible_assistant_text(raw, true);
657        assert!(captured.boundary_failures().is_empty());
658    }
659
660    /// The load-bearing separation: only the first-time projection reports.
661    /// Every other consumer re-derives a projection over text that was already
662    /// projected, and re-derivation must be silent or one lost paragraph gets
663    /// re-reported on every pass.
664    #[test]
665    fn re_sanitizing_already_projected_text_never_reports() {
666        let captured = CapturedEvents::install();
667        for _ in 0..5 {
668            assert_eq!(
669                sanitize_visible_assistant_text(SUPERSEDED, false),
670                "Visible answer."
671            );
672        }
673        assert!(
674            captured.boundary_failures().is_empty(),
675            "re-derivation must not emit: {:?}",
676            captured.boundary_failures(),
677        );
678    }
679
680    /// A turn is projected once, so one loss produces exactly one event no
681    /// matter how many times the resulting text is later re-read.
682    #[test]
683    fn one_lost_paragraph_produces_exactly_one_event() {
684        let captured = CapturedEvents::install();
685        let visible = project_visible_assistant_text(SUPERSEDED);
686        // Everything downstream re-reads the same turn: the session finalize
687        // walk, the sub-agent synthesizer, its transcript fallback walk.
688        for _ in 0..3 {
689            sanitize_visible_assistant_text(SUPERSEDED, false);
690            sanitize_visible_assistant_text(&visible, false);
691        }
692        assert_eq!(captured.boundary_failures().len(), 1);
693    }
694
695    /// Replay walks history. If it reported, it would write boundary events for
696    /// old turns into the very record it replays from.
697    #[test]
698    fn replaying_a_transcript_of_historical_turns_reports_nothing() {
699        let captured = CapturedEvents::install();
700        let history = [SUPERSEDED, SUPERSEDED, "plain narration, no wrapper"];
701        for turn in history.iter().rev() {
702            sanitize_visible_assistant_text(turn, false);
703        }
704        assert!(captured.boundary_failures().is_empty());
705    }
706
707    #[test]
708    fn a_user_response_with_nothing_else_around_it_stays_quiet() {
709        let captured = CapturedEvents::install();
710        let raw = "<user_response>Visible answer.</user_response>";
711        assert_eq!(project_visible_assistant_text(raw), "Visible answer.");
712        assert!(
713            captured.boundary_failures().is_empty(),
714            "stripping only the wrapper tags is not a loss",
715        );
716    }
717
718    #[test]
719    fn push_emits_incremental_visible_delta_for_plain_chunks() {
720        let mut state = VisibleTextState::default();
721        let (visible, delta) = state.push("Hello", true);
722        assert_eq!(visible, "Hello");
723        assert_eq!(delta, "Hello");
724
725        let (visible, delta) = state.push(" world", true);
726        assert_eq!(visible, "Hello world");
727        assert_eq!(delta, " world");
728    }
729
730    #[test]
731    fn push_hides_open_think_block_until_closed() {
732        let mut state = VisibleTextState::default();
733        let (visible, delta) = state.push("Hi <think>secret", true);
734        assert_eq!(visible, "Hi");
735        assert_eq!(delta, "Hi");
736
737        let (visible, delta) = state.push(" plan</think> bye", true);
738        assert_eq!(visible, "Hi  bye");
739        assert_eq!(delta, "  bye");
740    }
741
742    #[test]
743    fn push_emits_full_visible_text_when_sanitization_shrinks_output() {
744        let mut state = VisibleTextState::default();
745        let (visible, _) = state.push("ok", true);
746        assert_eq!(visible, "ok");
747
748        let (visible, delta) = state.push(" <think>", true);
749        assert_eq!(visible, "ok");
750        // No prefix change so delta is empty.
751        assert_eq!(delta, "");
752    }
753
754    #[test]
755    fn push_partial_marker_suffix_is_held_back_until_resolved() {
756        let mut state = VisibleTextState::default();
757        let (visible, delta) = state.push("Hello\n##DON", true);
758        assert_eq!(visible, "Hello");
759        assert_eq!(delta, "Hello");
760
761        let (visible, delta) = state.push("E##\nmore", true);
762        assert_eq!(visible, "Hello\n\nmore");
763        assert_eq!(delta, "\n\nmore");
764    }
765
766    #[test]
767    fn clear_resets_streaming_state() {
768        let mut state = VisibleTextState::default();
769        let _ = state.push("Hello world", true);
770        state.clear();
771        let (visible, delta) = state.push("fresh", true);
772        assert_eq!(visible, "fresh");
773        assert_eq!(delta, "fresh");
774    }
775
776    #[test]
777    fn sanitize_drops_inline_planner_json_only_with_planner_mode() {
778        let raw = r#"{"mode":"plan_then_execute","plan":[]}"#;
779        assert_eq!(sanitize_visible_assistant_text(raw, false), "");
780        let raw = r#"{"status":"ok","message":"hello"}"#;
781        assert_eq!(sanitize_visible_assistant_text(raw, false), raw);
782    }
783
784    #[test]
785    fn sanitize_strips_orphan_tool_call_residue_and_truncations() {
786        // Real leak: weak/GLM models emit truncated `</tool_call>` fragments as
787        // standalone visible text. None match the well-formed block patterns.
788        assert_eq!(sanitize_visible_assistant_text("_call>", false), "");
789        assert_eq!(sanitize_visible_assistant_text("l_call>l_call>", false), "");
790        assert_eq!(
791            sanitize_visible_assistant_text("Done.\n})\n</tool_call>_call>", false),
792            "Done.\n})"
793        );
794        assert_eq!(
795            sanitize_visible_assistant_text("Implemented.</assistant_prose>", false),
796            "Implemented."
797        );
798        // `_prose>` close-tag truncation (no opening tag) is also litter.
799        assert_eq!(
800            sanitize_visible_assistant_text("Implemented.\nnt_prose>", false),
801            "Implemented."
802        );
803        // Fence-aware: a fenced example showing the tag is preserved verbatim.
804        let fenced = "```\n</tool_call>\n```\nDone.";
805        assert_eq!(sanitize_visible_assistant_text(fenced, false), fenced);
806    }
807
808    #[test]
809    fn sanitize_does_not_touch_ordinary_prose_or_inequalities() {
810        // Guard against over-eager residue stripping.
811        let raw = "Use a_call> only as— wait, compare x > y and y > z here.";
812        // `a_call>` IS residue-shaped (`_call>` truncation); ensure the rest survives.
813        let out = sanitize_visible_assistant_text(raw, false);
814        assert!(out.contains("compare x > y and y > z here."), "got: {out}");
815        assert_eq!(
816            sanitize_visible_assistant_text("The phrase tool call is normal prose.", false),
817            "The phrase tool call is normal prose."
818        );
819    }
820
821    #[test]
822    fn sanitize_drops_bare_completion_judge_verdict_json() {
823        let raw = r#"{"verdict":"done","reasoning":"All tests pass.","next_step":""}"#;
824        assert_eq!(sanitize_visible_assistant_text(raw, false), "");
825        // A bare verdict blob surrounded by whitespace is still recognized.
826        let padded = "\n  {\"verdict\":\"continue\",\"reasoning\":\"does not compile\"}  \n";
827        assert_eq!(sanitize_visible_assistant_text(padded, false), "");
828        // Legitimate non-internal JSON is preserved (consistent with existing behavior).
829        let keep = r#"{"status":"ok","message":"hello"}"#;
830        assert_eq!(sanitize_visible_assistant_text(keep, false), keep);
831        // Guard against blanking legitimate JSON-only answers that happen to
832        // use broad planning-ish keys. The bare verdict sanitizer is scoped to
833        // small internal control envelopes, not arbitrary structured answers.
834        let visible_answer =
835            r#"{"tasks":["ship"],"steps":["test"],"reasoning":"user-visible rationale"}"#;
836        assert_eq!(
837            sanitize_visible_assistant_text(visible_answer, false),
838            visible_answer
839        );
840        let visible_verdict = r#"{"verdict":"pass","summary":"public result"}"#;
841        assert_eq!(
842            sanitize_visible_assistant_text(visible_verdict, false),
843            visible_verdict
844        );
845        let visible_verdict_rationale = r#"{"verdict":"pass","reasoning":"public rationale"}"#;
846        assert_eq!(
847            sanitize_visible_assistant_text(visible_verdict_rationale, false),
848            visible_verdict_rationale
849        );
850    }
851
852    #[test]
853    fn sanitize_drops_appended_completion_judge_verdict_json() {
854        let raw = r#"What can I help with today?{"verdict":"done","reasoning":"greeting","next_step":""}"#;
855        assert_eq!(
856            sanitize_visible_assistant_text(raw, false),
857            "What can I help with today?"
858        );
859        let visible_json = r#"Visible answer {"status":"ok","message":"hello"}"#;
860        assert_eq!(
861            sanitize_visible_assistant_text(visible_json, false),
862            visible_json
863        );
864    }
865
866    #[test]
867    fn sanitize_drops_done_marker_prefixed_internal_control() {
868        let raw = r#"/done>{"verdict":"continue","reasoning":"needs final"}Visible answer."#;
869        assert_eq!(
870            sanitize_visible_assistant_text(raw, false),
871            "Visible answer."
872        );
873        assert_eq!(
874            sanitize_visible_assistant_text(r#"done>{"verdict":"done","reasoning":"done"}"#, false),
875            ""
876        );
877        let inline = "The literal /done> marker can be mentioned inline.";
878        assert_eq!(sanitize_visible_assistant_text(inline, false), inline);
879    }
880
881    #[test]
882    fn sanitize_prefers_user_response_blocks_over_other_prose() {
883        let raw = "Working...\n<assistant_prose>internal narration</assistant_prose>\n<user_response>Visible answer.</user_response>\n##DONE##";
884        assert_eq!(
885            sanitize_visible_assistant_text(raw, false),
886            "Visible answer."
887        );
888    }
889
890    #[test]
891    fn sanitize_strips_trailing_runtime_sentinel_after_answer_text() {
892        assert_eq!(
893            sanitize_visible_assistant_text("HARN_LOCAL_TOOL_OK##DONE##", false),
894            "HARN_LOCAL_TOOL_OK"
895        );
896        assert_eq!(
897            sanitize_visible_assistant_text("Done.\nPLAN_READY", false),
898            "Done."
899        );
900    }
901
902    #[test]
903    fn sanitize_accepts_compact_protocol_tag_aliases_without_hiding_plain_words() {
904        let raw = "The phrase tool call is normal prose.\n<assistantprose>hidden</assistantprose>\n<toolcall>\nrun({ command: \"git status\" })\n</toolcall>\n<userresponse>Visible answer.</userresponse>\n<done>##DONE##</done>";
905        assert_eq!(
906            sanitize_visible_assistant_text(raw, false),
907            "Visible answer."
908        );
909
910        assert_eq!(
911            sanitize_visible_assistant_text("A tool call summary is fine.", false),
912            "A tool call summary is fine."
913        );
914    }
915
916    #[test]
917    fn sanitize_ignores_inline_user_response_placeholder() {
918        let raw = "Wrap final answers in `<user_response>...</user_response>`.\nAudit: real answer";
919        assert_eq!(sanitize_visible_assistant_text(raw, false), raw);
920    }
921
922    #[test]
923    fn sanitize_prefers_top_level_user_response_over_inline_placeholder() {
924        let raw =
925            "Remember `<user_response>...</user_response>` is the wrapper.\n<user_response>Visible answer.</user_response>";
926        assert_eq!(
927            sanitize_visible_assistant_text(raw, false),
928            "Visible answer."
929        );
930    }
931
932    #[test]
933    fn sanitize_ignores_user_response_inside_markdown_fence() {
934        let raw = "```xml\n<user_response>example only</user_response>\n```\nFinal plain answer.";
935        assert_eq!(sanitize_visible_assistant_text(raw, false), raw);
936    }
937
938    #[test]
939    fn sanitize_partial_keeps_inline_protocol_prefixes() {
940        let raw = "Mention `<user_resp";
941        assert_eq!(sanitize_visible_assistant_text(raw, true), raw);
942    }
943
944    #[test]
945    fn sanitize_partial_hides_top_level_protocol_prefixes() {
946        assert_eq!(sanitize_visible_assistant_text("<user_resp", true), "");
947    }
948}