1 //! Recovery for tool calls a model emits as literal text instead of the
2 //! endpoint's structured `tool_calls` field.
3 //!
4 //! Several open-weight families siGit Code can drive are fine-tuned on chat
5 //! templates that render a tool call inside assistant content. A serving stack
6 //! is supposed to parse that back into the OpenAI-shaped `tool_calls` field
7 //! before it reaches us. When it doesn't, the raw tag arrives as ordinary
8 //! content.
9 //!
10 //! Nothing then executes it. Worse, `consume_stream` forwards content to the
11 //! UI as it arrives, so the tag is rendered verbatim in the editor and the
12 //! turn ends with no tool calls, which reads to the agent loop as "the model
13 //! chose to answer in prose". Whatever it was actually trying to do is
14 //! dropped.
15 //!
16 //! This lives here rather than only in the gateway because siGit Code talks to
17 //! more than one kind of endpoint: siGit Code Cloud, an arbitrary
18 //! OpenAI-compatible base URL from `providers.toml` or `OPENAI_BASE_URL`, and
19 //! on-device models. A fix in any single upstream leaves the others exposed.
20 //!
21 //! Only well-formed shapes are recovered. A block that doesn't match is never
22 //! guessed at: reissuing a `run_command` is cheap, but guessing wrong at a
23 //! half-parsed `edit_file` would write the wrong change to a file. It isn't
24 //! shown either. A tool-call block that fails to parse, or that is still open
25 //! when the reply ends, is reported as [`ScanEvent::Malformed`] and kept out of
26 //! both the UI and the conversation history. Left in the history, the model
27 //! reads back a call it "made" with no result attached, and later turns start
28 //! inventing `<function_results>` blocks of their own. The backend tells the
29 //! model the call didn't run and asks for it again instead.
30 //!
31 //! A marker the model merely mentions in prose, say while explaining this very
32 //! scanner, is not a call. So a block that fails to parse is only treated as
33 //! malformed when what follows the marker looks like the start of a call (see
34 //! [`looks_like_xml_call`] and [`looks_like_k3_call`]); otherwise it stays
35 //! text, as it always did.
36
37 use std::collections::HashMap;
38
39 use crate::backend::ToolSpec;
40
41 const XML_OPEN_TAG: &str = "<tool_call>";
42 const XML_CLOSE_TAG: &str = "</tool_call>";
43
44 const K3_TOOLS_OPEN: &str = "<|open|>tools<|sep|>";
45 const K3_TOOLS_CLOSE: &str = "<|close|>tools<|sep|>";
46 const K3_CALL_OPEN: &str = "<|open|>call";
47 const K3_CALL_CLOSE: &str = "<|close|>call<|sep|>";
48 const K3_ARGUMENT_OPEN: &str = "<|open|>argument";
49 const K3_ARGUMENT_CLOSE: &str = "<|close|>argument<|sep|>";
50 const K3_RESPONSE_OPEN: &str = "<|open|>response<|sep|>";
51 const K3_RESPONSE_CLOSE: &str = "<|close|>response<|sep|>";
52 const K3_THINK_OPEN: &str = "<|open|>think<|sep|>";
53 const K3_THINK_CLOSE: &str = "<|close|>think<|sep|>";
54 const K3_SEP: &str = "<|sep|>";
55
56 const START_MARKERS: &[&str] = &[XML_OPEN_TAG, K3_TOOLS_OPEN, K3_RESPONSE_OPEN, K3_THINK_OPEN];
57
58 /// One tool call recovered from inline text.
59 #[derive(Debug, Clone, PartialEq, Eq)]
60 pub struct Recovered {
61 pub name: String,
62 /// Arguments as a JSON-encoded string, matching `ToolCall::arguments`.
63 pub arguments: String,
64 }
65
66 /// What [`extract`] found in a complete text blob.
67 #[derive(Debug, Default, PartialEq, Eq)]
68 pub struct Extracted {
69 /// The text with every tool-call block removed, recovered or not.
70 pub text: String,
71 /// Calls recovered from well-formed blocks, in order.
72 pub calls: Vec<Recovered>,
73 /// Tool-call blocks that were dropped because they couldn't be parsed.
74 pub malformed: usize,
75 }
76
77 /// Scan a complete text blob for inline tool-call blocks. Recovered blocks
78 /// become calls; blocks that don't parse cleanly, including an unterminated
79 /// trailing one, are dropped and counted in [`Extracted::malformed`].
80 pub fn extract(text: &str, tools: &[ToolSpec]) -> Extracted {
81 let mut scanner = StreamScanner::new(tools);
82 let mut extracted = Extracted {
83 text: String::with_capacity(text.len()),
84 ..Extracted::default()
85 };
86
87 for event in scanner.push(text).into_iter().chain(scanner.finish()) {
88 match event {
89 ScanEvent::Text(text) => extracted.text.push_str(&text),
90 ScanEvent::ToolCall(call) => extracted.calls.push(call),
91 ScanEvent::Malformed(_) => extracted.malformed += 1,
92 }
93 }
94
95 extracted
96 }
97
98 /// Parse one legacy XML block's inner text: an offered tool name, then zero or
99 /// more `<arg_key>K</arg_key><arg_value>V</arg_value>` pairs. The malformed
100 /// opening marker observed from GLM is accepted only for the first argument;
101 /// later arguments stay strict so text that merely resembles a call cannot be
102 /// recovered as one.
103 fn parse_xml_block(inner: &str, tools: &[ToolSpec]) -> Option<Recovered> {
104 let key_idx = inner.find("<arg_key>");
105 let malformed_key_idx = inner.find(XML_OPEN_TAG);
106 let check_status_name = key_idx.and_then(|idx| parse_check_status_preamble(&inner[..idx]));
107 let (name, mut rest, malformed_first_key) =
108 if let (Some(idx), Some(name)) = (key_idx, check_status_name) {
109 (name, &inner[idx..], false)
110 } else {
111 match (key_idx, malformed_key_idx) {
112 (Some(key_idx), Some(malformed_key_idx)) if malformed_key_idx < key_idx => (
113 inner[..malformed_key_idx].trim(),
114 &inner[malformed_key_idx..],
115 true,
116 ),
117 (Some(idx), _) => (inner[..idx].trim(), &inner[idx..], false),
118 (None, Some(idx)) => (inner[..idx].trim(), &inner[idx..], true),
119 (None, None) => (inner.trim(), "", false),
120 }
121 };
122 if name.is_empty() || name.contains(['<', '>']) || !offered_tool(tools, name) {
123 return None;
124 }
125
126 let mut args = serde_json::Map::new();
127 let mut first_key = true;
128 while !rest.is_empty() {
129 // The GLM alias is permitted exactly once, at the first key. A later
130 // `<tool_call>` must reject the complete block rather than letting a
131 // partial call escape recovery.
132 if !first_key && rest.starts_with(XML_OPEN_TAG) {
133 return None;
134 }
135 let key_open = if first_key && malformed_first_key {
136 XML_OPEN_TAG
137 } else {
138 "<arg_key>"
139 };
140 let (key, after_key) = split_once(rest.strip_prefix(key_open)?, "</arg_key>")?;
141 let (value, after_value) =
142 split_once(after_key.strip_prefix("<arg_value>")?, "</arg_value>")?;
143 let key = key.trim();
144 if key.is_empty() {
145 return None;
146 }
147 args.insert(
148 key.to_string(),
149 coerce(value, declared_type(tools, name, key).as_deref()),
150 );
151 rest = after_value;
152 first_key = false;
153 }
154
155 Some(Recovered {
156 name: name.to_string(),
157 arguments: serde_json::Value::Object(args).to_string(),
158 })
159 }
160
161 /// Some GLM-family responses prefix the real arguments with generation-control
162 /// metadata shaped like `tool_name CheckStatus=value</arg_value>`. It is not a
163 /// tool argument: accept that exact bounded preamble and discard it, while
164 /// leaving every other malformed prefix untouched.
165 fn parse_check_status_preamble(prefix: &str) -> Option<&str> {
166 let (name, status) = prefix.trim().split_once(" CheckStatus=")?;
167 let status = status.strip_suffix("</arg_value>")?;
168 if name.is_empty()
169 || status.is_empty()
170 || !status
171 .chars()
172 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
173 {
174 return None;
175 }
176 Some(name)
177 }
178
179 fn parse_k3_tools_block(inner: &str, tools: &[ToolSpec]) -> Option<Vec<Recovered>> {
180 let mut calls = Vec::new();
181 let mut rest = inner;
182
183 while !rest.trim().is_empty() {
184 rest = rest.trim_start();
185 let header = rest.strip_prefix(K3_CALL_OPEN)?;
186 let (attr_text, after_header) = split_once(header, K3_SEP)?;
187 let attrs = parse_k3_attributes(attr_text)?;
188 let name = attrs.get("tool")?;
189 if name.is_empty() || !offered_tool(tools, name) {
190 return None;
191 }
192
193 let (call_inner, after_call) = split_once(after_header, K3_CALL_CLOSE)?;
194 let arguments = parse_k3_arguments(call_inner, tools, name)?;
195 calls.push(Recovered {
196 name: name.to_string(),
197 arguments: serde_json::Value::Object(arguments).to_string(),
198 });
199 rest = after_call;
200 }
201
202 Some(calls)
203 }
204
205 fn parse_k3_arguments(
206 mut rest: &str,
207 tools: &[ToolSpec],
208 tool_name: &str,
209 ) -> Option<serde_json::Map<String, serde_json::Value>> {
210 let mut args = serde_json::Map::new();
211
212 while !rest.trim().is_empty() {
213 rest = rest.trim_start();
214 let header = rest.strip_prefix(K3_ARGUMENT_OPEN)?;
215 let (attr_text, after_header) = split_once(header, K3_SEP)?;
216 let attrs = parse_k3_attributes(attr_text)?;
217 let key = attrs.get("key")?.trim();
218 if key.is_empty() {
219 return None;
220 }
221 // K3 normally emits `type` for every argument. If an endpoint omits
222 // it, retain the legacy recovery path's schema-based typing instead of
223 // turning e.g. an integer `task_id` into a JSON string.
224 let schema_type = declared_type(tools, tool_name, key);
225 let value_type = attrs
226 .get("type")
227 .map(String::as_str)
228 .or(schema_type.as_deref())
229 .unwrap_or("string");
230
231 let (raw_value, after_argument) = split_once(after_header, K3_ARGUMENT_CLOSE)?;
232 let value = if value_type == "string" {
233 serde_json::Value::String(raw_value.to_string())
234 } else {
235 serde_json::from_str(raw_value).ok()?
236 };
237 args.insert(key.to_string(), value);
238 rest = after_argument;
239 }
240
241 Some(args)
242 }
243
244 fn parse_k3_attributes(mut text: &str) -> Option<HashMap<String, String>> {
245 let mut attrs = HashMap::new();
246 text = text.trim();
247
248 while !text.is_empty() {
249 let eq_idx = text.find('=')?;
250 let key = text[..eq_idx].trim();
251 if key.is_empty()
252 || !key
253 .chars()
254 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
255 {
256 return None;
257 }
258
259 let mut value_part = text[eq_idx + 1..].trim_start();
260 value_part = value_part.strip_prefix('"')?;
261 let end_idx = value_part.find('"')?;
262 let raw_value = &value_part[..end_idx];
263 attrs.insert(key.to_string(), decode_k3_attribute(raw_value));
264
265 text = value_part[end_idx + 1..].trim_start();
266 }
267
268 Some(attrs)
269 }
270
271 fn decode_k3_attribute(value: &str) -> String {
272 // K3's encoder escapes only `&` and `"` in attributes. These replacements
273 // deliberately mirror that format rather than implementing general HTML
274 // entity decoding (which could alter literal argument names).
275 value.replace("&quot;", "\"").replace("&amp;", "&")
276 }
277
278 fn split_once<'a>(s: &'a str, delim: &str) -> Option<(&'a str, &'a str)> {
279 let idx = s.find(delim)?;
280 Some((&s[..idx], &s[idx + delim.len()..]))
281 }
282
283 fn offered_tool(tools: &[ToolSpec], tool_name: &str) -> bool {
284 tools.iter().any(|spec| spec.name == tool_name)
285 }
286
287 /// The JSON Schema `type` declared for `tool_name`'s `key` parameter, e.g.
288 /// `"integer"` for `command_output`'s `task_id`. [`ToolSpec`] carries the
289 /// schema as an unparsed string, so this parses it per lookup; the argument
290 /// counts involved are tiny and this only runs on the recovery path.
291 fn declared_type(tools: &[ToolSpec], tool_name: &str, key: &str) -> Option<String> {
292 let spec = tools.iter().find(|spec| spec.name == tool_name)?;
293 let schema: serde_json::Value = serde_json::from_str(&spec.parameters_schema).ok()?;
294 Some(
295 schema
296 .get("properties")?
297 .get(key)?
298 .get("type")?
299 .as_str()?
300 .to_string(),
301 )
302 }
303
304 /// Convert a raw legacy text value to JSON per its declared schema type.
305 /// Falls back to a plain string for `"string"`, an unknown type, or a value
306 /// that doesn't parse as what it claims to be.
307 fn coerce(raw: &str, declared: Option<&str>) -> serde_json::Value {
308 match declared {
309 Some("integer") => raw
310 .trim()
311 .parse::<i64>()
312 .map(serde_json::Value::from)
313 .unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
314 Some("number") => raw
315 .trim()
316 .parse::<f64>()
317 .ok()
318 .and_then(serde_json::Number::from_f64)
319 .map(serde_json::Value::Number)
320 .unwrap_or_else(|| serde_json::Value::String(raw.to_string())),
321 Some("boolean") => match raw.trim() {
322 "true" => serde_json::Value::Bool(true),
323 "false" => serde_json::Value::Bool(false),
324 _ => serde_json::Value::String(raw.to_string()),
325 },
326 _ => serde_json::Value::String(raw.to_string()),
327 }
328 }
329
330 /// One event from incrementally scanning a stream of content deltas.
331 #[derive(Debug, Clone, PartialEq, Eq)]
332 pub enum ScanEvent {
333 /// Plain text, safe to forward to the UI.
334 Text(String),
335 /// A tool call recovered from a completed block.
336 ToolCall(Recovered),
337 /// A tool-call block that couldn't be parsed, or never closed. Carries the
338 /// raw block for logging; it must not be shown or executed.
339 Malformed(String),
340 }
341
342 /// Incremental scanner for a live delta stream.
343 ///
344 /// Holds back only the text that could still turn into a known inline protocol
345 /// marker, so ordinary answers still reach the UI token by token. Buffering
346 /// starts once an opening marker appears, and lasts only until the matching
347 /// close arrives.
348 pub struct StreamScanner<'a> {
349 tools: &'a [ToolSpec],
350 pending: String,
351 }
352
353 impl<'a> StreamScanner<'a> {
354 pub fn new(tools: &'a [ToolSpec]) -> Self {
355 Self {
356 tools,
357 pending: String::new(),
358 }
359 }
360
361 /// Feed the next content delta. Returns events to emit now, in order; may
362 /// be empty if the chunk was absorbed into a tag that hasn't closed yet.
363 pub fn push(&mut self, chunk: &str) -> Vec<ScanEvent> {
364 self.pending.push_str(chunk);
365 self.drain()
366 }
367
368 /// Call once the stream has ended to flush whatever is still held back.
369 /// A tool-call block that opened but never closed is [`ScanEvent::Malformed`]:
370 /// it can't be executed safely, and it isn't prose either. Anything else,
371 /// such as a partial marker prefix or an unclosed Kimi response wrapper, is
372 /// handed back as text.
373 pub fn finish(&mut self) -> Option<ScanEvent> {
374 if self.pending.is_empty() {
375 return None;
376 }
377 let rest = std::mem::take(&mut self.pending);
378 Some(
379 if rest
380 .strip_prefix(K3_TOOLS_OPEN)
381 .is_some_and(looks_like_k3_call)
382 || rest
383 .strip_prefix(XML_OPEN_TAG)
384 .is_some_and(looks_like_xml_call)
385 {
386 ScanEvent::Malformed(rest)
387 } else {
388 ScanEvent::Text(rest)
389 },
390 )
391 }
392
393 fn drain(&mut self) -> Vec<ScanEvent> {
394 let mut events = Vec::new();
395 loop {
396 let Some((open_idx, marker)) = find_next_marker(&self.pending) else {
397 let keep = max_partial_prefix_len(&self.pending, START_MARKERS);
398 let flush_len = self.pending.len() - keep;
399 if flush_len > 0 {
400 events.push(ScanEvent::Text(
401 self.pending.drain(..flush_len).collect::<String>(),
402 ));
403 }
404 break;
405 };
406
407 if open_idx > 0 {
408 events.push(ScanEvent::Text(
409 self.pending.drain(..open_idx).collect::<String>(),
410 ));
411 }
412
413 match marker {
414 XML_OPEN_TAG => {
415 let Some(event) = self.drain_xml() else {
416 break;
417 };
418 events.push(event);
419 }
420 K3_TOOLS_OPEN => match self.drain_k3_tools() {
421 Some(mut recovered) => events.append(&mut recovered),
422 None => break,
423 },
424 K3_RESPONSE_OPEN => {
425 let Some(mut unwrapped) =
426 self.drain_wrapped_text(K3_RESPONSE_OPEN, K3_RESPONSE_CLOSE, true)
427 else {
428 break;
429 };
430 events.append(&mut unwrapped);
431 }
432 K3_THINK_OPEN => {
433 let Some(mut unwrapped) =
434 self.drain_wrapped_text(K3_THINK_OPEN, K3_THINK_CLOSE, false)
435 else {
436 break;
437 };
438 events.append(&mut unwrapped);
439 }
440 _ => unreachable!("unknown inline marker"),
441 }
442 }
443 events
444 }
445
446 fn drain_xml(&mut self) -> Option<ScanEvent> {
447 let close_rel = self.pending[XML_OPEN_TAG.len()..].find(XML_CLOSE_TAG)?;
448 let close_idx = XML_OPEN_TAG.len() + close_rel;
449 let block: String = self
450 .pending
451 .drain(..close_idx + XML_CLOSE_TAG.len())
452 .collect();
453 let inner = &block[XML_OPEN_TAG.len()..block.len() - XML_CLOSE_TAG.len()];
454 Some(match parse_xml_block(inner, self.tools) {
455 Some(call) => ScanEvent::ToolCall(call),
456 None if looks_like_xml_call(inner) => ScanEvent::Malformed(block),
457 None => ScanEvent::Text(block),
458 })
459 }
460
461 fn drain_k3_tools(&mut self) -> Option<Vec<ScanEvent>> {
462 let close_rel = self.pending[K3_TOOLS_OPEN.len()..].find(K3_TOOLS_CLOSE)?;
463 let close_idx = K3_TOOLS_OPEN.len() + close_rel;
464 let block: String = self
465 .pending
466 .drain(..close_idx + K3_TOOLS_CLOSE.len())
467 .collect();
468 let inner = &block[K3_TOOLS_OPEN.len()..block.len() - K3_TOOLS_CLOSE.len()];
469
470 Some(match parse_k3_tools_block(inner, self.tools) {
471 Some(calls) => calls.into_iter().map(ScanEvent::ToolCall).collect(),
472 None if looks_like_k3_call(inner) => vec![ScanEvent::Malformed(block)],
473 None => vec![ScanEvent::Text(block)],
474 })
475 }
476
477 fn drain_wrapped_text(
478 &mut self,
479 open: &str,
480 close: &str,
481 emit_inner: bool,
482 ) -> Option<Vec<ScanEvent>> {
483 let after_open = &self.pending[open.len()..];
484 let close_rel = match after_open.find(close) {
485 Some(close_rel) => close_rel,
486 None => {
487 // A malformed response/think wrapper must not swallow a later
488 // valid tool block forever. Preserve the malformed prefix as
489 // text, leave the next marker pending, and resume scanning.
490 let (next_rel, _) = find_next_marker(after_open)?;
491 return Some(vec![ScanEvent::Text(
492 self.pending
493 .drain(..open.len() + next_rel)
494 .collect::<String>(),
495 )]);
496 }
497 };
498 let close_idx = open.len() + close_rel;
499 let block: String = self.pending.drain(..close_idx + close.len()).collect();
500
501 if emit_inner {
502 let inner = &block[open.len()..block.len() - close.len()];
503 Some(scan_complete_text(inner, self.tools))
504 } else {
505 Some(Vec::new())
506 }
507 }
508 }
509
510 /// Scan text from a wrapper whose closing marker has already arrived. This
511 /// preserves event order when a response body itself contains an inline block.
512 /// Whether the text after a `<tool_call>` tag reads as the start of a call:
513 /// a tool name straight after the tag or on the next line, a JSON object or
514 /// another tag after any whitespace, or nothing at all (a reply cut off right
515 /// after the tag). Prose that mentions the tag, like "`<tool_call>` wraps..."
516 /// or "<tool_call> and </tool_call> tags", fails every one of those.
517 fn looks_like_xml_call(after_tag: &str) -> bool {
518 let after_newlines = after_tag.trim_start_matches(['\n', '\r']);
519 if after_newlines
520 .chars()
521 .next()
522 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
523 {
524 return true;
525 }
526 let rest = after_newlines.trim_start();
527 rest.is_empty() || rest.starts_with(['{', '<'])
528 }
529
530 /// Whether the text after a Kimi `<|open|>tools<|sep|>` marker reads as the
531 /// start of a call: another `<|open|>call` marker after any whitespace, or
532 /// nothing at all. A quoted marker, like the constant in this file, fails.
533 fn looks_like_k3_call(after_marker: &str) -> bool {
534 let rest = after_marker.trim_start();
535 rest.is_empty() || rest.starts_with(K3_CALL_OPEN)
536 }
537
538 fn scan_complete_text(text: &str, tools: &[ToolSpec]) -> Vec<ScanEvent> {
539 let mut scanner = StreamScanner::new(tools);
540 let mut events = scanner.push(text);
541 events.extend(scanner.finish());
542 events
543 }
544
545 fn find_next_marker<'a>(text: &str) -> Option<(usize, &'a str)> {
546 START_MARKERS
547 .iter()
548 .filter_map(|marker| text.find(marker).map(|idx| (idx, *marker)))
549 .min_by_key(|(idx, _)| *idx)
550 }
551
552 /// Length of the longest suffix of `buf` that is a prefix of any known opening
553 /// marker. Lets a split opening tag be caught without delaying text that can't
554 /// possibly be part of one.
555 fn max_partial_prefix_len(buf: &str, needles: &[&str]) -> usize {
556 needles
557 .iter()
558 .map(|needle| partial_prefix_len(buf, needle))
559 .max()
560 .unwrap_or(0)
561 }
562
563 fn partial_prefix_len(buf: &str, needle: &str) -> usize {
564 let max = buf.len().min(needle.len() - 1);
565 for len in (1..=max).rev() {
566 let start = buf.len() - len;
567 if buf.is_char_boundary(start) && needle.starts_with(&buf[start..]) {
568 return len;
569 }
570 }
571 0
572 }
573
574 #[cfg(test)]
575 mod tests {
576 use super::*;
577
578 fn spec(name: &str, schema: serde_json::Value) -> ToolSpec {
579 ToolSpec {
580 name: name.to_string(),
581 description: String::new(),
582 parameters_schema: schema.to_string(),
583 }
584 }
585
586 fn command_output_spec() -> ToolSpec {
587 spec(
588 "command_output",
589 serde_json::json!({
590 "type": "object",
591 "properties": { "task_id": { "type": "integer" } }
592 }),
593 )
594 }
595
596 fn run_command_spec() -> ToolSpec {
597 spec(
598 "run_command",
599 serde_json::json!({
600 "type": "object",
601 "properties": {
602 "command": { "type": "string" },
603 "cwd": { "type": "string" },
604 "run_in_background": { "type": "boolean" }
605 }
606 }),
607 )
608 }
609
610 #[test]
611 fn an_integer_argument_is_recovered_as_a_json_number() {
612 let tools = vec![command_output_spec()];
613 let Extracted { text, calls, .. } = extract(
614 "Checking.<tool_call>command_output<arg_key>task_id</arg_key><arg_value>2</arg_value></tool_call>",
615 &tools,
616 );
617 assert_eq!(text, "Checking.");
618 assert_eq!(calls.len(), 1);
619 assert_eq!(calls[0].name, "command_output");
620 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
621 assert_eq!(args["task_id"], 2);
622 assert!(args["task_id"].is_number());
623 }
624
625 #[test]
626 fn recovers_multiple_string_arguments_in_order() {
627 let tools = vec![run_command_spec()];
628 let Extracted { text, calls, .. } = extract(
629 "<tool_call>run_command<arg_key>command</arg_key><arg_value>cargo test --locked 2>&1 | tail -20</arg_value><arg_key>cwd</arg_key><arg_value>/Users/x/sigit</arg_value><arg_key>run_in_background</arg_key><arg_value>true</arg_value></tool_call>",
630 &tools,
631 );
632 assert_eq!(text, "");
633 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
634 assert_eq!(args["command"], "cargo test --locked 2>&1 | tail -20");
635 assert_eq!(args["cwd"], "/Users/x/sigit");
636 assert_eq!(args["run_in_background"], true);
637 }
638
639 #[test]
640 fn recovers_glm_call_with_a_malformed_first_argument_marker() {
641 let tools = vec![run_command_spec()];
642 let Extracted { text, calls, .. } = extract(
643 "<tool_call>run_command<tool_call>command</arg_key><arg_value>pwd</arg_value><arg_key>cwd</arg_key><arg_value>/tmp</arg_value></tool_call>",
644 &tools,
645 );
646
647 assert_eq!(text, "");
648 assert_eq!(calls.len(), 1);
649 assert_eq!(calls[0].name, "run_command");
650 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
651 assert_eq!(args["command"], "pwd");
652 assert_eq!(args["cwd"], "/tmp");
653 }
654
655 #[test]
656 fn recovers_glm_call_with_check_status_before_real_arguments() {
657 let tools = vec![command_output_spec()];
658 let Extracted { text, calls, .. } = extract(
659 "polling <tool_call>command_output CheckStatus=true_or_poll_again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>",
660 &tools,
661 );
662
663 assert_eq!(text, "polling ");
664 assert_eq!(calls.len(), 1);
665 assert_eq!(calls[0].name, "command_output");
666 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
667 assert_eq!(args["task_id"], 1);
668 assert!(args["task_id"].is_number());
669 assert!(args.get("CheckStatus").is_none());
670 }
671
672 #[test]
673 fn malformed_check_status_preamble_is_dropped() {
674 let text = "<tool_call>command_output CheckStatus=keep going</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>";
675 let Extracted {
676 text: out,
677 calls,
678 malformed,
679 } = extract(text, &[command_output_spec()]);
680 assert_eq!(out, "");
681 assert_eq!(malformed, 1);
682 assert!(calls.is_empty());
683 }
684
685 #[test]
686 fn check_status_after_a_real_argument_is_dropped() {
687 let text = "<tool_call>command_output<arg_key>task_id</arg_key><arg_value>1</arg_value> CheckStatus=true_or_poll_again_with_different_params</arg_value></tool_call>";
688 let Extracted {
689 text: out,
690 calls,
691 malformed,
692 } = extract(text, &[command_output_spec()]);
693 assert_eq!(out, "");
694 assert_eq!(malformed, 1);
695 assert!(calls.is_empty());
696 }
697
698 #[test]
699 fn check_status_for_an_unknown_tool_is_dropped() {
700 let text = "<tool_call>not_offered CheckStatus=true_or_poll_again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>";
701 let Extracted {
702 text: out,
703 calls,
704 malformed,
705 } = extract(text, &[command_output_spec()]);
706 assert_eq!(out, "");
707 assert_eq!(malformed, 1);
708 assert!(calls.is_empty());
709 }
710
711 #[test]
712 fn unknown_glm_tool_is_dropped() {
713 let text = "<tool_call>not_offered<tool_call>command</arg_key><arg_value>pwd</arg_value></tool_call>";
714 let Extracted {
715 text: out,
716 calls,
717 malformed,
718 } = extract(text, &[run_command_spec()]);
719 assert_eq!(out, "");
720 assert_eq!(malformed, 1);
721 assert!(calls.is_empty());
722 }
723
724 #[test]
725 fn malformed_glm_marker_is_accepted_only_for_the_first_argument() {
726 let text = "<tool_call>run_command<arg_key>command</arg_key><arg_value>pwd</arg_value><tool_call>cwd</arg_key><arg_value>/tmp</arg_value></tool_call>";
727 let Extracted {
728 text: out,
729 calls,
730 malformed,
731 } = extract(text, &[run_command_spec()]);
732 assert_eq!(out, "");
733 assert_eq!(malformed, 1);
734 assert!(calls.is_empty());
735 }
736
737 #[test]
738 fn a_malformed_block_is_dropped_rather_than_guessed_at() {
739 let text = "<tool_call>edit_file<arg_value>new_text</arg_key><arg_value>def x; end</arg_value></tool_call>";
740 let Extracted {
741 text: out,
742 calls,
743 malformed,
744 } = extract(text, &[run_command_spec()]);
745 assert_eq!(out, "");
746 assert_eq!(malformed, 1);
747 assert!(calls.is_empty());
748 }
749
750 #[test]
751 fn an_unterminated_tag_is_dropped() {
752 let text = "working on it <tool_call>run_command<arg_key>command</arg_key>";
753 let Extracted {
754 text: out,
755 calls,
756 malformed,
757 } = extract(text, &[run_command_spec()]);
758 assert_eq!(out, "working on it ");
759 assert_eq!(malformed, 1);
760 assert!(calls.is_empty());
761 }
762
763 #[test]
764 fn ordinary_text_passes_through_untouched() {
765 let Extracted {
766 text: out, calls, ..
767 } = extract("if x < y then a<b, nothing to recover", &[]);
768 assert_eq!(out, "if x < y then a<b, nothing to recover");
769 assert!(calls.is_empty());
770 }
771
772 #[test]
773 fn kimi_k3_run_command_block_is_recovered_and_removed() {
774 let tools = vec![run_command_spec()];
775 let Extracted { text, calls, .. } = extract(
776 "Working <|open|>tools<|sep|><|open|>call tool=\"run_command\" index=\"1\"<|sep|><|open|>argument key=\"command\" type=\"string\"<|sep|>sleep 180; echo waited<|close|>argument<|sep|><|open|>argument key=\"cwd\" type=\"string\"<|sep|>/Users/setoelkahfi/Repositories/onde-ed<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
777 &tools,
778 );
779 assert_eq!(text, "Working ");
780 assert_eq!(calls.len(), 1);
781 assert_eq!(calls[0].name, "run_command");
782 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
783 assert_eq!(args["command"], "sleep 180; echo waited");
784 assert_eq!(args["cwd"], "/Users/setoelkahfi/Repositories/onde-ed");
785 }
786
787 #[test]
788 fn kimi_k3_non_string_arguments_are_json_decoded() {
789 let tools = vec![command_output_spec()];
790 let Extracted { calls, .. } = extract(
791 "<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
792 &tools,
793 );
794 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
795 assert_eq!(args["task_id"], 2);
796 assert!(args["task_id"].is_number());
797 }
798
799 #[test]
800 fn kimi_k3_missing_argument_type_uses_the_tool_schema() {
801 let tools = vec![command_output_spec()];
802 let Extracted { calls, .. } = extract(
803 "<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
804 &tools,
805 );
806 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
807 assert_eq!(args["task_id"], 2);
808 assert!(args["task_id"].is_number());
809 }
810
811 #[test]
812 fn kimi_k3_multiple_calls_are_recovered_in_order() {
813 let tools = vec![command_output_spec()];
814 let Extracted { calls, .. } = extract(
815 "<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>1<|close|>argument<|sep|><|close|>call<|sep|><|open|>call tool=\"command_output\" index=\"2\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
816 &tools,
817 );
818 let first: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
819 let second: serde_json::Value = serde_json::from_str(&calls[1].arguments).unwrap();
820 assert_eq!(calls.len(), 2);
821 assert_eq!(first["task_id"], 1);
822 assert_eq!(second["task_id"], 2);
823 }
824
825 #[test]
826 fn kimi_k3_response_is_unwrapped_and_think_is_hidden() {
827 let Extracted { text, calls, .. } = extract(
828 "a<|open|>think<|sep|>private<|close|>think<|sep|>b<|open|>response<|sep|>visible<|close|>response<|sep|>c",
829 &[],
830 );
831 assert_eq!(text, "abvisiblec");
832 assert!(calls.is_empty());
833 }
834
835 #[test]
836 fn kimi_k3_response_content_is_scanned_for_nested_tool_calls() {
837 let tools = vec![command_output_spec()];
838 let Extracted { text, calls, .. } = extract(
839 "<|open|>response<|sep|>before <|open|>think<|sep|>private<|close|>think<|sep|><|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|> after<|close|>response<|sep|>",
840 &tools,
841 );
842 assert_eq!(text, "before after");
843 assert_eq!(calls.len(), 1);
844 assert_eq!(calls[0].name, "command_output");
845 }
846
847 #[test]
848 fn kimi_k3_attribute_entities_are_decoded() {
849 let attrs = parse_k3_attributes(r#"tool="run&quot;command" key="a&amp;b""#).unwrap();
850 assert_eq!(attrs["tool"], "run\"command");
851 assert_eq!(attrs["key"], "a&b");
852 assert_eq!(decode_k3_attribute("line&#10;break"), "line&#10;break");
853 }
854
855 #[test]
856 fn unterminated_kimi_k3_tools_block_is_dropped() {
857 let text = "<|open|>tools<|sep|><|open|>call tool=\"run_command\" index=\"1\"<|sep|><|open|>argument key=\"command\" type=\"string\"<|sep|>echo hi<|close|>argument<|sep|>";
858 let Extracted {
859 text: out,
860 calls,
861 malformed,
862 } = extract(text, &[run_command_spec()]);
863 assert_eq!(out, "");
864 assert_eq!(malformed, 1);
865 assert!(calls.is_empty());
866 }
867
868 #[test]
869 fn unknown_kimi_k3_tool_block_is_dropped() {
870 let text = "<|open|>tools<|sep|><|open|>call tool=\"delete_everything\" index=\"1\"<|sep|><|close|>call<|sep|><|close|>tools<|sep|>";
871 let Extracted {
872 text: out,
873 calls,
874 malformed,
875 } = extract(text, &[run_command_spec()]);
876 assert_eq!(out, "");
877 assert_eq!(malformed, 1);
878 assert!(calls.is_empty());
879 }
880
881 #[test]
882 fn invalid_kimi_k3_non_string_argument_is_dropped() {
883 let text = "<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>not-json<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>";
884 let Extracted {
885 text: out,
886 calls,
887 malformed,
888 } = extract(text, &[command_output_spec()]);
889 assert_eq!(out, "");
890 assert_eq!(malformed, 1);
891 assert!(calls.is_empty());
892 }
893
894 #[test]
895 fn scanner_recovers_a_legacy_tag_split_across_chunk_boundaries() {
896 let tools = vec![command_output_spec()];
897 let mut scanner = StreamScanner::new(&tools);
898 let mut text = String::new();
899 let mut calls = Vec::new();
900
901 for chunk in [
902 "polling ",
903 "<tool_c",
904 "all>command_output<arg_",
905 "key>task_id</arg_key><arg_value>2</arg_v",
906 "alue></tool_call>",
907 " done",
908 ] {
909 for event in scanner.push(chunk) {
910 match event {
911 ScanEvent::Text(t) => text.push_str(&t),
912 ScanEvent::ToolCall(c) => calls.push(c),
913 ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
914 }
915 }
916 }
917 if let Some(ScanEvent::Text(rest)) = scanner.finish() {
918 text.push_str(&rest);
919 }
920
921 assert_eq!(text, "polling done", "no tag fragment may reach the UI");
922 assert_eq!(calls.len(), 1);
923 assert_eq!(calls[0].name, "command_output");
924 }
925
926 #[test]
927 fn scanner_recovers_glm_call_with_malformed_marker_split_across_chunks() {
928 let tools = vec![run_command_spec()];
929 let mut scanner = StreamScanner::new(&tools);
930 let mut text = String::new();
931 let mut calls = Vec::new();
932
933 for chunk in [
934 "before ",
935 "<tool_call>run_command<tool_",
936 "call>command</arg_key><arg_value>pwd</arg_value></tool_call>",
937 " after",
938 ] {
939 for event in scanner.push(chunk) {
940 match event {
941 ScanEvent::Text(value) => text.push_str(&value),
942 ScanEvent::ToolCall(call) => calls.push(call),
943 ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
944 }
945 }
946 }
947 if let Some(ScanEvent::Text(rest)) = scanner.finish() {
948 text.push_str(&rest);
949 }
950
951 assert_eq!(text, "before after");
952 assert_eq!(calls.len(), 1);
953 assert_eq!(calls[0].name, "run_command");
954 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
955 assert_eq!(args["command"], "pwd");
956 }
957
958 #[test]
959 fn scanner_recovers_check_status_call_split_across_chunks() {
960 let tools = vec![command_output_spec()];
961 let mut scanner = StreamScanner::new(&tools);
962 let mut text = String::new();
963 let mut calls = Vec::new();
964
965 for chunk in [
966 "before <tool_call>command_output CheckStatus=true_or_poll_",
967 "again_with_different_params</arg_",
968 "value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_",
969 "call> after",
970 ] {
971 for event in scanner.push(chunk) {
972 match event {
973 ScanEvent::Text(value) => text.push_str(&value),
974 ScanEvent::ToolCall(call) => calls.push(call),
975 ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
976 }
977 }
978 }
979 if let Some(ScanEvent::Text(rest)) = scanner.finish() {
980 text.push_str(&rest);
981 }
982
983 assert_eq!(text, "before after");
984 assert_eq!(calls.len(), 1);
985 assert_eq!(calls[0].name, "command_output");
986 }
987
988 #[test]
989 fn scanner_recovers_kimi_k3_tools_split_across_chunk_boundaries() {
990 let tools = vec![run_command_spec()];
991 let mut scanner = StreamScanner::new(&tools);
992 let mut text = String::new();
993 let mut calls = Vec::new();
994
995 for chunk in [
996 "pre ",
997 "<|open|>too",
998 "ls<|sep|><|open|>call tool=\"run_command\" index=\"1\"<|sep|>",
999 "<|open|>argument key=\"command\" type=\"string\"<|sep|>echo hi",
1000 "<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
1001 " post",
1002 ] {
1003 for event in scanner.push(chunk) {
1004 match event {
1005 ScanEvent::Text(t) => text.push_str(&t),
1006 ScanEvent::ToolCall(c) => calls.push(c),
1007 ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
1008 }
1009 }
1010 }
1011 if let Some(ScanEvent::Text(rest)) = scanner.finish() {
1012 text.push_str(&rest);
1013 }
1014
1015 assert_eq!(text, "pre post");
1016 assert_eq!(calls.len(), 1);
1017 assert_eq!(calls[0].name, "run_command");
1018 }
1019
1020 #[test]
1021 fn scanner_recovers_tools_after_an_unclosed_k3_wrapper() {
1022 let tools = vec![command_output_spec()];
1023 let mut scanner = StreamScanner::new(&tools);
1024 let mut text = String::new();
1025 let mut calls = Vec::new();
1026
1027 for chunk in [
1028 "before <|open|>response<|sep|>broken ",
1029 "<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|> after",
1030 ] {
1031 for event in scanner.push(chunk) {
1032 match event {
1033 ScanEvent::Text(chunk) => text.push_str(&chunk),
1034 ScanEvent::ToolCall(call) => calls.push(call),
1035 ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
1036 }
1037 }
1038 }
1039 if let Some(ScanEvent::Text(rest)) = scanner.finish() {
1040 text.push_str(&rest);
1041 }
1042
1043 assert_eq!(text, "before <|open|>response<|sep|>broken after");
1044 assert_eq!(calls.len(), 1);
1045 assert_eq!(calls[0].name, "command_output");
1046 }
1047
1048 #[test]
1049 fn scanner_recovers_tools_after_an_unclosed_k3_think_wrapper() {
1050 let tools = vec![command_output_spec()];
1051 let Extracted { text, calls, .. } = extract(
1052 "<|open|>think<|sep|>broken <|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
1053 &tools,
1054 );
1055 assert_eq!(text, "<|open|>think<|sep|>broken ");
1056 assert_eq!(calls.len(), 1);
1057 assert_eq!(calls[0].name, "command_output");
1058 }
1059
1060 #[test]
1061 fn scanner_forwards_ordinary_text_without_holding_it_back() {
1062 let mut scanner = StreamScanner::new(&[]);
1063 let events = scanner.push("a normal streamed answer");
1064 assert_eq!(
1065 events,
1066 vec![ScanEvent::Text("a normal streamed answer".to_string())]
1067 );
1068 assert!(scanner.finish().is_none());
1069 }
1070
1071 #[test]
1072 fn scanner_releases_a_near_miss_prefix_once_it_cannot_match() {
1073 let mut scanner = StreamScanner::new(&[]);
1074 assert_eq!(
1075 scanner.push("see <tool"),
1076 vec![ScanEvent::Text("see ".to_string())]
1077 );
1078 assert_eq!(
1079 scanner.push("box for details"),
1080 vec![ScanEvent::Text("<toolbox for details".to_string())]
1081 );
1082 assert!(scanner.finish().is_none());
1083 }
1084
1085 #[test]
1086 fn scanner_reports_an_unclosed_tag_at_stream_end_as_malformed() {
1087 let tools = vec![run_command_spec()];
1088 let mut scanner = StreamScanner::new(&tools);
1089 let events = scanner.push("partial <tool_call>run_command<arg_key>cmd");
1090 assert_eq!(events, vec![ScanEvent::Text("partial ".to_string())]);
1091 assert_eq!(
1092 scanner.finish(),
1093 Some(ScanEvent::Malformed(
1094 "<tool_call>run_command<arg_key>cmd".to_string()
1095 ))
1096 );
1097 }
1098
1099 #[test]
1100 fn scanner_hands_back_a_partial_marker_prefix_at_stream_end_as_text() {
1101 let mut scanner = StreamScanner::new(&[]);
1102 assert_eq!(
1103 scanner.push("see <tool_"),
1104 vec![ScanEvent::Text("see ".to_string())]
1105 );
1106 assert_eq!(
1107 scanner.finish(),
1108 Some(ScanEvent::Text("<tool_".to_string()))
1109 );
1110
1111 let mut scanner = StreamScanner::new(&[]);
1112 assert_eq!(
1113 scanner.push("see <|open|>to"),
1114 vec![ScanEvent::Text("see ".to_string())]
1115 );
1116 assert_eq!(
1117 scanner.finish(),
1118 Some(ScanEvent::Text("<|open|>to".to_string()))
1119 );
1120 }
1121
1122 #[test]
1123 fn a_tag_mentioned_in_prose_stays_text() {
1124 let tools = vec![run_command_spec()];
1125 for text in [
1126 "Wrap it in `<tool_call>` and `</tool_call>` tags.",
1127 "The scanner holds <tool_call> and </tool_call> blocks back.",
1128 "The model opens with `<tool_call>` and never closes it.",
1129 ] {
1130 assert_eq!(
1131 extract(text, &tools),
1132 Extracted {
1133 text: text.to_string(),
1134 ..Extracted::default()
1135 },
1136 "{text}"
1137 );
1138 }
1139 }
1140
1141 #[test]
1142 fn a_kimi_marker_mentioned_in_prose_stays_text() {
1143 let tools = vec![run_command_spec()];
1144 for text in [
1145 "The marker is `<|open|>tools<|sep|>` and it never closes here.",
1146 "Quote `<|open|>tools<|sep|>` then `<|close|>tools<|sep|>` in docs.",
1147 ] {
1148 assert_eq!(
1149 extract(text, &tools),
1150 Extracted {
1151 text: text.to_string(),
1152 ..Extracted::default()
1153 },
1154 "{text}"
1155 );
1156 }
1157 }
1158
1159 #[test]
1160 fn a_json_shaped_block_is_dropped() {
1161 let text = "<tool_call>\n{\"name\": \"run_command\", \"arguments\": {}}\n</tool_call>";
1162 let Extracted {
1163 text: out,
1164 calls,
1165 malformed,
1166 } = extract(text, &[run_command_spec()]);
1167 assert_eq!(out, "");
1168 assert_eq!(malformed, 1);
1169 assert!(calls.is_empty());
1170 }
1171
1172 /// Issue #105: a GLM-family reply that opened a call, then wrote an
1173 /// invented invoke/result transcript instead of closing it. None of that
1174 /// may reach the editor, and it must not count as a recovered call.
1175 #[test]
1176 fn a_hallucinated_invoke_transcript_is_dropped() {
1177 let tools = vec![command_output_spec()];
1178 let reply = "Checking.<tool_call>command_output\n\n<invokeID>reply_A</invokeID>\n\
1179 <parameter_name>task_id</parameter_name>\n<parameter>6</parameter>\n\
1180 </invoke>\n\n<function_results>PyPI and NuGet are queued.\n\
1181 <tool_call>command_output<arg_key>task_id</arg_key><arg_value>5</task_id>\n\
1182 </invoke>\n</function_results>";
1183 let Extracted {
1184 text,
1185 calls,
1186 malformed,
1187 } = extract(reply, &tools);
1188 assert_eq!(text, "Checking.");
1189 assert!(calls.is_empty());
1190 assert_eq!(malformed, 1);
1191 }
1192
1193 /// Issue #97: the well-formed blocks from that thread are recovered as
1194 /// real calls, multi-line argument values included.
1195 #[test]
1196 fn well_formed_blocks_from_issue_97_are_recovered() {
1197 let tools = vec![
1198 run_command_spec(),
1199 spec(
1200 "edit_file",
1201 serde_json::json!({
1202 "type": "object",
1203 "properties": {
1204 "path": { "type": "string" },
1205 "old_text": { "type": "string" },
1206 "new_text": { "type": "string" }
1207 }
1208 }),
1209 ),
1210 ];
1211 let reply = "Now wire `body_path` into the step:<tool_call>edit_file<arg_key>new_text</arg_key><arg_value> - name: Release\n with:\n body_path: release_notes.md</arg_value><arg_key>old_text</arg_key><arg_value> - name: Release\n with:</arg_value><arg_key>path</arg_key><arg_value>/repo/.github/workflows/release-github.yml</arg_value></tool_call>";
1212 let Extracted {
1213 text,
1214 calls,
1215 malformed,
1216 } = extract(reply, &tools);
1217 assert_eq!(text, "Now wire `body_path` into the step:");
1218 assert_eq!(malformed, 0);
1219 assert_eq!(calls.len(), 1);
1220 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1221 assert_eq!(
1222 args["new_text"],
1223 " - name: Release\n with:\n body_path: release_notes.md"
1224 );
1225 assert_eq!(args["path"], "/repo/.github/workflows/release-github.yml");
1226
1227 let Extracted { calls, .. } = extract(
1228 "<tool_call>run_command<arg_key>command</arg_key><arg_value>sleep 90</arg_value><arg_key>cwd</arg_key><arg_value>/repo</arg_value></tool_call>",
1229 &tools,
1230 );
1231 assert_eq!(calls.len(), 1);
1232 assert_eq!(calls[0].name, "run_command");
1233 }
1234 }