Recover Kimi K3 XTML inline tool calls from streamed content

Extend the inline tool-call recovery beyond the legacy <tool_call> tag to the Kimi K3 XTML protocol: <|open|>tools blocks are parsed into structured calls (multiple calls per block, typed arguments decoded as JSON when a type is given), <|open|>response blocks are unwrapped so their text still renders, and <|open|>think blocks are dropped as private reasoning. Unknown tool names and malformed blocks are left visible rather than executed, and the stream scanner now watches all opening markers so split chunks still recover cleanly. Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Sep 13, 2026 at 08:16 UTC 811f08a433822d62bf4c3bef53f8a2bad8a997a9
2 files changed +452 -97
src/inline_tool_calls.rs
+372 -97
@@ -1,13 +1,11 @@
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 — Qwen 3, GLM, DeepSeek — are
5 -//! fine-tuned on a chat template that renders a tool call as
6 -//! `<tool_call>NAME<arg_key>K</arg_key><arg_value>V</arg_value>...</tool_call>`.
7 -//! A serving stack is supposed to parse that back into the OpenAI-shaped
8 -//! `tool_calls` field before it reaches us. When it doesn't — seen against the
9 -//! hosted GLM tiers once the model has just been told a call was rejected as a
10 -//! repeat (getsigit/sigit#73) — the raw tag arrives as ordinary content.
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
@@ -20,16 +18,32 @@
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 //!
23 -//! Only the well-formed shape is recovered: a flat sequence of key/value pairs
24 -//! with no nesting. A block that doesn't match is left in the text untouched,
25 -//! deliberately. Reissuing a `run_command` is cheap, but guessing wrong at a
26 -//! half-parsed `edit_file` would write the wrong change to a file, so a
27 -//! malformed block stays visible rather than being silently misinterpreted.
21 +//! Only well-formed shapes are recovered. A block that doesn't match is left
22 +//! in the text untouched, deliberately. Reissuing a `run_command` is cheap,
23 +//! but guessing wrong at a half-parsed `edit_file` would write the wrong
24 +//! change to a file, so a malformed block stays visible rather than being
25 +//! silently misinterpreted.
26 +
27 +use std::collections::HashMap;
28
29 use crate::backend::ToolSpec;
30
31 -const OPEN_TAG: &str = "<tool_call>";
32 -const CLOSE_TAG: &str = "</tool_call>";
31 +const XML_OPEN_TAG: &str = "<tool_call>";
32 +const XML_CLOSE_TAG: &str = "</tool_call>";
33 +
34 +const K3_TOOLS_OPEN: &str = "<|open|>tools<|sep|>";
35 +const K3_TOOLS_CLOSE: &str = "<|close|>tools<|sep|>";
36 +const K3_CALL_OPEN: &str = "<|open|>call";
37 +const K3_CALL_CLOSE: &str = "<|close|>call<|sep|>";
38 +const K3_ARGUMENT_OPEN: &str = "<|open|>argument";
39 +const K3_ARGUMENT_CLOSE: &str = "<|close|>argument<|sep|>";
40 +const K3_RESPONSE_OPEN: &str = "<|open|>response<|sep|>";
41 +const K3_RESPONSE_CLOSE: &str = "<|close|>response<|sep|>";
42 +const K3_THINK_OPEN: &str = "<|open|>think<|sep|>";
43 +const K3_THINK_CLOSE: &str = "<|close|>think<|sep|>";
44 +const K3_SEP: &str = "<|sep|>";
45 +
46 +const START_MARKERS: &[&str] = &[XML_OPEN_TAG, K3_TOOLS_OPEN, K3_RESPONSE_OPEN, K3_THINK_OPEN];
47
48 /// One tool call recovered from inline text.
49 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -41,51 +55,35 @@ pub struct Recovered {
55
56 /// Scan a complete text blob for inline tool-call blocks. Returns the text
57 /// with recovered blocks removed, plus the calls recovered from them. Blocks
44 -/// that don't parse cleanly, and an unterminated trailing tag, are left in the
58 +/// that don't parse cleanly, and unterminated trailing tags, are left in the
59 /// returned text untouched.
46 -///
47 -/// `tools` are the specs offered for this turn, used to type each argument
48 -/// value: a field the schema declares as an integer has to arrive as a JSON
49 -/// number or the tool's own argument parsing rejects it (`command_output`
50 -/// reads `task_id` with `as_u64`, which refuses the string `"2"`). Anything
51 -/// the schema doesn't cover stays a string.
60 pub fn extract(text: &str, tools: &[ToolSpec]) -> (String, Vec<Recovered>) {
61 + let mut scanner = StreamScanner::new(tools);
62 let mut out = String::with_capacity(text.len());
63 let mut calls = Vec::new();
55 - let mut cursor = text;
56 -
57 - while let Some(open_idx) = cursor.find(OPEN_TAG) {
58 - let Some(close_rel) = cursor[open_idx..].find(CLOSE_TAG) else {
59 - // Unterminated tag: leave the rest alone rather than guess at a
60 - // block we never saw the end of.
61 - break;
62 - };
63 - let close_idx = open_idx + close_rel;
64 - let inner = &cursor[open_idx + OPEN_TAG.len()..close_idx];
64
66 - match parse_block(inner, tools) {
67 - Some(call) => {
68 - out.push_str(&cursor[..open_idx]);
69 - calls.push(call);
70 - }
71 - // Didn't parse cleanly: keep the whole tag as visible text.
72 - None => out.push_str(&cursor[..close_idx + CLOSE_TAG.len()]),
65 + for event in scanner.push(text) {
66 + match event {
67 + ScanEvent::Text(text) => out.push_str(&text),
68 + ScanEvent::ToolCall(call) => calls.push(call),
69 }
74 - cursor = &cursor[close_idx + CLOSE_TAG.len()..];
70 }
76 - out.push_str(cursor);
71 + if let Some(rest) = scanner.take_pending() {
72 + out.push_str(&rest);
73 + }
74 +
75 (out, calls)
76 }
77
80 -/// Parse one block's inner text: a name, then zero or more
78 +/// Parse one legacy XML block's inner text: a name, then zero or more
79 /// `<arg_key>K</arg_key><arg_value>V</arg_value>` pairs. Returns `None` the
80 /// moment anything departs from that shape.
83 -fn parse_block(inner: &str, tools: &[ToolSpec]) -> Option<Recovered> {
81 +fn parse_xml_block(inner: &str, tools: &[ToolSpec]) -> Option<Recovered> {
82 let (name, mut rest) = match inner.find("<arg_key>") {
83 Some(idx) => (inner[..idx].trim(), &inner[idx..]),
84 None => (inner.trim(), ""),
85 };
88 - if name.is_empty() || name.contains(['<', '>']) {
86 + if name.is_empty() || name.contains(['<', '>']) || !offered_tool(tools, name) {
87 return None;
88 }
89
@@ -111,12 +109,100 @@ fn parse_block(inner: &str, tools: &[ToolSpec]) -> Option<Recovered> {
109 })
110 }
111
112 +fn parse_k3_tools_block(inner: &str, tools: &[ToolSpec]) -> Option<Vec<Recovered>> {
113 + let mut calls = Vec::new();
114 + let mut rest = inner;
115 +
116 + while !rest.trim().is_empty() {
117 + rest = rest.trim_start();
118 + let header = rest.strip_prefix(K3_CALL_OPEN)?;
119 + let (attr_text, after_header) = split_once(header, K3_SEP)?;
120 + let attrs = parse_k3_attributes(attr_text)?;
121 + let name = attrs.get("tool")?;
122 + if name.is_empty() || !offered_tool(tools, name) {
123 + return None;
124 + }
125 +
126 + let (call_inner, after_call) = split_once(after_header, K3_CALL_CLOSE)?;
127 + let arguments = parse_k3_arguments(call_inner)?;
128 + calls.push(Recovered {
129 + name: name.to_string(),
130 + arguments: serde_json::Value::Object(arguments).to_string(),
131 + });
132 + rest = after_call;
133 + }
134 +
135 + Some(calls)
136 +}
137 +
138 +fn parse_k3_arguments(mut rest: &str) -> Option<serde_json::Map<String, serde_json::Value>> {
139 + let mut args = serde_json::Map::new();
140 +
141 + while !rest.trim().is_empty() {
142 + rest = rest.trim_start();
143 + let header = rest.strip_prefix(K3_ARGUMENT_OPEN)?;
144 + let (attr_text, after_header) = split_once(header, K3_SEP)?;
145 + let attrs = parse_k3_attributes(attr_text)?;
146 + let key = attrs.get("key")?.trim();
147 + let value_type = attrs.get("type").map(String::as_str).unwrap_or("string");
148 + if key.is_empty() {
149 + return None;
150 + }
151 +
152 + let (raw_value, after_argument) = split_once(after_header, K3_ARGUMENT_CLOSE)?;
153 + let value = if value_type == "string" {
154 + serde_json::Value::String(raw_value.to_string())
155 + } else {
156 + serde_json::from_str(raw_value).ok()?
157 + };
158 + args.insert(key.to_string(), value);
159 + rest = after_argument;
160 + }
161 +
162 + Some(args)
163 +}
164 +
165 +fn parse_k3_attributes(mut text: &str) -> Option<HashMap<String, String>> {
166 + let mut attrs = HashMap::new();
167 + text = text.trim();
168 +
169 + while !text.is_empty() {
170 + let eq_idx = text.find('=')?;
171 + let key = text[..eq_idx].trim();
172 + if key.is_empty()
173 + || !key
174 + .chars()
175 + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
176 + {
177 + return None;
178 + }
179 +
180 + let mut value_part = text[eq_idx + 1..].trim_start();
181 + value_part = value_part.strip_prefix('"')?;
182 + let end_idx = value_part.find('"')?;
183 + let raw_value = &value_part[..end_idx];
184 + attrs.insert(key.to_string(), decode_k3_attribute(raw_value));
185 +
186 + text = value_part[end_idx + 1..].trim_start();
187 + }
188 +
189 + Some(attrs)
190 +}
191 +
192 +fn decode_k3_attribute(value: &str) -> String {
193 + value.replace("&quot;", "\"").replace("&amp;", "&")
194 +}
195 +
196 fn split_once<'a>(s: &'a str, delim: &str) -> Option<(&'a str, &'a str)> {
197 let idx = s.find(delim)?;
198 Some((&s[..idx], &s[idx + delim.len()..]))
199 }
200
119 -/// The JSON Schema `type` declared for `tool_name`'s `key` parameter — e.g.
201 +fn offered_tool(tools: &[ToolSpec], tool_name: &str) -> bool {
202 + tools.iter().any(|spec| spec.name == tool_name)
203 +}
204 +
205 +/// The JSON Schema `type` declared for `tool_name`'s `key` parameter, e.g.
206 /// `"integer"` for `command_output`'s `task_id`. [`ToolSpec`] carries the
207 /// schema as an unparsed string, so this parses it per lookup; the argument
208 /// counts involved are tiny and this only runs on the recovery path.
@@ -133,12 +219,9 @@ fn declared_type(tools: &[ToolSpec], tool_name: &str, key: &str) -> Option<Strin
219 )
220 }
221
136 -/// Convert a raw (unescaped) text value to JSON per its declared schema type.
222 +/// Convert a raw legacy text value to JSON per its declared schema type.
223 /// Falls back to a plain string for `"string"`, an unknown type, or a value
138 -/// that doesn't parse as what it claims to be. This only ever narrows a value
139 -/// to what the schema already promises — it never invents structure the text
140 -/// doesn't have, since a bad coercion would corrupt a call that was otherwise
141 -/// recoverable.
224 +/// that doesn't parse as what it claims to be.
225 fn coerce(raw: &str, declared: Option<&str>) -> serde_json::Value {
226 match declared {
227 Some("integer") => raw
@@ -173,10 +256,10 @@ pub enum ScanEvent {
256
257 /// Incremental scanner for a live delta stream.
258 ///
176 -/// Holds back only the text that could still turn into a `<tool_call>` tag, so
177 -/// ordinary answers — the overwhelming majority of turns, which never contain
178 -/// one — still reach the UI token by token. Buffering starts only once an
179 -/// opening tag actually appears, and lasts only until it closes.
259 +/// Holds back only the text that could still turn into a known inline protocol
260 +/// marker, so ordinary answers still reach the UI token by token. Buffering
261 +/// starts once an opening marker appears, and lasts only until the matching
262 +/// close arrives.
263 pub struct StreamScanner<'a> {
264 tools: &'a [ToolSpec],
265 pending: String,
@@ -197,11 +280,9 @@ impl<'a> StreamScanner<'a> {
280 self.drain()
281 }
282
200 - /// Take whatever is still held back — e.g. a `<tool_call` prefix that
201 - /// never closed. Text we never saw the end of can't be interpreted
202 - /// safely, so the honest thing is to hand it back as-is, exactly as if
203 - /// recovery had never run. Call this wherever the stream can end, or
204 - /// held-back text is lost.
283 + /// Take whatever is still held back, e.g. a `<tool_call` or Kimi marker
284 + /// prefix that never closed. Text we never saw the end of can't be
285 + /// interpreted safely, so the honest thing is to hand it back as-is.
286 pub fn take_pending(&mut self) -> Option<String> {
287 if self.pending.is_empty() {
288 None
@@ -213,10 +294,8 @@ impl<'a> StreamScanner<'a> {
294 fn drain(&mut self) -> Vec<ScanEvent> {
295 let mut events = Vec::new();
296 loop {
216 - let Some(open_idx) = self.pending.find(OPEN_TAG) else {
217 - // No tag yet: flush all but a suffix that could still grow
218 - // into "<tool_call>" once the next chunk lands.
219 - let keep = partial_prefix_len(&self.pending, OPEN_TAG);
297 + let Some((open_idx, marker)) = find_next_marker(&self.pending) else {
298 + let keep = max_partial_prefix_len(&self.pending, START_MARKERS);
299 let flush_len = self.pending.len() - keep;
300 if flush_len > 0 {
301 events.push(ScanEvent::Text(
@@ -225,30 +304,115 @@ impl<'a> StreamScanner<'a> {
304 }
305 break;
306 };
307 +
308 if open_idx > 0 {
309 events.push(ScanEvent::Text(
310 self.pending.drain(..open_idx).collect::<String>(),
311 ));
312 }
233 - let Some(close_rel) = self.pending[OPEN_TAG.len()..].find(CLOSE_TAG) else {
234 - break; // opened but not closed: wait for more
235 - };
236 - let close_idx = OPEN_TAG.len() + close_rel;
237 - let block: String = self.pending.drain(..close_idx + CLOSE_TAG.len()).collect();
238 - let inner = &block[OPEN_TAG.len()..block.len() - CLOSE_TAG.len()];
239 - match parse_block(inner, self.tools) {
240 - Some(call) => events.push(ScanEvent::ToolCall(call)),
241 - None => events.push(ScanEvent::Text(block)),
313 +
314 + match marker {
315 + XML_OPEN_TAG => {
316 + let Some(event) = self.drain_xml() else {
317 + break;
318 + };
319 + events.push(event);
320 + }
321 + K3_TOOLS_OPEN => match self.drain_k3_tools() {
322 + Some(mut recovered) => events.append(&mut recovered),
323 + None => break,
324 + },
325 + K3_RESPONSE_OPEN => {
326 + let Some(event) =
327 + self.drain_wrapped_text(K3_RESPONSE_OPEN, K3_RESPONSE_CLOSE, true)
328 + else {
329 + break;
330 + };
331 + if let Some(event) = event {
332 + events.push(event);
333 + }
334 + }
335 + K3_THINK_OPEN => {
336 + let Some(event) = self.drain_wrapped_text(K3_THINK_OPEN, K3_THINK_CLOSE, false)
337 + else {
338 + break;
339 + };
340 + if let Some(event) = event {
341 + events.push(event);
342 + }
343 + }
344 + _ => unreachable!("unknown inline marker"),
345 }
346 }
347 events
348 }
349 +
350 + fn drain_xml(&mut self) -> Option<ScanEvent> {
351 + let close_rel = self.pending[XML_OPEN_TAG.len()..].find(XML_CLOSE_TAG)?;
352 + let close_idx = XML_OPEN_TAG.len() + close_rel;
353 + let block: String = self
354 + .pending
355 + .drain(..close_idx + XML_CLOSE_TAG.len())
356 + .collect();
357 + let inner = &block[XML_OPEN_TAG.len()..block.len() - XML_CLOSE_TAG.len()];
358 + Some(match parse_xml_block(inner, self.tools) {
359 + Some(call) => ScanEvent::ToolCall(call),
360 + None => ScanEvent::Text(block),
361 + })
362 + }
363 +
364 + fn drain_k3_tools(&mut self) -> Option<Vec<ScanEvent>> {
365 + let close_rel = self.pending[K3_TOOLS_OPEN.len()..].find(K3_TOOLS_CLOSE)?;
366 + let close_idx = K3_TOOLS_OPEN.len() + close_rel;
367 + let block: String = self
368 + .pending
369 + .drain(..close_idx + K3_TOOLS_CLOSE.len())
370 + .collect();
371 + let inner = &block[K3_TOOLS_OPEN.len()..block.len() - K3_TOOLS_CLOSE.len()];
372 +
373 + Some(match parse_k3_tools_block(inner, self.tools) {
374 + Some(calls) => calls.into_iter().map(ScanEvent::ToolCall).collect(),
375 + None => vec![ScanEvent::Text(block)],
376 + })
377 + }
378 +
379 + fn drain_wrapped_text(
380 + &mut self,
381 + open: &str,
382 + close: &str,
383 + emit_inner: bool,
384 + ) -> Option<Option<ScanEvent>> {
385 + let close_rel = self.pending[open.len()..].find(close)?;
386 + let close_idx = open.len() + close_rel;
387 + let block: String = self.pending.drain(..close_idx + close.len()).collect();
388 +
389 + if emit_inner {
390 + let inner = &block[open.len()..block.len() - close.len()];
391 + Some(Some(ScanEvent::Text(inner.to_string())))
392 + } else {
393 + Some(None)
394 + }
395 + }
396 +}
397 +
398 +fn find_next_marker<'a>(text: &str) -> Option<(usize, &'a str)> {
399 + START_MARKERS
400 + .iter()
401 + .filter_map(|marker| text.find(marker).map(|idx| (idx, *marker)))
402 + .min_by_key(|(idx, _)| *idx)
403 +}
404 +
405 +/// Length of the longest suffix of `buf` that is a prefix of any known opening
406 +/// marker. Lets a split opening tag be caught without delaying text that can't
407 +/// possibly be part of one.
408 +fn max_partial_prefix_len(buf: &str, needles: &[&str]) -> usize {
409 + needles
410 + .iter()
411 + .map(|needle| partial_prefix_len(buf, needle))
412 + .max()
413 + .unwrap_or(0)
414 }
415
248 -/// Length of the longest suffix of `buf` that is a prefix of `needle` — how
249 -/// much of the tail could still become `needle`. Lets a split opening tag
250 -/// (one chunk ending `"<tool_c"`, the next starting `"all>"`) be caught
251 -/// without delaying text that can't possibly be part of one.
416 fn partial_prefix_len(buf: &str, needle: &str) -> usize {
417 let max = buf.len().min(needle.len() - 1);
418 for len in (1..=max).rev() {
@@ -282,10 +446,22 @@ mod tests {
446 )
447 }
448
449 + fn run_command_spec() -> ToolSpec {
450 + spec(
451 + "run_command",
452 + serde_json::json!({
453 + "type": "object",
454 + "properties": {
455 + "command": { "type": "string" },
456 + "cwd": { "type": "string" },
457 + "run_in_background": { "type": "boolean" }
458 + }
459 + }),
460 + )
461 + }
462 +
463 #[test]
464 fn an_integer_argument_is_recovered_as_a_json_number() {
287 - // `exec_command_output` reads task_id with `as_u64`, so a string "2"
288 - // here would parse as JSON and then fail at the tool.
465 let tools = vec![command_output_spec()];
466 let (text, calls) = extract(
467 "Checking.<tool_call>command_output<arg_key>task_id</arg_key><arg_value>2</arg_value></tool_call>",
@@ -301,17 +477,7 @@ mod tests {
477
478 #[test]
479 fn recovers_multiple_string_arguments_in_order() {
304 - let tools = vec![spec(
305 - "run_command",
306 - serde_json::json!({
307 - "type": "object",
308 - "properties": {
309 - "command": { "type": "string" },
310 - "cwd": { "type": "string" },
311 - "run_in_background": { "type": "boolean" }
312 - }
313 - }),
314 - )];
480 + let tools = vec![run_command_spec()];
481 let (text, calls) = extract(
482 "<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>",
483 &tools,
@@ -325,11 +491,8 @@ mod tests {
491
492 #[test]
493 fn a_malformed_block_is_left_alone_rather_than_guessed_at() {
328 - // The mis-tagged shape seen in practice: an opening `<arg_value>`
329 - // where `<arg_key>` was meant. Guessing at an edit_file call is worse
330 - // than leaving it visible.
494 let text = "<tool_call>edit_file<arg_value>new_text</arg_key><arg_value>def x; end</arg_value></tool_call>";
332 - let (out, calls) = extract(text, &[]);
495 + let (out, calls) = extract(text, &[run_command_spec()]);
496 assert_eq!(out, text);
497 assert!(calls.is_empty());
498 }
@@ -337,7 +500,7 @@ mod tests {
500 #[test]
501 fn an_unterminated_tag_is_left_alone() {
502 let text = "working on it <tool_call>run_command<arg_key>command</arg_key>";
340 - let (out, calls) = extract(text, &[]);
503 + let (out, calls) = extract(text, &[run_command_spec()]);
504 assert_eq!(out, text);
505 assert!(calls.is_empty());
506 }
@@ -350,7 +513,89 @@ mod tests {
513 }
514
515 #[test]
353 - fn scanner_recovers_a_tag_split_across_chunk_boundaries() {
516 + fn kimi_k3_run_command_block_is_recovered_and_removed() {
517 + let tools = vec![run_command_spec()];
518 + let (text, calls) = extract(
519 + "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|>",
520 + &tools,
521 + );
522 + assert_eq!(text, "Working ");
523 + assert_eq!(calls.len(), 1);
524 + assert_eq!(calls[0].name, "run_command");
525 + let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
526 + assert_eq!(args["command"], "sleep 180; echo waited");
527 + assert_eq!(args["cwd"], "/Users/setoelkahfi/Repositories/onde-ed");
528 + }
529 +
530 + #[test]
531 + fn kimi_k3_non_string_arguments_are_json_decoded() {
532 + let tools = vec![command_output_spec()];
533 + let (_, calls) = extract(
534 + "<|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|>",
535 + &tools,
536 + );
537 + let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
538 + assert_eq!(args["task_id"], 2);
539 + assert!(args["task_id"].is_number());
540 + }
541 +
542 + #[test]
543 + fn kimi_k3_multiple_calls_are_recovered_in_order() {
544 + let tools = vec![command_output_spec()];
545 + let (_, calls) = extract(
546 + "<|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|>",
547 + &tools,
548 + );
549 + let first: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
550 + let second: serde_json::Value = serde_json::from_str(&calls[1].arguments).unwrap();
551 + assert_eq!(calls.len(), 2);
552 + assert_eq!(first["task_id"], 1);
553 + assert_eq!(second["task_id"], 2);
554 + }
555 +
556 + #[test]
557 + fn kimi_k3_response_is_unwrapped_and_think_is_hidden() {
558 + let (text, calls) = extract(
559 + "a<|open|>think<|sep|>private<|close|>think<|sep|>b<|open|>response<|sep|>visible<|close|>response<|sep|>c",
560 + &[],
561 + );
562 + assert_eq!(text, "abvisiblec");
563 + assert!(calls.is_empty());
564 + }
565 +
566 + #[test]
567 + fn kimi_k3_attribute_entities_are_decoded() {
568 + let attrs = parse_k3_attributes(r#"tool="run&quot;command" key="a&amp;b""#).unwrap();
569 + assert_eq!(attrs["tool"], "run\"command");
570 + assert_eq!(attrs["key"], "a&b");
571 + }
572 +
573 + #[test]
574 + fn malformed_kimi_k3_tools_block_is_left_alone() {
575 + 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|>";
576 + let (out, calls) = extract(text, &[run_command_spec()]);
577 + assert_eq!(out, text);
578 + assert!(calls.is_empty());
579 + }
580 +
581 + #[test]
582 + fn unknown_kimi_k3_tool_block_is_left_alone() {
583 + let text = "<|open|>tools<|sep|><|open|>call tool=\"delete_everything\" index=\"1\"<|sep|><|close|>call<|sep|><|close|>tools<|sep|>";
584 + let (out, calls) = extract(text, &[run_command_spec()]);
585 + assert_eq!(out, text);
586 + assert!(calls.is_empty());
587 + }
588 +
589 + #[test]
590 + fn invalid_kimi_k3_non_string_argument_is_left_alone() {
591 + 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|>";
592 + let (out, calls) = extract(text, &[command_output_spec()]);
593 + assert_eq!(out, text);
594 + assert!(calls.is_empty());
595 + }
596 +
597 + #[test]
598 + fn scanner_recovers_a_legacy_tag_split_across_chunk_boundaries() {
599 let tools = vec![command_output_spec()];
600 let mut scanner = StreamScanner::new(&tools);
601 let mut text = String::new();
@@ -380,6 +625,37 @@ mod tests {
625 assert_eq!(calls[0].name, "command_output");
626 }
627
628 + #[test]
629 + fn scanner_recovers_kimi_k3_tools_split_across_chunk_boundaries() {
630 + let tools = vec![run_command_spec()];
631 + let mut scanner = StreamScanner::new(&tools);
632 + let mut text = String::new();
633 + let mut calls = Vec::new();
634 +
635 + for chunk in [
636 + "pre ",
637 + "<|open|>too",
638 + "ls<|sep|><|open|>call tool=\"run_command\" index=\"1\"<|sep|>",
639 + "<|open|>argument key=\"command\" type=\"string\"<|sep|>echo hi",
640 + "<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
641 + " post",
642 + ] {
643 + for event in scanner.push(chunk) {
644 + match event {
645 + ScanEvent::Text(t) => text.push_str(&t),
646 + ScanEvent::ToolCall(c) => calls.push(c),
647 + }
648 + }
649 + }
650 + if let Some(rest) = scanner.take_pending() {
651 + text.push_str(&rest);
652 + }
653 +
654 + assert_eq!(text, "pre post");
655 + assert_eq!(calls.len(), 1);
656 + assert_eq!(calls[0].name, "run_command");
657 + }
658 +
659 #[test]
660 fn scanner_forwards_ordinary_text_without_holding_it_back() {
661 let mut scanner = StreamScanner::new(&[]);
@@ -394,12 +670,10 @@ mod tests {
670 #[test]
671 fn scanner_releases_a_near_miss_prefix_once_it_cannot_match() {
672 let mut scanner = StreamScanner::new(&[]);
397 - // "<tool" could still become "<tool_call>", so it is held back...
673 assert_eq!(
674 scanner.push("see <tool"),
675 vec![ScanEvent::Text("see ".to_string())]
676 );
402 - // ...and released as soon as the next chunk rules that out.
677 assert_eq!(
678 scanner.push("box for details"),
679 vec![ScanEvent::Text("<toolbox for details".to_string())]
@@ -409,7 +683,8 @@ mod tests {
683
684 #[test]
685 fn scanner_hands_back_an_unclosed_tag_at_stream_end() {
412 - let mut scanner = StreamScanner::new(&[]);
686 + let tools = vec![run_command_spec()];
687 + let mut scanner = StreamScanner::new(&tools);
688 let events = scanner.push("partial <tool_call>run_command<arg_key>cmd");
689 assert_eq!(events, vec![ScanEvent::Text("partial ".to_string())]);
690 assert_eq!(
tests/acp_permissions.rs
+80
@@ -943,6 +943,86 @@ fn a_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
943 let _ = std::fs::remove_dir_all(&scratch);
944 }
945
946 +/// Kimi K3 may emit tool calls in XTML content blocks even when the endpoint is
947 +/// OpenAI-compatible. The backend should recover those before ACP sees them,
948 +/// unwrap visible response text, and hide private thinking text.
949 +#[test]
950 +fn a_kimi_k3_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
951 + let endpoint = start_fake_endpoint(vec![
952 + // Split across frames to cover the streaming scanner, not just the
953 + // complete-response extractor.
954 + sse_body(&[
955 + json!({"choices": [{"delta": {"content": "<|open|>think<|sep|>private notes<|close|>think<|sep|>"}}]}),
956 + json!({"choices": [{"delta": {"content": "<|open|>response<|sep|>Checking the repo:<|close|>response<|sep|> <|open|>too"}}]}),
957 + json!({"choices": [{"delta": {"content": "ls<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|>"}}]}),
958 + json!({"choices": [{"delta": {"content": "<|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>"}}]}),
959 + ]),
960 + sse_text("All done."),
961 + ]);
962 +
963 + let scratch = std::env::temp_dir().join(format!("sigit_acp_kimi_k3_{}", std::process::id()));
964 + let config_dir = scratch.join("config");
965 + let cwd = scratch.join("cwd");
966 + std::fs::create_dir_all(&config_dir).unwrap();
967 + std::fs::create_dir_all(&cwd).unwrap();
968 +
969 + let mut agent = spawn_agent(endpoint.port, &config_dir);
970 +
971 + let id = agent.request(
972 + "initialize",
973 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
974 + );
975 + agent.wait_for_response(id);
976 +
977 + let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
978 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
979 + .as_str()
980 + .expect("session id")
981 + .to_string();
982 +
983 + let prompt_id = agent.request(
984 + "session/prompt",
985 + json!({
986 + "sessionId": session_id,
987 + "prompt": [{"type": "text", "text": "check the repo"}],
988 + }),
989 + );
990 + let (_response, rendered) = agent.wait_for_prompt(prompt_id);
991 +
992 + assert!(
993 + !rendered.contains("<|open|>")
994 + && !rendered.contains("<|sep|>")
995 + && !rendered.contains("private notes"),
996 + "raw Kimi K3 markup reached the client: {rendered:?}"
997 + );
998 + assert!(
999 + rendered.contains("Checking the repo:"),
1000 + "response text should still be shown: {rendered:?}"
1001 + );
1002 +
1003 + let requests = endpoint.requests.lock().unwrap();
1004 + assert!(
1005 + requests.len() >= 2,
1006 + "expected a follow-up request carrying the tool result, got {}",
1007 + requests.len()
1008 + );
1009 + let messages = requests[1]["messages"].as_array().expect("messages");
1010 + assert!(
1011 + messages.iter().any(|message| message["role"] == "tool"),
1012 + "the follow-up should answer the recovered call: {messages:?}"
1013 + );
1014 + assert!(
1015 + messages
1016 + .iter()
1017 + .any(|message| { message["role"] == "assistant" && message["tool_calls"].is_array() }),
1018 + "history must record the recovered call, not the raw tag: {messages:?}"
1019 + );
1020 + drop(requests);
1021 +
1022 + drop(agent);
1023 + let _ = std::fs::remove_dir_all(&scratch);
1024 +}
1025 +
1026 #[test]
1027 fn write_todos_reaches_the_client_as_an_acp_plan() {
1028 let endpoint = start_fake_endpoint(vec![