Skip to main content

trace_stream/
viz.rs

1// Copyright (c) 2026 Enzo Lombardi
2// SPDX-License-Identifier: MIT
3
4//! Streaming tool-call visualization.
5//!
6//! Sits between raw model output and the terminal: it detects the DSML
7//! tool-call marker in plain text and inside `<think>` blocks, suppresses the
8//! raw DSML markup from display, and instead paints compact, human-friendly
9//! tool banners ("$ command", "Reading file 1:500...", streamed diff lines
10//! with `- `/`+ ` prefixes). Executable tool calls are still parsed by
11//! [`crate::dsml::DsmlParser`]; this module only rewrites the terminal
12//! projection, never the transcript.
13//!
14//! Port of `agent_tool_visualizer`, `agent_dsml_marker_detector`, and
15//! `agent_stream_renderer` from `ds4_agent.c`.
16
17use crate::dsml::{
18    DsmlParser, DsmlState, MARKER_NAMES, ToolCall, tag_prefix_len, tag_prefix_partial,
19};
20
21/// Told to the model when it emitted a tool call inside `<think>`.
22///
23/// Lives here rather than in plank's system prompt module because the stream
24/// renderer is what detects the violation. `plank::sysprompt` re-exports it,
25/// and `tests/c_parity.rs` locks its text against `refs/ds4`.
26pub const IN_THINK_PROHIBITION: &str =
27    "Tool calls are not allowed inside <think></think>; finish thinking before emitting DSML.";
28
29/// The canonical DSML tool-call opening marker.
30const DSML_START: &[u8] = "<|DSML|tool_calls>".as_bytes();
31/// Canonical invoke opener, seeded when the model skips the outer wrapper.
32const CANONICAL_INVOKE: &[u8] = "<|DSML|invoke".as_bytes();
33const DSML_BAR: &[u8] = "|".as_bytes();
34
35const THINK_OPEN: &[u8] = b"<think>";
36const THINK_CLOSE: &[u8] = b"</think>";
37
38/// Whether the opt-out env var permits logging. Split out from
39/// [`tool_error_logging_enabled`] so the decision is testable: the
40/// `cfg!(test)` half of that gate is always true inside a test binary.
41fn logging_enabled_for(opt_out: Option<&std::ffi::OsStr>) -> bool {
42    opt_out.is_none_or(|v| v != "1")
43}
44
45/// Whether tool-call errors are appended to `~/.plank/tool-call-errors.log`.
46///
47/// Off under `cargo test`, and off in a spawned binary when
48/// `PLANK_NO_TOOL_ERROR_LOG=1` is set — the e2e harness sets it so fixture
49/// stanzas (`echo plank-e2e`) never enter the developer's real log.
50fn tool_error_logging_enabled() -> bool {
51    if cfg!(test) {
52        return false;
53    }
54    logging_enabled_for(std::env::var_os("PLANK_NO_TOOL_ERROR_LOG").as_deref())
55}
56
57/// Best-effort append of a rejected tool call to `$HOME/.plank/tool-call-errors.log`.
58///
59/// Records the rejection reason plus the raw DSML stanza the model emitted so a
60/// rejected tool call can be inspected after the fact. Always on; any IO error
61/// (missing HOME, unwritable dir, …) is silently swallowed so the parse path is
62/// never affected.
63fn log_tool_error(reason: &str, raw: &[u8]) {
64    use std::io::Write;
65
66    if !tool_error_logging_enabled() {
67        return;
68    }
69
70    let Some(home) = std::env::var_os("HOME").filter(|h| !h.is_empty()) else {
71        return;
72    };
73    let dir = std::path::PathBuf::from(home).join(".plank");
74    if std::fs::create_dir_all(&dir).is_err() {
75        return;
76    }
77    let secs = std::time::SystemTime::now()
78        .duration_since(std::time::UNIX_EPOCH)
79        .map_or(0, |d| d.as_secs());
80    let snippet = String::from_utf8_lossy(raw);
81    if let Ok(mut f) = std::fs::OpenOptions::new()
82        .create(true)
83        .append(true)
84        .open(dir.join("tool-call-errors.log"))
85    {
86        // Ignore write failures; logging must never break parsing.
87        let record = format!("[{secs}] {reason}\n---\n{snippet}\n===\n");
88        let _ = f.write_all(record.as_bytes());
89    }
90}
91
92/// Destination for rendered output; the UI layer routes this to its renderer.
93///
94/// The stream renderer never emits raw DSML through this trait: DSML bytes are
95/// replaced by tool banners, which always arrive via
96/// [`visible_text`](Self::visible_text).
97///
98/// This trait is also the animation boundary. Motion is Ratatui-only: the
99/// Ratatui sink drives `plank`'s animation module effects (throbber, shimmer, pulse,
100/// flash, stall-fade) off the shared 20 Hz clock, while the plain-stdout and
101/// `--non-interactive` sinks render the static/reduced-motion form. The stream
102/// renderer feeds bytes through here without knowing which sink animates, so the
103/// plain path stays untouched.
104pub trait RenderSink {
105    /// Receives ordinary visible output.
106    fn visible_text(&mut self, text: &str);
107    /// Receives text produced inside a `<think>` block.
108    fn think_text(&mut self, text: &str);
109    /// Receives tool banner output (never markdown); defaults to visible.
110    fn tool_text(&mut self, text: &str) {
111        self.visible_text(text);
112    }
113    /// Receives error banners (e.g. `[invalid tool call: ...]`); sinks should
114    /// style these red. Defaults to tool text.
115    fn error_text(&mut self, text: &str) {
116        self.tool_text(text);
117    }
118}
119
120impl RenderSink for Box<dyn RenderSink> {
121    fn visible_text(&mut self, text: &str) {
122        (**self).visible_text(text);
123    }
124    fn think_text(&mut self, text: &str) {
125        (**self).think_text(text);
126    }
127    fn tool_text(&mut self, text: &str) {
128        (**self).tool_text(text);
129    }
130    fn error_text(&mut self, text: &str) {
131        (**self).error_text(text);
132    }
133}
134
135/// The `Send` flavour, for a sink that has to cross into a scoped thread — a
136/// parallel sub-agent fan-out drives several generations at once, each writing
137/// through its own sink.
138impl RenderSink for Box<dyn RenderSink + Send> {
139    fn visible_text(&mut self, text: &str) {
140        (**self).visible_text(text);
141    }
142    fn think_text(&mut self, text: &str) {
143        (**self).think_text(text);
144    }
145    fn tool_text(&mut self, text: &str) {
146        (**self).tool_text(text);
147    }
148    fn error_text(&mut self, text: &str) {
149        (**self).error_text(text);
150    }
151}
152
153/// A sink that accumulates everything it is given into a shared buffer.
154///
155/// Used by the parallel fan-out: the sub-agent pane holds one label and one log,
156/// so N concurrent sidechains streaming live would interleave into unreadable
157/// output. Each slot collects here instead and is flushed as one labelled block
158/// when it finishes.
159#[derive(Debug, Clone, Default)]
160pub struct CollectSink(pub std::sync::Arc<std::sync::Mutex<String>>);
161
162impl CollectSink {
163    /// Takes the collected text, leaving the buffer empty.
164    #[must_use]
165    pub fn take(&self) -> String {
166        let mut guard = self
167            .0
168            .lock()
169            .unwrap_or_else(std::sync::PoisonError::into_inner);
170        std::mem::take(&mut *guard)
171    }
172
173    fn push(&mut self, text: &str) {
174        self.0
175            .lock()
176            .unwrap_or_else(std::sync::PoisonError::into_inner)
177            .push_str(text);
178    }
179}
180
181impl RenderSink for CollectSink {
182    fn visible_text(&mut self, text: &str) {
183        self.push(text);
184    }
185    // Thinking is deliberately dropped: the pane shows a finished report, and a
186    // sub-agent's reasoning is not what the reader came for.
187    fn think_text(&mut self, _text: &str) {}
188    fn tool_text(&mut self, text: &str) {
189        self.push(text);
190    }
191}
192
193/// Kind of tool parameter, used to select the streaming display style.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
195enum ParamKind {
196    #[default]
197    Normal,
198    Path,
199    Content,
200    DiffOld,
201    DiffNew,
202    BashCommand,
203}
204
205fn param_kind_for(tool: &str, param: &str) -> ParamKind {
206    match (tool, param) {
207        ("bash", "command") => ParamKind::BashCommand,
208        ("edit", "old") => ParamKind::DiffOld,
209        ("edit", "new") => ParamKind::DiffNew,
210        (_, "path" | "file" | "filename") => ParamKind::Path,
211        (_, "content" | "text") => ParamKind::Content,
212        _ => ParamKind::Normal,
213    }
214}
215
216/// Display prefix for known tools; `None` falls back to the tool name.
217fn tool_prefix(name: &str) -> Option<&'static str> {
218    match name {
219        "bash" => Some("$ "),
220        "read" => Some("read "),
221        "write" => Some("write "),
222        "edit" => Some("edit "),
223        "search" => Some("search "),
224        "google_search" => Some("google "),
225        "visit_page" => Some("visit "),
226        name if name.starts_with("mcp_") => Some("mcp "),
227        _ => None,
228    }
229}
230
231fn diff_prefix(kind: ParamKind) -> Option<&'static str> {
232    match kind {
233        ParamKind::DiffOld => Some("- "),
234        ParamKind::DiffNew => Some("+ "),
235        _ => None,
236    }
237}
238
239fn parse_bool_default(s: &str, default: bool) -> bool {
240    if s.is_empty() {
241        return default;
242    }
243    if s.eq_ignore_ascii_case("true") || s.eq_ignore_ascii_case("yes") || s == "1" {
244        return true;
245    }
246    if s.eq_ignore_ascii_case("false") || s.eq_ignore_ascii_case("no") || s == "0" {
247        return false;
248    }
249    default
250}
251
252/// Extracts a `name="value"` attribute from a tag, if present.
253fn parse_attr(tag: &str, name: &str) -> Option<String> {
254    let pat = format!("{name}=\"");
255    let start = tag.find(&pat)? + pat.len();
256    let end = tag[start..].find('"')? + start;
257    Some(tag[start..end].to_string())
258}
259
260/// Recognizes a streamed parameter close tag prefix.
261///
262/// Returns true while `tail` could still become (or already is) a full
263/// `</|DSML|parameter...>` close tag; sets `complete` when the tail is a
264/// full close tag ending exactly at the last byte.
265fn parameter_close_tail(tail: &[u8], complete: &mut bool) -> bool {
266    *complete = false;
267    if tag_prefix_partial(tail, true, "parameter") {
268        return true;
269    }
270    let Some(mut i) = tag_prefix_len(tail, true, "parameter") else {
271        return false;
272    };
273    while i < tail.len() && tail[i].is_ascii_whitespace() {
274        i += 1;
275    }
276    if i < tail.len() && tail.len() - i <= DSML_BAR.len() && DSML_BAR.starts_with(&tail[i..]) {
277        return true;
278    }
279    if tail[i..].starts_with(DSML_BAR) {
280        i += DSML_BAR.len();
281    }
282    while i < tail.len() {
283        if tail[i] == b'>' {
284            *complete = i == tail.len() - 1;
285            return *complete;
286        }
287        if !tail[i].is_ascii_whitespace() {
288            return false;
289        }
290        i += 1;
291    }
292    true
293}
294
295/// Matches a growing tail against the accepted DSML start forms.
296///
297/// Returns true while `tail` is a prefix of any accepted opening form; sets
298/// `complete` when a form matched fully and `implicit_invoke` when the form
299/// was a direct invoke opener without the outer `tool_calls` wrapper.
300fn dsml_start_match(tail: &[u8], complete: &mut bool, implicit_invoke: &mut bool) -> bool {
301    *complete = false;
302    *implicit_invoke = false;
303    // Each marker contributes the canonical form and the dropped-leading-bar
304    // typo, for both the wrapper and the bare invoke opener. The wrapper also
305    // accepts a trailing `|` before `>` — the closing tags always tolerated it,
306    // and post-update weights emit it on the opener too.
307    let forms = MARKER_NAMES.iter().flat_map(|m| {
308        [
309            (format!("<|{m}|tool_calls>"), false),
310            (format!("<|{m}|tool_calls|>"), false),
311            (format!("<{m}|tool_calls>"), false),
312            (format!("<{m}|tool_calls|>"), false),
313            (format!("<|{m}|invoke"), true),
314            (format!("<{m}|invoke"), true),
315        ]
316    });
317    for (form, implicit) in forms {
318        let form = form.as_bytes();
319        if tail.len() <= form.len() && form[..tail.len()] == *tail {
320            *complete = tail.len() == form.len();
321            *implicit_invoke = implicit;
322            return true;
323        }
324    }
325    false
326}
327
328/// Sliding-tail detector for DSML-looking control markers in loose text.
329///
330/// This helper intentionally has no policy: inside `<think>` a hit means
331/// "tool call attempted too early", while in normal output it means malformed
332/// DSML the model should see as a tool error.
333#[derive(Debug, Default)]
334struct MarkerDetector {
335    tail: Vec<u8>,
336}
337
338impl MarkerDetector {
339    const CAP: usize = 32;
340    fn feed(&mut self, c: u8) -> bool {
341        if self.tail.len() == Self::CAP {
342            self.tail.remove(0);
343        }
344        self.tail.push(c);
345        MARKER_NAMES.iter().any(|m| {
346            [
347                format!("|{m}|"),
348                format!("|{m}|"),
349                format!("<{m}|"),
350                format!("</{m}|"),
351            ]
352            .iter()
353            .any(|n| self.tail.ends_with(n.as_bytes()))
354        })
355    }
356}
357
358/// Generic tool-call wrappers models fall back to when a tool name is
359/// unfamiliar. Matched regardless of the configured tool list.
360const GENERIC_PSEUDO_OPENERS: [&str; 3] = ["<tool_call>", "<function_call>", "<invoke "];
361
362/// Detects invented, non-DSML tool-call markup: a bare `<name>` opening a line
363/// where `name` is a registered tool, or one of the generic wrappers above.
364///
365/// Anchored at line start (the tail resets on newline) so prose mentioning
366/// `<task>` mid-sentence never matches, and disarmed inside fenced code
367/// blocks, where the model is showing markup rather than emitting it. Every
368/// byte of the stream is fed to the fence tracker, including thinking, so a
369/// fence's open/close parity survives a `<think>`/`</think>` boundary; only
370/// tag matching (and thus reporting) is skipped while thinking.
371#[derive(Debug, Default)]
372struct PseudoToolDetector {
373    /// Bytes since the last newline, capped.
374    line: Vec<u8>,
375    /// Inside a triple-backtick fenced block, where markup is shown rather
376    /// than called.
377    in_fence: bool,
378    /// Names of tools that are actually registered this session.
379    tool_names: Vec<String>,
380    /// One report per stream is enough; the turn ends after it.
381    fired: bool,
382}
383
384impl PseudoToolDetector {
385    const CAP: usize = 96;
386
387    fn set_tool_names(&mut self, names: Vec<String>) {
388        self.tool_names = names;
389    }
390
391    /// Clears the line-start anchor. `</think>` is a hard boundary for the
392    /// answer region even though no `\n` byte crosses it, so without this the
393    /// tail of a thinking line (never cleared, since tag matching — not line
394    /// tracking — is what's skipped while thinking) would still be glued
395    /// onto the first line of the answer and defeat the line-start anchor.
396    fn reset_line(&mut self) {
397        self.line.clear();
398    }
399
400    /// Feeds one byte; returns the matched line when the byte *completes* a
401    /// line that is nothing but a pseudo-tool-call opening.
402    ///
403    /// Matching deliberately waits for the end of the line. Firing at the `>`
404    /// would misjudge `<read> is how you spell it`, and since a stream error
405    /// freezes output that fabricated diagnosis would also discard the rest of
406    /// a legitimate answer. A stream that ends mid-line is matched by
407    /// [`Self::finish_line`].
408    ///
409    /// `in_think` still runs the byte through the fence tracker — a fence
410    /// opened inside `<think>` must flip `in_fence` there so that its
411    /// matching closer in the answer region is recognized as a *closer*,
412    /// not mistaken for a fresh opener that would silence the detector for
413    /// the rest of the stream (issue #51's reproduction: a fence opened in
414    /// thinking and closed just after `</think>`, right before a pseudo-tool
415    /// block). Only the tag-matching/report path is skipped while thinking.
416    fn feed(&mut self, c: u8, in_think: bool) -> Option<String> {
417        if c == b'\n' {
418            let hit = self.match_line(in_think);
419            if self.line.starts_with(b"```") {
420                // Parity is tracked unconditionally, even while `in_think`,
421                // but the trade-off cuts both ways. It's what makes the
422                // thinking-then-answer fence in the doc comment above work.
423                // The flip side: a fence opened in `<think>` and never closed
424                // there leaves `in_fence` true for the rest of the stream,
425                // silently disarming detection for the whole answer region.
426                // Resetting `in_fence` at `</think>` would fix that case but
427                // reopen the one this exists for, so the choice stands.
428                self.in_fence = !self.in_fence;
429            }
430            self.line.clear();
431            return hit;
432        }
433        if self.line.len() < Self::CAP {
434            self.line.push(c);
435        }
436        None
437    }
438
439    /// Matches the line held at end of stream, where no `\n` will arrive.
440    fn finish_line(&mut self, in_think: bool) -> Option<String> {
441        let hit = self.match_line(in_think);
442        self.line.clear();
443        hit
444    }
445
446    /// Tests the completed line, returning it when it is nothing but an
447    /// invented tool-call opening.
448    fn match_line(&mut self, in_think: bool) -> Option<String> {
449        if in_think || self.fired || self.in_fence {
450            return None;
451        }
452        // Only a tag *alone* on the line counts; surrounding whitespace is
453        // allowed, prose after the tag is not.
454        let trimmed = self.line.trim_ascii();
455        if !trimmed.starts_with(b"<") {
456            return None;
457        }
458        let text = std::str::from_utf8(trimmed).ok()?;
459        let matched = GENERIC_PSEUDO_OPENERS.iter().any(|opener| {
460            // `<invoke ` carries attributes, so it matches any single tag
461            // opening with it; the others must be the whole line.
462            if opener.ends_with(' ') {
463                text.starts_with(*opener) && is_lone_tag(text)
464            } else {
465                text == *opener
466            }
467        }) || self.tool_names.iter().any(|name| {
468            text.strip_prefix('<')
469                .and_then(|t| t.strip_suffix('>'))
470                .is_some_and(|inner| inner == name)
471        });
472        if !matched {
473            return None;
474        }
475        self.fired = true;
476        Some(text.to_string())
477    }
478}
479
480/// True when `text` is a single `<...>` tag and nothing else.
481fn is_lone_tag(text: &str) -> bool {
482    text.ends_with('>') && !text[..text.len() - 1].contains('>')
483}
484
485/// Scanner state mirroring the DSML parser for display purposes only.
486#[derive(Debug, Default)]
487enum DsmlScan {
488    /// Between tags; whitespace is skipped, `<` opens a tag.
489    #[default]
490    Between,
491    /// Accumulating a structural tag until `>`.
492    Tag(Vec<u8>),
493    /// Inside a parameter value.
494    Value,
495}
496
497/// Per-tool-call display state (port of `agent_tool_visualizer`).
498// The bool flags mirror the C state machine one-to-one; collapsing them into
499// enums would obscure the correspondence with the reference implementation.
500#[allow(clippy::struct_excessive_bools)]
501#[derive(Debug, Default)]
502struct ToolViz {
503    active: bool,
504    tool_announced: bool,
505    param_active: bool,
506    at_line_start: bool,
507    param_kind: ParamKind,
508    tool_name: String,
509    param_name: String,
510    param_end_tail: Vec<u8>,
511    read_style: bool,
512    read_prefix_rendered: bool,
513    read_line_rendered: bool,
514    read_path: String,
515    read_start: String,
516    read_max: String,
517    read_whole: String,
518    code_param_active: bool,
519    /// Destination captured from a `write` call's path param, for the dim
520    /// content-preview header.
521    write_path: String,
522    /// True when the current `write` targets a file that does not yet exist:
523    /// only then does the content stream as a dim preview (an overwrite is left
524    /// to the post-edit diff card).
525    write_is_create: bool,
526}
527
528impl ToolViz {
529    const END_TAIL_CAP: usize = 64;
530}
531
532/// Snapshot of stream results after generation ends.
533///
534/// Returned by [`StreamRenderer::finished`].
535#[derive(Debug, Clone, Copy)]
536pub struct Finished<'a> {
537    /// Executable tool calls completed by the DSML parser, in stream order.
538    pub calls: &'a [ToolCall],
539    /// Error message from malformed or misplaced DSML, if any.
540    pub error: Option<&'a str>,
541    /// True when a DSML marker was seen inside a `<think>` block.
542    pub dsml_in_think: bool,
543    /// True when a tool call was rejected for being inside `<think>`, so
544    /// [`Self::error`] is about placement rather than syntax. Callers word the
545    /// model-facing message from this — telling it the DSML was invalid when
546    /// it was merely misplaced is a wild goose chase.
547    pub in_think_rejected: bool,
548    /// True when the stream ended with a `<think>` block still open — the model
549    /// emitted a tool call mid-thought and stopped for the dispatch. The caller
550    /// closes the block in the transcript before appending the tool result.
551    pub ended_in_think: bool,
552}
553
554/// Streaming display state machine for assistant output.
555///
556/// Feed model text with [`push`](Self::push) and call
557/// [`finish`](Self::finish) once when the stream ends. Ordinary prose passes
558/// through to the sink; raw DSML is hidden and replaced by tool banners.
559/// Partial `<|DSML|` prefixes are held back until disambiguated, then either
560/// consumed (real tool call) or flushed verbatim (false alarm).
561///
562/// # Examples
563///
564/// ```no_run
565/// use trace_stream::viz::{RenderSink, StreamRenderer};
566///
567/// struct Stdout;
568/// impl RenderSink for Stdout {
569///     fn visible_text(&mut self, t: &str) { print!("{t}"); }
570///     fn think_text(&mut self, t: &str) { eprint!("{t}"); }
571/// }
572///
573/// let mut sr = StreamRenderer::new(Stdout);
574/// sr.push("Hello ");
575/// sr.push("world");
576/// sr.finish();
577/// assert!(sr.finished().calls.is_empty());
578/// ```
579// See ToolViz: the flags deliberately mirror the C state machine.
580#[allow(clippy::struct_excessive_bools)]
581#[derive(Debug)]
582pub struct StreamRenderer<S> {
583    sink: S,
584    parser: DsmlParser,
585    viz: ToolViz,
586    scan: DsmlScan,
587    in_think: bool,
588    dsml_active: bool,
589    dsml_ignored: bool,
590    /// Held-back bytes that may begin `<think>` / `</think>`.
591    pending: Vec<u8>,
592    /// Held-back bytes that may begin a DSML opening marker.
593    dsml_start_tail: Vec<u8>,
594    plain_dsml: MarkerDetector,
595    think_dsml: MarkerDetector,
596    /// Detects invented pseudo-tool markup (issue #51) in the answer region.
597    pseudo_tool: PseudoToolDetector,
598    dsml_in_think: bool,
599    dsml_in_think_reported: bool,
600    /// A `</think>` was consumed as parameter-value *content* while a stanza
601    /// was open (see [`Self::think_close_is_control`]). Cleared when that
602    /// stanza parses clean; if it instead dies malformed or is cut off, the
603    /// token was almost certainly the real control token and
604    /// [`Self::resolve_swallowed_think_close`] un-sticks `in_think`.
605    think_close_swallowed: bool,
606    /// A tool call was discarded *because* it sat inside `<think>`.
607    ///
608    /// Distinct from [`Self::dsml_in_think`], which only means the marker was
609    /// seen there — that is also true when `engine.thinkingToolCalls` is on
610    /// and the call was dispatched. Only this one means the model asked for a
611    /// tool and got nothing, so only this one is worth telling it about.
612    in_think_rejected: bool,
613    /// The stream error came from the pseudo-tool detector, which by
614    /// construction only fires in the answer region. It must outrank the
615    /// in-think prohibition in [`Self::finished`]: the model's mistake was
616    /// inventing markup after `</think>`, not calling a tool inside it.
617    pseudo_tool_fired: bool,
618    post_think_gap: bool,
619    /// Error from DSML markup outside a valid stanza; freezes further output.
620    stream_error: Option<String>,
621    last_output_newline: bool,
622    /// Calls snapshotted at parser `Done`, surviving later parser resets.
623    calls: Vec<ToolCall>,
624    /// UTF-8 carry buffers so multi-byte characters split across pushes are
625    /// never emitted partially.
626    vis_carry: Vec<u8>,
627    think_carry: Vec<u8>,
628    /// Mid-stream tool preflight hook and its first failure, mirroring the
629    /// C's `agent_stream_preflight_closed_param`: an `edit` call's `old`
630    /// selector is validated the moment that parameter closes, so a doomed
631    /// edit stops generation before `new` is streamed.
632    preflight: Preflight,
633    preflight_error: Option<String>,
634    /// When false, tool-call visualization (banners, params, read/diff lines)
635    /// is dropped — the DSML is still parsed and hidden, just not shown. Gated
636    /// by `ui.showToolCalls`; defaults true so tests and callers that don't set
637    /// it keep the banners. See [`StreamRenderer::set_show_tool_calls`].
638    show_tool_calls: bool,
639    /// When false, thinking text is parsed (so `<think>`/`</think>` still drive
640    /// state) but never emitted to the sink. Gated by `ui.showThinking`;
641    /// defaults true. See [`StreamRenderer::set_show_thinking`].
642    show_thinking: bool,
643    /// When true, a DSML stanza opened inside `<think>` is parsed and dispatched
644    /// like any other. Defaults **false**, which is strict `refs/ds4` parity:
645    /// the stanza is discarded with a `[tool call ignored: ...]` notice.
646    /// Production wires this from `engine.thinkingToolCalls`, which defaults
647    /// off too, so callers and tests keep the C behavior unless they opt in.
648    thinking_tool_calls: bool,
649    /// When true, this renderer is replaying text that was already streamed
650    /// (and diagnosed) once before, from a stored transcript. The pseudo-tool
651    /// detector is a new addition on top of an existing replay path that
652    /// discards `finished()`, so any error it raises can never reach the
653    /// model; it would only double-log to disk and truncate the replayed
654    /// text. Defaults false. See [`StreamRenderer::set_replay`].
655    replay: bool,
656}
657
658/// Hook validating a partially-parsed tool call mid-stream.
659type PreflightFn = Box<dyn FnMut(&ToolCall) -> Result<(), String>>;
660
661#[derive(Default)]
662struct Preflight(Option<PreflightFn>);
663
664impl std::fmt::Debug for Preflight {
665    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
666        f.write_str(if self.0.is_some() {
667            "Preflight(set)"
668        } else {
669            "Preflight(unset)"
670        })
671    }
672}
673
674const DSML_START_TAIL_CAP: usize = 64;
675
676impl<S: RenderSink> StreamRenderer<S> {
677    /// Creates a renderer that writes rendered output to `sink`.
678    pub fn new(sink: S) -> Self {
679        Self {
680            sink,
681            parser: DsmlParser::new(),
682            viz: ToolViz::default(),
683            scan: DsmlScan::Between,
684            in_think: false,
685            dsml_active: false,
686            dsml_ignored: false,
687            pending: Vec::new(),
688            dsml_start_tail: Vec::new(),
689            plain_dsml: MarkerDetector::default(),
690            think_dsml: MarkerDetector::default(),
691            pseudo_tool: PseudoToolDetector::default(),
692            dsml_in_think: false,
693            dsml_in_think_reported: false,
694            think_close_swallowed: false,
695            in_think_rejected: false,
696            pseudo_tool_fired: false,
697            post_think_gap: false,
698            stream_error: None,
699            last_output_newline: true,
700            calls: Vec::new(),
701            vis_carry: Vec::new(),
702            think_carry: Vec::new(),
703            preflight: Preflight(None),
704            preflight_error: None,
705            show_tool_calls: true,
706            show_thinking: true,
707            thinking_tool_calls: false,
708            replay: false,
709        }
710    }
711
712    /// Sets whether this renderer is replaying already-diagnosed text from a
713    /// stored transcript (default false). Replayed text was already streamed
714    /// and diagnosed the first time it was produced, so re-reporting it here
715    /// would both double-log to `~/.plank/tool-call-errors.log` and truncate
716    /// the rest of the replayed message via `stream_error`. Wire this at the
717    /// replay construction site only; live generation must leave it false.
718    pub fn set_replay(&mut self, replay: bool) {
719        self.replay = replay;
720    }
721
722    /// Sets the tool names the pseudo-tool detector recognizes (default none).
723    ///
724    /// Production wires this from the live registry; with an empty list only
725    /// the generic wrappers (`<tool_call>`, `<function_call>`, `<invoke `) are
726    /// matched, which is what unit tests and echo paths get.
727    pub fn set_tool_names(&mut self, names: Vec<String>) {
728        self.pseudo_tool.set_tool_names(names);
729    }
730
731    /// Sets whether tool-call visualization is shown (default true). Production
732    /// wires this from `ui.showToolCalls`; when false the DSML is still parsed
733    /// and hidden but no banner, param, read, or diff line is emitted.
734    pub fn set_show_tool_calls(&mut self, show: bool) {
735        self.show_tool_calls = show;
736    }
737
738    /// Sets whether thinking text is displayed (default true). Production wires
739    /// this from `ui.showThinking`; when false the model still produces its
740    /// thinking (and `<think>` tags still drive parser state) but none of it is
741    /// emitted to the sink.
742    pub fn set_show_thinking(&mut self, show: bool) {
743        self.show_thinking = show;
744    }
745
746    /// Sets whether tool calls emitted inside `<think></think>` are dispatched
747    /// (default false = strict C parity). Production wires this from
748    /// `engine.thinkingToolCalls`. When false an in-think stanza is discarded
749    /// with a `[tool call ignored: ...]` notice, exactly as the C agent does.
750    pub fn set_thinking_tool_calls(&mut self, allow: bool) {
751        self.thinking_tool_calls = allow;
752    }
753
754    /// Installs the mid-stream preflight hook: called with the pending call
755    /// each time an `edit` invoke's `old` parameter closes. A returned error
756    /// records [`preflight_error`](Self::preflight_error); the caller is
757    /// expected to stop generation and feed the error back to the model.
758    pub fn set_preflight(&mut self, f: impl FnMut(&ToolCall) -> Result<(), String> + 'static) {
759        self.preflight = Preflight(Some(Box::new(f)));
760    }
761
762    /// The first mid-stream preflight failure, if any (model-facing text).
763    #[must_use]
764    pub fn preflight_error(&self) -> Option<&str> {
765        self.preflight_error.as_deref()
766    }
767
768    /// Starts the stream already inside a `<think>` block.
769    ///
770    /// Use when the chat template opened thinking in the prefill prefix, so the
771    /// model streams thinking content before any `</think>` and without an
772    /// opening tag of its own.
773    pub fn begin_in_think(&mut self) {
774        self.in_think = true;
775    }
776
777    /// Feeds one streamed chunk of model output.
778    pub fn push(&mut self, text: impl AsRef<str>) {
779        self.stream_text(text.as_ref().as_bytes(), false);
780    }
781
782    /// Signals end of stream, flushing held-back bytes and open banners.
783    ///
784    /// An interrupted tool call is closed with a `[tool call interrupted]`
785    /// status line; DSML seen inside thinking is reported as ignored.
786    pub fn finish(&mut self) {
787        self.stream_text(b"", true);
788        self.flush_carry();
789        self.flush_pseudo_tool();
790    }
791
792    /// Results after the stream ends: completed calls and error state.
793    ///
794    /// A stream that ends mid-stanza (parser still structural or inside a
795    /// parameter value) reports `incomplete DSML tool call`, like the C's
796    /// worker loop; callers must check user interruption first, since an
797    /// interrupted stanza is not a model error.
798    #[must_use]
799    pub fn finished(&self) -> Finished<'_> {
800        // A call inside `<think>` is *misplaced*, not malformed, and that is
801        // what the model needs to hear: the parser's own verdict on the
802        // discarded stanza ("incomplete DSML tool call", say) would send it
803        // rewriting syntax that was never wrong. `dsml_ignored` covers a
804        // stanza still open when the stream ended; `in_think_rejected` covers
805        // one that completed and was thrown away. Mirrors the C worker loop
806        // (`ds4_agent.c:7853`), which likewise overwrites the parse error.
807        //
808        // The one exception is a pseudo-tool hit: it can only be raised in the
809        // answer region, after `</think>`, so overwriting it with the
810        // prohibition would tell the model to move a call it never made there.
811        // A stanza leaked from thinking *and* invented markup in the answer is
812        // exactly the reported case, and the answer-region mistake is the one
813        // the model must fix.
814        let in_think = (self.in_think_rejected || self.dsml_ignored) && !self.pseudo_tool_fired;
815        let error = self
816            .stream_error
817            .as_deref()
818            .filter(|_| !in_think)
819            .or_else(|| in_think.then_some(IN_THINK_PROHIBITION))
820            .or_else(|| (self.parser.state() == DsmlState::Error).then(|| self.parser.error()))
821            .or_else(|| {
822                matches!(
823                    self.parser.state(),
824                    DsmlState::Structural | DsmlState::ParamValue
825                )
826                .then_some("incomplete DSML tool call")
827            });
828        Finished {
829            calls: &self.calls,
830            error,
831            dsml_in_think: self.dsml_in_think,
832            in_think_rejected: in_think,
833            ended_in_think: self.in_think,
834        }
835    }
836
837    /// True while the sampler should run greedy (argmax), mirroring
838    /// `agent_stream_wants_greedy_sampling`: inside a tool-call stanza's
839    /// structural markup, while a parameter close tag is streaming, or once
840    /// the start detector holds a DSML-shaped prefix longer than one byte.
841    /// Derived purely from per-round parser state, so EOS, errors, or the
842    /// next turn can never leave sampling stuck greedy.
843    #[must_use]
844    pub fn wants_greedy_sampling(&self) -> bool {
845        if matches!(self.parser.state(), DsmlState::Error | DsmlState::Done) {
846            return false;
847        }
848        // A single '<' is too common in prose/code to justify forcing argmax;
849        // a longer held-back prefix is specifically DSML-shaped.
850        if self.dsml_start_tail.len() > 1 {
851            return true;
852        }
853        if !self.dsml_active {
854            return false;
855        }
856        match self.parser.state() {
857            DsmlState::Structural => true,
858            DsmlState::ParamValue => self.parser.param_close_prefix(),
859            _ => false,
860        }
861    }
862
863    /// Borrows the underlying sink.
864    pub fn sink(&self) -> &S {
865        &self.sink
866    }
867
868    /// Mutable access to the sink, for callers that must drain it mid-stream.
869    pub fn sink_mut(&mut self) -> &mut S {
870        &mut self.sink
871    }
872
873    /// Consumes the renderer, returning the sink.
874    pub fn into_sink(self) -> S {
875        self.sink
876    }
877
878    // ---- output helpers -------------------------------------------------
879
880    fn flush_stream(sink_write: impl FnOnce(&mut S, &str), sink: &mut S, carry: &mut Vec<u8>) {
881        if carry.is_empty() {
882            return;
883        }
884        match std::str::from_utf8(carry) {
885            Ok(s) => {
886                sink_write(sink, s);
887                carry.clear();
888            }
889            Err(e) if e.error_len().is_none() && e.valid_up_to() > 0 => {
890                let tail = carry.split_off(e.valid_up_to());
891                // The prefix is valid UTF-8 by construction.
892                sink_write(sink, std::str::from_utf8(carry).unwrap_or_default());
893                *carry = tail;
894            }
895            Err(e) if e.error_len().is_none() => {}
896            Err(_) => {
897                let s = String::from_utf8_lossy(carry).into_owned();
898                sink_write(sink, &s);
899                carry.clear();
900            }
901        }
902    }
903
904    fn emit_visible_bytes(&mut self, bytes: &[u8]) {
905        if bytes.is_empty() {
906            return;
907        }
908        // Tool-call visualization goes through the tool_text channel (that is
909        // what `self.viz.active` selects below). When banners are off, drop it
910        // entirely — parsing and DSML hiding are unaffected.
911        if self.viz.active && !self.show_tool_calls {
912            return;
913        }
914        self.last_output_newline = bytes.last() == Some(&b'\n');
915        self.vis_carry.extend_from_slice(bytes);
916        let write = if self.viz.active {
917            S::tool_text as fn(&mut S, &str)
918        } else {
919            S::visible_text
920        };
921        Self::flush_stream(write, &mut self.sink, &mut self.vis_carry);
922    }
923
924    fn emit_think_bytes(&mut self, bytes: &[u8]) {
925        if !self.show_thinking {
926            return;
927        }
928        self.think_carry.extend_from_slice(bytes);
929        Self::flush_stream(S::think_text, &mut self.sink, &mut self.think_carry);
930    }
931
932    /// Emits `write`-preview bytes in the thinking (dim) color. Independent of
933    /// `show_tool_calls` and `show_thinking`, so a write's content is always
934    /// visible as a dim preview of what is being saved.
935    fn emit_preview_bytes(&mut self, bytes: &[u8]) {
936        if bytes.is_empty() {
937            return;
938        }
939        self.last_output_newline = bytes.last() == Some(&b'\n');
940        self.think_carry.extend_from_slice(bytes);
941        Self::flush_stream(S::think_text, &mut self.sink, &mut self.think_carry);
942    }
943
944    fn viz_preview_puts(&mut self, s: &str) {
945        self.emit_preview_bytes(s.as_bytes());
946    }
947
948    /// True while streaming the `write` tool's content body, which renders as a
949    /// dim preview rather than through the normal (banner-gated) channel.
950    fn viz_is_write_preview(&self) -> bool {
951        self.viz.tool_name == "write" && self.viz.param_kind == ParamKind::Content
952    }
953
954    fn flush_carry(&mut self) {
955        for (write, carry) in [
956            (S::visible_text as fn(&mut S, &str), &mut self.vis_carry),
957            (S::think_text, &mut self.think_carry),
958        ] {
959            if !carry.is_empty() {
960                let s = String::from_utf8_lossy(carry).into_owned();
961                write(&mut self.sink, &s);
962                carry.clear();
963            }
964        }
965    }
966
967    /// Routes one ordinary output byte to the visible or think stream.
968    fn write_char(&mut self, c: u8) {
969        if self.in_think {
970            self.emit_think_bytes(&[c]);
971        } else {
972            self.emit_visible_bytes(&[c]);
973        }
974    }
975
976    fn viz_puts(&mut self, s: &str) {
977        self.emit_visible_bytes(s.as_bytes());
978    }
979
980    /// Emits an error banner through the sink's red-styled channel.
981    fn viz_error_puts(&mut self, s: &str) {
982        if s.is_empty() {
983            return;
984        }
985        self.last_output_newline = s.ends_with('\n');
986        self.sink.error_text(s);
987    }
988
989    fn viz_newline_if_open(&mut self) {
990        if !self.last_output_newline {
991            self.viz_puts("\n");
992        }
993    }
994
995    // ---- tool visualizer -------------------------------------------------
996
997    fn viz_start(&mut self) {
998        let line_open = !self.last_output_newline;
999        self.viz = ToolViz {
1000            active: true,
1001            at_line_start: true,
1002            ..ToolViz::default()
1003        };
1004        self.scan = DsmlScan::Between;
1005        if line_open {
1006            self.viz_puts("\n");
1007        }
1008    }
1009
1010    /// Starts a tool banner line: "🛠️ ".
1011    fn viz_line_prefix(&mut self) {
1012        self.viz_newline_if_open();
1013        self.viz_puts("🛠️ ");
1014        self.viz.at_line_start = false;
1015    }
1016
1017    fn viz_tool(&mut self, name: &str) {
1018        if self.viz.tool_announced && self.viz.tool_name == name {
1019            return;
1020        }
1021        if self.viz.tool_announced {
1022            self.viz_newline_if_open();
1023        }
1024        self.viz.tool_name = name.to_string();
1025        self.viz.tool_announced = true;
1026        self.viz.read_style = name == "read";
1027        self.viz_line_prefix();
1028        if self.viz.read_style {
1029            self.viz_puts("Reading ");
1030            self.viz.read_prefix_rendered = true;
1031            return;
1032        }
1033        if let Some(prefix) = tool_prefix(name) {
1034            self.viz_puts(prefix);
1035        } else {
1036            let owned = name.to_string();
1037            self.viz_puts(&owned);
1038            self.viz_puts(" ");
1039        }
1040    }
1041
1042    fn viz_read_value_byte(&mut self, c: u8) {
1043        let field = match self.viz.param_name.as_str() {
1044            "path" => &mut self.viz.read_path,
1045            "start_line" => &mut self.viz.read_start,
1046            "max_lines" => &mut self.viz.read_max,
1047            "whole" => &mut self.viz.read_whole,
1048            _ => return,
1049        };
1050        field.push(c as char);
1051        if self.viz.param_name == "path" && self.viz.read_prefix_rendered {
1052            self.emit_visible_bytes(&[c]);
1053        }
1054    }
1055
1056    /// Renders the one-line read banner, e.g. "Reading src/x.rs 1:500...".
1057    fn viz_render_read(&mut self) {
1058        if !self.viz.read_style || self.viz.read_line_rendered {
1059            return;
1060        }
1061        if !self.viz.read_prefix_rendered {
1062            self.viz_line_prefix();
1063            self.viz_puts("Reading ");
1064            let path = if self.viz.read_path.is_empty() {
1065                "<unknown>".to_string()
1066            } else {
1067                self.viz.read_path.clone()
1068            };
1069            self.viz_puts(&path);
1070        } else if self.viz.read_path.is_empty() {
1071            self.viz_puts("<unknown>");
1072        }
1073        let whole = parse_bool_default(&self.viz.read_whole, false);
1074        let range = if whole && (self.viz.read_start.is_empty() || self.viz.read_start == "1") {
1075            " (whole file)".to_string()
1076        } else if whole {
1077            format!(" {}:EOF", self.viz.read_start)
1078        } else {
1079            let start = if self.viz.read_start.is_empty() {
1080                "1"
1081            } else {
1082                &self.viz.read_start
1083            };
1084            let max = if self.viz.read_max.is_empty() {
1085                "500"
1086            } else {
1087                &self.viz.read_max
1088            };
1089            format!(" {start}:{max}")
1090        };
1091        self.viz_puts(&range);
1092        self.viz_puts("...\n");
1093        self.viz.read_line_rendered = true;
1094    }
1095
1096    fn viz_param_is_code_body(&self) -> bool {
1097        match self.viz.tool_name.as_str() {
1098            "write" => self.viz.param_kind == ParamKind::Content,
1099            "edit" => matches!(
1100                self.viz.param_kind,
1101                ParamKind::DiffOld | ParamKind::DiffNew | ParamKind::Content
1102            ),
1103            _ => false,
1104        }
1105    }
1106
1107    /// Emits the diff line prefix ("- " / "+ ") at the start of a code line.
1108    fn viz_code_prefix(&mut self) {
1109        if !self.viz.at_line_start {
1110            return;
1111        }
1112        if let Some(prefix) = diff_prefix(self.viz.param_kind) {
1113            self.viz_puts(prefix);
1114            self.viz.at_line_start = false;
1115        }
1116    }
1117
1118    fn viz_code_begin(&mut self) {
1119        self.viz.code_param_active = true;
1120        if matches!(self.viz.param_kind, ParamKind::DiffOld | ParamKind::DiffNew) {
1121            self.viz_code_prefix();
1122        }
1123    }
1124
1125    fn viz_code_end(&mut self) {
1126        if !self.viz.code_param_active {
1127            return;
1128        }
1129        self.viz.code_param_active = false;
1130        self.viz.at_line_start = true;
1131    }
1132
1133    fn viz_code_byte(&mut self, c: u8) {
1134        // A new file's body streams as a dim preview; an overwrite's body is
1135        // dropped here (the post-edit diff card shows it).
1136        if self.viz_is_write_preview() {
1137            if self.viz.write_is_create {
1138                self.emit_preview_bytes(&[c]);
1139            }
1140            self.viz.at_line_start = c == b'\n';
1141            return;
1142        }
1143        self.viz_code_prefix();
1144        self.emit_visible_bytes(&[c]);
1145        self.viz.at_line_start = c == b'\n';
1146    }
1147
1148    fn viz_param_begin(&mut self, name: &str) {
1149        self.viz.param_name = name.to_string();
1150        self.viz.param_kind = param_kind_for(&self.viz.tool_name, name);
1151        self.viz.param_active = true;
1152        self.viz.param_end_tail.clear();
1153
1154        if self.viz.read_style {
1155            return;
1156        }
1157        match self.viz.param_kind {
1158            ParamKind::DiffOld | ParamKind::DiffNew => {
1159                self.viz_newline_if_open();
1160                self.viz.at_line_start = true;
1161                self.viz_code_begin();
1162            }
1163            ParamKind::Content => {
1164                self.viz_newline_if_open();
1165                if self.viz.tool_name == "write" {
1166                    // Stream the content as a dim preview only for a new file;
1167                    // an overwrite is shown by the post-edit diff card instead.
1168                    self.viz.write_is_create = !std::path::Path::new(&self.viz.write_path).exists();
1169                    // A dim header names the file for the content preview. Only
1170                    // when the banner is off, else the banner already shows it.
1171                    if self.viz.write_is_create && !self.show_tool_calls {
1172                        let path = if self.viz.write_path.is_empty() {
1173                            "<file>".to_string()
1174                        } else {
1175                            self.viz.write_path.clone()
1176                        };
1177                        self.viz_preview_puts(&format!("write {path}\n"));
1178                    }
1179                } else {
1180                    let label = format!("{name}:\n");
1181                    self.viz_puts(&label);
1182                }
1183                self.viz.at_line_start = true;
1184                if self.viz_param_is_code_body() {
1185                    self.viz_code_begin();
1186                }
1187            }
1188            ParamKind::BashCommand => {}
1189            ParamKind::Normal | ParamKind::Path => {
1190                if !self.viz.at_line_start {
1191                    self.viz_puts(" ");
1192                }
1193                let label = format!("{name}=");
1194                self.viz_puts(&label);
1195            }
1196        }
1197    }
1198
1199    fn viz_param_end(&mut self) {
1200        self.viz.param_end_tail.clear();
1201        if self.viz.code_param_active {
1202            self.viz_code_end();
1203        }
1204        self.viz.param_active = false;
1205        self.viz.param_name.clear();
1206        self.scan = DsmlScan::Between;
1207    }
1208
1209    fn viz_param_raw_byte(&mut self, c: u8) {
1210        if self.viz.read_style {
1211            self.viz_read_value_byte(c);
1212            return;
1213        }
1214        if self.viz.code_param_active {
1215            self.viz_code_byte(c);
1216            return;
1217        }
1218        if matches!(self.viz.param_kind, ParamKind::DiffOld | ParamKind::DiffNew) {
1219            self.viz_code_begin();
1220            self.viz_code_byte(c);
1221            return;
1222        }
1223        // Capture the write destination for the content-preview header.
1224        if self.viz.tool_name == "write" && self.viz.param_kind == ParamKind::Path {
1225            self.viz.write_path.push(c as char);
1226        }
1227        self.emit_visible_bytes(&[c]);
1228        self.viz.at_line_start = c == b'\n';
1229    }
1230
1231    /// Streams one parameter value byte, hiding partial close-tag tails.
1232    ///
1233    /// The visualizer must not wait for the whole parameter: large write/edit
1234    /// contents should show progress while still detecting the closing tag.
1235    fn viz_param_value_byte(&mut self, c: u8) {
1236        if !self.viz.param_end_tail.is_empty() || c == b'<' {
1237            if self.viz.param_end_tail.len() == ToolViz::END_TAIL_CAP {
1238                let held = std::mem::take(&mut self.viz.param_end_tail);
1239                for b in held {
1240                    self.viz_param_raw_byte(b);
1241                }
1242                if c != b'<' {
1243                    self.viz_param_raw_byte(c);
1244                    return;
1245                }
1246            }
1247            self.viz.param_end_tail.push(c);
1248            let mut complete = false;
1249            if parameter_close_tail(&self.viz.param_end_tail, &mut complete) {
1250                if complete {
1251                    self.viz_param_end();
1252                }
1253                return;
1254            }
1255            let held = std::mem::take(&mut self.viz.param_end_tail);
1256            for b in held {
1257                self.viz_param_raw_byte(b);
1258            }
1259            return;
1260        }
1261        self.viz_param_raw_byte(c);
1262    }
1263
1264    /// Called when an invoke closes: flush the read banner, reset announce.
1265    fn viz_invoke_end(&mut self) {
1266        if !self.viz.tool_announced || self.viz.param_active {
1267            return;
1268        }
1269        self.viz_render_read();
1270        self.viz_newline_if_open();
1271        self.viz.read_style = false;
1272        self.viz.read_prefix_rendered = false;
1273        self.viz.read_line_rendered = false;
1274        self.viz.read_path.clear();
1275        self.viz.read_start.clear();
1276        self.viz.read_max.clear();
1277        self.viz.read_whole.clear();
1278        self.viz.tool_announced = false;
1279    }
1280
1281    fn viz_finish(&mut self, status: Option<&str>) {
1282        if !self.viz.active {
1283            return;
1284        }
1285        if self.viz.param_active {
1286            self.viz_param_end();
1287        }
1288        if status.is_none() {
1289            self.viz_render_read();
1290        }
1291        if let Some(status) = status {
1292            self.viz_newline_if_open();
1293            let owned = status.to_string();
1294            self.viz_error_puts(&owned);
1295        }
1296        self.viz_newline_if_open();
1297        self.viz.active = false;
1298    }
1299
1300    /// Suppresses the rejected stanza's on-screen rendering: only the red
1301    /// `[invalid tool call: ...]` banner (which names the offending tag) is
1302    /// shown, never the raw DSML bytes. The full raw output still reaches the
1303    /// transcript, so failures stay debuggable there.
1304    fn viz_drop_invalid_dsml(&mut self) {
1305        if !self.viz.active {
1306            return;
1307        }
1308        if self.viz.param_active {
1309            self.viz.param_active = false;
1310            self.viz.param_end_tail.clear();
1311            self.viz.param_name.clear();
1312        }
1313        self.viz_newline_if_open();
1314    }
1315
1316    // ---- DSML scanning ----------------------------------------------------
1317
1318    /// Mirrors parser progress into the visualizer from the raw byte stream.
1319    fn scan_dsml_byte(&mut self, c: u8) {
1320        match &mut self.scan {
1321            DsmlScan::Between => {
1322                if c == b'<' {
1323                    self.scan = DsmlScan::Tag(vec![c]);
1324                }
1325            }
1326            DsmlScan::Tag(tag) => {
1327                tag.push(c);
1328                if c == b'>' {
1329                    let tag = std::mem::take(tag);
1330                    self.scan = DsmlScan::Between;
1331                    self.scan_dsml_tag(&tag);
1332                }
1333            }
1334            DsmlScan::Value => self.viz_param_value_byte(c),
1335        }
1336    }
1337
1338    fn scan_dsml_tag(&mut self, tag: &[u8]) {
1339        let tag = String::from_utf8_lossy(tag).into_owned();
1340        let b = tag.as_bytes();
1341        if tag_prefix_len(b, true, "invoke").is_some() {
1342            self.viz_invoke_end();
1343        } else if tag_prefix_len(b, false, "invoke").is_some() {
1344            let name = parse_attr(&tag, "name").unwrap_or_else(|| "tool".to_string());
1345            self.viz_tool(&name);
1346        } else if tag_prefix_len(b, false, "parameter").is_some()
1347            && let Some(name) = parse_attr(&tag, "name")
1348        {
1349            self.viz_param_begin(&name);
1350            self.scan = DsmlScan::Value;
1351        } else if parse_attr(&tag, "name").is_none()
1352            && let Some(elem) = crate::dsml::element_name(&tag)
1353        {
1354            // The two shorthand forms the parser accepts, told apart the same
1355            // way it tells them apart: before an invoke is open a bare element
1356            // names the tool, inside one it names a parameter. `tool_announced`
1357            // is this side's copy of the parser's "an invoke is open".
1358            // Without mirroring it here a shorthand call runs with no banner,
1359            // or with its tool name rendered as a parameter.
1360            if self.viz.tool_announced {
1361                self.viz_param_begin(&elem);
1362                self.scan = DsmlScan::Value;
1363            } else {
1364                self.viz_tool(&elem);
1365            }
1366        }
1367        // Anything else is malformed; the strict parser reports it.
1368    }
1369
1370    fn feed_dsml_byte(&mut self, c: u8) {
1371        let was_param = self.parser.state() == DsmlState::ParamValue;
1372        self.parser.feed([c]);
1373        if !self.dsml_ignored {
1374            self.scan_dsml_byte(c);
1375            if was_param && self.parser.state() != DsmlState::ParamValue {
1376                self.preflight_closed_param();
1377            }
1378        }
1379        match self.parser.state() {
1380            DsmlState::Done => {
1381                // The stanza parsed clean, so a `</think>` inside it really was
1382                // payload text: leave `in_think` as it stands (the placement
1383                // verdict below depends on it) and drop the pending question.
1384                self.think_close_swallowed = false;
1385                if self.rejects_in_think() {
1386                    // A discarded in-think stanza must never surface as an
1387                    // executable call: this parser instance is shared across
1388                    // the whole stream, so leaving `self.calls` unset here
1389                    // (rather than syncing it from the parser, which parsed
1390                    // the ignored stanza too) keeps a prior real call intact
1391                    // and never lets the ignored one leak into dispatch.
1392                    self.reject_in_think_stanza(
1393                        "tool calling is not allowed inside <think></think>",
1394                    );
1395                } else {
1396                    self.calls = self.parser.calls().to_vec();
1397                    self.viz_finish(None);
1398                    self.dsml_active = false;
1399                }
1400            }
1401            DsmlState::Error => {
1402                // Before the placement verdict: a stanza that swallowed a
1403                // `</think>` and then failed to parse was not inside thinking
1404                // when it died, so it must not be reported as if it were.
1405                self.resolve_swallowed_think_close();
1406                if self.rejects_in_think() {
1407                    self.reject_in_think_stanza("malformed tool call inside <think></think>");
1408                } else {
1409                    let err = if self.parser.error().is_empty() {
1410                        "parse error"
1411                    } else {
1412                        self.parser.error()
1413                    };
1414                    log_tool_error(err, self.parser.raw());
1415                    let status = format!("[invalid tool call: {err}]\n");
1416                    self.viz_drop_invalid_dsml();
1417                    self.viz_finish(Some(&status));
1418                    self.dsml_active = false;
1419                }
1420            }
1421            _ => {}
1422        }
1423    }
1424
1425    /// Preflights an `edit` call as soon as its `old` parameter closes,
1426    /// mirroring `agent_stream_preflight_closed_param`: a selector that
1427    /// already fails to match means the pass is doomed, so record the error
1428    /// before the model wastes tokens streaming `new`.
1429    fn preflight_closed_param(&mut self) {
1430        if self.preflight_error.is_some() {
1431            return;
1432        }
1433        let Preflight(Some(check)) = &mut self.preflight else {
1434            return;
1435        };
1436        let Some(call) = self.parser.pending_call() else {
1437            return;
1438        };
1439        if call.name != "edit" || call.args.last().is_none_or(|a| a.name != "old") {
1440            return;
1441        }
1442        if let Err(err) = check(&call) {
1443            self.preflight_error = Some(format!(
1444                "edit old selector failed before new was generated: {err}"
1445            ));
1446        }
1447    }
1448
1449    /// Starts a DSML block; the parser is seeded with canonical bytes so all
1450    /// later parsing stays strict even when a typo form was accepted.
1451    ///
1452    /// A stanza opening inside `<think>` is always *tracked* — whether it is
1453    /// allowed is decided at its stop token by [`Self::rejects_in_think`], not
1454    /// here. It is not *rendered* yet, though: until it leaves the thinking
1455    /// block it may still turn out to be the model quoting syntax, and a
1456    /// banner for a call that never happens is worse than a late one. Rendering
1457    /// starts if and when `</think>` arrives with the stanza still open.
1458    fn start_dsml(&mut self) {
1459        self.dsml_active = true;
1460        self.dsml_ignored = self.rejects_in_think();
1461        if self.in_think {
1462            self.dsml_in_think = true;
1463        }
1464        self.dsml_start_tail.clear();
1465        self.post_think_gap = false;
1466        self.parser.feed(DSML_START);
1467        self.scan = DsmlScan::Between;
1468        if !self.dsml_ignored {
1469            self.viz_start();
1470        }
1471    }
1472
1473    /// Whether the stanza reaching its stop token right now must be discarded
1474    /// for sitting inside `<think>`.
1475    ///
1476    /// Judged at the **stop token**, against the think state at that moment —
1477    /// never at the opening marker. A model reasoning about DSML syntax writes
1478    /// an opening marker mid-thought all the time, and often goes on to close
1479    /// the thinking block and emit the real call (`repro-1785754509.md`).
1480    /// Deciding at the opening threw that correct call away and told the model
1481    /// to stop doing something it had not done, which reliably sent it into a
1482    /// rewrite loop. An opening marker is only ever a *candidate*; the model has
1483    /// not called a tool until the stanza closes.
1484    fn rejects_in_think(&self) -> bool {
1485        self.in_think && !self.thinking_tool_calls
1486    }
1487
1488    /// Rejects the stanza that just closed (or was cut off) inside thinking.
1489    /// The banner it already streamed is closed off first, the way an invalid
1490    /// stanza's is: rendering is optimistic because acceptance is not known
1491    /// until the stop token.
1492    fn reject_in_think_stanza(&mut self, msg: &str) {
1493        self.dsml_ignored = true;
1494        self.viz_drop_invalid_dsml();
1495        self.finish_ignored_dsml(msg);
1496    }
1497
1498    /// Whether a `</think>` at the current position is a control token rather
1499    /// than stanza content.
1500    ///
1501    /// Outside a stanza it always is. Inside one it is, *except* within a
1502    /// parameter value: a `write` or `edit` payload may legitimately contain
1503    /// the literal text `</think>` (this repo's own sources and docs do), and
1504    /// swallowing it there would silently corrupt what gets written.
1505    fn think_close_is_control(&self) -> bool {
1506        !self.dsml_active || self.parser.state() != DsmlState::ParamValue
1507    }
1508
1509    /// Settles a `</think>` that was swallowed as parameter-value content by a
1510    /// stanza that then failed to parse (or never finished).
1511    ///
1512    /// Treating it as content is right for a stanza that *completes* — a
1513    /// `write` payload may legitimately contain the literal text. But when the
1514    /// stanza dies malformed or the stream ends mid-flight, there is no payload
1515    /// to protect and the far likelier reading is that it was the control token
1516    /// all along. Without this the swallowed token left `in_think` stuck true,
1517    /// so every remaining byte of the answer was rendered as thinking, hidden
1518    /// from the visible transcript, and `ended_in_think` was reported wrongly.
1519    fn resolve_swallowed_think_close(&mut self) {
1520        if !std::mem::take(&mut self.think_close_swallowed) || !self.in_think {
1521            return;
1522        }
1523        self.in_think = false;
1524        // Same boundary bookkeeping as the control path in `stream_text`: the
1525        // answer region starts here even though no `\n` byte crossed it.
1526        self.pseudo_tool.reset_line();
1527        self.plain_dsml = MarkerDetector::default();
1528        // And the same un-holding of the stanza: it opened inside thinking, so
1529        // its banner was held back pending the placement verdict. Thinking is
1530        // over, so the call is real — render what there is of it rather than
1531        // reporting it as a call made inside a thought.
1532        if self.dsml_active && self.dsml_ignored {
1533            self.dsml_ignored = false;
1534            self.viz_start();
1535        }
1536    }
1537
1538    fn finish_ignored_dsml(&mut self, msg: &str) {
1539        // The parser buffer is often already drained by the time a rejection
1540        // lands, which left the most frequent failure in the log recorded with
1541        // an empty payload. Fall back to the held opener bytes so every entry
1542        // carries some evidence of what was rejected.
1543        let raw = if self.parser.raw().is_empty() {
1544            self.dsml_start_tail.clone()
1545        } else {
1546            self.parser.raw().to_vec()
1547        };
1548        log_tool_error(msg, &raw);
1549        self.resolve_swallowed_think_close();
1550        self.dsml_in_think = true;
1551        self.in_think_rejected = true;
1552        self.dsml_in_think_reported = true;
1553        self.viz_newline_if_open();
1554        let line = format!("[tool call ignored: {msg}]\n");
1555        self.viz_error_puts(&line);
1556        self.parser.reset();
1557        self.dsml_active = false;
1558        self.dsml_ignored = false;
1559    }
1560
1561    fn malformed_dsml(&mut self, msg: &str) {
1562        if self.stream_error.is_some() {
1563            return;
1564        }
1565        self.stream_error = Some(msg.to_string());
1566        log_tool_error(msg, self.parser.raw());
1567        self.viz_newline_if_open();
1568        let line = format!("[invalid tool call: {msg}]\n");
1569        self.viz_error_puts(&line);
1570    }
1571
1572    fn pseudo_tool_call(&mut self, opener: &str) {
1573        self.pseudo_tool_fired = true;
1574        self.malformed_dsml(&format!(
1575            "{opener} is not a tool call; tools are invoked with the DSML syntax in the system prompt"
1576        ));
1577    }
1578
1579    fn output_frozen(&self) -> bool {
1580        self.stream_error.is_some() || self.parser.state() == DsmlState::Error
1581    }
1582
1583    fn note_thinking_dsml_byte(&mut self, c: u8) {
1584        if !self.in_think || self.dsml_in_think {
1585            return;
1586        }
1587        if self.think_dsml.feed(c) {
1588            self.dsml_in_think = true;
1589        }
1590    }
1591
1592    fn note_plain_dsml_byte(&mut self, c: u8) {
1593        // Gated on `in_think_rejected`, NOT on `dsml_in_think`. Both are sticky,
1594        // but they mean different things: `dsml_in_think` is set the moment
1595        // DSML-*shaped* bytes appear inside `<think>`, which is what a model
1596        // reasoning about the syntax does, and letting that disable the plain
1597        // validator for the rest of the stream meant one quoted marker in
1598        // thinking bought the model silent impunity for genuinely malformed
1599        // markup in its answer. `in_think_rejected` means a stanza was actually
1600        // rejected and the model already has a tool error in hand, so a second
1601        // report would be noise.
1602        if self.output_frozen() || self.dsml_active || self.in_think || self.in_think_rejected {
1603            return;
1604        }
1605        if self.plain_dsml.feed(c) {
1606            self.malformed_dsml("DSML markup outside a valid tool_calls block");
1607        }
1608    }
1609
1610    fn note_pseudo_tool_byte(&mut self, c: u8) {
1611        // Deliberately NOT gated on `dsml_in_think`: that flag is sticky for
1612        // the whole stream, so a generation in which the model tried DSML
1613        // inside <think> and then fell back to `<task>` XML in its answer —
1614        // the exact issue #51 scenario — would go unreported.
1615        //
1616        // NOT gated on `in_think` either: the byte still has to reach the
1617        // detector's fence tracker so a fence opened inside thinking keeps
1618        // correct open/close parity across the `</think>` boundary (see
1619        // `PseudoToolDetector::feed`). `in_think` is passed through instead
1620        // so tag matching, and thus reporting, is still skipped while
1621        // thinking.
1622        if self.output_frozen() || self.dsml_active || self.replay {
1623            return;
1624        }
1625        if let Some(opener) = self.pseudo_tool.feed(c, self.in_think) {
1626            self.pseudo_tool_call(&opener);
1627        }
1628    }
1629
1630    /// Matches the last line at end of stream: a hallucinated tag as the final
1631    /// line, with no trailing newline, is still a hallucinated call.
1632    fn flush_pseudo_tool(&mut self) {
1633        if self.output_frozen() || self.dsml_active || self.replay {
1634            return;
1635        }
1636        if let Some(opener) = self.pseudo_tool.finish_line(self.in_think) {
1637            self.pseudo_tool_call(&opener);
1638        }
1639    }
1640
1641    fn flush_start_tail(&mut self) {
1642        if self.dsml_start_tail.is_empty() {
1643            return;
1644        }
1645        self.post_think_gap = false;
1646        let held = std::mem::take(&mut self.dsml_start_tail);
1647        for b in held {
1648            self.write_char(b);
1649            self.note_plain_dsml_byte(b);
1650            self.note_pseudo_tool_byte(b);
1651            if self.output_frozen() {
1652                break;
1653            }
1654        }
1655    }
1656
1657    /// Routes an ordinary byte to rendering or into the DSML start detector.
1658    ///
1659    /// The detector must hold short prefixes because the model can split
1660    /// `<|DSML|tool_calls>` across arbitrary tokens.
1661    fn normal_byte(&mut self, c: u8) {
1662        if self.output_frozen() {
1663            return;
1664        }
1665        self.note_thinking_dsml_byte(c);
1666
1667        // Swallow the visual whitespace gap the model emits right after
1668        // `</think>`; normal rendering resumes at the first non-space byte.
1669        if self.post_think_gap && matches!(c, b' ' | b'\t' | b'\r' | b'\n') {
1670            return;
1671        }
1672
1673        if !self.dsml_start_tail.is_empty() || c == b'<' {
1674            if self.dsml_start_tail.len() < DSML_START_TAIL_CAP {
1675                self.dsml_start_tail.push(c);
1676            }
1677            let (mut complete, mut implicit_invoke) = (false, false);
1678            if dsml_start_match(&self.dsml_start_tail, &mut complete, &mut implicit_invoke) {
1679                if complete {
1680                    // Parity mode discards an in-think stanza; otherwise it is
1681                    // an ordinary tool call that happens to sit inside a thought.
1682                    self.start_dsml();
1683                    if implicit_invoke {
1684                        for &b in CANONICAL_INVOKE {
1685                            self.feed_dsml_byte(b);
1686                        }
1687                    }
1688                }
1689                return;
1690            }
1691            // The mismatching byte may itself start a new marker: flush all
1692            // but the trailing '<' and keep matching from there.
1693            if self.dsml_start_tail.len() > 1 && self.dsml_start_tail.last() == Some(&b'<') {
1694                self.post_think_gap = false;
1695                let held = std::mem::take(&mut self.dsml_start_tail);
1696                for &b in &held[..held.len() - 1] {
1697                    self.write_char(b);
1698                    self.note_plain_dsml_byte(b);
1699                    self.note_pseudo_tool_byte(b);
1700                    if self.output_frozen() {
1701                        return;
1702                    }
1703                }
1704                self.dsml_start_tail.push(b'<');
1705                return;
1706            }
1707            self.flush_start_tail();
1708            return;
1709        }
1710
1711        self.post_think_gap = false;
1712        self.write_char(c);
1713        self.note_plain_dsml_byte(c);
1714        self.note_pseudo_tool_byte(c);
1715    }
1716
1717    /// The single streaming display state machine for assistant output.
1718    fn stream_text(&mut self, text: &[u8], finish: bool) {
1719        let mut buf = std::mem::take(&mut self.pending);
1720        buf.extend_from_slice(text);
1721
1722        let mut i = 0;
1723        while i < buf.len() {
1724            let rem = &buf[i..];
1725            if !self.dsml_active && rem.starts_with(THINK_OPEN) {
1726                self.flush_start_tail();
1727                self.post_think_gap = false;
1728                self.in_think = true;
1729                // The plain-DSML tail is not fed while thinking, so bytes from
1730                // before the block must not glue onto bytes from after it and
1731                // spell a marker that was never written.
1732                self.plain_dsml = MarkerDetector::default();
1733                i += THINK_OPEN.len();
1734                continue;
1735            }
1736            if rem.starts_with(THINK_CLOSE) && self.think_close_is_control() {
1737                if self.dsml_active {
1738                    // The model closed its thought part-way through a stanza.
1739                    // `</think>` is a control token, never stanza content, so
1740                    // it is consumed without reaching the parser and the call
1741                    // carries on — now outside thinking, so its stop token
1742                    // will accept it. The call is real after all, so start
1743                    // rendering it if the opener was held back.
1744                    self.in_think = false;
1745                    if self.dsml_ignored {
1746                        self.dsml_ignored = false;
1747                        self.viz_start();
1748                    }
1749                } else {
1750                    self.flush_start_tail();
1751                    self.in_think = false;
1752                    self.pseudo_tool.reset_line();
1753                    self.viz_newline_if_open();
1754                    self.emit_visible_bytes(b"\n");
1755                    self.post_think_gap = true;
1756                }
1757                self.plain_dsml = MarkerDetector::default();
1758                i += THINK_CLOSE.len();
1759                continue;
1760            }
1761            if rem.starts_with(THINK_CLOSE) {
1762                // Not a control token here (the branch above owns that case):
1763                // it is inside a parameter value and is about to be fed to the
1764                // parser as content. Remember it, in case the stanza never
1765                // completes — see `resolve_swallowed_think_close`.
1766                self.think_close_swallowed = true;
1767            }
1768            if !finish
1769                && rem[0] == b'<'
1770                && self.think_close_is_control()
1771                && (is_partial_prefix(rem, THINK_OPEN) || is_partial_prefix(rem, THINK_CLOSE))
1772            {
1773                self.pending = rem.to_vec();
1774                break;
1775            }
1776
1777            let c = rem[0];
1778            if self.dsml_active {
1779                self.feed_dsml_byte(c);
1780            } else {
1781                // In-think bytes still flow through the DSML start detector so
1782                // an accidental in-think tool stanza is suppressed cleanly.
1783                self.normal_byte(c);
1784            }
1785            i += 1;
1786        }
1787
1788        if finish {
1789            self.flush_start_tail();
1790            self.post_think_gap = false;
1791            if self.dsml_active {
1792                // A stanza cut off after swallowing a `</think>` never got to
1793                // prove the token was payload text, so it is settled as the
1794                // control token it most likely was — before the placement
1795                // verdict below reads `in_think`.
1796                self.resolve_swallowed_think_close();
1797                if self.rejects_in_think() {
1798                    // Cut off mid-stanza and still inside thinking: the model
1799                    // never left the block, so the placement rule applies.
1800                    self.reject_in_think_stanza(
1801                        "tool calling is not allowed inside <think></think>",
1802                    );
1803                } else {
1804                    self.viz_finish(Some(if self.preflight_error.is_some() {
1805                        "[tool call stopped: edit old selector failed]\n"
1806                    } else {
1807                        "[tool call interrupted]\n"
1808                    }));
1809                    self.dsml_active = false;
1810                }
1811            }
1812            // Deliberately nothing here for `dsml_in_think` alone. That flag is
1813            // set by `note_thinking_dsml_byte` the moment DSML-shaped bytes
1814            // appear inside `<think>`, which happens whenever the model reasons
1815            // *about* the syntax — quoting a marker is not calling a tool. It
1816            // used to raise the prohibition at finish, so a model explaining its
1817            // own tool-call format got a tool error back and rewrote correct
1818            // syntax. Only a stanza that reaches its stop token (or is cut off
1819            // mid-flight) is a call, and both are handled above.
1820        }
1821    }
1822}
1823
1824fn is_partial_prefix(bytes: &[u8], prefix: &[u8]) -> bool {
1825    bytes.len() < prefix.len() && prefix[..bytes.len()] == *bytes
1826}
1827
1828#[cfg(test)]
1829mod tests {
1830    use super::*;
1831
1832    #[derive(Debug, Default)]
1833    struct Cap {
1834        visible: String,
1835        think: String,
1836        errors: String,
1837    }
1838
1839    impl RenderSink for Cap {
1840        fn visible_text(&mut self, text: &str) {
1841            self.visible.push_str(text);
1842        }
1843        fn error_text(&mut self, text: &str) {
1844            self.errors.push_str(text);
1845            self.visible.push_str(text);
1846        }
1847        fn think_text(&mut self, text: &str) {
1848            self.think.push_str(text);
1849        }
1850    }
1851
1852    fn run_chunked(text: &str) -> StreamRenderer<Cap> {
1853        let mut sr = StreamRenderer::new(Cap::default());
1854        sr.push(text);
1855        sr.finish();
1856        sr
1857    }
1858
1859    fn pseudo_tool_renderer() -> StreamRenderer<Cap> {
1860        let mut sr = StreamRenderer::new(Cap::default());
1861        sr.set_tool_names(vec!["task".to_string(), "read".to_string()]);
1862        sr
1863    }
1864
1865    // Issue #51: the model invents <task> XML for tools it was not trained on.
1866    // Nothing recognized it, so the turn ended with no tool call and no error and
1867    // the model retried forever.
1868    #[test]
1869    fn pseudo_tool_block_after_think_is_reported() {
1870        let mut sr = pseudo_tool_renderer();
1871        sr.push("<think>planning</think>");
1872        sr.push("<task>\ntask_context: \"add headers\"\n</task>");
1873        sr.finish();
1874        assert!(
1875            sr.finished().error.is_some(),
1876            "hallucinated call produced no error for the model to correct from"
1877        );
1878    }
1879
1880    // While thinking, the model muses about markup; firing there would punish it
1881    // for reasoning. Only the answer region counts.
1882    #[test]
1883    fn pseudo_tool_block_inside_think_is_ignored() {
1884        let mut sr = pseudo_tool_renderer();
1885        sr.push("<think>\n<task>\n</task>\n</think>");
1886        sr.push("done");
1887        sr.finish();
1888        assert!(sr.finished().error.is_none());
1889    }
1890
1891    // A fence opened inside <think> IS observed by the detector: every byte
1892    // reaches the fence tracker, and only tag matching is gated on `!in_think`.
1893    // That is what makes its matching close in the answer region read as a
1894    // close rather than a fresh opener that would silence the detector for the
1895    // rest of the stream. Issue #51 case: the model tries DSML-like
1896    // markup inside thinking, then falls back to `<task>` XML in its answer.
1897    #[test]
1898    fn stray_fence_close_leaking_from_think_does_not_disarm_the_detector() {
1899        let mut sr = pseudo_tool_renderer();
1900        sr.push("<think>```rust\nfoo\n</think>```\n<task>\nx\n</task>");
1901        sr.finish();
1902        assert!(
1903            sr.finished().error.is_some(),
1904            "a fence opened inside <think> disarmed the detector for the rest of the stream"
1905        );
1906    }
1907
1908    // Discussing the markup in prose or code is not attempting to call it.
1909    #[test]
1910    fn pseudo_tool_name_in_prose_or_fence_is_ignored() {
1911        let mut sr = pseudo_tool_renderer();
1912        sr.push("the <task> element is XML, not DSML\n");
1913        sr.push("```xml\n<task>\n</task>\n```\n");
1914        sr.finish();
1915        assert!(sr.finished().error.is_none());
1916    }
1917
1918    // A pseudo-tool hit happens by construction in the ANSWER region, after
1919    // `</think>`. Reporting the in-think prohibition instead tells the model to
1920    // move a call it never made, which is the misdiagnosis this detector exists
1921    // to end.
1922    #[test]
1923    fn pseudo_tool_error_wins_over_the_in_think_prohibition() {
1924        let mut sr = pseudo_tool_renderer();
1925        sr.push(concat!(
1926            "<think><|DSML|tool_calls><|DSML|invoke name=\"bash\">",
1927            "</|DSML|invoke|></|DSML|tool_calls|></think>\n",
1928            "<task>\nx\n</task>",
1929        ));
1930        sr.finish();
1931        let fin = sr.finished();
1932        assert_eq!(
1933            fin.error,
1934            Some(
1935                "<task> is not a tool call; tools are invoked with the DSML syntax in the system prompt"
1936            ),
1937            "pseudo-tool hit must not be masked by the in-think prohibition"
1938        );
1939        assert!(
1940            !fin.in_think_rejected,
1941            "in-think framing would wrap the answer-region error in the wrong advice"
1942        );
1943    }
1944
1945    // The other direction: a genuinely leaked in-think stanza with no
1946    // pseudo-tool hit still reports the prohibition.
1947    #[test]
1948    fn leaked_in_think_stanza_alone_still_reports_the_prohibition() {
1949        let mut sr = pseudo_tool_renderer();
1950        sr.push(concat!(
1951            "<think><|DSML|tool_calls><|DSML|invoke name=\"bash\">",
1952            "</|DSML|invoke|></|DSML|tool_calls|></think>\n",
1953            "all done\n",
1954        ));
1955        sr.finish();
1956        let fin = sr.finished();
1957        assert_eq!(fin.error, Some(IN_THINK_PROHIBITION));
1958        assert!(fin.in_think_rejected);
1959    }
1960
1961    // A line that opens with a tool tag and then continues in prose is prose:
1962    // firing there both fabricates a tool error and truncates a legitimate
1963    // answer, because a stream error freezes output.
1964    #[test]
1965    fn prose_continuing_after_a_tool_tag_does_not_fire() {
1966        let mut sr = pseudo_tool_renderer();
1967        sr.push("<read> is how you spell it, unlike the others.\nand more text\n");
1968        sr.finish();
1969        assert!(sr.finished().error.is_none(), "{:?}", sr.finished().error);
1970        assert!(
1971            sr.sink().visible.contains("and more text"),
1972            "answer truncated: {:?}",
1973            sr.sink().visible
1974        );
1975    }
1976
1977    // A line that is nothing but the tag is still a hallucinated call.
1978    #[test]
1979    fn bare_tool_tag_on_its_own_line_fires() {
1980        let mut sr = pseudo_tool_renderer();
1981        sr.push("here goes\n<read>\npath: /tmp/x\n</read>\n");
1982        sr.finish();
1983        assert!(sr.finished().error.is_some());
1984    }
1985
1986    // ... including when the stream ends without a trailing newline.
1987    #[test]
1988    fn bare_tool_tag_as_final_line_without_newline_fires() {
1989        let mut sr = pseudo_tool_renderer();
1990        sr.push("here goes\n<task>");
1991        sr.finish();
1992        assert_eq!(
1993            sr.finished().error,
1994            Some(
1995                "<task> is not a tool call; tools are invoked with the DSML syntax in the system prompt"
1996            )
1997        );
1998    }
1999
2000    // The model-facing text names the line it actually saw.
2001    #[test]
2002    fn pseudo_tool_message_quotes_the_matched_line() {
2003        let mut sr = StreamRenderer::new(Cap::default());
2004        sr.push("<invoke name=\"bash\">\n");
2005        sr.finish();
2006        assert_eq!(
2007            sr.finished().error,
2008            Some(
2009                "<invoke name=\"bash\"> is not a tool call; tools are invoked with the DSML syntax in the system prompt"
2010            )
2011        );
2012    }
2013
2014    // Generic wrappers are matched with no tool list configured at all.
2015    #[test]
2016    fn generic_tool_call_wrapper_is_reported() {
2017        let mut sr = StreamRenderer::new(Cap::default());
2018        sr.push("<tool_call>\n{\"name\": \"read\"}\n</tool_call>");
2019        sr.finish();
2020        assert!(sr.finished().error.is_some());
2021    }
2022
2023    // Replaying stored transcript text must not produce new diagnostics: the
2024    // text was already diagnosed when it was first produced.
2025    #[test]
2026    fn pseudo_tool_block_is_not_reported_during_replay() {
2027        let mut sr = pseudo_tool_renderer();
2028        sr.set_replay(true);
2029        sr.push("<task>\ntask_context: \"add headers\"\n</task>");
2030        sr.finish();
2031        assert!(sr.finished().error.is_none());
2032    }
2033
2034    // A real DSML call must never be mistaken for a hallucination.
2035    #[test]
2036    fn real_dsml_call_is_untouched_by_the_pseudo_detector() {
2037        let mut sr = pseudo_tool_renderer();
2038        sr.push(
2039            "<|DSML|tool_calls><|DSML|invoke name=\"read\">\
2040             <|DSML|parameter name=\"path\" string=\"true\">/tmp/x</|DSML|parameter|>\
2041             </|DSML|invoke|></|DSML|tool_calls|>",
2042        );
2043        sr.finish();
2044        let fin = sr.finished();
2045        assert!(fin.error.is_none(), "error: {:?}", fin.error);
2046        assert_eq!(fin.calls.len(), 1);
2047        assert_eq!(fin.calls[0].name, "read");
2048    }
2049
2050    #[test]
2051    fn write_content_previews_dim_even_with_banners_off() {
2052        let stanza = concat!(
2053            "<|DSML|tool_calls>",
2054            "<|DSML|invoke name=\"write\">",
2055            "<|DSML|parameter name=\"path\">src/foo.rs</|DSML|parameter>",
2056            "<|DSML|parameter name=\"content\">fn main() {}\n</|DSML|parameter>",
2057            "</|DSML|invoke>",
2058            "</|DSML|tool_calls>",
2059        );
2060        // Banners off (default): the content still previews, on the think
2061        // (dim) channel, with a header naming the file — nothing in visible.
2062        let mut sr = StreamRenderer::new(Cap::default());
2063        sr.set_show_tool_calls(false);
2064        sr.push(stanza);
2065        sr.finish();
2066        let think = &sr.sink().think;
2067        assert!(think.contains("write src/foo.rs"), "header: {think:?}");
2068        assert!(think.contains("fn main() {}"), "content preview: {think:?}");
2069        assert!(
2070            !sr.sink().visible.contains("fn main()"),
2071            "content not on the visible channel: {:?}",
2072            sr.sink().visible
2073        );
2074        assert_eq!(sr.finished().calls.len(), 1, "call still parsed");
2075    }
2076
2077    #[test]
2078    fn show_tool_calls_false_suppresses_the_banner_but_keeps_visible_text() {
2079        let stanza = concat!(
2080            "answer before. ",
2081            "<|DSML|tool_calls>",
2082            "<|DSML|invoke name=\"bash\">",
2083            "<|DSML|parameter name=\"command\">ls -la</|DSML|parameter>",
2084            "</|DSML|invoke>",
2085            "</|DSML|tool_calls>",
2086        );
2087        // Default: banner shows.
2088        let shown = run_chunked(stanza);
2089        assert!(
2090            shown.sink().visible.contains("🛠️"),
2091            "{:?}",
2092            shown.sink().visible
2093        );
2094
2095        // Gated off: no banner, no `ls -la`, but the model's prose survives and
2096        // the tool call is still parsed (so it would still execute).
2097        let mut sr = StreamRenderer::new(Cap::default());
2098        sr.set_show_tool_calls(false);
2099        sr.push(stanza);
2100        sr.finish();
2101        let vis = &sr.sink().visible;
2102        assert!(vis.contains("answer before."), "prose kept: {vis:?}");
2103        assert!(!vis.contains("🛠️"), "banner suppressed: {vis:?}");
2104        assert!(!vis.contains("ls -la"), "params suppressed: {vis:?}");
2105        assert_eq!(sr.finished().calls.len(), 1, "call still parsed");
2106    }
2107
2108    #[test]
2109    fn show_thinking_false_suppresses_thinking_but_keeps_visible_text() {
2110        let text = "<think>secret reasoning</think>visible answer";
2111
2112        // Default: thinking is emitted to the think channel.
2113        let shown = run_chunked(text);
2114        assert_eq!(shown.sink().think, "secret reasoning");
2115        assert_eq!(shown.sink().visible.trim(), "visible answer");
2116
2117        // Gated off: nothing on the think channel, prose unaffected (a leading
2118        // post-think separator newline may remain).
2119        let mut sr = StreamRenderer::new(Cap::default());
2120        sr.set_show_thinking(false);
2121        sr.push(text);
2122        sr.finish();
2123        assert_eq!(sr.sink().think, "", "thinking suppressed");
2124        assert_eq!(sr.sink().visible.trim(), "visible answer", "prose kept");
2125    }
2126
2127    fn run_charwise(text: &str) -> StreamRenderer<Cap> {
2128        let mut sr = StreamRenderer::new(Cap::default());
2129        for ch in text.chars() {
2130            sr.push(ch.to_string());
2131        }
2132        sr.finish();
2133        sr
2134    }
2135
2136    const BASH_STANZA: &str = concat!(
2137        "<|DSML|tool_calls>",
2138        "<|DSML|invoke name=\"bash\">",
2139        "<|DSML|parameter name=\"command\">ls -la</|DSML|parameter|>",
2140        "</|DSML|invoke|>",
2141        "</|DSML|tool_calls|>",
2142    );
2143
2144    #[test]
2145    fn begin_in_think_routes_thinking_then_answer() {
2146        // The chat template opens <think> in the prefill prefix, so generation
2147        // streams thinking first and closes with a real </think> token.
2148        let mut sr = StreamRenderer::new(Cap::default());
2149        sr.begin_in_think();
2150        sr.push("weighing options</think>Final answer.");
2151        sr.finish();
2152        assert!(sr.sink().think.contains("weighing options"));
2153        assert!(sr.sink().visible.contains("Final answer."));
2154        assert!(!sr.sink().visible.contains("weighing options"));
2155        assert!(!sr.sink().visible.contains("think"));
2156    }
2157
2158    #[test]
2159    fn provider_explicit_think_tags_route_correctly() {
2160        // Provider engines do NOT begin_in_think: their translator emits its own
2161        // <think>/</think> tags around reasoning. A turn that opens, reasons, and
2162        // closes must route cleanly, and — the regression that turned visible
2163        // output gray — a turn with NO reasoning must stay fully visible.
2164        let mut sr = StreamRenderer::new(Cap::default());
2165        sr.push("<think>reasoning here</think>");
2166        sr.push("visible answer");
2167        sr.finish();
2168        assert!(sr.sink().think.contains("reasoning here"));
2169        assert!(!sr.sink().think.contains("visible answer"));
2170        assert!(sr.sink().visible.contains("visible answer"));
2171
2172        // No reasoning at all: content is emitted directly and stays visible
2173        // (with begin_in_think this would have been misclassified as thinking).
2174        let mut sr = StreamRenderer::new(Cap::default());
2175        sr.push("just an answer, no thinking");
2176        sr.finish();
2177        assert_eq!(sr.sink().visible, "just an answer, no thinking");
2178        assert_eq!(sr.sink().think, "");
2179    }
2180
2181    /// Repro `~/.plank/repro/repro-1785161356.md`: mid-session the model wrote
2182    /// `<|SSML|…>` for the whole stanza. Every other byte was correct, but the
2183    /// call parsed as nothing, printed raw, and the turn ended with no tool
2184    /// error — so the model could not even retry. `MARKER_NAMES` accepts SSML
2185    /// as an alias; the stanza must dispatch exactly like the DSML spelling.
2186    #[test]
2187    fn ssml_misspelling_is_accepted_as_an_alias() {
2188        let text = concat!(
2189            "Let me look at the documents module.\n",
2190            "<|SSML|tool_calls>\n",
2191            "<|SSML|invoke name=\"bash\">\n",
2192            "<|SSML|parameter name=\"command\" string=\"true\">cat documents.rs",
2193            "</|SSML|parameter>\n",
2194            "</|SSML|invoke>\n",
2195            "</|SSML|tool_calls>",
2196        );
2197        for sr in [run_chunked(text), run_charwise(text)] {
2198            let vis = &sr.sink().visible;
2199            assert!(vis.contains("🛠️ $ cat documents.rs"), "{vis:?}");
2200            assert!(!vis.contains("SSML"), "{vis:?}");
2201            let fin = sr.finished();
2202            assert_eq!(fin.calls.len(), 1);
2203            assert_eq!(fin.calls[0].name, "bash");
2204            assert_eq!(fin.calls[0].arg_value("command"), Some("cat documents.rs"));
2205            assert!(fin.error.is_none(), "{:?}", fin.error);
2206        }
2207    }
2208
2209    /// The alias is per-tag, not per-stanza: the drift is a sampling slip on a
2210    /// spelled-out marker, so it can hit one tag and not the next.
2211    #[test]
2212    fn dsml_and_ssml_tags_mix_within_one_stanza() {
2213        let text = concat!(
2214            "<|DSML|tool_calls>",
2215            "<|SSML|invoke name=\"bash\">",
2216            "<|DSML|parameter name=\"command\">ls -la</|SSML|parameter>",
2217            "</|DSML|invoke>",
2218            "</|SSML|tool_calls>",
2219        );
2220        for sr in [run_chunked(text), run_charwise(text)] {
2221            let fin = sr.finished();
2222            assert_eq!(fin.calls.len(), 1);
2223            assert_eq!(fin.calls[0].arg_value("command"), Some("ls -la"));
2224            assert!(fin.error.is_none(), "{:?}", fin.error);
2225        }
2226    }
2227
2228    /// The alias must not widen to any four letters: only the one misspelling
2229    /// the model actually produces is recovered, so unrelated markup in prose
2230    /// still passes through as text rather than becoming a tool call.
2231    #[test]
2232    fn unrelated_marker_names_are_still_plain_text() {
2233        let text = "<|XSML|tool_calls><|XSML|invoke name=\"bash\">";
2234        for sr in [run_chunked(text), run_charwise(text)] {
2235            assert!(sr.finished().calls.is_empty());
2236            assert_eq!(sr.sink().visible, text);
2237        }
2238    }
2239
2240    #[test]
2241    fn prose_passes_through() {
2242        for sr in [run_chunked("Hello, world."), run_charwise("Hello, world.")] {
2243            assert_eq!(sr.sink().visible, "Hello, world.");
2244            assert_eq!(sr.sink().think, "");
2245            assert!(sr.finished().calls.is_empty());
2246            assert!(sr.finished().error.is_none());
2247        }
2248    }
2249
2250    #[test]
2251    fn bash_stanza_hides_dsml_and_shows_banner() {
2252        let text = format!("Let me look.\n{BASH_STANZA}");
2253        for sr in [run_chunked(&text), run_charwise(&text)] {
2254            let vis = &sr.sink().visible;
2255            assert!(vis.starts_with("Let me look.\n"), "{vis:?}");
2256            assert!(vis.contains("🛠️ $ ls -la"), "{vis:?}");
2257            assert!(!vis.contains("DSML"), "{vis:?}");
2258            let fin = sr.finished();
2259            assert_eq!(fin.calls.len(), 1);
2260            assert_eq!(fin.calls[0].name, "bash");
2261            assert_eq!(fin.calls[0].arg_value("command"), Some("ls -la"));
2262            assert!(fin.error.is_none());
2263        }
2264    }
2265
2266    #[test]
2267    fn read_banner_shows_path_and_range() {
2268        let stanza = concat!(
2269            "<|DSML|tool_calls>",
2270            "<|DSML|invoke name=\"read\">",
2271            "<|DSML|parameter name=\"path\" string=\"true\">src/main.rs</|DSML|parameter|>",
2272            "</|DSML|invoke|>",
2273            "</|DSML|tool_calls|>",
2274        );
2275        for sr in [run_chunked(stanza), run_charwise(stanza)] {
2276            let vis = &sr.sink().visible;
2277            assert!(vis.contains("🛠️ Reading src/main.rs 1:500...\n"), "{vis:?}");
2278            assert!(!vis.contains("DSML"), "{vis:?}");
2279        }
2280    }
2281
2282    #[test]
2283    fn read_banner_whole_file() {
2284        let stanza = concat!(
2285            "<|DSML|tool_calls>",
2286            "<|DSML|invoke name=\"read\">",
2287            "<|DSML|parameter name=\"path\" string=\"true\">a.c</|DSML|parameter|>",
2288            "<|DSML|parameter name=\"whole\">true</|DSML|parameter|>",
2289            "</|DSML|invoke|>",
2290            "</|DSML|tool_calls|>",
2291        );
2292        let sr = run_chunked(stanza);
2293        assert!(
2294            sr.sink()
2295                .visible
2296                .contains("🛠️ Reading a.c (whole file)...\n"),
2297            "{:?}",
2298            sr.sink().visible
2299        );
2300    }
2301
2302    #[test]
2303    fn edit_diff_uses_minus_plus_prefixes() {
2304        let stanza = concat!(
2305            "<|DSML|tool_calls>",
2306            "<|DSML|invoke name=\"edit\">",
2307            "<|DSML|parameter name=\"path\" string=\"true\">a.rs</|DSML|parameter|>",
2308            "<|DSML|parameter name=\"old\">let a = 1;</|DSML|parameter|>",
2309            "<|DSML|parameter name=\"new\">let a = 2;</|DSML|parameter|>",
2310            "</|DSML|invoke|>",
2311            "</|DSML|tool_calls|>",
2312        );
2313        for sr in [run_chunked(stanza), run_charwise(stanza)] {
2314            let vis = &sr.sink().visible;
2315            assert!(vis.contains("🛠️ edit  path=a.rs"), "{vis:?}");
2316            assert!(vis.contains("- let a = 1;"), "{vis:?}");
2317            assert!(vis.contains("+ let a = 2;"), "{vis:?}");
2318            assert!(!vis.contains("DSML"), "{vis:?}");
2319            assert_eq!(sr.finished().calls[0].arg_value("new"), Some("let a = 2;"));
2320        }
2321    }
2322
2323    #[test]
2324    fn partial_marker_false_alarm_is_flushed() {
2325        let mut sr = StreamRenderer::new(Cap::default());
2326        sr.push("<|DSM");
2327        // Nothing shown while the prefix is still ambiguous.
2328        assert_eq!(sr.sink().visible, "");
2329        sr.push("ok");
2330        sr.finish();
2331        assert_eq!(sr.sink().visible, "<|DSMok");
2332        assert!(sr.finished().error.is_none());
2333    }
2334
2335    #[test]
2336    fn partial_marker_flushed_at_stream_end() {
2337        // A held-back prefix containing a complete loose marker is flushed at
2338        // end of stream and then flagged by the plain-marker detector, exactly
2339        // as in the C reference.
2340        let mut sr = StreamRenderer::new(Cap::default());
2341        sr.push("done <|DSML|tool_c");
2342        sr.finish();
2343        assert!(
2344            sr.sink().visible.starts_with("done <|DSML|"),
2345            "{:?}",
2346            sr.sink().visible
2347        );
2348        assert!(
2349            sr.sink().visible.contains("[invalid tool call: "),
2350            "{:?}",
2351            sr.sink().visible
2352        );
2353    }
2354
2355    #[test]
2356    fn think_text_routes_to_think_sink() {
2357        let sr = run_chunked("<think>pondering</think>Answer.");
2358        assert_eq!(sr.sink().think, "pondering");
2359        assert!(
2360            sr.sink().visible.ends_with("Answer."),
2361            "{:?}",
2362            sr.sink().visible
2363        );
2364        assert!(!sr.sink().visible.contains("pondering"));
2365    }
2366
2367    #[test]
2368    fn think_tag_split_across_pushes() {
2369        let mut sr = StreamRenderer::new(Cap::default());
2370        sr.push("<th");
2371        sr.push("ink>hidden</th");
2372        sr.push("ink>shown");
2373        sr.finish();
2374        assert_eq!(sr.sink().think, "hidden");
2375        assert!(
2376            sr.sink().visible.ends_with("shown"),
2377            "{:?}",
2378            sr.sink().visible
2379        );
2380    }
2381
2382    #[test]
2383    fn dsml_inside_think_is_ignored_and_reported() {
2384        let text = format!("<think>{BASH_STANZA}</think>ok");
2385        for sr in [run_chunked(&text), run_charwise(&text)] {
2386            let fin = sr.finished();
2387            assert!(fin.dsml_in_think);
2388            // An ignored stanza must never surface as an executable call:
2389            // if `feed_dsml_byte`'s `Done` arm ever syncs `self.calls` before
2390            // checking `dsml_ignored` again, this call would leak through and
2391            // the turn loop would dispatch it despite the "ignored" notice.
2392            assert!(fin.calls.is_empty(), "{:?}", fin.calls);
2393            assert!(
2394                sr.sink().visible.contains(
2395                    "[tool call ignored: tool calling is not allowed inside <think></think>]"
2396                ),
2397                "{:?}",
2398                sr.sink().visible
2399            );
2400            assert!(!sr.sink().think.contains("DSML"), "{:?}", sr.sink().think);
2401        }
2402    }
2403
2404    fn run_allowing_in_think(text: &str) -> StreamRenderer<Cap> {
2405        let mut sr = StreamRenderer::new(Cap::default());
2406        sr.set_thinking_tool_calls(true);
2407        sr.push(text);
2408        sr.finish();
2409        sr
2410    }
2411
2412    fn run_allowing_in_think_charwise(text: &str) -> StreamRenderer<Cap> {
2413        let mut sr = StreamRenderer::new(Cap::default());
2414        sr.set_thinking_tool_calls(true);
2415        for ch in text.chars() {
2416            sr.push(ch.to_string());
2417        }
2418        sr.finish();
2419        sr
2420    }
2421
2422    /// The model stopped mid-stanza inside `<think>`: the parser's own verdict
2423    /// would be "incomplete DSML tool call", which is true and useless — the
2424    /// markup was cut off only because the call had no business being there.
2425    /// The reported error is the placement rule.
2426    #[test]
2427    fn an_unfinished_in_think_stanza_reports_placement_not_syntax() {
2428        let mut sr = StreamRenderer::new(Cap::default());
2429        // Opens a stanza inside thinking and stops: no closing tags.
2430        sr.push("<think>let me look<|DSML|tool_calls><|DSML|invoke name=\"bash\">");
2431        sr.finish();
2432        let fin = sr.finished();
2433        assert!(fin.calls.is_empty(), "{:?}", fin.calls);
2434        assert!(fin.in_think_rejected, "rejected for placement");
2435        assert_eq!(fin.error, Some(IN_THINK_PROHIBITION));
2436        assert!(fin.ended_in_think);
2437    }
2438
2439    /// The verdict belongs at the stanza's *stop* token, not its opening.
2440    ///
2441    /// From `repro-1785754509.md`: the model was reasoning about the correct
2442    /// syntax, wrote an opening `<|DSML|tool_calls>` as part of that thought,
2443    /// then closed the thinking block and emitted the real call. Judging at the
2444    /// opening threw the whole (correct, post-`</think>`) call away and told
2445    /// the model to stop calling tools inside thinking — which it had not done.
2446    #[test]
2447    fn a_stanza_opened_in_think_but_closed_after_it_is_dispatched() {
2448        let mut sr = StreamRenderer::new(Cap::default());
2449        sr.push("<think>the correct format is:\n\n<|DSML|tool_calls>\n</think>\n\n");
2450        sr.push("<|DSML|invoke name=\"bash\">");
2451        sr.push("<|DSML|parameter name=\"command\">ls -la</|DSML|parameter|>");
2452        sr.push("</|DSML|invoke|></|DSML|tool_calls|>");
2453        sr.finish();
2454        let fin = sr.finished();
2455        assert_eq!(fin.calls.len(), 1, "{:?}", fin.calls);
2456        assert_eq!(fin.calls[0].arg_value("command"), Some("ls -la"));
2457        assert!(!fin.in_think_rejected, "the call closed outside thinking");
2458        assert!(fin.error.is_none(), "{:?}", fin.error);
2459        assert!(
2460            !sr.sink().visible.contains("[tool call ignored:"),
2461            "{:?}",
2462            sr.sink().visible
2463        );
2464    }
2465
2466    /// Merely *mentioning* the markup while reasoning is not a tool call and
2467    /// must not poison the turn: with no stanza and no stop token there is
2468    /// nothing to block. The marker detector alone used to raise the
2469    /// prohibition at finish, so a model recalling its own syntax mid-thought
2470    /// got a tool error back and went off rewriting correct markup.
2471    #[test]
2472    fn dsml_mentioned_while_thinking_without_a_stop_token_is_not_rejected() {
2473        for text in [
2474            // A parameter line quoted mid-thought: DSML-shaped, but not an
2475            // opener, so no stanza is ever tracked.
2476            "<think>each arg is <|DSML|parameter name=\"x\" string=\"true\">v</|DSML|parameter|>              inside the invoke</think>the answer",
2477            // A bare closing tag, the other half of the same recollection.
2478            "<think>and it ends with </|DSML|tool_calls|> of course</think>the answer",
2479        ] {
2480            let sr = run_chunked(text);
2481            let fin = sr.finished();
2482            assert!(fin.calls.is_empty(), "{:?}", fin.calls);
2483            assert!(!fin.in_think_rejected, "nothing completed; {text}");
2484            assert_eq!(fin.error, None, "{text}");
2485            // The marker was still *seen* in thinking, which is reported as
2486            // information; it just is not an error any more.
2487            assert!(fin.dsml_in_think, "{text}");
2488            assert!(sr.sink().visible.contains("the answer"), "{text}");
2489        }
2490    }
2491
2492    /// `</think>` is only a control token where it cannot be data. Inside a
2493    /// parameter value it is payload — this repo's own sources and docs contain
2494    /// the literal text — so it must reach the parser untouched. Swallowing it
2495    /// there would silently corrupt every file written about thinking blocks.
2496    #[test]
2497    fn think_close_inside_a_parameter_value_is_payload_not_a_control_token() {
2498        let mut sr = StreamRenderer::new(Cap::default());
2499        sr.push("<think>writing it up</think>");
2500        sr.push("<|DSML|tool_calls><|DSML|invoke name=\"write\">");
2501        sr.push("<|DSML|parameter name=\"content\">close it with </think> when done");
2502        sr.push("</|DSML|parameter|></|DSML|invoke|></|DSML|tool_calls|>");
2503        sr.finish();
2504        let fin = sr.finished();
2505        assert_eq!(fin.calls.len(), 1, "{:?}", fin.calls);
2506        assert_eq!(
2507            fin.calls[0].arg_value("content"),
2508            Some("close it with </think> when done")
2509        );
2510        assert!(fin.error.is_none(), "{:?}", fin.error);
2511    }
2512
2513    /// A stanza that both opens and closes inside thinking is still rejected —
2514    /// that is the trained rule — and it renders no banner on the way, since it
2515    /// never became a real call.
2516    #[test]
2517    fn a_stanza_wholly_inside_think_is_rejected_without_a_banner() {
2518        let mut sr = StreamRenderer::new(Cap::default());
2519        sr.push(format!("<think>thinking{BASH_STANZA}</think>done"));
2520        sr.finish();
2521        let fin = sr.finished();
2522        assert!(fin.calls.is_empty(), "{:?}", fin.calls);
2523        assert!(fin.in_think_rejected);
2524        assert_eq!(fin.error, Some(IN_THINK_PROHIBITION));
2525        assert!(
2526            !sr.sink().visible.contains("🛠️"),
2527            "no banner for a call that never happened: {:?}",
2528            sr.sink().visible
2529        );
2530    }
2531
2532    /// A shorthand invoke (`<|DSML|edit>`) dispatches, so it must also draw a
2533    /// banner naming the tool — a call that runs invisibly is worse than one
2534    /// that is refused.
2535    #[test]
2536    fn shorthand_invoke_renders_a_banner() {
2537        let mut sr = StreamRenderer::new(Cap::default());
2538        sr.push("<|DSML|tool_calls><|DSML|bash>");
2539        sr.push("<|DSML|parameter name=\"command\" string=\"true\">ls -la</|DSML|parameter|>");
2540        sr.push("</|DSML|invoke|></|DSML|tool_calls|>");
2541        sr.finish();
2542        let fin = sr.finished();
2543        assert_eq!(fin.calls.len(), 1, "{:?}", fin.calls);
2544        assert_eq!(fin.calls[0].name, "bash");
2545        assert!(
2546            sr.sink().visible.contains("🛠️ $ ls -la"),
2547            "{:?}",
2548            sr.sink().visible
2549        );
2550        assert!(
2551            !sr.sink().visible.contains("DSML"),
2552            "{:?}",
2553            sr.sink().visible
2554        );
2555    }
2556
2557    /// The parameter shorthand renders under its own name too — the same
2558    /// mirroring, one level down.
2559    #[test]
2560    fn shorthand_parameter_renders_under_its_element_name() {
2561        let mut sr = StreamRenderer::new(Cap::default());
2562        sr.push("<|DSML|tool_calls><|DSML|invoke name=\"bash\">");
2563        sr.push("<|DSML|command string=\"true\">ls -la</|DSML|invoke>");
2564        sr.push("</|DSML|invoke|></|DSML|tool_calls|>");
2565        sr.finish();
2566        let fin = sr.finished();
2567        assert_eq!(fin.calls.len(), 1, "{:?}", fin.calls);
2568        assert_eq!(fin.calls[0].arg_value("command"), Some("ls -la"));
2569        assert!(
2570            sr.sink().visible.contains("🛠️ $ ls -la"),
2571            "{:?}",
2572            sr.sink().visible
2573        );
2574    }
2575
2576    /// A completed stanza inside `<think>` reports the same placement error,
2577    /// rather than falling through to whatever the parser concluded.
2578    #[test]
2579    fn a_completed_in_think_stanza_reports_placement() {
2580        let mut sr = StreamRenderer::new(Cap::default());
2581        sr.push(format!("<think>thinking{BASH_STANZA}"));
2582        sr.finish();
2583        let fin = sr.finished();
2584        assert!(fin.calls.is_empty(), "the call must not be dispatched");
2585        assert!(fin.in_think_rejected);
2586        assert_eq!(fin.error, Some(IN_THINK_PROHIBITION));
2587    }
2588
2589    /// With `engine.thinkingToolCalls` on, an in-think call is dispatched and
2590    /// there is nothing to report: the placement error must not leak into the
2591    /// allow path just because the marker was seen inside thinking.
2592    #[test]
2593    fn allowing_in_think_calls_reports_no_placement_error() {
2594        let text = format!("<think>{BASH_STANZA}</think>ok");
2595        let sr = run_allowing_in_think(&text);
2596        let fin = sr.finished();
2597        assert_eq!(fin.calls.len(), 1);
2598        assert!(!fin.in_think_rejected, "nothing was rejected");
2599        assert!(fin.error.is_none(), "{:?}", fin.error);
2600        assert!(fin.dsml_in_think, "the marker was still seen in thinking");
2601    }
2602
2603    #[test]
2604    fn dsml_inside_think_is_executed_when_allowed() {
2605        let text = format!("<think>{BASH_STANZA}</think>ok");
2606        for sr in [
2607            run_allowing_in_think(&text),
2608            run_allowing_in_think_charwise(&text),
2609        ] {
2610            let fin = sr.finished();
2611            assert_eq!(fin.calls.len(), 1, "{:?}", fin.calls);
2612            assert_eq!(fin.calls[0].name, "bash");
2613            assert_eq!(fin.calls[0].arg_value("command"), Some("ls -la"));
2614            assert!(fin.error.is_none(), "{:?}", fin.error);
2615            assert!(
2616                !sr.sink().visible.contains("[tool call ignored:"),
2617                "{:?}",
2618                sr.sink().visible
2619            );
2620            // The banner renders like any other tool call, and raw DSML never
2621            // reaches either sink.
2622            assert!(
2623                sr.sink().visible.contains("🛠️ $ ls -la"),
2624                "{:?}",
2625                sr.sink().visible
2626            );
2627            assert!(
2628                !sr.sink().visible.contains("DSML"),
2629                "{:?}",
2630                sr.sink().visible
2631            );
2632            assert!(!sr.sink().think.contains("DSML"), "{:?}", sr.sink().think);
2633        }
2634    }
2635
2636    #[test]
2637    fn ended_in_think_reports_an_open_block() {
2638        // A stanza fired mid-thought: the stream ends with <think> still open.
2639        let sr = run_allowing_in_think(&format!("<think>let me look{BASH_STANZA}"));
2640        assert!(sr.finished().ended_in_think);
2641
2642        // A closed block, and a stream that never thought at all, both report
2643        // false.
2644        let closed = run_allowing_in_think(&format!("<think>done</think>{BASH_STANZA}"));
2645        assert!(!closed.finished().ended_in_think);
2646        assert!(!run_chunked("plain answer").finished().ended_in_think);
2647
2648        // A stanza inside a think block that then closes: the DSML marker
2649        // sets `dsml_active` mid-block, but `</think>` still arrives before
2650        // the stream ends, so the block is not open at finish.
2651        let stanza_then_close =
2652            run_allowing_in_think(&format!("<think>a{BASH_STANZA}</think>tail"));
2653        assert!(!stanza_then_close.finished().ended_in_think);
2654    }
2655
2656    #[test]
2657    fn interrupted_stanza_reports_status() {
2658        let mut sr = StreamRenderer::new(Cap::default());
2659        sr.push("<|DSML|tool_calls><|DSML|invoke name=\"bash\">");
2660        sr.push("<|DSML|parameter name=\"command\">sleep 1");
2661        sr.finish();
2662        let vis = &sr.sink().visible;
2663        assert!(vis.contains("🛠️ $ sleep 1"), "{vis:?}");
2664        assert!(vis.contains("[tool call interrupted]\n"), "{vis:?}");
2665        assert!(sr.finished().calls.is_empty());
2666    }
2667
2668    #[test]
2669    fn incomplete_stanza_reports_incomplete_error() {
2670        let sr = run_chunked(concat!(
2671            "<|DSML|tool_calls>",
2672            "<|DSML|invoke name=\"bash\">",
2673            "<|DSML|parameter name=\"command\">ls",
2674        ));
2675        assert_eq!(sr.finished().error, Some("incomplete DSML tool call"));
2676        assert!(sr.finished().calls.is_empty());
2677    }
2678
2679    #[test]
2680    fn greedy_sampling_tracks_dsml_state() {
2681        let mut sr = StreamRenderer::new(Cap::default());
2682        sr.push("hello ");
2683        assert!(!sr.wants_greedy_sampling(), "prose");
2684        sr.push("<|DS");
2685        assert!(sr.wants_greedy_sampling(), "DSML-shaped held prefix");
2686        sr.push("ML|tool_calls><|DSML|invoke name=\"bash\">");
2687        assert!(sr.wants_greedy_sampling(), "structural markup");
2688        sr.push("<|DSML|parameter name=\"command\">ls -la");
2689        assert!(!sr.wants_greedy_sampling(), "free-form parameter value");
2690        sr.push("</|DSML|parameter");
2691        assert!(sr.wants_greedy_sampling(), "close tag streaming");
2692        sr.push("|></|DSML|invoke|></|DSML|tool_calls|>");
2693        assert!(!sr.wants_greedy_sampling(), "stanza done");
2694    }
2695
2696    #[test]
2697    fn edit_old_preflight_failure_is_reported_midstream() {
2698        let mut sr = StreamRenderer::new(Cap::default());
2699        sr.set_preflight(|call| {
2700            assert_eq!(call.name, "edit");
2701            assert_eq!(call.arg_value("path"), Some("src/a.rs"));
2702            Err("old text is not a unique match".to_string())
2703        });
2704        sr.push(concat!(
2705            "<|DSML|tool_calls>",
2706            "<|DSML|invoke name=\"edit\">",
2707            "<|DSML|parameter name=\"path\">src/a.rs</|DSML|parameter|>",
2708            "<|DSML|parameter name=\"old\">nope</|DSML|parameter|>",
2709        ));
2710        // The failure is recorded the moment `old` closes, before `new`.
2711        assert_eq!(
2712            sr.preflight_error(),
2713            Some(
2714                "edit old selector failed before new was generated: \
2715                 old text is not a unique match"
2716            )
2717        );
2718        sr.finish();
2719        assert!(
2720            sr.sink()
2721                .errors
2722                .contains("[tool call stopped: edit old selector failed]"),
2723            "{:?}",
2724            sr.sink().errors
2725        );
2726    }
2727
2728    #[test]
2729    fn edit_old_preflight_pass_leaves_stream_clean() {
2730        let mut sr = StreamRenderer::new(Cap::default());
2731        sr.set_preflight(|_| Ok(()));
2732        sr.push(concat!(
2733            "<|DSML|tool_calls>",
2734            "<|DSML|invoke name=\"edit\">",
2735            "<|DSML|parameter name=\"path\">src/a.rs</|DSML|parameter|>",
2736            "<|DSML|parameter name=\"old\">a</|DSML|parameter|>",
2737            "<|DSML|parameter name=\"new\">b</|DSML|parameter|>",
2738            "</|DSML|invoke|>",
2739            "</|DSML|tool_calls|>",
2740        ));
2741        sr.finish();
2742        assert!(sr.preflight_error().is_none());
2743        assert_eq!(sr.finished().calls.len(), 1);
2744        assert!(sr.finished().error.is_none());
2745    }
2746
2747    #[test]
2748    fn malformed_stanza_suppresses_raw_and_reports_error() {
2749        let sr = run_chunked("<|DSML|tool_calls><b>");
2750        let vis = &sr.sink().visible;
2751        // The banner names the offending tag through the error channel...
2752        assert!(
2753            sr.sink()
2754                .errors
2755                .contains("[invalid tool call: unexpected DSML tag: <b>]"),
2756            "{:?}",
2757            sr.sink().errors
2758        );
2759        // ...but the raw stanza bytes never reach the screen.
2760        assert!(!vis.contains("tool_calls"), "{vis:?}");
2761        assert!(sr.finished().error.is_some());
2762    }
2763
2764    #[test]
2765    fn loose_dsml_marker_is_flagged() {
2766        let sr = run_chunked("junk |DSML| junk");
2767        assert!(
2768            sr.sink()
2769                .visible
2770                .contains("[invalid tool call: DSML markup outside a valid tool_calls block]"),
2771            "{:?}",
2772            sr.sink().visible
2773        );
2774        assert_eq!(
2775            sr.finished().error,
2776            Some("DSML markup outside a valid tool_calls block")
2777        );
2778    }
2779
2780    /// The model quoting a DSML marker while thinking used to set the sticky
2781    /// `dsml_in_think` flag, which disabled the loose-marker validator for the
2782    /// whole rest of the stream — so genuinely malformed markup in the answer
2783    /// went unreported and the turn ended with nothing for the model to correct.
2784    #[test]
2785    fn a_quoted_marker_in_think_does_not_disarm_the_loose_marker_validator() {
2786        let sr = run_chunked("<think>the |DSML| marker opens a call</think>junk |DSML| junk");
2787        assert_eq!(
2788            sr.finished().error,
2789            Some("DSML markup outside a valid tool_calls block"),
2790            "{:?}",
2791            sr.sink().visible
2792        );
2793    }
2794
2795    /// A `</think>` inside a parameter value is consumed as content so a
2796    /// payload containing that literal text is not corrupted. When the stanza
2797    /// never completes, the token was the real control token: `in_think` must
2798    /// not stay stuck, or the stream is reported as having ended mid-thought
2799    /// and the cut-off call is misdiagnosed as an in-think placement error.
2800    #[test]
2801    fn a_think_close_swallowed_by_an_unfinished_stanza_still_ends_thinking() {
2802        let sr = run_chunked(concat!(
2803            "<think>",
2804            "<|DSML|tool_calls>",
2805            "<|DSML|invoke name=\"write\">",
2806            "<|DSML|parameter name=\"content\">x</think>",
2807        ));
2808        let fin = sr.finished();
2809        assert!(!fin.ended_in_think, "in_think stayed stuck after </think>");
2810        assert!(
2811            !fin.in_think_rejected,
2812            "a call cut off after thinking closed is not an in-think call"
2813        );
2814        assert!(
2815            sr.sink().visible.contains("[tool call interrupted]"),
2816            "{:?}",
2817            sr.sink()
2818        );
2819    }
2820
2821    /// The mirror case: a stanza that *completes* proves the `</think>` in its
2822    /// payload was content, so thinking is still open and the payload keeps the
2823    /// literal text.
2824    #[test]
2825    fn a_think_close_inside_a_valid_payload_stays_payload_text() {
2826        let sr = run_chunked(concat!(
2827            "<think>",
2828            "<|DSML|tool_calls>",
2829            "<|DSML|invoke name=\"bash\">",
2830            "<|DSML|parameter name=\"command\">echo </think></|DSML|parameter|>",
2831            "</|DSML|invoke|>",
2832            "</|DSML|tool_calls|>",
2833        ));
2834        let fin = sr.finished();
2835        assert!(fin.ended_in_think, "the thinking block never closed");
2836        assert_eq!(fin.calls.len(), 0, "an in-think stanza is not dispatched");
2837    }
2838
2839    #[test]
2840    fn implicit_invoke_opener_is_accepted() {
2841        let stanza = concat!(
2842            "<|DSML|invoke name=\"bash\">",
2843            "<|DSML|parameter name=\"command\">pwd</|DSML|parameter|>",
2844            "</|DSML|invoke|>",
2845            "</|DSML|tool_calls|>",
2846        );
2847        for sr in [run_chunked(stanza), run_charwise(stanza)] {
2848            assert!(
2849                sr.sink().visible.contains("🛠️ $ pwd"),
2850                "{:?}",
2851                sr.sink().visible
2852            );
2853            let fin = sr.finished();
2854            assert_eq!(fin.calls.len(), 1);
2855            assert_eq!(fin.calls[0].arg_value("command"), Some("pwd"));
2856        }
2857    }
2858
2859    /// Regression for the repro captured after a weights update: every turn
2860    /// opened with `<|DSML|tool_calls|>`, which matched no opener form, so the
2861    /// stanza streamed as prose and the inner `<|DSML|invoke` tripped the
2862    /// loose-marker detector instead of dispatching the tool.
2863    #[test]
2864    fn opener_with_trailing_bar_is_accepted() {
2865        let stanza = concat!(
2866            "<|DSML|tool_calls|>",
2867            "<|DSML|invoke name=\"bash\">",
2868            "<|DSML|parameter name=\"command\" string=\"true\">pwd</|DSML|parameter|>",
2869            "</|DSML|invoke|>",
2870            "</|DSML|tool_calls|>",
2871        );
2872        for sr in [run_chunked(stanza), run_charwise(stanza)] {
2873            let fin = sr.finished();
2874            assert_eq!(fin.error, None);
2875            assert_eq!(fin.calls.len(), 1);
2876            assert_eq!(fin.calls[0].name, "bash");
2877            assert_eq!(fin.calls[0].arg_value("command"), Some("pwd"));
2878        }
2879    }
2880
2881    /// Second recorded repro: the parameter written as its own element, closed
2882    /// with `</|DSML|invoke>`. Rejecting it cost three turns and ended with the
2883    /// model breaking the think gate, so the shorthand dispatches instead.
2884    #[test]
2885    fn shorthand_parameter_element_dispatches() {
2886        let stanza = concat!(
2887            "<|DSML|tool_calls|>",
2888            "<|DSML|invoke name=\"bash\">",
2889            "<|DSML|command string=\"true\">ls</|DSML|invoke>",
2890            "</|DSML|invoke>",
2891            "</|DSML|tool_calls|>",
2892        );
2893        for sr in [run_chunked(stanza), run_charwise(stanza)] {
2894            let fin = sr.finished();
2895            assert_eq!(fin.error, None);
2896            assert_eq!(fin.calls.len(), 1);
2897            assert_eq!(fin.calls[0].name, "bash");
2898            assert_eq!(fin.calls[0].arg_value("command"), Some("ls"));
2899        }
2900    }
2901
2902    #[test]
2903    fn write_content_streams_without_label() {
2904        let stanza = concat!(
2905            "<|DSML|tool_calls>",
2906            "<|DSML|invoke name=\"write\">",
2907            "<|DSML|parameter name=\"path\" string=\"true\">x.txt</|DSML|parameter|>",
2908            "<|DSML|parameter name=\"content\">line one\nline two</|DSML|parameter|>",
2909            "</|DSML|invoke|>",
2910            "</|DSML|tool_calls|>",
2911        );
2912        for sr in [run_chunked(stanza), run_charwise(stanza)] {
2913            let vis = &sr.sink().visible;
2914            assert!(vis.contains("🛠️ write  path=x.txt"), "{vis:?}");
2915            // The content now previews on the dim (think) channel, not visible.
2916            assert!(!vis.contains("line one"), "{vis:?}");
2917            assert!(
2918                sr.sink().think.contains("line one\nline two"),
2919                "{:?}",
2920                sr.sink().think
2921            );
2922            assert!(!vis.contains("content:"), "{vis:?}");
2923            assert!(!vis.contains("DSML"), "{vis:?}");
2924        }
2925    }
2926
2927    #[test]
2928    fn post_think_whitespace_gap_is_swallowed() {
2929        let sr = run_chunked("<think>x</think>\n\n  Answer");
2930        assert!(
2931            sr.sink().visible.ends_with("Answer"),
2932            "{:?}",
2933            sr.sink().visible
2934        );
2935        assert!(!sr.sink().visible.contains("\n\n  Answer"));
2936    }
2937
2938    #[test]
2939    fn charwise_and_chunked_agree() {
2940        let text = format!("hi <not dsml> there\n{BASH_STANZA}");
2941        let a = run_chunked(&text);
2942        let b = run_charwise(&text);
2943        assert_eq!(a.sink().visible, b.sink().visible);
2944        assert_eq!(a.finished().calls, b.finished().calls);
2945    }
2946
2947    #[test]
2948    fn tool_error_logging_honors_the_opt_out_env_var() {
2949        use std::ffi::OsStr;
2950        // The e2e harness sets this when spawning the binary so fixture stanzas
2951        // never enter the developer's real ~/.plank log, where they previously
2952        // outnumbered genuine model failures four to one.
2953        assert!(!super::logging_enabled_for(Some(OsStr::new("1"))));
2954        assert!(super::logging_enabled_for(None));
2955        // Only an exact "1" disables it; anything else is not an opt-out.
2956        assert!(super::logging_enabled_for(Some(OsStr::new("0"))));
2957    }
2958}