diff --git a/src/openhuman/agent/harness/session/tests.rs b/src/openhuman/agent/harness/session/tests.rs index c8e639f69..26908925b 100644 --- a/src/openhuman/agent/harness/session/tests.rs +++ b/src/openhuman/agent/harness/session/tests.rs @@ -795,3 +795,47 @@ fn bound_cached_transcript_messages_without_system_prefix_keeps_tail() { assert_eq!(bounded[1].content, "a2"); assert_eq!(bounded[2].content, "u3"); } + +/// The cached-transcript resume path operates on wire-form `ChatMessage`s. When +/// the window cut lands so the tail opens on a `tool` result whose `tool_calls` +/// opener fell outside the window, `bound_cached_transcript_messages` must snap +/// past it — a leading `tool` message has no preceding `tool_calls` and the +/// provider 400s (surfacing as "Something went wrong"). +#[test] +fn bound_cached_transcript_messages_snaps_past_leading_orphan_tool() { + use crate::openhuman::inference::provider::ChatMessage; + + let mut agent = build_minimal_agent_with_definition_name(Some("orchestrator")); + agent.config.max_history_messages = 3; + + // 5 messages, cap 3: the tail slice is [tool(a), user(u2), assistant(a2)]; + // the assistant `tool_calls` opener fell outside the window. + let messages = vec![ + ChatMessage::assistant( + r#"{"content":"calling","tool_calls":[{"id":"call_a","name":"shell","arguments":"{}"}]}"#, + ), + ChatMessage::tool(r#"{"tool_call_id":"call_a","content":"orphaned"}"#), + ChatMessage::user("u2"), + ChatMessage::assistant("a2"), + ChatMessage::user("u3"), + ]; + + let bounded = agent.bound_cached_transcript_messages(messages); + + assert!( + bounded.first().map(|m| m.role.as_str()) != Some("tool"), + "window must not open on an orphaned tool result" + ); + assert!( + !bounded.iter().any(|m| m.role == "tool"), + "the orphaned tool result must be dropped" + ); + // tail [tool, u2, a2, u3] -> drop leading tool -> [u2, a2, u3]. + assert_eq!( + bounded + .iter() + .map(|m| m.content.as_str()) + .collect::>(), + vec!["u2", "a2", "u3"] + ); +} diff --git a/src/openhuman/agent/harness/session/turn.rs b/src/openhuman/agent/harness/session/turn.rs index be21039c6..bb7c7d06a 100644 --- a/src/openhuman/agent/harness/session/turn.rs +++ b/src/openhuman/agent/harness/session/turn.rs @@ -1590,6 +1590,25 @@ impl Agent { other_messages.drain(0..drop_count); } + // A cut that lands *between* an `AssistantToolCalls` and its + // `ToolResults` leaves the window opening on an orphaned `ToolResults`. + // Serialized, that is a `tool` message with no preceding `tool_calls`, + // which the provider rejects with a 400 (the response streams back + // empty and surfaces to the user as "Something went wrong"). Snap the + // boundary forward past any leading orphaned results so the window + // always starts on a clean turn (a `Chat` or an `AssistantToolCalls`). + let orphan_lead = other_messages + .iter() + .take_while(|m| matches!(m, ConversationMessage::ToolResults(_))) + .count(); + if orphan_lead > 0 { + log::debug!( + "[agent] trim_history snapped window past {orphan_lead} orphaned ToolResults \ + (tool-cycle bisected by the {max}-message cap)" + ); + other_messages.drain(0..orphan_lead); + } + self.history = system_messages; self.history.extend(other_messages); } @@ -1610,19 +1629,34 @@ impl Agent { return messages; } - if matches!(messages.first(), Some(msg) if msg.role == "system") { - let keep_tail = max.saturating_sub(1); - let start = messages.len().saturating_sub(keep_tail); - let mut bounded = Vec::with_capacity(max); - bounded.push(messages[0].clone()); - if keep_tail > 0 { - bounded.extend(messages[start..].iter().cloned()); - } - bounded + let has_system = matches!(messages.first(), Some(msg) if msg.role == "system"); + let keep_tail = if has_system { + max.saturating_sub(1) } else { - let start = messages.len().saturating_sub(max); - messages[start..].to_vec() + max + }; + let start = messages.len().saturating_sub(keep_tail); + + // Same hazard as `trim_history`: the tail slice can open on a `tool` + // message whose `tool_calls` opener fell outside the window, which the + // provider rejects. Advance past any leading orphaned `tool` results so + // the window starts on a clean turn. + let tail = &messages[start..]; + let orphan_lead = tail.iter().take_while(|m| m.role == "tool").count(); + if orphan_lead > 0 { + log::debug!( + "[agent] bound_cached_transcript_messages snapped window past {orphan_lead} \ + orphaned tool result(s) (tool-cycle bisected by the {max}-message cap)" + ); } + let tail = &tail[orphan_lead..]; + + let mut bounded = Vec::with_capacity(tail.len() + usize::from(has_system)); + if has_system { + bounded.push(messages[0].clone()); + } + bounded.extend(tail.iter().cloned()); + bounded } /// Pre-fetches learned context data from memory (observations, patterns, user profile). diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index 5ce997174..1bb405e63 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -370,6 +370,53 @@ fn trim_history_preserves_system_and_keeps_latest_non_system_entries() { .any(|msg| matches!(msg, ConversationMessage::Chat(chat) if chat.content == "a2"))); } +/// When the `max_history_messages` cap drops an `AssistantToolCalls` opener but +/// keeps its `ToolResults`, the window would otherwise open on an orphaned tool +/// result — serialized, a `tool` message with no preceding `tool_calls`, which +/// the provider rejects (the 400 that surfaces as "Something went wrong"). +/// `trim_history` must snap past the orphan so the window starts on a clean turn. +#[test] +fn trim_history_snaps_past_orphaned_tool_results() { + use crate::openhuman::inference::provider::{ToolCall, ToolResultMessage}; + + let mut agent = make_agent(None); // max_history_messages = 3 + agent.history = vec![ + ConversationMessage::Chat(ChatMessage::system("sys")), + // This opener is the oldest non-system entry, so the cap drops it... + ConversationMessage::AssistantToolCalls { + text: Some("calling".into()), + tool_calls: vec![ToolCall { + id: "call_x".into(), + name: "shell".into(), + arguments: "{}".into(), + }], + }, + // ...orphaning this result at the head of the kept window. + ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: "call_x".into(), + content: "result".into(), + }]), + ConversationMessage::Chat(ChatMessage::user("u2")), + ConversationMessage::Chat(ChatMessage::assistant("a2")), + ]; + + agent.trim_history(); + + assert!( + !agent + .history + .iter() + .any(|m| matches!(m, ConversationMessage::ToolResults(_))), + "orphaned ToolResults must be dropped, not left at the window head" + ); + assert!( + matches!(agent.history.first(), Some(ConversationMessage::Chat(c)) if c.role == "system"), + "system message is preserved" + ); + // system + u2 + a2 (the bisected cycle is gone entirely). + assert_eq!(agent.history.len(), 3); +} + #[test] fn build_parent_context_and_sanitize_helpers_cover_snapshot_paths() { let mut agent = make_agent(None); diff --git a/src/openhuman/context/pipeline.rs b/src/openhuman/context/pipeline.rs index e5a96a508..1e97507c2 100644 --- a/src/openhuman/context/pipeline.rs +++ b/src/openhuman/context/pipeline.rs @@ -13,9 +13,10 @@ //! `ToolResults` envelopes with a placeholder, preserving the //! `AssistantToolCalls ⇔ ToolResults` API invariant. //! 4. **Autocompact** — prose summarisation of older messages. -//! OpenHuman's existing `auto_compact_history` lives in -//! `agent/loop_/history.rs` and operates on `ChatMessage` (not -//! `ConversationMessage`), so we don't call it here — the pipeline +//! The summariser (`ProviderSummarizer` in `context/summarizer.rs`, +//! optionally wrapped by `segment_recap_summarizer.rs`) operates on +//! `ConversationMessage` and issues LLM calls, so we don't run it +//! here — the pipeline //! instead signals a `PipelineOutcome::AutocompactionRequested` to //! the caller and trusts the caller to dispatch its own summariser //! when ready. Keeping the pipeline pure (no LLM calls) means the diff --git a/src/openhuman/inference/provider/compatible.rs b/src/openhuman/inference/provider/compatible.rs index b289cc574..f2789c296 100644 --- a/src/openhuman/inference/provider/compatible.rs +++ b/src/openhuman/inference/provider/compatible.rs @@ -545,79 +545,186 @@ impl OpenAiCompatibleProvider { } fn convert_messages_for_native(messages: &[ChatMessage]) -> Vec { - messages - .iter() - .map(|message| { - if message.role == "assistant" { - if let Ok(value) = serde_json::from_str::(&message.content) - { - if let Some(tool_calls_value) = value.get("tool_calls") { - if let Ok(parsed_calls) = - serde_json::from_value::>( - tool_calls_value.clone(), - ) - { - let tool_calls = parsed_calls - .into_iter() - .map(|tc| ToolCall { - id: Some(tc.id), - kind: Some("function".to_string()), - function: Some(Function { - name: Some(tc.name), - arguments: Some(serde_json::Value::String( - tc.arguments, - )), - }), - }) - .collect::>(); + let converted: Vec = + messages + .iter() + .map(|message| { + if message.role == "assistant" { + if let Ok(value) = + serde_json::from_str::(&message.content) + { + if let Some(tool_calls_value) = value.get("tool_calls") { + if let Ok(parsed_calls) = + serde_json::from_value::>( + tool_calls_value.clone(), + ) + { + let tool_calls = parsed_calls + .into_iter() + .map(|tc| ToolCall { + id: Some(tc.id), + kind: Some("function".to_string()), + function: Some(Function { + name: Some(tc.name), + arguments: Some(serde_json::Value::String( + tc.arguments, + )), + }), + }) + .collect::>(); - let content = value - .get("content") - .and_then(serde_json::Value::as_str) - .map(ToString::to_string); + let content = value + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); - return NativeMessage { - role: "assistant".to_string(), - content, - tool_call_id: None, - tool_calls: Some(tool_calls), - }; + return NativeMessage { + role: "assistant".to_string(), + content, + tool_call_id: None, + tool_calls: Some(tool_calls), + }; + } } } } + + if message.role == "tool" { + if let Ok(value) = + serde_json::from_str::(&message.content) + { + let tool_call_id = value + .get("tool_call_id") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + let content = value + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + .or_else(|| Some(message.content.clone())); + + return NativeMessage { + role: "tool".to_string(), + content, + tool_call_id, + tool_calls: None, + }; + } + } + + NativeMessage { + role: message.role.clone(), + content: Some(message.content.clone()), + tool_call_id: None, + tool_calls: None, + } + }) + .collect(); + + Self::enforce_tool_message_invariants(converted) + } + + /// Enforce the OpenAI-compatible tool-message ordering invariants on the + /// fully-serialized wire array, immediately before it goes on the wire. + /// + /// Several upstream defects can leave the array malformed and trip a 400 + /// (`messages with role 'tool' must be a response to a preceding message + /// with 'tool_calls'`). That 400 streams back as an empty completion, which + /// the agent loop collapses to "The model returned an empty response" and + /// the chat surface shows as a generic "Something went wrong": + /// + /// * **(A)** History tail-trimming (`session::turn::trim_history` / + /// `bound_cached_transcript_messages`) cuts *between* an + /// `assistant(tool_calls)` and its `tool` result, dropping the assistant + /// and orphaning the result at the head of the window. + /// * **(B)** A persisted assistant tool-call message whose `content` no + /// longer deserializes as `tool_calls` (format drift) falls through the + /// parser above and is emitted as plain text with its `tool_calls` + /// stripped — again orphaning the following `tool` result. + /// * **(C)** An `assistant(tool_calls)` whose results never arrived (an + /// aborted / max-iteration turn, or a partially-answered multi-call + /// cycle) leaves dangling tool-call ids with no matching `tool` response. + /// + /// This pass makes the contract hold *by construction* regardless of which + /// path produced the array. It is **position-aware**: each + /// `assistant(tool_calls)` is paired with the *contiguous run of `tool` + /// messages that immediately follows it* (the only place valid responses can + /// live in the OpenAI wire format), then: + /// + /// * `tool_calls` entries with no matching response *in that run* are pruned + /// (C); if none survive, the field is dropped so the message serializes as + /// plain assistant text rather than an empty tool-call block. + /// * `tool` messages that are **not** part of such a run — a leading orphan + /// from trimming (A), or one stranded after an assistant whose `tool_calls` + /// were stripped (B) — are dropped. + /// + /// Pairing by adjacency (rather than a global "is this id answered anywhere" + /// set) is what keeps **sequential** cycles (`asst(A)→tool(A)`, + /// `asst(B)→tool(B)`, …) and **parallel** calls (one `asst([X,Y,Z])` answered + /// by `tool(X) tool(Y) tool(Z)`) correct, and makes the result well-formed + /// even if responses are reordered or a cycle is bisected mid-sequence — no + /// causal-ordering assumption required. + fn enforce_tool_message_invariants(messages: Vec) -> Vec { + use std::collections::HashSet; + + let mut out: Vec = Vec::with_capacity(messages.len()); + let mut dropped_orphans = 0usize; + let mut pruned_calls = 0usize; + + let mut iter = messages.into_iter().peekable(); + while let Some(mut msg) = iter.next() { + if msg.role == "assistant" && msg.tool_calls.is_some() { + // Gather the contiguous run of `tool` messages that answer this + // block (responses must immediately follow, in any order). + let mut run: Vec = Vec::new(); + while iter.peek().is_some_and(|m| m.role == "tool") { + run.push(iter.next().expect("peeked tool message")); } + let responded: HashSet = + run.iter().filter_map(|t| t.tool_call_id.clone()).collect(); - if message.role == "tool" { - if let Ok(value) = - serde_json::from_str::(&message.content) - { - let tool_call_id = value - .get("tool_call_id") - .and_then(serde_json::Value::as_str) - .map(ToString::to_string); - let content = value - .get("content") - .and_then(serde_json::Value::as_str) - .map(ToString::to_string) - .or_else(|| Some(message.content.clone())); + // (C) keep only tool_calls answered within this run. + let calls = msg.tool_calls.take().unwrap_or_default(); + let before = calls.len(); + let kept: Vec = calls + .into_iter() + .filter(|c| c.id.as_deref().is_some_and(|id| responded.contains(id))) + .collect(); + pruned_calls += before - kept.len(); + let kept_ids: HashSet = kept.iter().filter_map(|c| c.id.clone()).collect(); + msg.tool_calls = if kept.is_empty() { None } else { Some(kept) }; + out.push(msg); - return NativeMessage { - role: "tool".to_string(), - content, - tool_call_id, - tool_calls: None, - }; + // Emit the run's responses that map to a surviving call; drop the + // rest (e.g. a stray tool whose id wasn't in this block). + for tool_msg in run { + let kept = tool_msg + .tool_call_id + .as_deref() + .is_some_and(|id| kept_ids.contains(id)); + if kept { + out.push(tool_msg); + } else { + dropped_orphans += 1; } } + } else if msg.role == "tool" { + // (A, B) a `tool` not consumed by a preceding assistant block. + dropped_orphans += 1; + } else { + out.push(msg); + } + } - NativeMessage { - role: message.role.clone(), - content: Some(message.content.clone()), - tool_call_id: None, - tool_calls: None, - } - }) - .collect() + if dropped_orphans > 0 || pruned_calls > 0 { + log::warn!( + "[provider] sanitized malformed tool-message ordering before send: \ + dropped {dropped_orphans} orphaned tool result(s), pruned {pruned_calls} \ + unanswered tool_call(s)" + ); + } + + out } fn with_prompt_guided_tool_instructions( diff --git a/src/openhuman/inference/provider/compatible_tests.rs b/src/openhuman/inference/provider/compatible_tests.rs index fd7d0266e..3e1b7dbff 100644 --- a/src/openhuman/inference/provider/compatible_tests.rs +++ b/src/openhuman/inference/provider/compatible_tests.rs @@ -659,15 +659,242 @@ fn parse_native_response_preserves_tool_call_id() { #[test] fn convert_messages_for_native_maps_tool_result_payload() { - let input = vec![ChatMessage::tool( - r#"{"tool_call_id":"call_abc","content":"done"}"#, - )]; + // A `tool` result must be opened by a preceding `assistant(tool_calls)`, + // else the invariant sanitizer drops it as an orphan (see `tool_invariants_*`). + // Pair it with its opener so this test exercises payload mapping only. + let input = vec![ + ChatMessage::assistant( + r#"{"content":"on it","tool_calls":[{"id":"call_abc","name":"shell","arguments":"{}"}]}"#, + ), + ChatMessage::tool(r#"{"tool_call_id":"call_abc","content":"done"}"#), + ]; let converted = OpenAiCompatibleProvider::convert_messages_for_native(&input); - assert_eq!(converted.len(), 1); - assert_eq!(converted[0].role, "tool"); - assert_eq!(converted[0].tool_call_id.as_deref(), Some("call_abc")); - assert_eq!(converted[0].content.as_deref(), Some("done")); + assert_eq!(converted.len(), 2); + assert_eq!(converted[1].role, "tool"); + assert_eq!(converted[1].tool_call_id.as_deref(), Some("call_abc")); + assert_eq!(converted[1].content.as_deref(), Some("done")); +} + +/// Helper: roles in serialized order. +fn roles(messages: &[NativeMessage]) -> Vec<&str> { + messages.iter().map(|m| m.role.as_str()).collect() +} + +/// Mechanism (A): history tail-trimming dropped an `assistant(tool_calls)` but +/// kept its `tool` result, orphaning the result at the head of the window. The +/// sanitizer must drop the orphan so the wire array never starts a tool block +/// without a preceding `tool_calls`. +#[test] +fn tool_invariants_drop_orphaned_tool_result_from_trim(/* A */) { + let input = vec![ + ChatMessage::system("system prompt"), + // assistant(tool_calls=call_orphan) was sliced off by trim_history; + // only its result survived as the first non-system message. + ChatMessage::tool(r#"{"tool_call_id":"call_orphan","content":"stale result"}"#), + ChatMessage::user("and then?"), + ]; + + let converted = OpenAiCompatibleProvider::convert_messages_for_native(&input); + + assert_eq!(roles(&converted), vec!["system", "user"]); + assert!( + converted.iter().all(|m| m.role != "tool"), + "orphaned tool result must be dropped" + ); +} + +/// Mechanism (B): a persisted assistant tool-call message whose `content` no +/// longer parses as `{tool_calls: [...]}` is emitted as plain assistant text +/// with its `tool_calls` stripped, orphaning the following `tool` result. The +/// assistant message stays; the now-orphaned tool result is dropped. +#[test] +fn tool_invariants_drop_tool_after_unparseable_assistant_call(/* B */) { + let input = vec![ + // Plain text, not the JSON tool-call shape -> tool_calls stripped on convert. + ChatMessage::assistant("let me check that for you"), + ChatMessage::tool(r#"{"tool_call_id":"call_b","content":"tool ran"}"#), + ]; + + let converted = OpenAiCompatibleProvider::convert_messages_for_native(&input); + + assert_eq!(roles(&converted), vec!["assistant"]); + assert!(converted[0].tool_calls.is_none()); + assert!( + converted.iter().all(|m| m.role != "tool"), + "tool result with no opening tool_calls must be dropped" + ); +} + +/// Mechanism (C): an `assistant(tool_calls=[answered, missing])` whose second +/// call never received a `tool` response (aborted / max-iteration turn, or a +/// partially-answered multi-call cycle). The sanitizer prunes the dangling +/// tool-call entry while keeping the answered one and its result. +#[test] +fn tool_invariants_prune_unanswered_tool_call(/* C */) { + let input = vec![ + ChatMessage::assistant( + r#"{"content":"on it","tool_calls":[{"id":"call_done","name":"shell","arguments":"{}"},{"id":"call_missing","name":"shell","arguments":"{}"}]}"#, + ), + ChatMessage::tool(r#"{"tool_call_id":"call_done","content":"finished"}"#), + // call_missing never gets a tool response. + ]; + + let converted = OpenAiCompatibleProvider::convert_messages_for_native(&input); + + let assistant = converted + .iter() + .find(|m| m.role == "assistant") + .expect("assistant message present"); + let calls = assistant + .tool_calls + .as_ref() + .expect("answered tool_call retained"); + assert_eq!(calls.len(), 1, "dangling tool_call must be pruned"); + assert_eq!(calls[0].id.as_deref(), Some("call_done")); + assert!( + converted + .iter() + .any(|m| m.role == "tool" && m.tool_call_id.as_deref() == Some("call_done")), + "answered tool result must survive" + ); +} + +/// (C) extreme: an `assistant(tool_calls)` with NO response at all collapses to +/// a plain assistant message (tool_calls dropped) rather than a dangling block. +#[test] +fn tool_invariants_collapse_fully_unanswered_assistant_call() { + let input = vec![ + ChatMessage::assistant( + r#"{"content":"on it","tool_calls":[{"id":"call_x","name":"shell","arguments":"{}"}]}"#, + ), + ChatMessage::assistant("never mind, here's the answer"), + ]; + + let converted = OpenAiCompatibleProvider::convert_messages_for_native(&input); + + assert_eq!(roles(&converted), vec!["assistant", "assistant"]); + assert!( + converted[0].tool_calls.is_none(), + "fully-unanswered tool_calls must be dropped" + ); + assert_eq!(converted[0].content.as_deref(), Some("on it")); +} + +/// Regression guard: a well-formed tool cycle is passed through untouched — +/// the sanitizer must not strip or reorder valid messages. +#[test] +fn tool_invariants_preserve_well_formed_cycle() { + let input = vec![ + ChatMessage::system("system prompt"), + ChatMessage::user("run it"), + ChatMessage::assistant( + r#"{"content":"on it","tool_calls":[{"id":"call_ok","name":"shell","arguments":"{}"}]}"#, + ), + ChatMessage::tool(r#"{"tool_call_id":"call_ok","content":"done"}"#), + ChatMessage::assistant("all set"), + ]; + + let converted = OpenAiCompatibleProvider::convert_messages_for_native(&input); + + assert_eq!( + roles(&converted), + vec!["system", "user", "assistant", "tool", "assistant"] + ); + assert_eq!(converted[2].tool_calls.as_ref().unwrap().len(), 1); + assert_eq!( + converted[2].tool_calls.as_ref().unwrap()[0].id.as_deref(), + Some("call_ok") + ); + assert_eq!(converted[3].tool_call_id.as_deref(), Some("call_ok")); +} + +/// Sequential tool cycles — successive agent iterations, each its own +/// `assistant(tool_calls)` → `tool` block. Distinct ids, opened then immediately +/// consumed. All survive untouched. +#[test] +fn tool_invariants_preserve_sequential_cycles() { + let input = vec![ + ChatMessage::user("go"), + ChatMessage::assistant( + r#"{"content":"step 1","tool_calls":[{"id":"call_a","name":"shell","arguments":"{}"}]}"#, + ), + ChatMessage::tool(r#"{"tool_call_id":"call_a","content":"a done"}"#), + ChatMessage::assistant( + r#"{"content":"step 2","tool_calls":[{"id":"call_b","name":"shell","arguments":"{}"}]}"#, + ), + ChatMessage::tool(r#"{"tool_call_id":"call_b","content":"b done"}"#), + ChatMessage::assistant( + r#"{"content":"step 3","tool_calls":[{"id":"call_c","name":"shell","arguments":"{}"}]}"#, + ), + ChatMessage::tool(r#"{"tool_call_id":"call_c","content":"c done"}"#), + ChatMessage::assistant("all done"), + ]; + + let converted = OpenAiCompatibleProvider::convert_messages_for_native(&input); + + assert_eq!( + roles(&converted), + vec![ + "user", + "assistant", + "tool", + "assistant", + "tool", + "assistant", + "tool", + "assistant" + ] + ); + for idx in [1usize, 3, 5] { + assert_eq!( + converted[idx].tool_calls.as_ref().unwrap().len(), + 1, + "cycle at index {idx} must keep its call" + ); + } +} + +/// Parallel tool calls — one `assistant` issuing N calls, answered by N `tool` +/// messages arriving out of order. All survive; pairing is by membership, not +/// position, so order does not matter. +#[test] +fn tool_invariants_preserve_parallel_calls() { + let input = vec![ + ChatMessage::assistant( + r#"{"content":"fanning out","tool_calls":[{"id":"call_x","name":"shell","arguments":"{}"},{"id":"call_y","name":"shell","arguments":"{}"},{"id":"call_z","name":"shell","arguments":"{}"}]}"#, + ), + ChatMessage::tool(r#"{"tool_call_id":"call_y","content":"y"}"#), + ChatMessage::tool(r#"{"tool_call_id":"call_z","content":"z"}"#), + ChatMessage::tool(r#"{"tool_call_id":"call_x","content":"x"}"#), + ]; + + let converted = OpenAiCompatibleProvider::convert_messages_for_native(&input); + + assert_eq!(roles(&converted), vec!["assistant", "tool", "tool", "tool"]); + assert_eq!(converted[0].tool_calls.as_ref().unwrap().len(), 3); +} + +/// Trim bisecting a sequence: the window opens inside cycle A (its assistant was +/// sliced off), followed by an intact cycle B. The orphaned A result is dropped; +/// cycle B survives — proving adjacency-pairing localizes the damage. +#[test] +fn tool_invariants_drop_orphan_but_keep_following_cycle() { + let input = vec![ + // assistant(call_a) was sliced off by trim; only its result remains. + ChatMessage::tool(r#"{"tool_call_id":"call_a","content":"orphaned"}"#), + ChatMessage::assistant( + r#"{"content":"step 2","tool_calls":[{"id":"call_b","name":"shell","arguments":"{}"}]}"#, + ), + ChatMessage::tool(r#"{"tool_call_id":"call_b","content":"b done"}"#), + ChatMessage::assistant("done"), + ]; + + let converted = OpenAiCompatibleProvider::convert_messages_for_native(&input); + + assert_eq!(roles(&converted), vec!["assistant", "tool", "assistant"]); + assert_eq!(converted[0].tool_calls.as_ref().unwrap().len(), 1); + assert_eq!(converted[1].tool_call_id.as_deref(), Some("call_b")); } #[test]