diff --git a/src/openhuman/agent/dispatcher.rs b/src/openhuman/agent/dispatcher.rs index 612ef9758..6019cbb9d 100644 --- a/src/openhuman/agent/dispatcher.rs +++ b/src/openhuman/agent/dispatcher.rs @@ -477,9 +477,78 @@ impl ToolDispatcher for NativeToolDispatcher { } fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec { - history - .iter() - .flat_map(|msg| match msg { + // TAURI-RUST-7: providers (incl. the OpenHuman backend) reject any + // assistant `tool_calls` message that isn't immediately followed by + // `tool` messages responding to every `tool_call_id`, with + // `400 An assistant message with 'tool_calls' must be followed by + // tool messages responding to each 'tool_call_id'`. Cached transcript + // restores, mid-turn aborts, and history compaction can all produce a + // bisected tool cycle (assistant tool_calls preserved, ToolResults + // dropped or never persisted). Filter those out here, just before + // serialising to provider wire format, so a bisected pair never + // reaches the wire. Symmetric drop: a `ToolResults` whose preceding + // `AssistantToolCalls` was dropped is also stripped to keep the + // sequence well-formed. + // CodeRabbit follow-up: backend's 400 says "insufficient tool messages + // following tool_calls", which fires on either (a) zero following tool + // messages, or (b) a tool message set whose `tool_call_id`s don't + // cover every `tool_calls[].id` on the opener. Adjacency alone is + // not sufficient — require the full set of opener ids to equal the + // set of follower `tool_call_id`s. + let mut paired_indices: Vec = Vec::with_capacity(history.len()); + for (i, msg) in history.iter().enumerate() { + match msg { + ConversationMessage::AssistantToolCalls { tool_calls, .. } => { + let Some(ConversationMessage::ToolResults(results)) = history.get(i + 1) else { + log::debug!( + "[agent][dispatcher] dropping unpaired AssistantToolCalls at index \ + {i} of {} (no immediately following ToolResults — would trip \ + provider 400 'tool_calls must be followed by tool messages')", + history.len() + ); + continue; + }; + let opener_ids: std::collections::BTreeSet<&str> = + tool_calls.iter().map(|tc| tc.id.as_str()).collect(); + let result_ids: std::collections::BTreeSet<&str> = + results.iter().map(|r| r.tool_call_id.as_str()).collect(); + if !opener_ids.is_empty() && opener_ids == result_ids { + paired_indices.push(i); + } else { + log::debug!( + "[agent][dispatcher] dropping AssistantToolCalls at index {i}: \ + tool_call_id set mismatch between opener ({:?}) and ToolResults \ + ({:?})", + opener_ids, + result_ids + ); + } + } + ConversationMessage::ToolResults(_) => { + let preceded_by_kept_tool_calls = i > 0 + && matches!( + history.get(i - 1), + Some(ConversationMessage::AssistantToolCalls { .. }) + ) + && paired_indices.last() == Some(&(i - 1)); + if preceded_by_kept_tool_calls { + paired_indices.push(i); + } else { + log::debug!( + "[agent][dispatcher] dropping orphan ToolResults at index {i} \ + (no preceding AssistantToolCalls in the emitted sequence)" + ); + } + } + ConversationMessage::Chat(_) => { + paired_indices.push(i); + } + } + } + + paired_indices + .into_iter() + .flat_map(|i| match &history[i] { ConversationMessage::Chat(chat) => vec![chat.clone()], ConversationMessage::AssistantToolCalls { text, tool_calls } => { let payload = serde_json::json!({ diff --git a/src/openhuman/agent/dispatcher_tests.rs b/src/openhuman/agent/dispatcher_tests.rs index 6c9c69f4e..d6b1ee516 100644 --- a/src/openhuman/agent/dispatcher_tests.rs +++ b/src/openhuman/agent/dispatcher_tests.rs @@ -252,3 +252,244 @@ fn native_format_results_keeps_tool_call_id() { _ => panic!("expected ToolResults variant"), } } + +// ── TAURI-RUST-7 regression: tool_calls / ToolResults pairing ────────── +// +// Providers reject any assistant `tool_calls` message that isn't immediately +// followed by `tool` messages responding to every `tool_call_id`. Cached +// transcript restores and mid-turn aborts can produce bisected pairs. The +// fix in `to_provider_messages` drops unpaired AssistantToolCalls and orphan +// ToolResults so the wire payload is always well-formed. + +fn assistant_tool_calls(id: &str) -> ConversationMessage { + ConversationMessage::AssistantToolCalls { + text: Some("calling tool".into()), + tool_calls: vec![crate::openhuman::inference::provider::ToolCall { + id: id.into(), + name: "shell".into(), + arguments: "{}".into(), + }], + } +} + +fn tool_results(id: &str) -> ConversationMessage { + use crate::openhuman::inference::provider::ToolResultMessage; + ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: id.into(), + content: "ok".into(), + }]) +} + +fn user_chat(text: &str) -> ConversationMessage { + ConversationMessage::Chat(crate::openhuman::inference::provider::ChatMessage::user( + text, + )) +} + +fn assistant_chat(text: &str) -> ConversationMessage { + ConversationMessage::Chat(crate::openhuman::inference::provider::ChatMessage::assistant(text)) +} + +#[test] +fn to_provider_messages_keeps_paired_tool_cycle() { + let dispatcher = NativeToolDispatcher; + let history = vec![ + user_chat("hi"), + assistant_tool_calls("tc-1"), + tool_results("tc-1"), + assistant_chat("done"), + ]; + let out = dispatcher.to_provider_messages(&history); + let roles: Vec<&str> = out.iter().map(|m| m.role.as_str()).collect(); + assert_eq!(roles, vec!["user", "assistant", "tool", "assistant"]); +} + +#[test] +fn to_provider_messages_drops_trailing_unpaired_tool_calls() { + // The assistant emitted tool_calls but the run was aborted before the + // ToolResults were persisted. The trailing tool_calls must not reach + // the wire. + let dispatcher = NativeToolDispatcher; + let history = vec![ + user_chat("hi"), + assistant_chat("ok"), + user_chat("again"), + assistant_tool_calls("tc-2"), + ]; + let out = dispatcher.to_provider_messages(&history); + let roles: Vec<&str> = out.iter().map(|m| m.role.as_str()).collect(); + assert_eq!( + roles, + vec!["user", "assistant", "user"], + "trailing unpaired AssistantToolCalls must be stripped" + ); +} + +#[test] +fn to_provider_messages_drops_mid_history_unpaired_tool_calls() { + // History with a bisected pair in the middle: tool_calls followed + // directly by a Chat (not ToolResults). Drop the tool_calls; keep + // everything else. + let dispatcher = NativeToolDispatcher; + let history = vec![ + user_chat("hi"), + assistant_tool_calls("tc-3"), // bisected — no following ToolResults + user_chat("nevermind"), + assistant_chat("ok"), + ]; + let out = dispatcher.to_provider_messages(&history); + let roles: Vec<&str> = out.iter().map(|m| m.role.as_str()).collect(); + assert_eq!(roles, vec!["user", "user", "assistant"]); +} + +#[test] +fn to_provider_messages_drops_orphan_tool_results() { + // Symmetric drop: ToolResults whose preceding AssistantToolCalls was + // never emitted (either never persisted or already dropped above) + // must not appear in the wire payload. + let dispatcher = NativeToolDispatcher; + let history = vec![ + user_chat("hi"), + tool_results("tc-4"), // orphan — no preceding AssistantToolCalls + assistant_chat("ok"), + ]; + let out = dispatcher.to_provider_messages(&history); + let roles: Vec<&str> = out.iter().map(|m| m.role.as_str()).collect(); + assert_eq!(roles, vec!["user", "assistant"]); +} + +#[test] +fn to_provider_messages_handles_multiple_tool_cycles() { + // Two paired cycles in a row — both must survive. + let dispatcher = NativeToolDispatcher; + let history = vec![ + user_chat("a"), + assistant_tool_calls("tc-5"), + tool_results("tc-5"), + assistant_tool_calls("tc-6"), + tool_results("tc-6"), + assistant_chat("final"), + ]; + let out = dispatcher.to_provider_messages(&history); + let roles: Vec<&str> = out.iter().map(|m| m.role.as_str()).collect(); + assert_eq!( + roles, + vec![ + "user", + "assistant", + "tool", + "assistant", + "tool", + "assistant" + ] + ); +} + +// ── tool_call_id set-pairing (CodeRabbit follow-up) ───────────────────── + +fn assistant_tool_calls_multi(ids: &[&str]) -> ConversationMessage { + ConversationMessage::AssistantToolCalls { + text: Some("calling tools".into()), + tool_calls: ids + .iter() + .map(|id| crate::openhuman::inference::provider::ToolCall { + id: (*id).into(), + name: "shell".into(), + arguments: "{}".into(), + }) + .collect(), + } +} + +fn tool_results_multi(ids: &[&str]) -> ConversationMessage { + use crate::openhuman::inference::provider::ToolResultMessage; + ConversationMessage::ToolResults( + ids.iter() + .map(|id| ToolResultMessage { + tool_call_id: (*id).into(), + content: "ok".into(), + }) + .collect(), + ) +} + +#[test] +fn to_provider_messages_drops_pair_when_tool_call_ids_mismatch() { + // Opener requests `tc-1`, but the only ToolResults entry answers `tc-x`. + // Backend would 400 with "insufficient tool messages following tool_calls" + // — drop both. + let dispatcher = NativeToolDispatcher; + let history = vec![ + user_chat("hi"), + assistant_tool_calls_multi(&["tc-1"]), + tool_results_multi(&["tc-x"]), + assistant_chat("done"), + ]; + let out = dispatcher.to_provider_messages(&history); + let roles: Vec<&str> = out.iter().map(|m| m.role.as_str()).collect(); + assert_eq!( + roles, + vec!["user", "assistant"], + "id-set mismatch must drop the bisected pair entirely, kept: {roles:?}" + ); +} + +#[test] +fn to_provider_messages_drops_pair_when_results_are_partial() { + // Opener requests two tool_call_ids, results answer only one. Backend + // rejects with "insufficient tool messages". Strict set equality drops + // the pair. + let dispatcher = NativeToolDispatcher; + let history = vec![ + user_chat("hi"), + assistant_tool_calls_multi(&["tc-1", "tc-2"]), + tool_results_multi(&["tc-1"]), + assistant_chat("done"), + ]; + let out = dispatcher.to_provider_messages(&history); + let roles: Vec<&str> = out.iter().map(|m| m.role.as_str()).collect(); + assert_eq!( + roles, + vec!["user", "assistant"], + "partial tool-result coverage must drop the pair, kept: {roles:?}" + ); +} + +#[test] +fn to_provider_messages_keeps_pair_with_full_id_coverage() { + // Strict set equality: opener has {tc-1, tc-2}, results cover both, + // even if listed in a different order. Both messages must be emitted. + let dispatcher = NativeToolDispatcher; + let history = vec![ + user_chat("hi"), + assistant_tool_calls_multi(&["tc-1", "tc-2"]), + tool_results_multi(&["tc-2", "tc-1"]), + assistant_chat("done"), + ]; + let out = dispatcher.to_provider_messages(&history); + let roles: Vec<&str> = out.iter().map(|m| m.role.as_str()).collect(); + assert_eq!( + roles, + vec!["user", "assistant", "tool", "tool", "assistant"] + ); +} + +#[test] +fn to_provider_messages_drops_pair_with_extra_unsolicited_results() { + // Opener requests `tc-1`; results answer `tc-1` *and* an unsolicited + // `tc-extra`. The id sets differ, so the pair is dropped. + let dispatcher = NativeToolDispatcher; + let history = vec![ + user_chat("hi"), + assistant_tool_calls_multi(&["tc-1"]), + tool_results_multi(&["tc-1", "tc-extra"]), + assistant_chat("done"), + ]; + let out = dispatcher.to_provider_messages(&history); + let roles: Vec<&str> = out.iter().map(|m| m.role.as_str()).collect(); + assert_eq!( + roles, + vec!["user", "assistant"], + "extra unsolicited tool_call_ids must invalidate the pair, kept: {roles:?}" + ); +} diff --git a/src/openhuman/agent/harness/session/turn.rs b/src/openhuman/agent/harness/session/turn.rs index 8833f02bd..6d36b8ff0 100644 --- a/src/openhuman/agent/harness/session/turn.rs +++ b/src/openhuman/agent/harness/session/turn.rs @@ -51,6 +51,51 @@ use anyhow::Result; use std::hash::{Hash, Hasher}; use std::sync::Arc; +/// True when `msg` is an `assistant` ChatMessage whose JSON-encoded content +/// carries a non-empty `tool_calls` array. +/// +/// `to_provider_messages` (in `agent/dispatcher.rs`) serialises an +/// `AssistantToolCalls` ConversationMessage as a single `assistant` ChatMessage +/// with a JSON body of the form `{"content": "...", "tool_calls": [...]}`. To +/// detect those at the `ChatMessage` boundary (where `bound_cached_transcript_messages` +/// operates) we have to peek inside the JSON. See TAURI-RUST-7 for the +/// failure mode this guards against. +fn assistant_message_has_tool_calls(msg: &ChatMessage) -> bool { + if msg.role != "assistant" { + return false; + } + let Ok(value) = serde_json::from_str::(&msg.content) else { + return false; + }; + // CodeRabbit follow-up: only treat this as the native tool_calls envelope + // when the full expected shape is present: + // - top-level JSON object + // - `content` key present (the envelope `dispatcher.rs` emits — see + // `to_provider_messages`) + // - non-empty `tool_calls` array whose every element carries an `id` + // string, a `name` string, and an `arguments` field + // This stops a legitimate assistant text reply that happens to contain + // the literal string `tool_calls` from being misclassified and dropped at + // the bound-cached-transcript boundary. + let Some(obj) = value.as_object() else { + return false; + }; + if !obj.contains_key("content") { + return false; + } + let Some(tool_calls) = obj.get("tool_calls").and_then(|tc| tc.as_array()) else { + return false; + }; + if tool_calls.is_empty() { + return false; + } + tool_calls.iter().all(|tc| { + tc.get("id").and_then(|v| v.as_str()).is_some() + && tc.get("name").and_then(|v| v.as_str()).is_some() + && tc.get("arguments").is_some() + }) +} + /// Instruction appended (as a synthetic user turn) to the provider /// messages when a turn hits the tool-call iteration cap. Asks the model /// to wrap up with a resumable checkpoint instead of letting the turn die. @@ -1725,6 +1770,30 @@ impl Agent { bounded.push(messages[0].clone()); } bounded.extend(tail.iter().cloned()); + + // TAURI-RUST-7: symmetric guard to the leading-orphan strip above. A + // resumed transcript that ends on an `assistant` message containing + // `tool_calls` (because the cached transcript was captured mid-cycle, + // before the tool responses were persisted) is rejected by the + // provider with `400 An assistant message with 'tool_calls' must be + // followed by tool messages`. Pop any such trailing assistant + // tool_calls so the bounded transcript ends on a clean turn boundary. + let mut dropped_tail = 0usize; + while bounded + .last() + .map(assistant_message_has_tool_calls) + .unwrap_or(false) + { + bounded.pop(); + dropped_tail += 1; + } + if dropped_tail > 0 { + log::debug!( + "[agent] bound_cached_transcript_messages stripped {dropped_tail} trailing \ + assistant tool_calls message(s) without paired tool responses" + ); + } + bounded } diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index d2801da1b..5a06e420b 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -1324,3 +1324,190 @@ async fn fetch_learned_context_loads_general_prefs_when_learning_enabled() { learned.user_profile ); } + +// ── assistant_message_has_tool_calls — TAURI-RUST-7 envelope check ───── + +#[test] +fn assistant_message_has_tool_calls_detects_native_envelope() { + let body = serde_json::json!({ + "content": "calling tool", + "tool_calls": [{ + "id": "tc-1", + "name": "shell", + "arguments": "{}" + }] + }) + .to_string(); + let msg = ChatMessage::assistant(body); + assert!(super::assistant_message_has_tool_calls(&msg)); +} + +#[test] +fn assistant_message_has_tool_calls_rejects_non_assistant_role() { + let body = serde_json::json!({ + "content": "x", + "tool_calls": [{ "id": "tc-1", "name": "shell", "arguments": "{}" }] + }) + .to_string(); + let msg = ChatMessage::user(body); + assert!(!super::assistant_message_has_tool_calls(&msg)); +} + +#[test] +fn assistant_message_has_tool_calls_rejects_plain_text_reply() { + // Most common positive case for the previous over-broad check: a plain + // assistant reply whose text happens to mention `tool_calls`. + let msg = ChatMessage::assistant("I considered using tool_calls but chose not to."); + assert!(!super::assistant_message_has_tool_calls(&msg)); +} + +#[test] +fn assistant_message_has_tool_calls_rejects_envelope_without_content_field() { + // A bare `{"tool_calls": [...]}` JSON in the content (no `content` field) + // is not the envelope `dispatcher.rs` emits. + let body = serde_json::json!({ + "tool_calls": [{ "id": "tc-1", "name": "shell", "arguments": "{}" }] + }) + .to_string(); + let msg = ChatMessage::assistant(body); + assert!(!super::assistant_message_has_tool_calls(&msg)); +} + +#[test] +fn assistant_message_has_tool_calls_rejects_empty_tool_calls_array() { + let body = serde_json::json!({ + "content": "no tools", + "tool_calls": [] + }) + .to_string(); + let msg = ChatMessage::assistant(body); + assert!(!super::assistant_message_has_tool_calls(&msg)); +} + +#[test] +fn assistant_message_has_tool_calls_rejects_malformed_tool_call_items() { + // tool_call object missing `id` — not the native envelope shape. + let body_no_id = serde_json::json!({ + "content": "x", + "tool_calls": [{ "name": "shell", "arguments": "{}" }] + }) + .to_string(); + assert!(!super::assistant_message_has_tool_calls( + &ChatMessage::assistant(body_no_id) + )); + + // tool_call object missing `arguments` — also rejected. + let body_no_args = serde_json::json!({ + "content": "x", + "tool_calls": [{ "id": "tc-1", "name": "shell" }] + }) + .to_string(); + assert!(!super::assistant_message_has_tool_calls( + &ChatMessage::assistant(body_no_args) + )); +} + +#[test] +fn assistant_message_has_tool_calls_rejects_non_object_root() { + // Content is a JSON array, not an object. + let msg = ChatMessage::assistant(r#"["just", "an", "array"]"#.to_string()); + assert!(!super::assistant_message_has_tool_calls(&msg)); +} + +#[test] +fn assistant_message_has_tool_calls_rejects_non_json_content() { + // Plain prose that doesn't parse as JSON at all — early-returns false via + // the `let Ok(value) = serde_json::from_str(...)` arm. Keeps the message + // when the trailing-strip uses this helper. + let msg = ChatMessage::assistant("Just a normal text reply, no JSON here."); + assert!(!super::assistant_message_has_tool_calls(&msg)); +} + +// ── bound_cached_transcript_messages — TAURI-RUST-7 trailing-strip ───── +// +// `bound_cached_transcript_messages` operates on a `Vec` (the +// dispatcher-serialised wire format), so its detection runs through +// `assistant_message_has_tool_calls`. Verify the symmetric trailing-strip +// pops unpaired tool_calls envelopes while leaving plain assistant replies +// untouched. + +fn tool_calls_envelope(id: &str) -> String { + serde_json::json!({ + "content": "calling tool", + "tool_calls": [{ + "id": id, + "name": "shell", + "arguments": "{}" + }] + }) + .to_string() +} + +#[test] +fn bound_cached_transcript_messages_pops_trailing_tool_calls_envelope() { + let agent = make_agent(None); // max_history_messages = 3 + // Need > max so the bound runs (early-returns when len <= max). + let messages = vec![ + ChatMessage::system("sys"), + ChatMessage::user("u1"), + ChatMessage::assistant("a1"), + ChatMessage::user("u2"), + ChatMessage::assistant(tool_calls_envelope("tc-trailing")), + ]; + + // With `max_history_messages = 3` and the leading `system` message, + // `bound_cached_transcript_messages` keeps the last 2 non-system entries + // — i.e. `[system, u2, trailing-envelope]`. After the envelope pop the + // tail is `user("u2")`, not the dropped assistant message. + let bounded = agent.bound_cached_transcript_messages(messages); + assert!( + bounded + .last() + .is_some_and(|m| m.role == "user" && m.content == "u2"), + "trailing tool_calls envelope must be popped; expected user tail 'u2' — got tail role={:?} content={:?}", + bounded.last().map(|m| m.role.as_str()), + bounded.last().map(|m| m.content.as_str()) + ); + assert!( + !bounded.iter().any(super::assistant_message_has_tool_calls), + "no tool_calls envelope should survive the strip" + ); +} + +#[test] +fn bound_cached_transcript_messages_leaves_plain_assistant_tail_intact() { + let agent = make_agent(None); // max_history_messages = 3 + let messages = vec![ + ChatMessage::system("sys"), + ChatMessage::user("u1"), + ChatMessage::assistant("a1"), + ChatMessage::user("u2"), + ChatMessage::assistant("plain text reply, no tool_calls"), + ]; + + let bounded = agent.bound_cached_transcript_messages(messages); + let tail = bounded.last().expect("bounded transcript is non-empty"); + assert_eq!(tail.role, "assistant"); + assert_eq!(tail.content, "plain text reply, no tool_calls"); +} + +#[test] +fn bound_cached_transcript_messages_strips_multiple_trailing_envelopes() { + // Defence-in-depth: if the cached transcript ends on multiple consecutive + // unpaired tool_calls envelopes (e.g. two abortive turns), pop them all. + let agent = make_agent(None); + let messages = vec![ + ChatMessage::system("sys"), + ChatMessage::user("u1"), + ChatMessage::assistant("a1"), + ChatMessage::assistant(tool_calls_envelope("tc-1")), + ChatMessage::assistant(tool_calls_envelope("tc-2")), + ]; + + let bounded = agent.bound_cached_transcript_messages(messages); + let any_envelope = bounded.iter().any(super::assistant_message_has_tool_calls); + assert!( + !any_envelope, + "all trailing tool_calls envelopes must be stripped" + ); +} diff --git a/src/openhuman/agent/tests.rs b/src/openhuman/agent/tests.rs index f23d0890d..f6372ae0f 100644 --- a/src/openhuman/agent/tests.rs +++ b/src/openhuman/agent/tests.rs @@ -1317,22 +1317,67 @@ fn xml_dispatcher_converts_history_to_provider_messages() { #[test] fn native_dispatcher_converts_tool_results_to_tool_messages() { + // TAURI-RUST-7: `to_provider_messages` now drops orphan `ToolResults` + // that aren't preceded by an emitted `AssistantToolCalls`, since the + // OpenAI chat-completions contract rejects a `tool` message that does + // not follow a `tool_calls` opener. Feed a properly paired cycle so + // this test continues to verify the serialisation shape of ToolResults + // (one `tool` ChatMessage per result) rather than the orphan path. let dispatcher = NativeToolDispatcher; - let history = vec![ConversationMessage::ToolResults(vec![ - ToolResultMessage { - tool_call_id: "tc1".into(), - content: "output1".into(), + let history = vec![ + ConversationMessage::AssistantToolCalls { + text: None, + tool_calls: vec![ + ToolCall { + id: "tc1".into(), + name: "shell".into(), + arguments: "{}".into(), + }, + ToolCall { + id: "tc2".into(), + name: "shell".into(), + arguments: "{}".into(), + }, + ], }, - ToolResultMessage { - tool_call_id: "tc2".into(), - content: "output2".into(), - }, - ])]; + ConversationMessage::ToolResults(vec![ + ToolResultMessage { + tool_call_id: "tc1".into(), + content: "output1".into(), + }, + ToolResultMessage { + tool_call_id: "tc2".into(), + content: "output2".into(), + }, + ]), + ]; let messages = dispatcher.to_provider_messages(&history); - assert_eq!(messages.len(), 2); - assert_eq!(messages[0].role, "tool"); + // assistant tool_calls opener + one `tool` message per tool_call_id. + assert_eq!(messages.len(), 3); + assert_eq!(messages[0].role, "assistant"); assert_eq!(messages[1].role, "tool"); + assert_eq!(messages[2].role, "tool"); +} + +#[test] +fn native_dispatcher_drops_standalone_orphan_tool_results() { + // TAURI-RUST-7 regression: a `ToolResults` without a preceding + // `AssistantToolCalls` is an invalid wire shape (the provider rejects + // a `tool` message that doesn't follow a `tool_calls` opener). The + // dispatcher must drop it before serialising. + let dispatcher = NativeToolDispatcher; + let history = vec![ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: "tc-orphan".into(), + content: "stranded".into(), + }])]; + + let messages = dispatcher.to_provider_messages(&history); + assert!( + messages.is_empty(), + "standalone ToolResults must be dropped, got {} message(s)", + messages.len() + ); } // ═══════════════════════════════════════════════════════════════════════════