Drop broken inline blocks beside structured calls
On the non-streamed path, a reply with structured tool_calls kept its raw content, so an unparseable inline block next to a real call still reached the reply and history. Strip it there too, and run any inline calls that did parse, which matches what the streaming path does.
Seto Elkahfi committed
Sep 25, 2026 at 18:15 UTC
ecc292c3a5fe39d1c2c3192f510038428b64c1d1
1 file changed
+74
-5
src/backend.rs
+74
-5
@@ -642,7 +642,7 @@ impl OpenAiBackend {
642
.ok_or_else(|| "endpoint returned no choices".to_string())?;
643
644
let mut text = message.content.clone().unwrap_or_default();
645
- let tool_calls: Vec<ToolCall> = message
645
+ let mut tool_calls: Vec<ToolCall> = message
646
.tool_calls
647
.iter()
648
.flatten()
@@ -736,6 +736,34 @@ impl OpenAiBackend {
736
malformed,
737
));
738
}
739
+ } else if malformed > 0 {
740
+ // Structured calls arrived with a broken inline block beside
741
+ // them. The structured calls run and the block is kept out of
742
+ // the reply and history, as the streaming path does. That path
743
+ // also runs inline calls that did parse, so this one does too.
744
+ let offset = tool_calls.len();
745
+ tool_calls.extend(
746
+ extracted
747
+ .calls
748
+ .into_iter()
749
+ .enumerate()
750
+ .map(|(index, call)| ToolCall {
751
+ id: format!("call_recovered_{}", offset + index),
752
+ name: call.name,
753
+ arguments: call.arguments,
754
+ }),
755
+ );
756
+ self.history
757
+ .lock()
758
+ .await
759
+ .push(streamed_assistant_history(&extracted.text, &tool_calls));
760
+ return Ok((
761
+ TurnResult {
762
+ text: extracted.text,
763
+ tool_calls,
764
+ },
765
+ malformed,
766
+ ));
767
}
768
769
// Record the assistant turn so later tool results have context.
@@ -1787,16 +1815,20 @@ mod tests {
1815
/// receiver yields the request body the backend actually put on the wire.
1816
fn spawn_completion_stub(
1817
summary: &str,
1818
+ ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
1819
+ spawn_message_stub(serde_json::json!({ "role": "assistant", "content": summary }))
1820
+ }
1821
+
1822
+ /// Like [`spawn_completion_stub`], answering with an arbitrary message.
1823
+ fn spawn_message_stub(
1824
+ message: serde_json::Value,
1825
) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
1826
use std::io::{Read, Write};
1827
1828
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1829
let addr = listener.local_addr().unwrap();
1830
let (sender, receiver) = std::sync::mpsc::channel();
1796
- let body = serde_json::json!({
1797
- "choices": [{ "message": { "role": "assistant", "content": summary } }]
1798
- })
1799
- .to_string();
1831
+ let body = serde_json::json!({ "choices": [{ "message": message }] }).to_string();
1832
std::thread::spawn(move || {
1833
let (mut stream, _) = listener.accept().unwrap();
1834
// Read until the full request (headers + content-length body) is in.
@@ -1839,6 +1871,43 @@ mod tests {
1871
(addr, receiver)
1872
}
1873
1874
+ /// A structured call can come back with a broken inline block in the same
1875
+ /// message. The call runs; the block reaches neither the reply nor the
1876
+ /// history, where the model would read back a call that never ran.
1877
+ #[tokio::test]
1878
+ async fn a_broken_inline_block_beside_a_structured_call_is_dropped() {
1879
+ let (addr, _requests) = spawn_message_stub(serde_json::json!({
1880
+ "role": "assistant",
1881
+ "content": "Checking.<tool_call>run_command CheckStatus=nope</tool_call>",
1882
+ "tool_calls": [{
1883
+ "id": "call_1",
1884
+ "type": "function",
1885
+ "function": { "name": "run_command", "arguments": "{\"command\":\"pwd\"}" },
1886
+ }],
1887
+ }));
1888
+ let backend =
1889
+ OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
1890
+ let tools = vec![ToolSpec {
1891
+ name: "run_command".to_string(),
1892
+ description: "Run a command".to_string(),
1893
+ parameters_schema: r#"{"type":"object","properties":{"command":{"type":"string"}}}"#
1894
+ .to_string(),
1895
+ }];
1896
+
1897
+ let result = backend
1898
+ .send_message_with_tools("where am I", &tools, None)
1899
+ .await
1900
+ .unwrap();
1901
+
1902
+ assert_eq!(result.text, "Checking.");
1903
+ assert_eq!(result.tool_calls.len(), 1);
1904
+ assert_eq!(result.tool_calls[0].id, "call_1");
1905
+ let history = backend.history_snapshot().await;
1906
+ let reply = history.last().unwrap();
1907
+ assert_eq!(reply["content"], "Checking.");
1908
+ assert_eq!(reply["tool_calls"][0]["id"], "call_1");
1909
+ }
1910
+
1911
#[tokio::test]
1912
async fn compact_history_rebuilds_system_summary_and_tail() {
1913
let (addr, _requests) = spawn_completion_stub("We refactored backend.rs; tests pass.");