diff --git a/deepseek-recipe-python/tests/test_messages_turns.py b/deepseek-recipe-python/tests/test_messages_turns.py new file mode 100644 index 0000000..925e0da --- /dev/null +++ b/deepseek-recipe-python/tests/test_messages_turns.py @@ -0,0 +1,177 @@ +"""Messages turn coalescing precedes tool-call boundary validation.""" + +import copy + +import pytest +from deepseek_recipe import ( + ConversionError, + ConversionOptions, + DeepseekV41Encoding, + MessagesRequest, +) + +USES = [ + {"type": "tool_use", "id": ident, "name": "lookup", "input": {"key": key}} + for ident, key in (("call_a", "a"), ("call_b", "b")) +] +RESULTS = [ + {"type": "tool_result", "tool_use_id": ident, "content": value} + for ident, value in (("call_a", "1"), ("call_b", "2")) +] + + +def convert(messages): + return MessagesRequest( + { + "model": "DeepSeek-V4.1-Flash", + "thinking": {"type": "disabled"}, + "messages": copy.deepcopy(messages), + "tools": [ + {"name": "lookup", "input_schema": {"type": "object", "properties": {}}} + ], + } + ).convert(ConversionOptions()) + + +def render(messages): + return ( + DeepseekV41Encoding().render_conversation(convert(messages).conversation).prompt + ) + + +def history(): + return [ + {"role": "user", "content": "Look up both keys."}, + {"role": "assistant", "content": USES}, + ] + + +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("split_assistant", [False, True]) +def test_parallel_tool_results_across_consecutive_user_messages( + reverse, split_assistant +): + results = RESULTS[::-1] if reverse else RESULTS + messages = history() + if split_assistant: + messages[1:] = [{"role": "assistant", "content": [use]} for use in USES] + split = messages + [{"role": "user", "content": [result]} for result in results] + combined = history() + [{"role": "user", "content": results}] + assert render(split) == render(combined) + converted = convert(split).conversation.messages + assert [message.role for message in converted] == [ + "user", + "assistant", + "tool", + "tool", + ] + assert [message.tool_call_id for message in converted[-2:]] == [ + result["tool_use_id"] for result in results + ] + + +@pytest.mark.parametrize("role", ["user", "assistant"]) +@pytest.mark.parametrize("raw_first", [False, True]) +@pytest.mark.parametrize("raw_second", [False, True]) +def test_raw_and_block_content_coalesce(role, raw_first, raw_second): + first = "first" if raw_first else [{"type": "text", "text": "first"}] + second = "second" if raw_second else [{"type": "text", "text": "second"}] + split = [{"role": role, "content": first}, {"role": role, "content": second}] + combined = [ + { + "role": role, + "content": [ + {"type": "text", "text": "first"}, + {"type": "text", "text": "second"}, + ], + } + ] + assert render(split) == render(combined) + + +def test_text_can_follow_all_parallel_results_in_next_user_message(): + split = history() + [ + *[{"role": "user", "content": [result]} for result in RESULTS], + {"role": "user", "content": "Now sum the values."}, + ] + combined = history() + [ + { + "role": "user", + "content": [*RESULTS, {"type": "text", "text": "Now sum the values."}], + } + ] + assert render(split) == render(combined) + + +@pytest.mark.parametrize( + "tail,match", + [ + ([{"role": "user", "content": RESULTS[:1]}], "without `tool_result`"), + ( + [ + {"role": "user", "content": [RESULTS[0]]}, + {"role": "user", "content": [RESULTS[0], RESULTS[1]]}, + ], + "multiple `tool_result`", + ), + ( + [ + {"role": "user", "content": [RESULTS[0]]}, + {"role": "user", "content": "interrupted"}, + {"role": "user", "content": [RESULTS[1]]}, + ], + "without `tool_result`", + ), + ( + [ + {"role": "user", "content": [RESULTS[0]]}, + {"role": "assistant", "content": "interrupted"}, + {"role": "user", "content": [RESULTS[1]]}, + ], + "without `tool_result`", + ), + ( + [ + {"role": "user", "content": [RESULTS[0]]}, + {"role": "system", "content": "boundary"}, + {"role": "user", "content": [RESULTS[1]]}, + ], + "without `tool_result`", + ), + ( + [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "unknown", + "content": "x", + } + ], + } + ], + "unexpected", + ), + ], +) +def test_invalid_tool_histories_still_fail(tail, match): + with pytest.raises(ConversionError, match=match): + convert(history() + tail) + + +def test_duplicate_tool_ids_across_assistant_messages_fail(): + with pytest.raises(ConversionError, match="must be unique"): + convert( + [ + {"role": "assistant", "content": [USES[0]]}, + {"role": "assistant", "content": [USES[0]]}, + {"role": "user", "content": [RESULTS[0]]}, + ] + ) + + +@pytest.mark.parametrize("role", ["user", "assistant"]) +def test_empty_block_message_is_not_hidden_by_coalescing(role): + with pytest.raises(ConversionError, match="messages.1: all messages"): + convert([{"role": role, "content": "valid"}, {"role": role, "content": []}]) diff --git a/deepseek-recipe/src/protocol/anthropic/messages/request/convert.rs b/deepseek-recipe/src/protocol/anthropic/messages/request/convert.rs index d28748f..929ec35 100644 --- a/deepseek-recipe/src/protocol/anthropic/messages/request/convert.rs +++ b/deepseek-recipe/src/protocol/anthropic/messages/request/convert.rs @@ -36,6 +36,7 @@ use super::schema::{ MessagesMessage, MessagesRequest, MessagesSystemBlock, MessagesSystemContent, MessagesTool, MessagesToolChoice, MessagesToolReference, MessagesToolResultBlock, MessagesToolResultContent, }; +use super::turns::coalesce_messages; /// The tools of a request after name validation. struct MessagesToolSet { @@ -415,11 +416,12 @@ fn ensure_no_pending_tool_uses( /// message has an empty block list; when a `tool_use`, `server_tool_use`, /// `thinking`, or `web_search_tool_result` block appears outside an /// `assistant` message, or a `tool_result` block outside a `user` message; -/// when a `tool_use` ID repeats within one message, a `tool_result` answers no +/// when a `tool_use` ID repeats within one turn, a `tool_result` answers no /// `tool_use` of the preceding message or repeats an answered ID, a text, /// image, or document block follows a `tool_use` block in an `assistant` /// message, or a `tool_use` is not answered by the `tool_result` blocks at -/// the start of the next message; when, for a model with image input, an +/// the start of the next turn; consecutive messages with the same `user` or +/// `assistant` role form one turn. When, for a model with image input, an /// image source is invalid or an `assistant` message holds an image block; /// or when an `assistant` message holds a `server_tool_use` or /// `web_search_tool_result` block under [`WebSearchBehavior::Reject`]. @@ -453,7 +455,7 @@ fn transform_messages( .map(|tool| (tool.name.as_str(), tool)) .collect(); - for (message_index, message) in messages.into_iter().enumerate() { + for (message_index, message) in coalesce_messages(messages)? { match message { MessagesMessage::User(MessagesContent::Raw(content)) => { ensure_no_pending_tool_uses(message_index, &pending_tool_use_ids)?; diff --git a/deepseek-recipe/src/protocol/anthropic/messages/request/mod.rs b/deepseek-recipe/src/protocol/anthropic/messages/request/mod.rs index e1a5547..938ec8e 100644 --- a/deepseek-recipe/src/protocol/anthropic/messages/request/mod.rs +++ b/deepseek-recipe/src/protocol/anthropic/messages/request/mod.rs @@ -2,3 +2,4 @@ pub use schema::*; mod convert; mod schema; +mod turns; diff --git a/deepseek-recipe/src/protocol/anthropic/messages/request/turns.rs b/deepseek-recipe/src/protocol/anthropic/messages/request/turns.rs new file mode 100644 index 0000000..4259538 --- /dev/null +++ b/deepseek-recipe/src/protocol/anthropic/messages/request/turns.rs @@ -0,0 +1,45 @@ +use crate::request::ConversionError; + +use super::schema::{MessagesContent, MessagesContentBlock, MessagesMessage}; + +/// Anthropic combines consecutive user or assistant messages into one turn. +/// Validate tool-call boundaries only after those turns have been assembled. +pub(super) fn coalesce_messages( + messages: Vec, +) -> Result, ConversionError> { + let mut turns = Vec::with_capacity(messages.len()); + for (index, message) in messages.into_iter().enumerate() { + if matches!( + &message, + MessagesMessage::User(MessagesContent::Blocks(blocks)) + | MessagesMessage::Assistant(MessagesContent::Blocks(blocks)) + if blocks.is_empty() + ) { + return Err(ConversionError::bad_request(format!( + "messages.{index}: all messages must have non-empty content" + ))); + } + match (turns.last_mut(), message) { + (Some((_, MessagesMessage::User(left))), MessagesMessage::User(right)) + | (Some((_, MessagesMessage::Assistant(left))), MessagesMessage::Assistant(right)) => { + append_content(left, right); + } + (_, message) => turns.push((index, message)), + } + } + Ok(turns) +} + +fn append_content(left: &mut MessagesContent, right: MessagesContent) { + let previous = std::mem::replace(left, MessagesContent::Blocks(Vec::new())); + let mut blocks = into_blocks(previous); + blocks.extend(into_blocks(right)); + *left = MessagesContent::Blocks(blocks); +} + +fn into_blocks(content: MessagesContent) -> Vec { + match content { + MessagesContent::Raw(text) => vec![MessagesContentBlock::Text { text }], + MessagesContent::Blocks(blocks) => blocks, + } +}