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(""", "\"").replace("&", "&")
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"command" key="a&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![