Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions tests/anthropic_stream_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
148 changes: 148 additions & 0 deletions tests/openai_compat_stream_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
Loading