Files
2026-07-13 11:59:58 +08:00

146 lines
5.5 KiB
Python

"""Tests for the runtime bridge: StreamEvent -> reducer actions.
The translation layer is pure and is exercised here against real
``StreamEvent`` objects plus a fake client, with no Textual involved.
"""
from deerflow.client import StreamEvent
from deerflow.tui.runtime import stream_actions, translate
from deerflow.tui.view_state import (
AssistantDelta,
AssistantError,
RunEnded,
RunStarted,
ThreadTitle,
ToolResult,
ToolStarted,
initial_state,
reduce,
)
def test_translate_ai_text_delta():
event = StreamEvent(type="messages-tuple", data={"type": "ai", "content": "Hello", "id": "m1"})
actions = translate(event)
assert actions == [AssistantDelta(id="m1", text="Hello")]
def test_translate_ai_tool_call_emits_tool_started_not_empty_delta():
event = StreamEvent(
type="messages-tuple",
data={
"type": "ai",
"content": "",
"id": "m1",
"tool_calls": [{"name": "read_file", "args": {"path": "a.py"}, "id": "t1"}],
},
)
actions = translate(event)
assert actions == [ToolStarted(tool_call_id="t1", tool_name="read_file", args={"path": "a.py"})]
def test_translate_ai_content_blocks_list_extracts_text():
event = StreamEvent(
type="messages-tuple",
data={"type": "ai", "content": [{"type": "text", "text": "abc"}, {"type": "text", "text": "def"}], "id": "m9"},
)
actions = translate(event)
assert actions == [AssistantDelta(id="m9", text="abcdef")]
def test_translate_tool_call_with_none_id_yields_empty_id():
# Some providers' first tool-call chunk has id=None; it must coerce to "" (not
# "None"), so the empty-id guard in the reducer drops the noise chunk.
event = StreamEvent(
type="messages-tuple",
data={"type": "ai", "content": "", "id": "m1", "tool_calls": [{"id": None, "name": None, "args": {}}]},
)
assert translate(event) == [ToolStarted(tool_call_id="", tool_name="", args={})]
def test_translate_tool_result_with_none_id_yields_empty_id():
event = StreamEvent(type="messages-tuple", data={"type": "tool", "content": "x", "name": None, "tool_call_id": None})
assert translate(event) == [ToolResult(tool_call_id="", content="x", is_error=False, tool_name="")]
def test_translate_tool_result_with_error_status():
event = StreamEvent(
type="messages-tuple",
data={"type": "tool", "content": "boom", "name": "bash", "tool_call_id": "t1", "status": "error"},
)
actions = translate(event)
assert actions == [ToolResult(tool_call_id="t1", content="boom", is_error=True, tool_name="bash")]
def test_translate_end_event_carries_usage():
usage = {"input_tokens": 3, "output_tokens": 7, "total_tokens": 10}
actions = translate(StreamEvent(type="end", data={"usage": usage}))
assert actions == [RunEnded(usage=usage)]
def test_translate_values_surfaces_title_only():
assert translate(StreamEvent(type="values", data={"title": "My Thread", "messages": []})) == [ThreadTitle(title="My Thread")]
assert translate(StreamEvent(type="values", data={"title": None, "messages": []})) == []
assert translate(StreamEvent(type="custom", data={"anything": 1})) == []
class _FakeClient:
def __init__(self, events):
self._events = events
self.calls = []
def stream(self, message, *, thread_id=None, **kwargs):
self.calls.append((message, thread_id))
yield from self._events
def test_stream_actions_brackets_with_run_started_and_ended():
client = _FakeClient(
[
StreamEvent(type="messages-tuple", data={"type": "ai", "content": "Hi", "id": "m1"}),
StreamEvent(type="end", data={"usage": {"total_tokens": 5}}),
]
)
actions = list(stream_actions(client, "hello", thread_id="th-1"))
assert isinstance(actions[0], RunStarted)
assert isinstance(actions[-1], RunEnded)
assert client.calls == [("hello", "th-1")]
def test_stream_actions_reduces_to_expected_transcript():
client = _FakeClient(
[
StreamEvent(type="messages-tuple", data={"type": "ai", "content": "Let me look. ", "id": "m1"}),
StreamEvent(
type="messages-tuple",
data={"type": "ai", "content": "", "id": "m1", "tool_calls": [{"name": "read_file", "args": {"path": "a.py"}, "id": "t1"}]},
),
StreamEvent(type="messages-tuple", data={"type": "tool", "content": "file body", "name": "read_file", "tool_call_id": "t1"}),
StreamEvent(type="messages-tuple", data={"type": "ai", "content": "Done.", "id": "m2"}),
StreamEvent(type="end", data={"usage": {"total_tokens": 9}}),
]
)
state = initial_state()
for action in stream_actions(client, "go"):
state = reduce(state, action)
kinds = [r.kind for r in state.rows]
assert kinds == ["assistant", "tool", "assistant"]
assert state.rows[0].text == "Let me look. "
assert state.rows[1].status == "ok"
assert state.rows[2].text == "Done."
assert state.streaming is False
assert state.usage == {"total_tokens": 9}
class _BoomClient:
def stream(self, message, *, thread_id=None, **kwargs):
yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "partial", "id": "m1"})
raise RuntimeError("model down")
def test_stream_actions_surfaces_exception_as_error_then_ends():
actions = list(stream_actions(_BoomClient(), "go"))
assert any(isinstance(a, AssistantError) and "model down" in a.text for a in actions)
assert isinstance(actions[-1], RunEnded)