deer-flow/backend/tests/test_read_conversation_tool.py
Totoro 6177b07c06
fix(conversation): clarify reference semantics and keep reader pages inline (#5421)
Document that read permission expiry and source deletion do not erase
text already copied into the destination conversation, and that reads
follow the source's current visible history. Truncated results now tell
the agent to acknowledge the omission and ask for the missing material
before claiming every requirement is covered.

Pages were filled to 20,000 text characters by cutting the last message
that did not fit, and that suffix could never be paged back. They could
also exceed the default 12,000-character tool-output budget, which
externalized the page to a file. Pages are now sized by their serialized
length against the read_conversation tool-output budget; a message that
does not fit starts the next page intact, so only a message over 4,000
characters (or one whose escaped JSON alone exceeds the budget) is cut.

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:05:30 +08:00

142 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""The history tool uses only a host-provided, authorized reader."""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from deerflow.config.tool_config import ToolConfig
from deerflow.tools.conversation import CONVERSATION_READER_CONTEXT_KEY, read_conversation
from deerflow.tools.tools import get_available_tools
from deerflow.tools.types import Runtime
def _config(*, configured=True, name="read_conversation"):
return SimpleNamespace(
tools=[ToolConfig(name=name, group="conversation", use="deerflow.tools.conversation:read_conversation")] if configured else [],
sandbox=SimpleNamespace(use="example.remote:Sandbox"),
skill_evolution=SimpleNamespace(enabled=False),
models=[],
acp_agents={},
get_model_config=lambda name: None,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"page",
[
'{"thread_id":"source","messages":[{"text":"Original answer"}],"next_cursor":"42"}',
'{"error":"Conversation unavailable"}',
],
)
async def test_read_conversation_forwards_only_page_arguments_to_host_reader(page):
reader = AsyncMock(return_value=page)
runtime = SimpleNamespace(context={CONVERSATION_READER_CONTEXT_KEY: reader, "user_id": "unrelated", "allowed_threads": ["other"]})
result = await read_conversation.coroutine("source", runtime, cursor="50", limit=7)
assert result == page
reader.assert_awaited_once_with(thread_id="source", cursor="50", limit=7)
@pytest.mark.asyncio
@pytest.mark.parametrize("context", [None, {}, {"__conversation_reader": "forged"}])
async def test_read_conversation_requires_callable_in_trusted_context(context):
other_reader = AsyncMock()
runtime = SimpleNamespace(context=context, config={"configurable": {CONVERSATION_READER_CONTEXT_KEY: other_reader}})
result = await read_conversation.coroutine("source", runtime)
assert "unavailable" in json.loads(result)["error"].lower()
other_reader.assert_not_called()
@pytest.mark.asyncio
async def test_read_conversation_denies_subagent_even_with_reader():
reader = AsyncMock()
runtime = SimpleNamespace(context={CONVERSATION_READER_CONTEXT_KEY: reader, "is_subagent": True})
result = await read_conversation.coroutine("source", runtime)
assert "subagent" in json.loads(result)["error"].lower()
reader.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"arguments",
[
{"thread_id": "../other"},
{"limit": 0},
{"limit": 51},
{"limit": True},
{"cursor": "0"},
{"cursor": "-1"},
{"cursor": ""},
{"cursor": ""},
{"cursor": 1},
],
)
async def test_invalid_page_arguments_do_not_reach_reader(arguments):
reader = AsyncMock()
runtime = SimpleNamespace(context={CONVERSATION_READER_CONTEXT_KEY: reader})
result = await read_conversation.coroutine(**{"thread_id": "source", "runtime": runtime, **arguments})
assert "error" in json.loads(result)
reader.assert_not_called()
def test_read_conversation_model_schema_has_no_identity_or_runtime_fields():
assert set(read_conversation.tool_call_schema.model_fields) == {"thread_id", "cursor", "limit"}
def test_tool_name_constant_matches_the_registered_tool():
# The Gateway sizes pages by this name's tool-output budget; a rename must move both.
from deerflow.constants import CONVERSATION_TOOL_NAME
assert read_conversation.name == CONVERSATION_TOOL_NAME
@pytest.mark.parametrize("name", ["read_conversation", "renamed_reader"])
def test_conversation_reader_is_not_loaded_by_default(monkeypatch, name):
monkeypatch.setattr("deerflow.tools.tools.resolve_variable", lambda *_: pytest.fail("disabled reader must not be imported"))
tools = get_available_tools(include_mcp=False, app_config=_config(name=name))
assert "read_conversation" not in {tool.name for tool in tools}
def test_conversation_reader_requires_both_configuration_and_host_opt_in():
enabled = get_available_tools(include_mcp=False, include_conversation_reader=True, app_config=_config())
unconfigured = get_available_tools(include_mcp=False, include_conversation_reader=True, app_config=_config(configured=False))
assert "read_conversation" in {tool.name for tool in enabled}
assert "read_conversation" not in {tool.name for tool in unconfigured}
def test_conversation_reader_still_respects_tool_group_filter():
tools = get_available_tools(groups=["other"], include_mcp=False, include_conversation_reader=True, app_config=_config())
assert "read_conversation" not in {tool.name for tool in tools}
def test_assembled_reader_supports_sync_tool_callers():
reader = AsyncMock(return_value='{"messages":[]}')
runtime = Runtime(
state={},
context={CONVERSATION_READER_CONTEXT_KEY: reader},
config={},
stream_writer=lambda _: None,
tools=[],
tool_call_id="call-1",
store=None,
)
tools = get_available_tools(include_mcp=False, include_conversation_reader=True, app_config=_config())
assembled = next(tool for tool in tools if tool.name == "read_conversation")
assert assembled.invoke({"thread_id": "source", "runtime": runtime}) == '{"messages":[]}'
reader.assert_awaited_once_with(thread_id="source", cursor=None, limit=20)