Run inline calls beside structured ones without streaming

The non-streamed path only merged inline calls into a reply with structured tool_calls when a broken block was also present. A well-formed inline block on its own was left in the reply and history as raw markup and never ran. Handle both cases the way the streaming path already does.

Seto Elkahfi committed Sep 25, 2026 at 19:56 UTC b3b7651a433d9df1a981f9af105bb6c141aeee8b
1 file changed +42 -5
src/backend.rs
+42 -5
@@ -736,11 +736,11 @@ 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.
739 + } else if malformed > 0 || !extracted.calls.is_empty() {
740 + // Structured calls arrived with inline blocks beside them. As on
741 + // the streaming path, inline calls that parse run alongside the
742 + // structured ones, broken blocks are dropped, and neither kind of
743 + // block is left in the reply or the history.
744 let offset = tool_calls.len();
745 tool_calls.extend(
746 extracted
@@ -1908,6 +1908,43 @@ mod tests {
1908 assert_eq!(reply["tool_calls"][0]["id"], "call_1");
1909 }
1910
1911 + /// A structured call plus a well-formed inline block: both run, and the
1912 + /// inline markup leaves the reply and history, as on the streaming path.
1913 + #[tokio::test]
1914 + async fn a_well_formed_inline_block_beside_a_structured_call_also_runs() {
1915 + let (addr, _requests) = spawn_message_stub(serde_json::json!({
1916 + "role": "assistant",
1917 + "content": "Checking.<tool_call>run_command<arg_key>command</arg_key><arg_value>ls</arg_value></tool_call>",
1918 + "tool_calls": [{
1919 + "id": "call_1",
1920 + "type": "function",
1921 + "function": { "name": "run_command", "arguments": "{\"command\":\"pwd\"}" },
1922 + }],
1923 + }));
1924 + let backend =
1925 + OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
1926 + let tools = vec![ToolSpec {
1927 + name: "run_command".to_string(),
1928 + description: "Run a command".to_string(),
1929 + parameters_schema: r#"{"type":"object","properties":{"command":{"type":"string"}}}"#
1930 + .to_string(),
1931 + }];
1932 +
1933 + let result = backend
1934 + .send_message_with_tools("where am I", &tools, None)
1935 + .await
1936 + .unwrap();
1937 +
1938 + assert_eq!(result.text, "Checking.");
1939 + let ids: Vec<&str> = result.tool_calls.iter().map(|c| c.id.as_str()).collect();
1940 + assert_eq!(ids, ["call_1", "call_recovered_1"]);
1941 + assert_eq!(result.tool_calls[1].arguments, r#"{"command":"ls"}"#);
1942 + let history = backend.history_snapshot().await;
1943 + let reply = history.last().unwrap();
1944 + assert_eq!(reply["content"], "Checking.");
1945 + assert_eq!(reply["tool_calls"].as_array().unwrap().len(), 2);
1946 + }
1947 +
1948 #[tokio::test]
1949 async fn compact_history_rebuilds_system_summary_and_tail() {
1950 let (addr, _requests) = spawn_completion_stub("We refactored backend.rs; tests pass.");