Files
deepset-ai--haystack/test/token_counters/test_utils.py
2026-08-19 15:05:25 +02:00

124 lines
4.4 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Annotated
import pytest
from haystack.dataclasses import ChatMessage, FileContent, ImageContent, ReasoningContent, TextContent, ToolCall
from haystack.token_counters.utils import (
_non_text_placeholder,
_render_message,
_rendered_conversation,
_rendered_tools,
_tool_result_text,
)
from haystack.tools import tool
IMAGE = ImageContent(base64_image="Zm9v", mime_type="image/png")
FILE = FileContent(base64_data="Zm9v", mime_type="application/pdf", filename="report.pdf")
def _tool_result(result: str, *, error: bool = False) -> ChatMessage:
return ChatMessage.from_tool(
tool_result=result, origin=ToolCall(tool_name="search", arguments={}, id="c1"), error=error
)
class TestRenderMessage:
def test_renders_every_kind_of_message(self):
messages = [
ChatMessage.from_system("rules"),
ChatMessage.from_user("what is haystack?"),
ChatMessage.from_assistant(
"let me look", tool_calls=[ToolCall(tool_name="search", arguments={"b": 2, "a": 1})]
),
_tool_result("found it"),
_tool_result("boom", error=True),
ChatMessage.from_user(content_parts=["look:", IMAGE, FILE]),
ChatMessage.from_assistant(),
]
assert "\n".join(_render_message(message) for message in messages) == (
"[system] rules\n"
"[user] what is haystack?\n"
"[assistant] let me look\n"
'[assistant -> tool_call] search({"a": 1, "b": 2})\n'
"[tool:search] found it\n"
"[tool:search (error)] boom\n"
"[user] look:\n"
"[user] <image>\n"
"[user] <file: report.pdf>\n"
"[assistant] <no content>"
)
class TestToolResultText:
@pytest.mark.parametrize(
("result", "expected"),
[
pytest.param("hello", "hello", id="plain-string"),
pytest.param([TextContent(text="a"), TextContent(text="b")], "ab", id="text-blocks-concatenated"),
pytest.param([TextContent(text="see "), IMAGE], "see <image>", id="non-text-placeholder"),
],
)
def test_tool_result_text(self, result, expected):
assert _tool_result_text(result) == expected
class TestRenderedConversation:
def test_joins_messages_with_newlines(self):
messages = [ChatMessage.from_user("a"), ChatMessage.from_assistant("b")]
assert _rendered_conversation(messages) == "[user] a\n[assistant] b"
def test_empty_conversation(self):
assert _rendered_conversation([]) == ""
def test_a_custom_placeholder_reaches_nested_tool_results(self):
messages = [
ChatMessage.from_user(content_parts=["look:", IMAGE]),
ChatMessage.from_tool(
tool_result=[TextContent(text="screenshot: "), IMAGE],
origin=ToolCall(tool_name="browse", arguments={}, id="c1"),
),
]
rendered = _rendered_conversation(messages, placeholder=lambda content: "<redacted>")
assert rendered == "[user] look:\n[user] <redacted>\n[tool:browse] screenshot: <redacted>"
@tool
def search(query: Annotated[str, "the search query"]) -> str:
"""Search the web for a query."""
return "result"
class TestNonTextPlaceholder:
@pytest.mark.parametrize(
("content", "expected"),
[
pytest.param(IMAGE, "<image>", id="image"),
pytest.param(FILE, "<file: report.pdf>", id="file-with-name"),
pytest.param(
FileContent(base64_data="Zm9v", mime_type="application/pdf"), "<file: unnamed>", id="file-unnamed"
),
# Anything else falls back to naming its type, so an unexpected block still shows up in the text.
pytest.param(ReasoningContent(reasoning_text="thinking"), "<ReasoningContent>", id="unknown-type"),
],
)
def test_placeholder(self, content, expected):
assert _non_text_placeholder(content) == expected
class TestRenderedTools:
def test_no_tools_renders_nothing(self):
assert _rendered_tools(None) == ""
assert _rendered_tools([]) == ""
def test_renders_the_schema_a_provider_would_be_sent(self):
rendered = _rendered_tools([search])
assert "search" in rendered
assert "Search the web for a query." in rendered
assert "the search query" in rendered