deer-flow/backend/tests/test_tui_persistence.py
EastonJiang 5b2063d8b9
fix(tui): cancel pending persistence work when the bridge times out (#6927)
Co-authored-by: Jaxon1216 <224378083+Jaxon1216@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-17 19:25:18 +08:00

158 lines
5.0 KiB
Python

"""Tests for the shared-persistence writer (thread_meta visibility).
Uses the in-memory ThreadMetaStore so no SQL engine is required, but exercises
the real async store + background-loop wiring used by the TUI.
"""
import asyncio
import threading
import pytest
from langgraph.store.memory import InMemoryStore
from deerflow.persistence.thread_meta import make_thread_store
from deerflow.tui.persistence import ThreadMetaWriter, _LoopThread
@pytest.fixture
def writer_store_loop():
loop = _LoopThread()
store = make_thread_store(None, store=InMemoryStore())
writer = ThreadMetaWriter(loop, store)
try:
yield writer, store, loop
finally:
loop.close()
def test_writer_is_enabled_with_a_store(writer_store_loop):
writer, _store, _loop = writer_store_loop
assert writer.enabled is True
assert writer.user_id == "default"
def test_ensure_created_writes_row_owned_by_default_user(writer_store_loop):
writer, store, loop = writer_store_loop
writer.ensure_created("th-1", assistant_id="lead-agent", metadata={"source": "tui"})
rows = loop.run(store.search(user_id="default"))
assert "th-1" in [r["thread_id"] for r in rows]
def test_ensure_created_is_idempotent(writer_store_loop):
writer, store, loop = writer_store_loop
writer.ensure_created("th-1")
writer.ensure_created("th-1")
rows = loop.run(store.search(user_id="default"))
assert sum(1 for r in rows if r["thread_id"] == "th-1") == 1
def test_set_title_updates_display_name(writer_store_loop):
writer, store, loop = writer_store_loop
writer.ensure_created("th-1")
writer.set_title("th-1", "Refactor the bridge")
row = loop.run(store.get("th-1", user_id="default"))
assert row["display_name"] == "Refactor the bridge"
def test_disabled_writer_is_a_silent_noop():
loop = _LoopThread()
try:
writer = ThreadMetaWriter(loop, None)
assert writer.enabled is False
# Must not raise even though there is no store.
writer.ensure_created("x", assistant_id="lead-agent")
writer.set_title("x", "title")
finally:
loop.close()
def test_loop_timeout_cancels_pending_coroutine():
loop = _LoopThread()
release = asyncio.Event()
started = threading.Event()
cancelled = threading.Event()
finished = threading.Event()
async def pending_write():
started.set()
try:
await release.wait()
except asyncio.CancelledError:
cancelled.set()
raise
finally:
finished.set()
try:
with pytest.raises(TimeoutError):
loop.run(pending_write(), timeout=0.1)
assert started.wait(2)
assert cancelled.wait(2), "the timed-out coroutine is still running"
finally:
loop._loop.call_soon_threadsafe(release.set)
assert finished.wait(2)
loop.run(asyncio.sleep(0))
loop.close()
loop._thread.join(timeout=2)
assert not loop._thread.is_alive()
loop._loop.close()
def test_timed_out_title_write_cannot_resume_after_newer_title(writer_store_loop, monkeypatch):
writer, store, loop = writer_store_loop
writer.ensure_created("th-timeout")
release = asyncio.Event()
started = threading.Event()
finished = threading.Event()
original_update = store.update_display_name
original_run = loop.run
async def delayed_update(thread_id, title, **kwargs):
if title == "old title":
started.set()
try:
await release.wait()
return await original_update(thread_id, title, **kwargs)
finally:
finished.set()
return await original_update(thread_id, title, **kwargs)
def short_run(coro):
return original_run(coro, timeout=0.1)
monkeypatch.setattr(store, "update_display_name", delayed_update)
monkeypatch.setattr(loop, "run", short_run)
try:
# ThreadMetaWriter intentionally swallows the bridge timeout.
writer.set_title("th-timeout", "old title")
assert started.wait(2)
writer.set_title("th-timeout", "new title")
loop._loop.call_soon_threadsafe(release.set)
assert finished.wait(2)
row = original_run(store.get("th-timeout", user_id="default"))
assert row["display_name"] == "new title"
finally:
loop._loop.call_soon_threadsafe(release.set)
assert finished.wait(2)
original_run(asyncio.sleep(0))
@pytest.mark.parametrize("error_type", [TimeoutError, ValueError])
def test_loop_preserves_coroutine_errors(error_type):
loop = _LoopThread()
error = error_type("operation failed")
async def fail():
raise error
try:
with pytest.raises(error_type) as exc:
loop.run(fail())
assert str(exc.value) == "operation failed"
assert loop.run(asyncio.sleep(0, result="still usable")) == "still usable"
finally:
loop.close()
loop._thread.join(timeout=2)
assert not loop._thread.is_alive()
loop._loop.close()