1use crate::dsml::{
18 DsmlParser, DsmlState, MARKER_NAMES, ToolCall, tag_prefix_len, tag_prefix_partial,
19};
20
21pub const IN_THINK_PROHIBITION: &str =
27 "Tool calls are not allowed inside <think></think>; finish thinking before emitting DSML.";
28
29const DSML_START: &[u8] = "<|DSML|tool_calls>".as_bytes();
31const 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
38fn logging_enabled_for(opt_out: Option<&std::ffi::OsStr>) -> bool {
42 opt_out.is_none_or(|v| v != "1")
43}
44
45fn 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
57fn 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 let record = format!("[{secs}] {reason}\n---\n{snippet}\n===\n");
88 let _ = f.write_all(record.as_bytes());
89 }
90}
91
92pub trait RenderSink {
105 fn visible_text(&mut self, text: &str);
107 fn think_text(&mut self, text: &str);
109 fn tool_text(&mut self, text: &str) {
111 self.visible_text(text);
112 }
113 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
135impl 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#[derive(Debug, Clone, Default)]
160pub struct CollectSink(pub std::sync::Arc<std::sync::Mutex<String>>);
161
162impl CollectSink {
163 #[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 fn think_text(&mut self, _text: &str) {}
188 fn tool_text(&mut self, text: &str) {
189 self.push(text);
190 }
191}
192
193#[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
216fn 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
252fn 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
260fn 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
295fn dsml_start_match(tail: &[u8], complete: &mut bool, implicit_invoke: &mut bool) -> bool {
301 *complete = false;
302 *implicit_invoke = false;
303 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#[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
358const GENERIC_PSEUDO_OPENERS: [&str; 3] = ["<tool_call>", "<function_call>", "<invoke "];
361
362#[derive(Debug, Default)]
372struct PseudoToolDetector {
373 line: Vec<u8>,
375 in_fence: bool,
378 tool_names: Vec<String>,
380 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 fn reset_line(&mut self) {
397 self.line.clear();
398 }
399
400 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 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 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 fn match_line(&mut self, in_think: bool) -> Option<String> {
449 if in_think || self.fired || self.in_fence {
450 return None;
451 }
452 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 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
480fn is_lone_tag(text: &str) -> bool {
482 text.ends_with('>') && !text[..text.len() - 1].contains('>')
483}
484
485#[derive(Debug, Default)]
487enum DsmlScan {
488 #[default]
490 Between,
491 Tag(Vec<u8>),
493 Value,
495}
496
497#[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 write_path: String,
522 write_is_create: bool,
526}
527
528impl ToolViz {
529 const END_TAIL_CAP: usize = 64;
530}
531
532#[derive(Debug, Clone, Copy)]
536pub struct Finished<'a> {
537 pub calls: &'a [ToolCall],
539 pub error: Option<&'a str>,
541 pub dsml_in_think: bool,
543 pub in_think_rejected: bool,
548 pub ended_in_think: bool,
552}
553
554#[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 pending: Vec<u8>,
592 dsml_start_tail: Vec<u8>,
594 plain_dsml: MarkerDetector,
595 think_dsml: MarkerDetector,
596 pseudo_tool: PseudoToolDetector,
598 dsml_in_think: bool,
599 dsml_in_think_reported: bool,
600 think_close_swallowed: bool,
606 in_think_rejected: bool,
613 pseudo_tool_fired: bool,
618 post_think_gap: bool,
619 stream_error: Option<String>,
621 last_output_newline: bool,
622 calls: Vec<ToolCall>,
624 vis_carry: Vec<u8>,
627 think_carry: Vec<u8>,
628 preflight: Preflight,
633 preflight_error: Option<String>,
634 show_tool_calls: bool,
639 show_thinking: bool,
643 thinking_tool_calls: bool,
649 replay: bool,
656}
657
658type 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 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 pub fn set_replay(&mut self, replay: bool) {
719 self.replay = replay;
720 }
721
722 pub fn set_tool_names(&mut self, names: Vec<String>) {
728 self.pseudo_tool.set_tool_names(names);
729 }
730
731 pub fn set_show_tool_calls(&mut self, show: bool) {
735 self.show_tool_calls = show;
736 }
737
738 pub fn set_show_thinking(&mut self, show: bool) {
743 self.show_thinking = show;
744 }
745
746 pub fn set_thinking_tool_calls(&mut self, allow: bool) {
751 self.thinking_tool_calls = allow;
752 }
753
754 pub fn set_preflight(&mut self, f: impl FnMut(&ToolCall) -> Result<(), String> + 'static) {
759 self.preflight = Preflight(Some(Box::new(f)));
760 }
761
762 #[must_use]
764 pub fn preflight_error(&self) -> Option<&str> {
765 self.preflight_error.as_deref()
766 }
767
768 pub fn begin_in_think(&mut self) {
774 self.in_think = true;
775 }
776
777 pub fn push(&mut self, text: impl AsRef<str>) {
779 self.stream_text(text.as_ref().as_bytes(), false);
780 }
781
782 pub fn finish(&mut self) {
787 self.stream_text(b"", true);
788 self.flush_carry();
789 self.flush_pseudo_tool();
790 }
791
792 #[must_use]
799 pub fn finished(&self) -> Finished<'_> {
800 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 #[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 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 pub fn sink(&self) -> &S {
865 &self.sink
866 }
867
868 pub fn sink_mut(&mut self) -> &mut S {
870 &mut self.sink
871 }
872
873 pub fn into_sink(self) -> S {
875 self.sink
876 }
877
878 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 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 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 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 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 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 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 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 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 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 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 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 self.viz.write_is_create = !std::path::Path::new(&self.viz.write_path).exists();
1169 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 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 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 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 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 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 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 }
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 self.think_close_swallowed = false;
1385 if self.rejects_in_think() {
1386 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 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 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 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 fn rejects_in_think(&self) -> bool {
1485 self.in_think && !self.thinking_tool_calls
1486 }
1487
1488 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 fn think_close_is_control(&self) -> bool {
1506 !self.dsml_active || self.parser.state() != DsmlState::ParamValue
1507 }
1508
1509 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 self.pseudo_tool.reset_line();
1527 self.plain_dsml = MarkerDetector::default();
1528 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 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 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 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 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 fn normal_byte(&mut self, c: u8) {
1662 if self.output_frozen() {
1663 return;
1664 }
1665 self.note_thinking_dsml_byte(c);
1666
1667 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 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 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 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 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 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 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 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 self.resolve_swallowed_think_close();
1797 if self.rejects_in_think() {
1798 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 }
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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 let shown = run_chunked(stanza);
2089 assert!(
2090 shown.sink().visible.contains("🛠️"),
2091 "{:?}",
2092 shown.sink().visible
2093 );
2094
2095 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 let shown = run_chunked(text);
2114 assert_eq!(shown.sink().think, "secret reasoning");
2115 assert_eq!(shown.sink().visible.trim(), "visible answer");
2116
2117 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 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 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 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 #[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 #[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 #[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 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 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 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 #[test]
2427 fn an_unfinished_in_think_stanza_reports_placement_not_syntax() {
2428 let mut sr = StreamRenderer::new(Cap::default());
2429 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 #[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 #[test]
2472 fn dsml_mentioned_while_thinking_without_a_stop_token_is_not_rejected() {
2473 for text in [
2474 "<think>each arg is <|DSML|parameter name=\"x\" string=\"true\">v</|DSML|parameter|> inside the invoke</think>the answer",
2477 "<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 assert!(fin.dsml_in_think, "{text}");
2488 assert!(sr.sink().visible.contains("the answer"), "{text}");
2489 }
2490 }
2491
2492 #[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 #[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 #[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 #[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 #[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 #[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 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 let sr = run_allowing_in_think(&format!("<think>let me look{BASH_STANZA}"));
2640 assert!(sr.finished().ended_in_think);
2641
2642 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 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 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 assert!(
2753 sr.sink()
2754 .errors
2755 .contains("[invalid tool call: unexpected DSML tag: <b>]"),
2756 "{:?}",
2757 sr.sink().errors
2758 );
2759 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 #[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 #[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 #[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 #[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 #[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 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 assert!(!super::logging_enabled_for(Some(OsStr::new("1"))));
2954 assert!(super::logging_enabled_for(None));
2955 assert!(super::logging_enabled_for(Some(OsStr::new("0"))));
2957 }
2958}