deer-flow/backend/tests/test_read_conversation_tool.py
Totoro 4ad55f598f
feat(conversation): continue reading a cut message by offset (#5434)
* feat(conversation): continue reading a cut message by offset

A referenced message longer than 4,000 characters was cut, and its
suffix could not be read back. Cut messages now carry a continuation
(message_seq, offset). read_conversation(thread_id, message_seq, offset)
returns the next part of that one message, sized to the same
tool-output budget as pages. The read scans only the requested row
under the existing visibility rules and rechecks ownership. Offsets
follow the source's current text; an offset past the end is rejected.

Related to #5398.

* docs(conversation): say continuations ignore limit

A continuation always returns one part of one message, so limit does not apply there. The tool schema now says so instead of discarding it silently.

Related to #5398.

* fix(conversation): stop instead of looping when no text fits the budget

With a read_conversation tool-output budget below the envelope size, the fitted text was empty and the continuation repeated the requested offset, so an agent would repeat the identical call forever. Page and continuation reads now return output_budget_too_small with no continuation.

Related to #5398.

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-09-15 08:25:08 +08:00

165 lines
6.3 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
async def test_read_conversation_forwards_a_continuation_without_page_arguments():
reader = AsyncMock(return_value='{"messages":[]}')
runtime = SimpleNamespace(context={CONVERSATION_READER_CONTEXT_KEY: reader})
await read_conversation.coroutine("source", runtime, message_seq=5, offset=4000)
reader.assert_awaited_once_with(thread_id="source", message_seq=5, offset=4000)
@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},
{"message_seq": 0, "offset": 0},
{"message_seq": True, "offset": 0},
{"message_seq": "5", "offset": 0},
{"message_seq": 5, "offset": -1},
{"message_seq": 5, "offset": True},
{"message_seq": 5},
{"offset": 4000},
{"message_seq": 5, "offset": 0, "cursor": "9"},
],
)
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", "message_seq", "offset"}
def test_limit_description_says_continuations_ignore_it():
# A continuation returns one message part, so the model must not expect limit to apply there.
assert "ignored when continuing a message" in read_conversation.tool_call_schema.model_fields["limit"].description
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)