diff --git a/tests/anthropic_stream_test.rs b/tests/anthropic_stream_test.rs index 39a44ca..be29927 100644 --- a/tests/anthropic_stream_test.rs +++ b/tests/anthropic_stream_test.rs @@ -422,6 +422,83 @@ async fn message_delta_without_usage_still_yields_its_stop_reason() { /// This is on the happy path for this crate's own configuration: we send the /// `fine-grained-tool-streaming` beta, which Anthropic documents as able to /// emit incomplete tool JSON when a response hits `max_tokens`. +/// A tool with **no parameters** must be callable. +/// +/// The sibling of `malformed_tool_arguments_fail_the_turn_instead_of_defaulting`, +/// and the case that was missing when a real bug shipped: a zero-argument tool +/// has no JSON to stream, but Anthropic still emits an `input_json_delta` +/// carrying `""`. `serde_json::from_str("")` fails with "EOF while parsing a +/// value", so *empty* was treated as *malformed* — the `__partial_json` +/// sentinel survived `content_block_stop` and the post-stream sweep failed the +/// whole turn with "tool call(s) with unusable arguments, not executed". +/// +/// Every `get_status` / `list_files` / `read_log`-shaped tool was affected, in +/// released versions. The malformed case was pinned; this one was not, so the +/// two were conflated and the conflation shipped. +/// +/// 0.18.1 fixed it and unit-tested `resolve_tool_arguments`, but that is the +/// pure decision. This drives the empty stream through the actual parser. +#[tokio::test] +async fn a_zero_argument_tool_call_is_usable_not_malformed() { + let server = MockServer::start().await; + // Exactly what Anthropic sends for a tool whose schema takes no arguments: + // the block opens, one delta carries an empty string, the block closes. + let body = "event: message_start\n\ + data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":10,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}}\n\n\ + event: content_block_start\n\ + data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"tu_1\",\"name\":\"list_files\",\"input\":{}}}\n\n\ + event: content_block_delta\n\ + data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}\n\n\ + event: content_block_stop\n\ + data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\ + event: message_delta\n\ + data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":9}}\n\n\ + event: message_stop\n\ + data: {\"type\":\"message_stop\"}\n\n"; + Mock::given(method("POST")) + .and(path("/messages")) + .respond_with(ResponseTemplate::new(200).set_body_raw(body, "text/event-stream")) + .mount(&server) + .await; + + let message = run_stream(stream_config(&server.uri(), None)) + .await + .expect("the stream is well-formed"); + let Message::Assistant { + stop_reason, + error_message, + content, + .. + } = &message + else { + panic!("expected assistant message"); + }; + + assert_eq!( + *stop_reason, + StopReason::ToolUse, + "a no-argument tool call is a usable turn, not an error. error_message: {error_message:?}" + ); + let call = content + .iter() + .find_map(|c| match c { + Content::ToolCall { + name, arguments, .. + } if name == "list_files" => Some(arguments), + _ => None, + }) + .expect("the tool call must reach the caller"); + assert_eq!( + *call, + serde_json::json!({}), + "an empty argument stream is an empty argument object, not a sentinel" + ); + assert!( + call.get("__partial_json").is_none(), + "the accumulator sentinel must not survive into the caller's arguments" + ); +} + #[tokio::test] async fn malformed_tool_arguments_fail_the_turn_instead_of_defaulting() { let server = MockServer::start().await; diff --git a/tests/openai_compat_stream_test.rs b/tests/openai_compat_stream_test.rs index 0b778cd..e480c29 100644 --- a/tests/openai_compat_stream_test.rs +++ b/tests/openai_compat_stream_test.rs @@ -134,3 +134,151 @@ async fn test_usage_chunk_after_finish_reason_survives_doneless_close() { ); assert_eq!(usage.output, 3); } + +/// A tool call split across chunks reassembles into the arguments the model +/// sent. +/// +/// This module backs 15+ providers — OpenAI, Groq, Together, DeepSeek, +/// Fireworks, Mistral, xAI — and had three tests, none of which touched tool +/// calls at all. Argument streaming is the fiddliest part of the format and the +/// part with the widest blast radius. +#[tokio::test] +async fn tool_call_arguments_reassemble_across_chunks() { + let server = MockServer::start().await; + let body = format!( + "{}{}{}{}", + chunk( + r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\"q\":"}}]}}]}"# + ), + chunk( + r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"rust\"}"}}]}}]}"# + ), + chunk(r#"{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#), + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_raw(body, "text/event-stream")) + .mount(&server) + .await; + + let message = run_stream(stream_config(&server.uri())) + .await + .expect("stream should complete"); + let Message::Assistant { content, .. } = &message else { + panic!("expected assistant message"); + }; + let args = content + .iter() + .find_map(|c| match c { + Content::ToolCall { + name, arguments, .. + } if name == "search" => Some(arguments), + _ => None, + }) + .expect("the tool call must reach the caller"); + assert_eq!( + *args, + serde_json::json!({"q": "rust"}), + "arguments split across chunks must reassemble, not truncate at the first" + ); +} + +/// A tool with no parameters is callable here too. +/// +/// The Anthropic sibling of this shipped a bug: an empty argument stream was +/// treated as malformed and failed the whole turn. This module reaches the same +/// outcome by a different route — `from_str("")` fails and it falls back to +/// `{}` — so the behaviour is correct but incidental. Pinned so it stays. +#[tokio::test] +async fn a_zero_argument_tool_call_is_usable() { + let server = MockServer::start().await; + let body = format!( + "{}{}{}", + chunk( + r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"list_files","arguments":""}}]}}]}"# + ), + chunk(r#"{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#), + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_raw(body, "text/event-stream")) + .mount(&server) + .await; + + let message = run_stream(stream_config(&server.uri())) + .await + .expect("stream should complete"); + let Message::Assistant { + content, + stop_reason, + .. + } = &message + else { + panic!("expected assistant message"); + }; + assert_eq!( + *stop_reason, + StopReason::ToolUse, + "a no-argument tool call is a usable turn" + ); + let args = content + .iter() + .find_map(|c| match c { + Content::ToolCall { + name, arguments, .. + } if name == "list_files" => Some(arguments), + _ => None, + }) + .expect("the tool call must reach the caller"); + assert_eq!(*args, serde_json::json!({})); +} + +/// **Documents a provider divergence, deliberately.** +/// +/// Anthropic fails the turn on unparseable tool arguments, and says why: "a +/// tool handed `{"__partial_json": ...}` runs on its defaults instead of what +/// the model asked for". This module does the opposite — it falls back to `{}` +/// and warns, so the tool *does* run on its defaults. +/// +/// Pinned rather than fixed, because changing it is a behavioural decision +/// affecting 15+ providers, not a test fix. If the divergence is ever closed, +/// this test should fail and be updated deliberately. +#[tokio::test] +async fn truncated_tool_arguments_fall_back_to_empty_unlike_anthropic() { + let server = MockServer::start().await; + let body = format!( + "{}{}{}", + chunk( + r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\"q\":"}}]}}]}"# + ), + chunk(r#"{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#), + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_raw(body, "text/event-stream")) + .mount(&server) + .await; + + let message = run_stream(stream_config(&server.uri())) + .await + .expect("stream should complete"); + let Message::Assistant { content, .. } = &message else { + panic!("expected assistant message"); + }; + let args = content + .iter() + .find_map(|c| match c { + Content::ToolCall { + name, arguments, .. + } if name == "search" => Some(arguments), + _ => None, + }) + .expect("the call still reaches the caller here"); + assert_eq!( + *args, + serde_json::json!({}), + "truncated arguments currently degrade to an empty object — the tool runs on its \ + defaults. Anthropic fails the turn instead. If this assertion changes, the \ + divergence was closed on purpose" + ); +}