diff --git a/CHANGELOG.md b/CHANGELOG.md index 91bf8d8..e421f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to `yoagent` are documented here. The format loosely follows [Keep a Changelog](https://keepachangelog.com/), and the project adheres to [Semantic Versioning](https://semver.org/). +## Unreleased + +- **Bedrock, Gemini and Vertex: a parallel tool call's results go back in one message** (#223). Each tool result was sent as its own user-role message, and a steering message, stop marker or compaction summary after them added another, while Bedrock's Converse requires roles to alternate and Gemini requires a turn's function responses in one content, as many as its function calls. So the request after any turn with two or more tool calls (the default parallel strategy) could be rejected. The three request builders now merge consecutive same-role messages, keeping every block in order: the results in call order, then whatever followed; on Gemini and Vertex a merged turn's function responses come first, then any images and text (images among the responses are a reported cause of Gemini's response-count 400). Pinned by request-body tests; not run against a live endpoint. + ## 0.25.12 (2026-10-21) Additive: one new function. diff --git a/CLAUDE.md b/CLAUDE.md index 8ba04cc..4039766 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ The Responses and Azure providers share one request builder + read loop (`respon Anthropic SSE (`anthropic.rs`): block `index` → position in `content` via a map, never padding, so a block type it does not surface (server tool blocks, `fallback`, anything new) leaves no placeholder and later blocks keep their deltas; unknown block/delta types `warn_once` per type by name, unparseable start/delta events are logged. `redacted_thinking` (`data` arrives whole at `content_block_start`, no delta type) → `Content::thinking_redacted(ApiProtocol::AnthropicMessages, data)` → replayed as `{"type":"redacted_thinking","data"}` in place (#197). -Bedrock is the one non-SSE provider: ConverseStream answers with binary `application/vnd.amazon.eventstream` frames, decoded by the private `provider/eventstream.rs` (bytes buffered across chunks; prelude + message CRC-32 verified with an in-house table, no dependency; all ten header types; checksum/malformed → `Other`, a body ending mid-frame → `Network`; pinned by aws-sdk-go's published test vectors). `bedrock.rs` dispatches on the `:message-type` / `:event-type` / `:exception-type` headers — payloads are the event structs themselves (no wrapper key), shapes per the AWS Bedrock Runtime API reference. Blocks accumulate per `contentBlockIndex`; a tool call is pushed with the `__partial_json` marker and only `contentBlockStop` finalizes it (`finalize_tool_arguments`), so an unclosed block is never runnable. Exceptions classify via their documented HTTP status (`throttlingException`/`serviceUnavailableException` → `RateLimited`; other 5xx stay `Api`). No `messageStop` (incl. empty body) or a transport error → `Network`, never an empty `Ok` — except once `messageStop` **and** `metadata` arrived: a later drop/truncation keeps the response (no re-billed retry). `messageStop` without `metadata` → zero usage + `warn!(usage_missing = true)` (same zero as other providers). Unsurfaced content (image/citation/toolResult, server_tool_use, unknown union members via `#[serde(flatten)] other`, unknown event types) is dropped with a once-per-block warning; an unknown `contentBlockStart` member creates no block, so the first delta types it. Reasoning replay: signed → `reasoningText`, `redactedContent` kept in `Content::Thinking::redacted` (base64, bytes concatenated across deltas; `redacted_protocol = BedrockConverseStream`) → `redactedContent`, only if it came from Bedrock (another API's redacted data is skipped, logged at debug); unsigned → `reasoningText {text}` with no signature key, except to a model id containing `claude` (signature-verifying), where it is skipped at debug (never `signature: ""`); the Anthropic provider skips Bedrock's redacted blocks. Signature deltas are appended. Tested only with mock frames (`tests/bedrock_stream_test.rs`). Auth (`resolve_auth`, first match wins): an `authorization` key in `ModelConfig.headers` (any case) → sent alone; non-empty `api_key` → `access:secret[:token]` = SigV4 (`ASIA…` without a token refused), no `:` = Bedrock API key (`Bearer`) after `checked_bearer` refuses whitespace/control chars, an `AKIA`/`ASIA` start, non-base64 chars and a bare 40-char secret (unknown prefix — not `bedrock-api-key-`/`ABSK` — only warns once); empty `api_key` → env read per request (type `Env` = `std::env::var`-shaped, faked in tests), `AWS_BEARER_TOKEN_BEDROCK` (same checks) then `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`(/`AWS_SESSION_TOKEN`); half a pair / NotUnicode / none → `Auth` naming the variable, before sending. `resolve_api_key_or_warn(Option<&ModelConfig>)` (Agent, SubAgentTool, LlmCompaction) keys on the **protocol**: any `BedrockConverseStream` config gets an empty key (never `API_KEY`/`YOAGENT_API_KEY`, whatever the provider string), so the provider reads env itself; `resolve_api_key("bedrock")` (public) prefers `AWS_BEARER_TOKEN_BEDROCK`. `request_headers` builds the whole `HeaderMap`: every value via `header_value` (credential values must be ASCII; failure → `Auth` naming the header, never the value; user non-auth header → `Other`), `authorization`/`x-amz-security-token` `set_sensitive`; on the SigV4 path a user header named like a signed one is refused; a `send()` `is_builder()` error → `Other`, not `Network`. SigV4 is the private `provider/sigv4.rs` (sha2 + hmac, no AWS SDK; pinned to aws-c-auth's `aws-sig-v4-test-suite` vectors incl. the `*-normalized` path ones): signing name `bedrock`, region from the host (`bedrock-runtime[-fips]..`, suffix ∈ `AWS_DNS_SUFFIXES` = partition dnsSuffix/dualStackDnsSuffix from botocore + VPC forms) > `AWS_REGION` > `AWS_DEFAULT_REGION` > `Auth` error; signs `host`, `content-type`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token` over the exact body bytes sent (serialize once — never `.json()`); `amz_date_now` errors on a pre-1970 clock; a 403 "Signature expired"/"not yet current" gets a check-the-clock hint. `base_url` is `trim_end_matches('/')`-ed; `canonical_uri` normalizes (drops empty/`.` segments, resolves `..`, keeps a trailing `/`) and encodes each segment again, so the model id (`:` → `%3A` on the wire, as the SDKs send it) is `%253A` in the canonical URI. `tests/bedrock_auth_test.rs` (env-mutating, serialized by a tokio `Mutex`) recomputes each signature from what wiremock received and uses `expect(0)` for every refusal. Never run against a live endpoint. +Bedrock is the one non-SSE provider: ConverseStream answers with binary `application/vnd.amazon.eventstream` frames, decoded by the private `provider/eventstream.rs` (bytes buffered across chunks; prelude + message CRC-32 verified with an in-house table, no dependency; all ten header types; checksum/malformed → `Other`, a body ending mid-frame → `Network`; pinned by aws-sdk-go's published test vectors). `bedrock.rs` dispatches on the `:message-type` / `:event-type` / `:exception-type` headers — payloads are the event structs themselves (no wrapper key), shapes per the AWS Bedrock Runtime API reference. Blocks accumulate per `contentBlockIndex`; a tool call is pushed with the `__partial_json` marker and only `contentBlockStop` finalizes it (`finalize_tool_arguments`), so an unclosed block is never runnable. Exceptions classify via their documented HTTP status (`throttlingException`/`serviceUnavailableException` → `RateLimited`; other 5xx stay `Api`). No `messageStop` (incl. empty body) or a transport error → `Network`, never an empty `Ok` — except once `messageStop` **and** `metadata` arrived: a later drop/truncation keeps the response (no re-billed retry). `messageStop` without `metadata` → zero usage + `warn!(usage_missing = true)` (same zero as other providers). Unsurfaced content (image/citation/toolResult, server_tool_use, unknown union members via `#[serde(flatten)] other`, unknown event types) is dropped with a once-per-block warning; an unknown `contentBlockStart` member creates no block, so the first delta types it. Reasoning replay: signed → `reasoningText`, `redactedContent` kept in `Content::Thinking::redacted` (base64, bytes concatenated across deltas; `redacted_protocol = BedrockConverseStream`) → `redactedContent`, only if it came from Bedrock (another API's redacted data is skipped, logged at debug); unsigned → `reasoningText {text}` with no signature key, except to a model id containing `claude` (signature-verifying), where it is skipped at debug (never `signature: ""`); the Anthropic provider skips Bedrock's redacted blocks. Signature deltas are appended. Tested only with mock frames (`tests/bedrock_stream_test.rs`). Consecutive same-role messages are merged before sending (`provider::merge_same_role(messages, "content")`; Gemini and Vertex do the same with `"parts"`, then `google::function_responses_first` puts each user content's `functionResponse` parts before its images and text), so a parallel tool call's results go back as one user message and roles alternate (#223). Auth (`resolve_auth`, first match wins): an `authorization` key in `ModelConfig.headers` (any case) → sent alone; non-empty `api_key` → `access:secret[:token]` = SigV4 (`ASIA…` without a token refused), no `:` = Bedrock API key (`Bearer`) after `checked_bearer` refuses whitespace/control chars, an `AKIA`/`ASIA` start, non-base64 chars and a bare 40-char secret (unknown prefix — not `bedrock-api-key-`/`ABSK` — only warns once); empty `api_key` → env read per request (type `Env` = `std::env::var`-shaped, faked in tests), `AWS_BEARER_TOKEN_BEDROCK` (same checks) then `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`(/`AWS_SESSION_TOKEN`); half a pair / NotUnicode / none → `Auth` naming the variable, before sending. `resolve_api_key_or_warn(Option<&ModelConfig>)` (Agent, SubAgentTool, LlmCompaction) keys on the **protocol**: any `BedrockConverseStream` config gets an empty key (never `API_KEY`/`YOAGENT_API_KEY`, whatever the provider string), so the provider reads env itself; `resolve_api_key("bedrock")` (public) prefers `AWS_BEARER_TOKEN_BEDROCK`. `request_headers` builds the whole `HeaderMap`: every value via `header_value` (credential values must be ASCII; failure → `Auth` naming the header, never the value; user non-auth header → `Other`), `authorization`/`x-amz-security-token` `set_sensitive`; on the SigV4 path a user header named like a signed one is refused; a `send()` `is_builder()` error → `Other`, not `Network`. SigV4 is the private `provider/sigv4.rs` (sha2 + hmac, no AWS SDK; pinned to aws-c-auth's `aws-sig-v4-test-suite` vectors incl. the `*-normalized` path ones): signing name `bedrock`, region from the host (`bedrock-runtime[-fips]..`, suffix ∈ `AWS_DNS_SUFFIXES` = partition dnsSuffix/dualStackDnsSuffix from botocore + VPC forms) > `AWS_REGION` > `AWS_DEFAULT_REGION` > `Auth` error; signs `host`, `content-type`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token` over the exact body bytes sent (serialize once — never `.json()`); `amz_date_now` errors on a pre-1970 clock; a 403 "Signature expired"/"not yet current" gets a check-the-clock hint. `base_url` is `trim_end_matches('/')`-ed; `canonical_uri` normalizes (drops empty/`.` segments, resolves `..`, keeps a trailing `/`) and encodes each segment again, so the model id (`:` → `%3A` on the wire, as the SDKs send it) is `%253A` in the canonical URI. `tests/bedrock_auth_test.rs` (env-mutating, serialized by a tokio `Mutex`) recomputes each signature from what wiremock received and uses `expect(0)` for every refusal. Never run against a live endpoint. `ModelConfig` + quirk structs handle per-provider differences: `OpenAiCompat` (reasoning format, max_tokens field name, `max_reasoning_effort` ceiling — the ceiling, and `encrypted_reasoning`, are also read by Responses/Azure), `AnthropicCompat` (adaptive vs budget thinking, bearer auth, `native_structured_output`; `for_claude_id` infers them from an id), `GoogleCompat` (`thinkingLevel` vs `thinkingBudget`). `ThinkingLevel::Off` sends no thinking/effort field on any provider (DeepSeek's explicit `thinking: disabled` aside), so always-thinking models run at their default. diff --git a/src/provider/bedrock.rs b/src/provider/bedrock.rs index ded395a..12e8bec 100644 --- a/src/provider/bedrock.rs +++ b/src/provider/bedrock.rs @@ -1321,6 +1321,8 @@ fn build_bedrock_body(config: &StreamConfig) -> serde_json::Value { } } + // Converse wants roles to alternate, a turn's tool results in one message. + let messages = super::merge_same_role(messages, "content"); let mut body = serde_json::json!({"messages": messages}); if !config.system_prompt.is_empty() { @@ -1609,6 +1611,26 @@ impl TokenUsage { mod tests { use super::*; + /// A parallel tool call's results go back as one user message, the + /// steering message after them, so Converse's roles alternate (#223). + #[test] + fn parallel_tool_results_go_back_in_one_message() { + let mut config = StreamConfig::new("anthropic.claude-sonnet", "a:b"); + config.messages = crate::provider::parallel_tool_conversation(); + let body = build_bedrock_body(&config); + let messages = body["messages"].as_array().unwrap(); + let roles: Vec<&str> = messages + .iter() + .map(|m| m["role"].as_str().unwrap()) + .collect(); + assert_eq!(roles, ["user", "assistant", "user"], "{body}"); + let content = messages[2]["content"].as_array().unwrap(); + assert_eq!(content[0]["toolResult"]["toolUseId"], "call-a"); + assert_eq!(content[1]["toolResult"]["toolUseId"], "call-b"); + assert_eq!(content[2]["text"], "also check c.rs"); + assert_eq!(content.len(), 3); + } + #[test] fn thinking_blocks_are_replayed_with_signature() { // Anthropic-on-Bedrock rejects replayed assistant messages whose diff --git a/src/provider/google.rs b/src/provider/google.rs index 50363fb..b2e6f39 100644 --- a/src/provider/google.rs +++ b/src/provider/google.rs @@ -591,6 +591,30 @@ pub(crate) fn gemini_thinking_config(config: &StreamConfig) -> Option, +) -> Vec { + for content in &mut contents { + if let Some(parts) = content + .get_mut("parts") + .and_then(serde_json::Value::as_array_mut) + { + let (mut responses, rest): (Vec<_>, Vec<_>) = std::mem::take(parts) + .into_iter() + .partition(|part| part.get("functionResponse").is_some()); + responses.extend(rest); + *parts = responses; + } + } + contents +} + fn build_request_body(config: &StreamConfig) -> serde_json::Value { let mut contents: Vec = Vec::new(); @@ -644,6 +668,9 @@ fn build_request_body(config: &StreamConfig) -> serde_json::Value { } } + // Gemini wants a turn's function responses in one content, matching its + // function calls — the responses first, then any images and text. + let contents = function_responses_first(super::merge_same_role(contents, "parts")); let mut body = serde_json::json!({ "contents": contents, }); @@ -806,6 +833,60 @@ mod tests { use super::*; use crate::provider::{GoogleCompat, ModelConfig}; + /// A parallel tool call's results go back as one user turn, with the + /// steering message after them, so roles alternate (#223). + #[test] + fn parallel_tool_results_go_back_in_one_turn() { + let mut config = StreamConfig::new("gemini-2.5-pro", "k"); + config.messages = crate::provider::parallel_tool_conversation(); + let body = build_request_body(&config); + let contents = body["contents"].as_array().unwrap(); + let roles: Vec<&str> = contents + .iter() + .map(|c| c["role"].as_str().unwrap()) + .collect(); + assert_eq!(roles, ["user", "model", "user"], "{body}"); + let parts = contents[2]["parts"].as_array().unwrap(); + assert_eq!(parts[0]["functionResponse"]["name"], "read_file"); + assert_eq!( + parts[0]["functionResponse"]["response"]["result"], + "fn a() {}" + ); + assert_eq!( + parts[1]["functionResponse"]["response"]["result"], + "fn b() {}" + ); + assert_eq!(parts[2]["text"], "also check c.rs"); + assert_eq!(parts.len(), 3); + } + + /// A result with an image: every function response first, in call + /// order, then the image and the text that followed. + #[test] + fn merged_tool_results_put_the_function_responses_first() { + let mut config = StreamConfig::new("gemini-2.5-pro", "k"); + config.messages = crate::provider::parallel_tool_conversation(); + if let Message::ToolResult { content, .. } = &mut config.messages[2] { + content.push(Content::Image { + data: "AAAA".into(), + mime_type: "image/png".into(), + }); + } + let body = build_request_body(&config); + let parts = body["contents"][2]["parts"].as_array().unwrap(); + assert_eq!(parts.len(), 4, "{body}"); + assert_eq!( + parts[0]["functionResponse"]["response"]["result"], + "fn a() {}" + ); + assert_eq!( + parts[1]["functionResponse"]["response"]["result"], + "fn b() {}" + ); + assert_eq!(parts[2]["inlineData"]["data"], "AAAA"); + assert_eq!(parts[3]["text"], "also check c.rs"); + } + #[test] fn thinking_blocks_are_dropped_on_replay() { // Gemini does not accept echoed thought summaries; signatures ride on diff --git a/src/provider/google_vertex.rs b/src/provider/google_vertex.rs index d64841e..6df8912 100644 --- a/src/provider/google_vertex.rs +++ b/src/provider/google_vertex.rs @@ -173,6 +173,9 @@ fn build_vertex_request_body(config: &StreamConfig) -> serde_json::Value { } } + // As on the Gemini API: a turn's function responses in one content, first. + let contents = + super::google::function_responses_first(super::merge_same_role(contents, "parts")); let mut body = serde_json::json!({"contents": contents}); if !config.system_prompt.is_empty() { @@ -370,6 +373,46 @@ mod tests { ); } + /// A parallel tool call's results go back as one user turn (#223). + #[test] + fn parallel_tool_results_go_back_in_one_turn() { + let mut c = config(ThinkingLevel::Off); + c.messages = crate::provider::parallel_tool_conversation(); + let body = build_vertex_request_body(&c); + let contents = body["contents"].as_array().unwrap(); + let roles: Vec<&str> = contents + .iter() + .map(|c| c["role"].as_str().unwrap()) + .collect(); + assert_eq!(roles, ["user", "model", "user"], "{body}"); + let parts = contents[2]["parts"].as_array().unwrap(); + assert_eq!( + parts[0]["functionResponse"]["response"]["result"], + "fn a() {}" + ); + assert_eq!( + parts[1]["functionResponse"]["response"]["result"], + "fn b() {}" + ); + assert_eq!(parts[2]["text"], "also check c.rs"); + assert_eq!(parts.len(), 3, "{body}"); + // Vertex sends no response ids: Gemini matches them by name and position. + assert_eq!(parts[0]["functionResponse"]["name"], "read_file"); + + // A result with an image: the responses still come first. + if let Message::ToolResult { content, .. } = &mut c.messages[2] { + content.push(Content::Image { + data: "AAAA".into(), + mime_type: "image/png".into(), + }); + } + let body = build_vertex_request_body(&c); + let parts = body["contents"][2]["parts"].as_array().unwrap(); + assert_eq!(parts.len(), 4, "{body}"); + assert!(parts[1]["functionResponse"].is_object(), "{body}"); + assert_eq!(parts[2]["inlineData"]["data"], "AAAA"); + } + #[test] fn tool_result_sends_every_text_block() { // An MCP server may answer in several text blocks (ESLint's: an intro, diff --git a/src/provider/mod.rs b/src/provider/mod.rs index 8a8cf63..515c588 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -62,6 +62,106 @@ pub(crate) fn tool_result_text(content: &[crate::types::Content]) -> String { texts.join("\n") } +/// Merge each message into the one before it when both have the same `role`, +/// appending its `key` array (`"content"` on Bedrock, `"parts"` on Gemini). +/// +/// Bedrock's Converse and Gemini require user and assistant/model turns to +/// alternate, and a turn's tool results to arrive together, in one message. +/// The loop keeps one `Message::ToolResult` per call (and may put a steering +/// message, a stop marker or a compaction summary right after them), so these +/// builders produce one wire message per result. Merging keeps every block in +/// order: the results first, in call order, then whatever followed them. +pub(crate) fn merge_same_role( + messages: Vec, + key: &str, +) -> Vec { + let mut merged: Vec = Vec::with_capacity(messages.len()); + for message in messages { + if let Some(last) = merged.last_mut() { + if last.get("role") == message.get("role") { + let from = message.get(key).and_then(serde_json::Value::as_array); + if let (Some(into), Some(from)) = ( + last.get_mut(key).and_then(serde_json::Value::as_array_mut), + from, + ) { + into.extend(from.iter().cloned()); + continue; + } + } + } + merged.push(message); + } + merged +} + +/// A conversation with a parallel tool call: the user's request, an assistant +/// turn calling two tools, both results, then a steering message — four user +/// role messages in a row once results are user turns on the wire. +#[cfg(test)] +pub(crate) fn parallel_tool_conversation() -> Vec { + use crate::types::{Content, Message, StopReason, Usage}; + let result = |id: &str, name: &str, text: &str| Message::ToolResult { + tool_call_id: id.into(), + tool_name: name.into(), + content: vec![Content::Text { text: text.into() }], + is_error: false, + timestamp: 0, + }; + vec![ + Message::user("look at both files"), + Message::assistant( + vec![ + Content::tool_call("call-a", "read_file", serde_json::json!({"path": "a.rs"})), + Content::tool_call("call-b", "read_file", serde_json::json!({"path": "b.rs"})), + ], + StopReason::ToolUse, + "m", + "p", + Usage::default(), + ), + result("call-a", "read_file", "fn a() {}"), + result("call-b", "read_file", "fn b() {}"), + Message::user("also check c.rs"), + ] +} + +#[cfg(test)] +mod merge_same_role_tests { + use super::merge_same_role; + use serde_json::json; + + #[test] + fn adjacent_same_role_messages_become_one_in_order() { + let merged = merge_same_role( + vec![ + json!({"role": "user", "content": [{"text": "1"}]}), + json!({"role": "assistant", "content": [{"text": "2"}]}), + json!({"role": "user", "content": []}), + json!({"role": "user", "content": [{"text": "3"}]}), + json!({"role": "user", "content": [{"text": "4"}, {"text": "5"}]}), + json!({"role": "user", "content": []}), + json!({"role": "assistant", "content": [{"text": "6"}]}), + json!({"role": "assistant", "content": [{"text": "7"}]}), + // Without the key: kept as it is, never given a `null` one. + json!({"role": "user"}), + json!({"role": "user", "content": [{"text": "8"}]}), + ], + "content", + ); + assert_eq!( + merged, + vec![ + json!({"role": "user", "content": [{"text": "1"}]}), + json!({"role": "assistant", "content": [{"text": "2"}]}), + json!({"role": "user", "content": [{"text": "3"}, {"text": "4"}, {"text": "5"}]}), + json!({"role": "assistant", "content": [{"text": "6"}, {"text": "7"}]}), + json!({"role": "user"}), + json!({"role": "user", "content": [{"text": "8"}]}), + ] + ); + } +} + #[cfg(test)] mod tool_result_text_tests { use super::tool_result_text; diff --git a/tests/bedrock_stream_test.rs b/tests/bedrock_stream_test.rs index 2a96855..1e3f8f6 100644 --- a/tests/bedrock_stream_test.rs +++ b/tests/bedrock_stream_test.rs @@ -634,6 +634,39 @@ async fn reasoning_with_signature_is_accumulated_and_replayed() { ); } +/// A parallel tool call through the real loop: both results go back in one +/// user message, in call order, so Converse's roles alternate (#223). +#[tokio::test] +async fn parallel_tool_results_go_back_in_one_message() { + let first = [ + message_start(), + tool_start(0, "a", "read_file"), + tool_input(0, "{\"path\":\"a\"}"), + block_stop(0), + tool_start(1, "b", "read_file"), + tool_input(1, "{\"path\":\"b\"}"), + block_stop(1), + message_stop("tool_use"), + small_usage(), + ] + .concat(); + let (ran, _, server) = run_agent(first, text_response("Done.")).await; + assert_eq!(ran.len(), 2); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2); + let body: Value = serde_json::from_slice(&requests[1].body).unwrap(); + let messages = body["messages"].as_array().unwrap(); + let roles: Vec<&str> = messages + .iter() + .map(|m| m["role"].as_str().unwrap()) + .collect(); + assert_eq!(roles, ["user", "assistant", "user"], "{body}"); + let results = messages[2]["content"].as_array().unwrap(); + assert_eq!(results.len(), 2, "{body}"); + assert_eq!(results[0]["toolResult"]["toolUseId"], "a"); + assert_eq!(results[1]["toolResult"]["toolUseId"], "b"); +} + // --------------------------------------------------------------------------- // Framing over the network // ---------------------------------------------------------------------------