Recover tool calls a model emits as text instead of a structured call

Qwen 3, GLM and DeepSeek render a tool call as <tool_call>NAME<arg_key>K</arg_key>... in their chat template. The serving stack is meant to parse that back into the tool_calls field. When it doesn't, the tag arrives as ordinary content: consume_stream forwards content to the UI as it lands, so the tag was rendered verbatim in the editor, and with no tool calls on the response the turn ended as if the model had chosen to answer in prose. Whatever it meant to do was dropped. Scan content for those blocks on both the streaming and non-streaming paths, and turn well-formed ones back into real tool calls. The streaming scanner holds back only enough text to catch a tag straddling chunk boundaries, so ordinary answers still stream token by token. Argument values are typed from the turn's own tool schemas, since a field declared integer has to arrive as a JSON number or the tool that runs it rejects the call. History records the recovered call rather than the raw tag. The tool results that follow have to answer an assistant message that actually carries those calls, or the next request is rejected for orphaned results. A block that doesn't match the expected shape is left in the text untouched. Reissuing a run_command is cheap, but guessing wrong at a half-parsed edit_file would write the wrong change to a file. This belongs here and not only in the gateway: siGit Code talks to siGit Code Cloud, to arbitrary OpenAI-compatible endpoints from providers.toml or OPENAI_BASE_URL, and to on-device models. Fixing any one upstream leaves the others exposed. Fixes #73 Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Sep 7, 2026 at 18:54 UTC f12e6843c99ea2010d1763a83ed726c60b6b0df1
4 files changed +590 -8
src/backend.rs
+88 -8
@@ -28,7 +28,8 @@ use tokio::sync::Mutex;
28 // ── Neutral types ───────────────────────────────────────────────────────────────
29
30 /// A tool the model may call, in a provider-neutral form. `parameters_schema` is
31 -/// a JSON Schema encoded as a string (matching how siGit already declares tools).
31 +/// a JSON Schema encoded as a string (matching how siGit Code already declares
32 +/// tools).
33 #[derive(Debug, Clone)]
34 pub struct ToolSpec {
35 pub name: String,
@@ -482,15 +483,22 @@ impl OpenAiBackend {
483 return Err(describe_api_error(status, &body));
484 }
485
486 + // The specs are needed downstream to type the arguments of any tool
487 + // call the model emitted as text rather than as a structured call.
488 + let tools = tools.unwrap_or(&[]);
489 if let Some(sink) = sink {
486 - self.consume_stream(response, sink).await
490 + self.consume_stream(response, sink, tools).await
491 } else {
488 - self.consume_json(response).await
492 + self.consume_json(response, tools).await
493 }
494 }
495
496 /// Parse a single non-streaming chat-completion response.
493 - async fn consume_json(&self, response: reqwest::Response) -> Result<TurnResult, BackendError> {
497 + async fn consume_json(
498 + &self,
499 + response: reqwest::Response,
500 + tools: &[ToolSpec],
501 + ) -> Result<TurnResult, BackendError> {
502 let parsed: ChatCompletion = response
503 .json()
504 .await
@@ -515,6 +523,41 @@ impl OpenAiBackend {
523 })
524 .collect();
525
526 + // Some models write a tool call out as literal `<tool_call>` text
527 + // instead of using the structured field (see `inline_tool_calls`).
528 + // Recover it, or the turn ends with the tag rendered as prose and
529 + // whatever the model meant to do is dropped.
530 + if tool_calls.is_empty() {
531 + let (cleaned, recovered) = crate::inline_tool_calls::extract(&text, tools);
532 + if !recovered.is_empty() {
533 + log::warn!(
534 + "recovered {} tool call(s) the model emitted as text instead of a structured call",
535 + recovered.len()
536 + );
537 + let tool_calls: Vec<ToolCall> = recovered
538 + .into_iter()
539 + .enumerate()
540 + .map(|(index, call)| ToolCall {
541 + id: format!("call_recovered_{index}"),
542 + name: call.name,
543 + arguments: call.arguments,
544 + })
545 + .collect();
546 + // Record the recovered shape, not the raw tag: the tool
547 + // results that follow have to answer an assistant message
548 + // that actually carries these calls, or the next request is
549 + // rejected for orphaned tool results.
550 + self.history
551 + .lock()
552 + .await
553 + .push(streamed_assistant_history(&cleaned, &tool_calls));
554 + return Ok(TurnResult {
555 + text: cleaned,
556 + tool_calls,
557 + });
558 + }
559 + }
560 +
561 // Record the assistant turn so later tool results have context.
562 self.history.lock().await.push(message.into_history_value());
563
@@ -528,6 +571,7 @@ impl OpenAiBackend {
571 &self,
572 response: reqwest::Response,
573 sink: &TokenSink,
574 + tools: &[ToolSpec],
575 ) -> Result<TurnResult, BackendError> {
576 use futures::StreamExt;
577
@@ -538,6 +582,13 @@ impl OpenAiBackend {
582 let mut text = String::new();
583 let mut tool_accum: Vec<StreamingToolCall> = Vec::new();
584 let mut done = false;
585 + // Recovers a tool call the model wrote as literal `<tool_call>` text
586 + // instead of a structured delta. Scanning here (rather than after the
587 + // stream) keeps the tag off the UI: content goes straight to `sink` as
588 + // it arrives, so by the time a whole turn is assembled the tag has
589 + // already been rendered. See `inline_tool_calls`.
590 + let mut scanner = crate::inline_tool_calls::StreamScanner::new(tools);
591 + let mut recovered: Vec<ToolCall> = Vec::new();
592
593 while let Some(item) = stream.next().await {
594 let bytes = item.map_err(|error| format!("stream read error: {error}"))?;
@@ -583,9 +634,31 @@ impl OpenAiBackend {
634 if let Some(content) = choice.delta.content
635 && !content.is_empty()
636 {
586 - text.push_str(&content);
587 - if sink.send(content).is_err() {
588 - // Consumer dropped (turn cancelled) — stop reading.
637 + let mut cancelled = false;
638 + for event in scanner.push(&content) {
639 + match event {
640 + crate::inline_tool_calls::ScanEvent::Text(chunk) => {
641 + text.push_str(&chunk);
642 + if sink.send(chunk).is_err() {
643 + // Consumer dropped (turn cancelled).
644 + cancelled = true;
645 + break;
646 + }
647 + }
648 + crate::inline_tool_calls::ScanEvent::ToolCall(call) => {
649 + log::warn!(
650 + "recovered tool call '{}' the model emitted as text instead of a structured call",
651 + call.name
652 + );
653 + recovered.push(ToolCall {
654 + id: format!("call_recovered_{}", recovered.len()),
655 + name: call.name,
656 + arguments: call.arguments,
657 + });
658 + }
659 + }
660 + }
661 + if cancelled {
662 done = true;
663 break;
664 }
@@ -615,7 +688,13 @@ impl OpenAiBackend {
688 }
689 }
690
618 - let tool_calls: Vec<ToolCall> = tool_accum
691 + // Text held back waiting on a tag that never closed is just text.
692 + if let Some(leftover) = scanner.take_pending() {
693 + text.push_str(&leftover);
694 + let _ = sink.send(leftover);
695 + }
696 +
697 + let mut tool_calls: Vec<ToolCall> = tool_accum
698 .iter()
699 .filter(|call| !call.name.is_empty())
700 .enumerate()
@@ -629,6 +708,7 @@ impl OpenAiBackend {
708 arguments: call.arguments.clone(),
709 })
710 .collect();
711 + tool_calls.extend(recovered);
712
713 // Record the assistant turn so later tool results have context.
714 self.history
src/inline_tool_calls.rs new
+420
@@ -0,0 +1,420 @@
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.
11 +//!
12 +//! Nothing then executes it. Worse, `consume_stream` forwards content to the
13 +//! UI as it arrives, so the tag is rendered verbatim in the editor and the
14 +//! turn ends with no tool calls, which reads to the agent loop as "the model
15 +//! chose to answer in prose". Whatever it was actually trying to do is
16 +//! dropped.
17 +//!
18 +//! This lives here rather than only in the gateway because siGit Code talks to
19 +//! more than one kind of endpoint: siGit Code Cloud, an arbitrary
20 +//! OpenAI-compatible base URL from `providers.toml` or `OPENAI_BASE_URL`, and
21 +//! on-device models. A fix in any single upstream leaves the others exposed.
22 +//!
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.
28 +
29 +use crate::backend::ToolSpec;
30 +
31 +const OPEN_TAG: &str = "<tool_call>";
32 +const CLOSE_TAG: &str = "</tool_call>";
33 +
34 +/// One tool call recovered from inline text.
35 +#[derive(Debug, Clone, PartialEq, Eq)]
36 +pub struct Recovered {
37 + pub name: String,
38 + /// Arguments as a JSON-encoded string, matching `ToolCall::arguments`.
39 + pub arguments: String,
40 +}
41 +
42 +/// Scan a complete text blob for inline tool-call blocks. Returns the text
43 +/// 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
45 +/// 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.
52 +pub fn extract(text: &str, tools: &[ToolSpec]) -> (String, Vec<Recovered>) {
53 + let mut out = String::with_capacity(text.len());
54 + 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];
65 +
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()]),
73 + }
74 + cursor = &cursor[close_idx + CLOSE_TAG.len()..];
75 + }
76 + out.push_str(cursor);
77 + (out, calls)
78 +}
79 +
80 +/// Parse one block's inner text: a name, then zero or more
81 +/// `<arg_key>K</arg_key><arg_value>V</arg_value>` pairs. Returns `None` the
82 +/// moment anything departs from that shape.
83 +fn parse_block(inner: &str, tools: &[ToolSpec]) -> Option<Recovered> {
84 + let (name, mut rest) = match inner.find("<arg_key>") {
85 + Some(idx) => (inner[..idx].trim(), &inner[idx..]),
86 + None => (inner.trim(), ""),
87 + };
88 + if name.is_empty() || name.contains(['<', '>']) {
89 + return None;
90 + }
91 +
92 + let mut args = serde_json::Map::new();
93 + while !rest.is_empty() {
94 + let (key, after_key) = split_once(rest.strip_prefix("<arg_key>")?, "</arg_key>")?;
95 + let (value, after_value) =
96 + split_once(after_key.strip_prefix("<arg_value>")?, "</arg_value>")?;
97 + let key = key.trim();
98 + if key.is_empty() {
99 + return None;
100 + }
101 + args.insert(
102 + key.to_string(),
103 + coerce(value, declared_type(tools, name, key).as_deref()),
104 + );
105 + rest = after_value;
106 + }
107 +
108 + Some(Recovered {
109 + name: name.to_string(),
110 + arguments: serde_json::Value::Object(args).to_string(),
111 + })
112 +}
113 +
114 +fn split_once<'a>(s: &'a str, delim: &str) -> Option<(&'a str, &'a str)> {
115 + let idx = s.find(delim)?;
116 + Some((&s[..idx], &s[idx + delim.len()..]))
117 +}
118 +
119 +/// The JSON Schema `type` declared for `tool_name`'s `key` parameter — e.g.
120 +/// `"integer"` for `command_output`'s `task_id`. [`ToolSpec`] carries the
121 +/// schema as an unparsed string, so this parses it per lookup; the argument
122 +/// counts involved are tiny and this only runs on the recovery path.
123 +fn declared_type(tools: &[ToolSpec], tool_name: &str, key: &str) -> Option<String> {
124 + let spec = tools.iter().find(|spec| spec.name == tool_name)?;
125 + let schema: serde_json::Value = serde_json::from_str(&spec.parameters_schema).ok()?;
126 + Some(
127 + schema
128 + .get("properties")?
129 + .get(key)?
130 + .get("type")?
131 + .as_str()?
132 + .to_string(),
133 + )
134 +}
135 +
136 +/// Convert a raw (unescaped) text value to JSON per its declared schema type.
137 +/// 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.
142 +fn coerce(raw: &str, declared: Option<&str>) -> serde_json::Value {
143 + match declared {
144 + Some("integer") => raw
145 + .trim()
146 + .parse::<i64>()
147 + .map(serde_json::Value::from)
148 + .unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
149 + Some("number") => raw
150 + .trim()
151 + .parse::<f64>()
152 + .ok()
153 + .and_then(serde_json::Number::from_f64)
154 + .map(serde_json::Value::Number)
155 + .unwrap_or_else(|| serde_json::Value::String(raw.to_string())),
156 + Some("boolean") => match raw.trim() {
157 + "true" => serde_json::Value::Bool(true),
158 + "false" => serde_json::Value::Bool(false),
159 + _ => serde_json::Value::String(raw.to_string()),
160 + },
161 + _ => serde_json::Value::String(raw.to_string()),
162 + }
163 +}
164 +
165 +/// One event from incrementally scanning a stream of content deltas.
166 +#[derive(Debug, Clone, PartialEq, Eq)]
167 +pub enum ScanEvent {
168 + /// Plain text, safe to forward to the UI.
169 + Text(String),
170 + /// A tool call recovered from a completed block.
171 + ToolCall(Recovered),
172 +}
173 +
174 +/// Incremental scanner for a live delta stream.
175 +///
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.
180 +pub struct StreamScanner<'a> {
181 + tools: &'a [ToolSpec],
182 + pending: String,
183 +}
184 +
185 +impl<'a> StreamScanner<'a> {
186 + pub fn new(tools: &'a [ToolSpec]) -> Self {
187 + Self {
188 + tools,
189 + pending: String::new(),
190 + }
191 + }
192 +
193 + /// Feed the next content delta. Returns events to emit now, in order; may
194 + /// be empty if the chunk was absorbed into a tag that hasn't closed yet.
195 + pub fn push(&mut self, chunk: &str) -> Vec<ScanEvent> {
196 + self.pending.push_str(chunk);
197 + self.drain()
198 + }
199 +
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.
205 + pub fn take_pending(&mut self) -> Option<String> {
206 + if self.pending.is_empty() {
207 + None
208 + } else {
209 + Some(std::mem::take(&mut self.pending))
210 + }
211 + }
212 +
213 + fn drain(&mut self) -> Vec<ScanEvent> {
214 + let mut events = Vec::new();
215 + 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);
220 + let flush_len = self.pending.len() - keep;
221 + if flush_len > 0 {
222 + events.push(ScanEvent::Text(
223 + self.pending.drain(..flush_len).collect::<String>(),
224 + ));
225 + }
226 + break;
227 + };
228 + if open_idx > 0 {
229 + events.push(ScanEvent::Text(
230 + self.pending.drain(..open_idx).collect::<String>(),
231 + ));
232 + }
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)),
242 + }
243 + }
244 + events
245 + }
246 +}
247 +
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.
252 +fn partial_prefix_len(buf: &str, needle: &str) -> usize {
253 + let max = buf.len().min(needle.len() - 1);
254 + for len in (1..=max).rev() {
255 + let start = buf.len() - len;
256 + if buf.is_char_boundary(start) && needle.starts_with(&buf[start..]) {
257 + return len;
258 + }
259 + }
260 + 0
261 +}
262 +
263 +#[cfg(test)]
264 +mod tests {
265 + use super::*;
266 +
267 + fn spec(name: &str, schema: serde_json::Value) -> ToolSpec {
268 + ToolSpec {
269 + name: name.to_string(),
270 + description: String::new(),
271 + parameters_schema: schema.to_string(),
272 + }
273 + }
274 +
275 + fn command_output_spec() -> ToolSpec {
276 + spec(
277 + "command_output",
278 + serde_json::json!({
279 + "type": "object",
280 + "properties": { "task_id": { "type": "integer" } }
281 + }),
282 + )
283 + }
284 +
285 + #[test]
286 + 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.
289 + let tools = vec![command_output_spec()];
290 + let (text, calls) = extract(
291 + "Checking.<tool_call>command_output<arg_key>task_id</arg_key><arg_value>2</arg_value></tool_call>",
292 + &tools,
293 + );
294 + assert_eq!(text, "Checking.");
295 + assert_eq!(calls.len(), 1);
296 + assert_eq!(calls[0].name, "command_output");
297 + let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
298 + assert_eq!(args["task_id"], 2);
299 + assert!(args["task_id"].is_number());
300 + }
301 +
302 + #[test]
303 + 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 + )];
315 + let (text, calls) = extract(
316 + "<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>",
317 + &tools,
318 + );
319 + assert_eq!(text, "");
320 + let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
321 + assert_eq!(args["command"], "cargo test --locked 2>&1 | tail -20");
322 + assert_eq!(args["cwd"], "/Users/x/sigit");
323 + assert_eq!(args["run_in_background"], true);
324 + }
325 +
326 + #[test]
327 + 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.
331 + 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, &[]);
333 + assert_eq!(out, text);
334 + assert!(calls.is_empty());
335 + }
336 +
337 + #[test]
338 + fn an_unterminated_tag_is_left_alone() {
339 + let text = "working on it <tool_call>run_command<arg_key>command</arg_key>";
340 + let (out, calls) = extract(text, &[]);
341 + assert_eq!(out, text);
342 + assert!(calls.is_empty());
343 + }
344 +
345 + #[test]
346 + fn ordinary_text_passes_through_untouched() {
347 + let (out, calls) = extract("if x < y then a<b, nothing to recover", &[]);
348 + assert_eq!(out, "if x < y then a<b, nothing to recover");
349 + assert!(calls.is_empty());
350 + }
351 +
352 + #[test]
353 + fn scanner_recovers_a_tag_split_across_chunk_boundaries() {
354 + let tools = vec![command_output_spec()];
355 + let mut scanner = StreamScanner::new(&tools);
356 + let mut text = String::new();
357 + let mut calls = Vec::new();
358 +
359 + for chunk in [
360 + "polling ",
361 + "<tool_c",
362 + "all>command_output<arg_",
363 + "key>task_id</arg_key><arg_value>2</arg_v",
364 + "alue></tool_call>",
365 + " done",
366 + ] {
367 + for event in scanner.push(chunk) {
368 + match event {
369 + ScanEvent::Text(t) => text.push_str(&t),
370 + ScanEvent::ToolCall(c) => calls.push(c),
371 + }
372 + }
373 + }
374 + if let Some(rest) = scanner.take_pending() {
375 + text.push_str(&rest);
376 + }
377 +
378 + assert_eq!(text, "polling done", "no tag fragment may reach the UI");
379 + assert_eq!(calls.len(), 1);
380 + assert_eq!(calls[0].name, "command_output");
381 + }
382 +
383 + #[test]
384 + fn scanner_forwards_ordinary_text_without_holding_it_back() {
385 + let mut scanner = StreamScanner::new(&[]);
386 + let events = scanner.push("a normal streamed answer");
387 + assert_eq!(
388 + events,
389 + vec![ScanEvent::Text("a normal streamed answer".to_string())]
390 + );
391 + assert!(scanner.take_pending().is_none());
392 + }
393 +
394 + #[test]
395 + fn scanner_releases_a_near_miss_prefix_once_it_cannot_match() {
396 + let mut scanner = StreamScanner::new(&[]);
397 + // "<tool" could still become "<tool_call>", so it is held back...
398 + assert_eq!(
399 + scanner.push("see <tool"),
400 + vec![ScanEvent::Text("see ".to_string())]
401 + );
402 + // ...and released as soon as the next chunk rules that out.
403 + assert_eq!(
404 + scanner.push("box for details"),
405 + vec![ScanEvent::Text("<toolbox for details".to_string())]
406 + );
407 + assert!(scanner.take_pending().is_none());
408 + }
409 +
410 + #[test]
411 + fn scanner_hands_back_an_unclosed_tag_at_stream_end() {
412 + let mut scanner = StreamScanner::new(&[]);
413 + let events = scanner.push("partial <tool_call>run_command<arg_key>cmd");
414 + assert_eq!(events, vec![ScanEvent::Text("partial ".to_string())]);
415 + assert_eq!(
416 + scanner.take_pending().as_deref(),
417 + Some("<tool_call>run_command<arg_key>cmd")
418 + );
419 + }
420 +}
src/main.rs
+1
@@ -37,6 +37,7 @@ mod credentials;
37 mod frontmatter;
38 mod headless;
39 mod hooks;
40 +mod inline_tool_calls;
41 mod instructions;
42 mod mcp;
43 mod models;
tests/acp_permissions.rs
+81
@@ -581,3 +581,84 @@ fn text_from_two_tool_rounds_reaches_the_client_as_separate_paragraphs() {
581 drop(agent);
582 let _ = std::fs::remove_dir_all(&scratch);
583 }
584 +
585 +/// A model that writes its tool call out as literal `<tool_call>` text instead
586 +/// of using the structured field must still drive the loop. Before recovery
587 +/// the tag was streamed to the client as prose and the turn ended with no tool
588 +/// calls, so the request silently went unanswered (getsigit/sigit#73).
589 +#[test]
590 +fn a_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
591 + let endpoint = start_fake_endpoint(vec![
592 + // Split mid-tag, the way a real stream arrives.
593 + sse_body(&[
594 + json!({"choices": [{"delta": {"content": "Checking the repo: <tool_c"}}]}),
595 + json!({"choices": [{"delta": {"content": "all>command_output<arg_key>task_id</arg_key>"}}]}),
596 + json!({"choices": [{"delta": {"content": "<arg_value>2</arg_value></tool_call>"}}]}),
597 + ]),
598 + sse_text("All done."),
599 + ]);
600 +
601 + let scratch = std::env::temp_dir().join(format!("sigit_acp_inline_{}", std::process::id()));
602 + let config_dir = scratch.join("config");
603 + let cwd = scratch.join("cwd");
604 + std::fs::create_dir_all(&config_dir).unwrap();
605 + std::fs::create_dir_all(&cwd).unwrap();
606 +
607 + let mut agent = spawn_agent(endpoint.port, &config_dir);
608 +
609 + let id = agent.request(
610 + "initialize",
611 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
612 + );
613 + agent.wait_for_response(id);
614 +
615 + let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
616 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
617 + .as_str()
618 + .expect("session id")
619 + .to_string();
620 +
621 + let prompt_id = agent.request(
622 + "session/prompt",
623 + json!({
624 + "sessionId": session_id,
625 + "prompt": [{"type": "text", "text": "check the repo"}],
626 + }),
627 + );
628 + let (_response, rendered) = agent.wait_for_prompt(prompt_id);
629 +
630 + // The raw tag must never be shown to the user.
631 + assert!(
632 + !rendered.contains("<tool_call>") && !rendered.contains("<arg_key>"),
633 + "raw tool-call markup reached the client: {rendered:?}"
634 + );
635 + assert!(
636 + rendered.contains("Checking the repo:"),
637 + "surrounding prose should still stream: {rendered:?}"
638 + );
639 +
640 + // The loop has to keep going: a second request means the recovered call
641 + // ran and its result was sent back. Before recovery the turn ended here.
642 + let requests = endpoint.requests.lock().unwrap();
643 + assert!(
644 + requests.len() >= 2,
645 + "expected a follow-up request carrying the tool result, got {}",
646 + requests.len()
647 + );
648 + let follow_up = &requests[1];
649 + let messages = follow_up["messages"].as_array().expect("messages");
650 + assert!(
651 + messages.iter().any(|message| message["role"] == "tool"),
652 + "the follow-up should answer the recovered call: {messages:?}"
653 + );
654 + assert!(
655 + messages
656 + .iter()
657 + .any(|message| { message["role"] == "assistant" && message["tool_calls"].is_array() }),
658 + "history must record the recovered call, not the raw tag: {messages:?}"
659 + );
660 + drop(requests);
661 +
662 + drop(agent);
663 + let _ = std::fs::remove_dir_all(&scratch);
664 +}