mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
Co-authored-by: Jaxon1216 <224378083+Jaxon1216@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
158 lines
5.0 KiB
Python
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()
|