|
| 1 | +"""Tests for the patterns demonstrated in examples 15 (streaming) and 16 (persistence). |
| 2 | +
|
| 3 | +These tests use mock LLMs so no API key is required. |
| 4 | +""" |
| 5 | + |
| 6 | +from __future__ import annotations |
| 7 | + |
| 8 | +import asyncio |
| 9 | +import uuid |
| 10 | +from unittest.mock import MagicMock, patch |
| 11 | + |
| 12 | +import pytest |
| 13 | +from langchain_core.messages import AIMessage, HumanMessage |
| 14 | + |
| 15 | +from BaseAgent.events import AgentEvent, EventType |
| 16 | + |
| 17 | + |
| 18 | +# --------------------------------------------------------------------------- |
| 19 | +# Helpers |
| 20 | +# --------------------------------------------------------------------------- |
| 21 | + |
| 22 | + |
| 23 | +def _make_agent(checkpoint_db_path: str = ":memory:"): |
| 24 | + mock_llm = MagicMock() |
| 25 | + mock_llm.model_name = "mock-model" |
| 26 | + mock_llm.invoke.return_value = MagicMock(content="<solution>done</solution>") |
| 27 | + with patch("BaseAgent.base_agent.get_llm", return_value=("Anthropic", mock_llm)): |
| 28 | + from BaseAgent.base_agent import BaseAgent |
| 29 | + return BaseAgent(checkpoint_db_path=checkpoint_db_path, require_approval="never") |
| 30 | + |
| 31 | + |
| 32 | +async def _collect(agen) -> list[AgentEvent]: |
| 33 | + return [item async for item in agen] |
| 34 | + |
| 35 | + |
| 36 | +def _make_raw_events(events: list[dict]): |
| 37 | + async def _gen(): |
| 38 | + for e in events: |
| 39 | + yield e |
| 40 | + return lambda *args, **kwargs: _gen() |
| 41 | + |
| 42 | + |
| 43 | +# --------------------------------------------------------------------------- |
| 44 | +# Streaming (example 15) patterns |
| 45 | +# --------------------------------------------------------------------------- |
| 46 | + |
| 47 | + |
| 48 | +@pytest.mark.unit |
| 49 | +class TestStreamingExample: |
| 50 | + @pytest.fixture |
| 51 | + def agent(self): |
| 52 | + return _make_agent() |
| 53 | + |
| 54 | + def _thinking_events(self): |
| 55 | + ai_msg = AIMessage(content="<think>reasoning</think>") |
| 56 | + state = {"input": [HumanMessage(content="task"), ai_msg], "next_step": "generate"} |
| 57 | + return [ |
| 58 | + {"event": "on_chain_end", "metadata": {"langgraph_node": "generate"}, "data": {"output": state}}, |
| 59 | + ] |
| 60 | + |
| 61 | + def test_stream_all_events_yields_thinking(self, agent): |
| 62 | + agent.app.astream_events = _make_raw_events(self._thinking_events()) |
| 63 | + collected = asyncio.run(_collect(agent.run_stream("task"))) |
| 64 | + assert len(collected) == 1 |
| 65 | + assert collected[0].event_type == EventType.THINKING |
| 66 | + |
| 67 | + def test_stream_filter_final_answer_only(self, agent): |
| 68 | + ai_msg = AIMessage(content="<solution>answer</solution>") |
| 69 | + state = {"input": [HumanMessage(content="task"), ai_msg], "next_step": "end"} |
| 70 | + raw = [{"event": "on_chain_end", "metadata": {"langgraph_node": "generate"}, "data": {"output": state}}] |
| 71 | + agent.app.astream_events = _make_raw_events(raw) |
| 72 | + |
| 73 | + collected = asyncio.run( |
| 74 | + _collect(agent.run_stream("task", event_types={EventType.FINAL_ANSWER})) |
| 75 | + ) |
| 76 | + assert len(collected) == 1 |
| 77 | + assert collected[0].event_type == EventType.FINAL_ANSWER |
| 78 | + |
| 79 | + def test_stream_filter_excludes_other_types(self, agent): |
| 80 | + ai_msg = AIMessage(content="<think>thinking</think>") |
| 81 | + state = {"input": [HumanMessage(content="task"), ai_msg], "next_step": "end"} |
| 82 | + raw = [{"event": "on_chain_end", "metadata": {"langgraph_node": "generate"}, "data": {"output": state}}] |
| 83 | + agent.app.astream_events = _make_raw_events(raw) |
| 84 | + |
| 85 | + collected = asyncio.run( |
| 86 | + _collect(agent.run_stream("task", event_types={EventType.FINAL_ANSWER})) |
| 87 | + ) |
| 88 | + assert collected == [] |
| 89 | + |
| 90 | + def test_event_to_json_is_serialisable(self, agent): |
| 91 | + import json |
| 92 | + agent.app.astream_events = _make_raw_events(self._thinking_events()) |
| 93 | + collected = asyncio.run(_collect(agent.run_stream("task"))) |
| 94 | + assert collected |
| 95 | + payload = collected[0].to_json() |
| 96 | + parsed = json.loads(payload) |
| 97 | + assert parsed["event_type"] == "thinking" |
| 98 | + |
| 99 | + def test_stream_with_thread_id(self, agent): |
| 100 | + agent.app.astream_events = _make_raw_events([]) |
| 101 | + tid = str(uuid.uuid4()) |
| 102 | + asyncio.run(_collect(agent.run_stream("task", thread_id=tid))) |
| 103 | + assert agent.thread_id == tid |
| 104 | + |
| 105 | + |
| 106 | +# --------------------------------------------------------------------------- |
| 107 | +# Persistence (example 16) patterns |
| 108 | +# --------------------------------------------------------------------------- |
| 109 | + |
| 110 | + |
| 111 | +@pytest.mark.unit |
| 112 | +class TestPersistenceExample: |
| 113 | + def test_file_checkpoint_path_is_stored(self, tmp_path): |
| 114 | + db = str(tmp_path / "conv.db") |
| 115 | + agent = _make_agent(checkpoint_db_path=db) |
| 116 | + assert agent.checkpoint_db_path == db |
| 117 | + |
| 118 | + def test_same_thread_id_reused_across_runs(self, tmp_path): |
| 119 | + db = str(tmp_path / "conv.db") |
| 120 | + agent = _make_agent(checkpoint_db_path=db) |
| 121 | + |
| 122 | + mock_resp = MagicMock() |
| 123 | + mock_resp.content = "<solution>done</solution>" |
| 124 | + agent.llm.invoke.return_value = mock_resp |
| 125 | + |
| 126 | + tid = "my-session" |
| 127 | + with patch("BaseAgent.nodes.extract_usage_metrics", return_value=None): |
| 128 | + agent.run("first question", thread_id=tid) |
| 129 | + assert agent.thread_id == tid |
| 130 | + agent.run("follow-up question", thread_id=tid) |
| 131 | + assert agent.thread_id == tid |
| 132 | + |
| 133 | + def test_different_thread_ids_produce_separate_histories(self, tmp_path): |
| 134 | + db = str(tmp_path / "conv.db") |
| 135 | + agent = _make_agent(checkpoint_db_path=db) |
| 136 | + |
| 137 | + mock_resp = MagicMock() |
| 138 | + mock_resp.content = "<solution>done</solution>" |
| 139 | + agent.llm.invoke.return_value = mock_resp |
| 140 | + |
| 141 | + with patch("BaseAgent.nodes.extract_usage_metrics", return_value=None): |
| 142 | + agent.run("question A", thread_id="session-a") |
| 143 | + tid_a = agent.thread_id |
| 144 | + agent.run("question B", thread_id="session-b") |
| 145 | + tid_b = agent.thread_id |
| 146 | + |
| 147 | + assert tid_a == "session-a" |
| 148 | + assert tid_b == "session-b" |
| 149 | + assert tid_a != tid_b |
| 150 | + |
| 151 | + def test_close_cleans_up_without_error(self, tmp_path): |
| 152 | + db = str(tmp_path / "conv.db") |
| 153 | + agent = _make_agent(checkpoint_db_path=db) |
| 154 | + agent.close() # must not raise |
0 commit comments