diff --git a/src/client/bedrock.rs b/src/client/bedrock.rs index 0988694..1a94f5d 100644 --- a/src/client/bedrock.rs +++ b/src/client/bedrock.rs @@ -457,10 +457,15 @@ fn build_chat_completions_body( } })); } - vec![ - json!({ "role": "assistant", "content": assistant_parts }), - json!({ "role": "user", "content": user_parts }), - ] + // Empty tool_results (reachable via deserialized sessions) must not emit an empty pair. + if assistant_parts.is_empty() { + vec![] + } else { + vec![ + json!({ "role": "assistant", "content": assistant_parts }), + json!({ "role": "user", "content": user_parts }), + ] + } } else { // One pair per round: Claude can reuse tool_use IDs across API calls. // A round boundary is detected by the presence of round text, but @@ -872,7 +877,7 @@ mod tests { } } - fn build_body(tool_results: Vec) -> Value { + fn build_body_with(tool_results: Vec, text: &str, sequence: bool) -> Value { let data = ChatCompletionsData { messages: vec![ Message::new(MessageRole::User, MessageContent::Text("hello".to_string())), @@ -880,8 +885,8 @@ mod tests { MessageRole::Assistant, MessageContent::ToolCalls(MessageContentToolCalls { tool_results, - text: String::new(), - sequence: true, + text: text.to_string(), + sequence, }), ), ], @@ -894,6 +899,10 @@ mod tests { build_chat_completions_body(data, &Model::new("bedrock", "test"), false).unwrap() } + fn build_body(tool_results: Vec) -> Value { + build_body_with(tool_results, "", true) + } + fn tool_result_content_block(output: Value) -> Value { let body = build_body(vec![tool_result(output)]); body["messages"][2]["content"][0]["toolResult"]["content"][0].clone() @@ -1063,6 +1072,44 @@ mod tests { assert_unique_tool_use_ids_per_message(&body); } + #[test] + fn non_sequence_emits_assistant_user_pair() { + let body = build_body_with( + vec![ + sequence_tool_result("toolu_A", None), + sequence_tool_result("toolu_B", None), + ], + "", + false, + ); + + let messages = body["messages"].as_array().unwrap(); + + assert_eq!(messages.len(), 3, "body: {body}"); + assert_eq!(messages[1]["role"], "assistant"); + assert_eq!(messages[1]["content"].as_array().unwrap().len(), 2); + assert_eq!(messages[2]["role"], "user"); + assert_eq!(messages[2]["content"].as_array().unwrap().len(), 2); + } + + #[test] + fn non_sequence_empty_tool_results_emits_no_messages() { + let body = build_body_with(vec![], "leftover text", false); + + let messages = body["messages"].as_array().unwrap(); + + assert_eq!(messages.len(), 1, "body: {body}"); + } + + #[test] + fn sequence_empty_tool_results_emits_no_messages() { + let body = build_body_with(vec![], "leftover text", true); + + let messages = body["messages"].as_array().unwrap(); + + assert_eq!(messages.len(), 1, "body: {body}"); + } + #[test] fn test_bedrock_tool_result_object_uses_json_block() { assert_eq!( diff --git a/src/client/claude.rs b/src/client/claude.rs index 019b379..9b2b051 100644 --- a/src/client/claude.rs +++ b/src/client/claude.rs @@ -381,10 +381,15 @@ pub fn claude_build_chat_completions_body( "content": tool_result.output.to_string(), })); } - vec![ - json!({ "role": "assistant", "content": assistant_parts }), - json!({ "role": "user", "content": user_parts }), - ] + // Empty tool_results (reachable via deserialized sessions) must not emit an empty pair. + if assistant_parts.is_empty() { + vec![] + } else { + vec![ + json!({ "role": "assistant", "content": assistant_parts }), + json!({ "role": "user", "content": user_parts }), + ] + } } else { // One pair per round: Claude can reuse tool_use IDs across API calls. // A round boundary is detected by the presence of round text, but @@ -638,7 +643,12 @@ mod tests { } } - fn build_body(tool_results: Vec, prompt_cache: bool) -> Value { + fn build_body_with( + tool_results: Vec, + text: &str, + sequence: bool, + prompt_cache: bool, + ) -> Value { let data = ChatCompletionsData { messages: vec![ Message::new(MessageRole::User, MessageContent::Text("hello".to_string())), @@ -646,8 +656,8 @@ mod tests { MessageRole::Assistant, MessageContent::ToolCalls(MessageContentToolCalls { tool_results, - text: String::new(), - sequence: true, + text: text.to_string(), + sequence, }), ), ], @@ -661,6 +671,10 @@ mod tests { .unwrap() } + fn build_body(tool_results: Vec, prompt_cache: bool) -> Value { + build_body_with(tool_results, "", true, prompt_cache) + } + fn multi_turn_data(functions: Option>) -> ChatCompletionsData { ChatCompletionsData { messages: vec![ @@ -766,6 +780,42 @@ mod tests { assert_unique_tool_use_ids_per_message(&body); } + #[test] + fn non_sequence_emits_assistant_user_pair() { + let body = build_body_with( + vec![tool_result("toolu_A", None), tool_result("toolu_B", None)], + "", + false, + false, + ); + + let messages = body["messages"].as_array().unwrap(); + + assert_eq!(messages.len(), 3, "body: {body}"); + assert_eq!(messages[1]["role"], "assistant"); + assert_eq!(messages[1]["content"].as_array().unwrap().len(), 2); + assert_eq!(messages[2]["role"], "user"); + assert_eq!(messages[2]["content"].as_array().unwrap().len(), 2); + } + + #[test] + fn non_sequence_empty_tool_results_emits_no_messages() { + let body = build_body_with(vec![], "leftover text", false, false); + + let messages = body["messages"].as_array().unwrap(); + + assert_eq!(messages.len(), 1, "body: {body}"); + } + + #[test] + fn sequence_empty_tool_results_emits_no_messages() { + let body = build_body_with(vec![], "leftover text", true, false); + + let messages = body["messages"].as_array().unwrap(); + + assert_eq!(messages.len(), 1, "body: {body}"); + } + #[test] fn prompt_cache_places_breakpoints() { let functions = vec![function_declaration("a"), function_declaration("b")]; diff --git a/src/client/openai.rs b/src/client/openai.rs index 6633be3..2c47d87 100644 --- a/src/client/openai.rs +++ b/src/client/openai.rs @@ -373,7 +373,10 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod text, sequence, }) => { - if !sequence { + // Empty tool_results (reachable via deserialized sessions) must not emit empty tool_calls. + if tool_results.is_empty() { + vec![] + } else if !sequence { let tool_calls: Vec<_> = tool_results .iter() .map(|tool_result| { @@ -745,3 +748,48 @@ pub async fn openai_responses_streaming( sse_stream(builder, handle).await } + +#[cfg(test)] +mod tests { + use super::*; + + fn build_body(sequence: bool) -> Value { + let data = ChatCompletionsData { + messages: vec![ + Message::new(MessageRole::User, MessageContent::Text("hello".to_string())), + Message::new( + MessageRole::Assistant, + MessageContent::ToolCalls(MessageContentToolCalls { + tool_results: vec![], + text: "leftover text".to_string(), + sequence, + }), + ), + ], + temperature: None, + top_p: None, + reasoning_effort: None, + functions: None, + stream: false, + }; + openai_build_chat_completions_body(data, &Model::new("openai", "gpt-test")) + } + + #[test] + fn non_sequence_empty_tool_results_emits_no_messages() { + let body = build_body(false); + + let messages = body["messages"].as_array().unwrap(); + + assert_eq!(messages.len(), 1, "body: {body}"); + } + + #[test] + fn sequence_empty_tool_results_emits_no_messages() { + let body = build_body(true); + + let messages = body["messages"].as_array().unwrap(); + + assert_eq!(messages.len(), 1, "body: {body}"); + } +} diff --git a/src/client/vertexai.rs b/src/client/vertexai.rs index 3f1f6f5..d9f3fed 100644 --- a/src/client/vertexai.rs +++ b/src/client/vertexai.rs @@ -412,10 +412,15 @@ pub fn gemini_build_chat_completions_body( } }) }).collect(); - vec![ - json!({ "role": "model", "parts": model_parts }), - json!({ "role": "function", "parts": function_parts }), - ] + // Empty tool_results (reachable via deserialized sessions) must not emit an empty pair. + if function_parts.is_empty() { + vec![] + } else { + vec![ + json!({ "role": "model", "parts": model_parts }), + json!({ "role": "function", "parts": function_parts }), + ] + } } } }) @@ -579,3 +584,37 @@ fn strip_model_version(name: &str) -> &str { None => name, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gemini_empty_tool_results_emits_no_messages() { + let data = ChatCompletionsData { + messages: vec![ + Message::new(MessageRole::User, MessageContent::Text("hello".to_string())), + Message::new( + MessageRole::Assistant, + MessageContent::ToolCalls(MessageContentToolCalls { + tool_results: vec![], + text: "leftover text".to_string(), + sequence: false, + }), + ), + ], + temperature: None, + top_p: None, + reasoning_effort: None, + functions: None, + stream: false, + }; + + let body = gemini_build_chat_completions_body(data, &Model::new("vertexai", "gemini-test")) + .unwrap(); + + let contents = body["contents"].as_array().unwrap(); + + assert_eq!(contents.len(), 1, "body: {body}"); + } +}