-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Python: Fix #2410: Combine text content and tool_calls into single message per OpenAI API spec #2418
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
ishanrajsingh
wants to merge
5
commits into
microsoft:main
from
ishanrajsingh:fix/openai-message-parser-split
Closed
Python: Fix #2410: Combine text content and tool_calls into single message per OpenAI API spec #2418
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
1c7f655
Fix #2410: Combine text content and tool_calls in single OpenAI message
ishanrajsingh 0318c70
Fix #2410: Combine text content and tool_calls in single OpenAI message
ishanrajsingh 196aca6
Merge branch 'main' into fix/openai-message-parser-split
ishanrajsingh fe62358
Merge branch 'main' into fix/openai-message-parser-split
ishanrajsingh afd9874
Merge branch 'main' into fix/openai-message-parser-split
ishanrajsingh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
179 changes: 179 additions & 0 deletions
179
python/packages/core/tests/openai/test_openai_chat_message_parser.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| # Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| from agent_framework import ( | ||
| ChatMessage, | ||
| FunctionCallContent, | ||
| Role, | ||
| TextContent, | ||
| ) | ||
| from agent_framework.openai import OpenAIChatClient | ||
|
|
||
|
|
||
| class TestOpenAIChatMessageParser: | ||
| """Test cases for _openai_chat_message_parser method.""" | ||
|
|
||
| def test_chat_message_with_text_and_tool_calls_combined(self): | ||
| """ | ||
| Test that a ChatMessage containing both text content and tool calls | ||
| is correctly parsed into a SINGLE message with both 'content' and 'tool_calls' fields. | ||
|
|
||
| This verifies the fix for issue #2410 where messages were incorrectly split. | ||
| """ | ||
| # Arrange | ||
| client = OpenAIChatClient(model_id="gpt-4", api_key="test-key") | ||
|
|
||
| message = ChatMessage( | ||
| role=Role.ASSISTANT, | ||
| contents=[ | ||
| TextContent(text="I'll help you with that calculation."), | ||
| FunctionCallContent(call_id="call-123", name="calculate", arguments={"x": 5, "y": 3}), | ||
| ], | ||
| ) | ||
|
|
||
| # Act | ||
| result = client._openai_chat_message_parser(message) | ||
|
|
||
| # Assert | ||
| assert len(result) == 1, "Should return exactly one message, not split into multiple" | ||
|
|
||
| parsed_message = result[0] | ||
|
|
||
| # Verify the message has the correct role | ||
| assert parsed_message["role"] == "assistant" | ||
|
|
||
| # Verify both content and tool_calls are present in the SAME message | ||
| assert "content" in parsed_message, "Message should contain 'content' field" | ||
| assert "tool_calls" in parsed_message, "Message should contain 'tool_calls' field" | ||
|
|
||
| # Verify content is correctly formatted | ||
| assert isinstance(parsed_message["content"], list) | ||
| assert len(parsed_message["content"]) == 1 | ||
| assert parsed_message["content"][0]["type"] == "text" | ||
| assert parsed_message["content"][0]["text"] == "I'll help you with that calculation." | ||
|
|
||
| # Verify tool_calls is correctly formatted | ||
| assert isinstance(parsed_message["tool_calls"], list) | ||
| assert len(parsed_message["tool_calls"]) == 1 | ||
| assert parsed_message["tool_calls"][0]["id"] == "call-123" | ||
| assert parsed_message["tool_calls"][0]["type"] == "function" | ||
| assert parsed_message["tool_calls"][0]["function"]["name"] == "calculate" | ||
|
|
||
| def test_chat_message_with_multiple_tool_calls_and_text(self): | ||
| """ | ||
| Test that a ChatMessage with text and multiple tool calls | ||
| keeps everything in a single message. | ||
| """ | ||
| # Arrange | ||
| client = OpenAIChatClient(model_id="gpt-4", api_key="test-key") | ||
|
|
||
| message = ChatMessage( | ||
| role=Role.ASSISTANT, | ||
| contents=[ | ||
| TextContent(text="Let me check both databases for you."), | ||
| FunctionCallContent( | ||
| call_id="call-456", name="query_database_a", arguments={"query": "SELECT * FROM users"} | ||
| ), | ||
| FunctionCallContent( | ||
| call_id="call-789", name="query_database_b", arguments={"query": "SELECT * FROM products"} | ||
| ), | ||
| ], | ||
| ) | ||
|
|
||
| # Act | ||
| result = client._openai_chat_message_parser(message) | ||
|
|
||
| # Assert | ||
| assert len(result) == 1, "Should return exactly one message with all tool calls" | ||
|
|
||
| parsed_message = result[0] | ||
| assert parsed_message["role"] == "assistant" | ||
| assert "content" in parsed_message | ||
| assert "tool_calls" in parsed_message | ||
|
|
||
| # Verify multiple tool calls are in the same message | ||
| assert len(parsed_message["tool_calls"]) == 2 | ||
| assert parsed_message["tool_calls"][0]["id"] == "call-456" | ||
| assert parsed_message["tool_calls"][1]["id"] == "call-789" | ||
|
|
||
| def test_chat_message_with_only_text(self): | ||
| """ | ||
| Test that a ChatMessage with only text content works correctly. | ||
| """ | ||
| # Arrange | ||
| client = OpenAIChatClient(model_id="gpt-4", api_key="test-key") | ||
|
|
||
| message = ChatMessage(role=Role.USER, contents=[TextContent(text="Hello, how are you?")]) | ||
|
|
||
| # Act | ||
| result = client._openai_chat_message_parser(message) | ||
|
|
||
| # Assert | ||
| assert len(result) == 1 | ||
| parsed_message = result[0] | ||
| assert parsed_message["role"] == "user" | ||
| assert "content" in parsed_message | ||
| assert "tool_calls" not in parsed_message | ||
|
|
||
| def test_chat_message_with_only_tool_calls(self): | ||
| """ | ||
| Test that a ChatMessage with only tool calls (no text) works correctly. | ||
| """ | ||
| # Arrange | ||
| client = OpenAIChatClient(model_id="gpt-4", api_key="test-key") | ||
|
|
||
| message = ChatMessage( | ||
| role=Role.ASSISTANT, | ||
| contents=[ | ||
| FunctionCallContent(call_id="call-999", name="get_weather", arguments={"location": "San Francisco"}) | ||
| ], | ||
| ) | ||
|
|
||
| # Act | ||
| result = client._openai_chat_message_parser(message) | ||
|
|
||
| # Assert | ||
| assert len(result) == 1 | ||
| parsed_message = result[0] | ||
| assert parsed_message["role"] == "assistant" | ||
| assert "tool_calls" in parsed_message | ||
| assert "content" not in parsed_message | ||
|
|
||
| def test_openai_api_compatibility(self): | ||
| """ | ||
| Test that the message format matches OpenAI API specification. | ||
| According to OpenAI docs, a single assistant message can have both | ||
| 'content' and 'tool_calls' in the same message object. | ||
| """ | ||
| # Arrange | ||
| client = OpenAIChatClient(model_id="gpt-4", api_key="test-key") | ||
|
|
||
| message = ChatMessage( | ||
| role=Role.ASSISTANT, | ||
| contents=[ | ||
| TextContent(text="I found the weather information."), | ||
| FunctionCallContent( | ||
| call_id="call_abc123", name="get_weather", arguments='{"location": "New York", "unit": "celsius"}' | ||
| ), | ||
| ], | ||
| ) | ||
|
|
||
| # Act | ||
| result = client._openai_chat_message_parser(message) | ||
|
|
||
| # Assert - Verify OpenAI API compatible structure | ||
| assert len(result) == 1 | ||
| api_message = result[0] | ||
|
|
||
| # Structure should match OpenAI API expectations | ||
| assert "role" in api_message | ||
| assert "content" in api_message | ||
| assert "tool_calls" in api_message | ||
|
|
||
| # Verify tool_call structure matches OpenAI spec | ||
| tool_call = api_message["tool_calls"][0] | ||
| assert "id" in tool_call | ||
| assert "type" in tool_call | ||
| assert tool_call["type"] == "function" | ||
| assert "function" in tool_call | ||
| assert "name" in tool_call["function"] | ||
| assert "arguments" in tool_call["function"] |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this will add a dict based on our class to the list, I don't think that makes sense, could you 1) add test cases with this and 2) add integration tests that have this. This is used with MCP server side tool calling, so the output format should be determined by openai, not by our type.