deer-flow/backend/tests/test_conversation_access_edge_cases.py
Totoro 533e30e7f2
[feat] add opt-in conversation reads for Gateway runs (#5399)
* feat: add scoped conversation reads to gateway runs

* refactor: share conversation tool path and reuse parsed text

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-09-14 10:51:01 +08:00

169 lines
6.1 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.

"""Transcript fidelity, link compatibility, and bounded read edge cases."""
from __future__ import annotations
import asyncio
import json
from collections import Counter
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from test_conversation_access import _put, _setup
from app.gateway.conversation_access import _visible_text
def test_visible_text_is_parsed_once_per_scan_and_refreshed_on_the_next_read(monkeypatch):
from app.gateway import conversation_access
calls = Counter()
def track_projection(row):
calls[row["seq"]] += 1
return _visible_text(row)
monkeypatch.setattr(conversation_access, "_visible_text", track_projection)
async def exercise():
prepare, events, threads, _, _ = _setup()
await threads.create("source", user_id="alice")
await _put(events, "oldest")
await _put(events, "<think>private</think>older")
newest = await _put(events, [{"type": "text", "text": "newest"}, {"type": "text", "text": "answer"}])
reader, _ = prepare(["source"])
page = json.loads(await reader(thread_id="source", limit=1))
assert page["messages"][0]["text"] == "newest\nanswer"
# Include the lookahead row, but do not repeat the returned row's work.
assert calls == {3: 1, 2: 1}
calls.clear()
newest["content"]["content"] = "updated answer"
refreshed = json.loads(await reader(thread_id="source", limit=1))
assert refreshed["messages"][0]["text"] == "updated answer"
assert calls == {3: 1, 2: 1}
calls.clear()
older = json.loads(await reader(thread_id="source", cursor=page["next_cursor"], limit=1))
assert older["messages"][0]["text"] == "older"
assert calls == {2: 1, 1: 1}
asyncio.run(exercise())
def test_multipart_text_preserves_rendered_block_boundaries():
row = {"content": {"type": "ai", "content": [{"type": "text", "text": "12"}, {"type": "text", "text": "34"}]}}
assert _visible_text(row) == ("assistant", "12\n34")
def test_only_visible_text_blocks_cross_the_reader():
row = {
"content": {
"type": "ai",
"content": [
{"type": "reasoning", "text": "private reasoning"},
{"type": "thinking", "text": "private thinking"},
{"text": "untyped content"},
{"type": "input_text", "text": "not a rendered text block"},
{"type": "output_text", "text": "not a rendered output block"},
{"type": "text", "text": "visible answer"},
],
}
}
assert _visible_text(row) == ("assistant", "visible answer")
@pytest.mark.parametrize("path", ["/workspace/chats/source", "/workspace/agents/researcher/chats/source"])
def test_reference_accepts_both_frontend_conversation_routes(path):
prepare, _, _, _, _ = _setup()
reader, ids = prepare([f"https://deerflow.example{path}"])
assert callable(reader)
assert ids == ("source",)
@pytest.mark.parametrize(
"url",
[
"https://deerflow.example/workspace/agents/researcher/chats/source/extra",
"https://deerflow.example/workspace/agents/researcher/not-chats/source",
"https://other.example/workspace/agents/researcher/chats/source",
],
)
def test_custom_agent_link_does_not_widen_the_path_or_origin_contract(url):
prepare, _, _, _, _ = _setup()
with pytest.raises(HTTPException) as exc:
prepare([url])
assert exc.value.status_code == 422
def test_page_text_budget_continues_without_skipping_earlier_messages():
async def exercise():
prepare, events, threads, _, _ = _setup()
await threads.create("source", user_id="alice")
expected = {}
for index in range(12):
text = f"message-{index}:".ljust(4000, "x")
row = await _put(events, text)
expected[row["seq"]] = text
reader, _ = prepare(["source"])
pages = []
cursor = None
for _ in range(4):
page = json.loads(await reader(thread_id="source", cursor=cursor, limit=50))
pages.append(page)
if not page["has_more"]:
break
cursor = page["next_cursor"]
return pages, expected
pages, expected = asyncio.run(exercise())
assert [[message["seq"] for message in page["messages"]] for page in pages] == [[8, 9, 10, 11, 12], [3, 4, 5, 6, 7], [1, 2]]
assert [page["next_cursor"] for page in pages] == ["8", "3", None]
for page in pages:
assert page["status"] == "ok"
assert sum(len(message["text"]) for message in page["messages"]) <= 20000
assert page["truncated"] is False
for message in page["messages"]:
assert message["text"] == expected[message["seq"]]
def test_single_long_message_is_an_explicitly_truncated_excerpt():
async def exercise():
prepare, events, threads, _, _ = _setup()
await threads.create("source", user_id="alice")
await _put(events, "x" * 4000 + " omitted suffix")
reader, _ = prepare(["source"])
return json.loads(await reader(thread_id="source"))
page = asyncio.run(exercise())
assert page["messages"][0]["text"] == "x" * 4000
assert page["messages"][0]["truncated"] is True
assert page["truncated"] is True
# The v1 cursor pages between messages; it does not promise suffix recovery.
assert page["has_more"] is False
assert page["next_cursor"] is None
@pytest.mark.parametrize("cursor", ["", "0", "-1", "1.0", " 1", "1 ", "١", "", "9" * 20, 1, True])
def test_invalid_cursor_is_rejected_before_transcript_queries(cursor):
async def exercise():
prepare, events, threads, manager, _ = _setup()
await threads.create("source", user_id="alice")
events.list_messages = AsyncMock(side_effect=AssertionError("invalid cursor reached storage"))
reader, _ = prepare(["source"])
page = json.loads(await reader(thread_id="source", cursor=cursor))
events.list_messages.assert_not_awaited()
manager.list_successful_regenerate_sources.assert_not_awaited()
return page
assert asyncio.run(exercise())["status"] == "invalid_request"