diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a73a9d..e3b468c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ - **Public File URLs**: Added `client.files.create_public_url()` and `client.files.revoke_public_url()` (sync and async) to create and revoke publicly shareable, unauthenticated URLs for stored files. `create_public_url()` accepts an optional `expires_after` (an `int` in seconds or a `datetime.timedelta`). - **Files List Filter**: `client.files.list()` (sync and async) now accepts an optional `filter` parameter to narrow results server-side by fields such as `content_type`, `size_bytes`, `created_at`, `upload_status`, and `public_url` (e.g. `filter='public_url != null'`). +### Fixed +- `chat.append(response)` now sets `tool_call_id` on replayed tool-role messages (recovered from the tool call echoed on the output), so servers can pair replayed tool turns with their originating calls. This fixes stateless multi-turn follow-ups to server-side tools whose results are re-hydrated by ID — e.g. editing a previously generated image in-memory without `previous_response_id`. + ## [v1.14.0] ### Added - **Image Search**: Added `enable_image_search` parameter to `web_search()` to return image results that can be embedded in responses diff --git a/src/xai_sdk/chat.py b/src/xai_sdk/chat.py index 9c47977..c6386b9 100644 --- a/src/xai_sdk/chat.py +++ b/src/xai_sdk/chat.py @@ -267,6 +267,20 @@ def _make_chat(self, conversation_id: Optional[str], batch_request_id: Optional[ """Creates the proto wrapper for chat requests.""" +def _replay_tool_call_id(output_message: chat_pb2.CompletionMessage) -> Optional[str]: + """Recovers the `tool_call_id` for replaying a completion output as an input message. + + `chat_pb2.CompletionMessage` does not carry a `tool_call_id` field, but server-side + tool outputs (ROLE_TOOL) echo the originating call in `tool_calls`. The server pairs + replayed tool turns with their calls via `Message.tool_call_id` (e.g. to re-hydrate + binary tool results such as generated images), so it must be set when appending a + response back onto the conversation. + """ + if output_message.role == chat_pb2.MessageRole.ROLE_TOOL and output_message.tool_calls: + return output_message.tool_calls[0].id + return None + + class BaseChat(ProtoDecorator[chat_pb2.GetCompletionsRequest]): """Utility class for simplifying the interaction with Chat requests and responses.""" @@ -377,16 +391,19 @@ def append(self, message: Union[chat_pb2.Message, "Response", "CompactContextRes reasoning_content=output.message.reasoning_content, encrypted_content=output.message.encrypted_content, tool_calls=output.message.tool_calls, + tool_call_id=_replay_tool_call_id(output.message), ) ) else: + output_message = message._get_output().message self._proto.messages.append( chat_pb2.Message( - role=message._get_output().message.role, + role=output_message.role, content=[text(message.content)], reasoning_content=message.reasoning_content, encrypted_content=message.encrypted_content, tool_calls=message.tool_calls, + tool_call_id=_replay_tool_call_id(output_message), ) ) else: diff --git a/tests/chat_test.py b/tests/chat_test.py index d5c0cb1..c37043e 100644 --- a/tests/chat_test.py +++ b/tests/chat_test.py @@ -792,6 +792,82 @@ def test_compact_context_response_empty_encrypted_content(): assert response.encrypted_content == "" +def test_append_agentic_response_sets_tool_call_id_on_tool_messages(): + """Test that append(Response) links replayed ROLE_TOOL outputs to their originating tool call. + + Server-side hydration pairs replayed tool turns via `Message.tool_call_id` (e.g. to + re-hydrate binary results like generated images), so appending an agentic response + must set it from the tool call echoed on the ROLE_TOOL output. + """ + from xai_sdk.proto import chat_pb2_grpc + from xai_sdk.sync.chat import Chat as SyncChat + + tool_call = chat_pb2.ToolCall( + id="call_1", + type=chat_pb2.TOOL_CALL_TYPE_WEB_SEARCH_TOOL, + status=chat_pb2.TOOL_CALL_STATUS_COMPLETED, + function=chat_pb2.FunctionCall(name="web_search", arguments="{}"), + ) + response_pb = chat_pb2.GetChatCompletionResponse( + outputs=[ + chat_pb2.CompletionOutput( + index=0, + message=chat_pb2.CompletionMessage(role=chat_pb2.MessageRole.ROLE_ASSISTANT, tool_calls=[tool_call]), + ), + chat_pb2.CompletionOutput( + index=1, + message=chat_pb2.CompletionMessage( + role=chat_pb2.MessageRole.ROLE_TOOL, + content='{"__type": "web_search_result"}', + tool_calls=[tool_call], + ), + ), + chat_pb2.CompletionOutput( + index=2, + message=chat_pb2.CompletionMessage(role=chat_pb2.MessageRole.ROLE_ASSISTANT, content="Done!"), + ), + ] + ) + response = Response(response_pb, None) # None means agentic: replay all outputs. + + stub = chat_pb2_grpc.ChatStub.__new__(chat_pb2_grpc.ChatStub) + chat = SyncChat(stub, None, None, model="grok-4.3") + chat.append(response) + + assert len(chat.messages) == 3 + assert chat.messages[0].role == chat_pb2.MessageRole.ROLE_ASSISTANT + assert chat.messages[0].tool_call_id == "" + assert chat.messages[1].role == chat_pb2.MessageRole.ROLE_TOOL + assert chat.messages[1].tool_call_id == "call_1" + assert [tc.id for tc in chat.messages[1].tool_calls] == ["call_1"] + assert chat.messages[2].role == chat_pb2.MessageRole.ROLE_ASSISTANT + assert chat.messages[2].tool_call_id == "" + + +def test_append_indexed_response_leaves_tool_call_id_unset_on_assistant_message(): + """Test that append(Response) with an index keeps assistant messages without a tool_call_id.""" + from xai_sdk.proto import chat_pb2_grpc + from xai_sdk.sync.chat import Chat as SyncChat + + response_pb = chat_pb2.GetChatCompletionResponse( + outputs=[ + chat_pb2.CompletionOutput( + index=0, + message=chat_pb2.CompletionMessage(role=chat_pb2.MessageRole.ROLE_ASSISTANT, content="Hello"), + ) + ] + ) + response = Response(response_pb, 0) + + stub = chat_pb2_grpc.ChatStub.__new__(chat_pb2_grpc.ChatStub) + chat = SyncChat(stub, None, None, model="grok-4.3") + chat.append(response) + + assert len(chat.messages) == 1 + assert chat.messages[0].role == chat_pb2.MessageRole.ROLE_ASSISTANT + assert chat.messages[0].tool_call_id == "" + + def test_append_compact_context_response_creates_user_message(): """Test that append(CompactContextResponse) produces a ROLE_USER message with encrypted_content.""" from xai_sdk.proto import chat_pb2_grpc