|
12 | 12 | # See the License for the specific language governing permissions and |
13 | 13 | # limitations under the License. |
14 | 14 |
|
| 15 | +from unittest.mock import AsyncMock |
15 | 16 | from unittest.mock import MagicMock |
16 | 17 |
|
17 | 18 | import pytest |
18 | 19 |
|
19 | | -# Skip all tests in this module if LangGraph dependencies are not available |
20 | | -LANGGRAPH_AVAILABLE = True |
21 | | -try: |
22 | | - from google.adk.agents.invocation_context import InvocationContext |
23 | | - from google.adk.agents.langgraph_agent import LangGraphAgent |
24 | | - from google.adk.events.event import Event |
25 | | - from google.adk.plugins.plugin_manager import PluginManager |
26 | | - from google.genai import types |
27 | | - from langchain_core.messages import AIMessage |
28 | | - from langchain_core.messages import HumanMessage |
29 | | - from langchain_core.messages import SystemMessage |
30 | | - from langgraph.graph.graph import CompiledGraph |
31 | | -except ImportError: |
32 | | - LANGGRAPH_AVAILABLE = False |
| 20 | +pytest.importorskip("langgraph", reason="LangGraph dependencies not available") |
33 | 21 |
|
34 | | - # IMPORTANT: Dummy classes are REQUIRED in this file but NOT in A2A test files. |
35 | | - # Here's why this file is different from A2A test files: |
36 | | - # |
37 | | - # 1. MODULE-LEVEL USAGE IN DECORATORS: |
38 | | - # This file uses @pytest.mark.parametrize decorator with complex nested structures |
39 | | - # that directly reference imported types like Event(), types.Content(), types.Part.from_text(). |
40 | | - # These decorator expressions are evaluated during MODULE COMPILATION TIME, |
41 | | - # not during test execution time. |
42 | | - # |
43 | | - # 2. A2A TEST FILES PATTERN: |
44 | | - # Most A2A test files only use imported types within test method bodies: |
45 | | - # - Inside test functions: def test_something(): Message(...) |
46 | | - # - These are evaluated during TEST EXECUTION TIME when tests are skipped |
47 | | - # - No NameError occurs because skipped tests don't execute their bodies |
48 | | - # |
49 | | - # 3. WHAT HAPPENS WITHOUT DUMMIES: |
50 | | - # If we remove dummy classes from this file: |
51 | | - # - Python tries to compile the @pytest.mark.parametrize decorator |
52 | | - # - It encounters Event(...), types.Content(...), etc. |
53 | | - # - These names are undefined → NameError during module compilation |
54 | | - # - Test collection fails before pytest.mark.skipif can even run |
55 | | - # |
56 | | - # 4. WHY DUMMIES WORK: |
57 | | - # - DummyTypes() can be called like Event() → returns DummyTypes instance |
58 | | - # - DummyTypes.__getattr__ handles types.Content → returns DummyTypes instance |
59 | | - # - DummyTypes.__call__ handles types.Part.from_text() → returns DummyTypes instance |
60 | | - # - The parametrize decorator gets dummy objects instead of real ones |
61 | | - # - Tests still get skipped due to pytestmark, so dummies never actually run |
62 | | - # |
63 | | - # 5. EXCEPTION CASES IN A2A FILES: |
64 | | - # A few A2A files DID need dummies initially because they had: |
65 | | - # - Type annotations: def create_helper(x: str) -> Message |
66 | | - # - But we removed those type annotations to eliminate the need for dummies |
67 | | - # |
68 | | - # This file cannot avoid dummies because the parametrize decorator usage |
69 | | - # is fundamental to the test structure and cannot be easily refactored. |
70 | | - |
71 | | - class DummyTypes: |
72 | | - |
73 | | - def __getattr__(self, name): |
74 | | - return DummyTypes() |
75 | | - |
76 | | - def __call__(self, *args, **kwargs): |
77 | | - return DummyTypes() |
78 | | - |
79 | | - InvocationContext = DummyTypes() |
80 | | - LangGraphAgent = DummyTypes() |
81 | | - Event = DummyTypes() |
82 | | - PluginManager = DummyTypes() |
83 | | - types = ( |
84 | | - DummyTypes() |
85 | | - ) # Must support chained calls like types.Content(), types.Part.from_text() |
86 | | - AIMessage = DummyTypes() |
87 | | - HumanMessage = DummyTypes() |
88 | | - SystemMessage = DummyTypes() |
89 | | - CompiledGraph = DummyTypes() |
90 | | - |
91 | | -pytestmark = pytest.mark.skipif( |
92 | | - not LANGGRAPH_AVAILABLE, reason="LangGraph dependencies not available" |
93 | | -) |
| 22 | +from google.adk.agents.invocation_context import InvocationContext |
| 23 | +from google.adk.agents.langgraph_agent import LangGraphAgent |
| 24 | +from google.adk.events.event import Event |
| 25 | +from google.adk.plugins.plugin_manager import PluginManager |
| 26 | +from google.genai import types |
| 27 | +from langchain_core.messages import AIMessage |
| 28 | +from langchain_core.messages import HumanMessage |
| 29 | +from langchain_core.messages import SystemMessage |
| 30 | +from langgraph.graph import MessagesState |
| 31 | +from langgraph.graph import StateGraph |
| 32 | +from langgraph.graph.state import CompiledStateGraph |
94 | 33 |
|
95 | 34 |
|
96 | 35 | @pytest.mark.parametrize( |
@@ -219,15 +158,15 @@ def __call__(self, *args, **kwargs): |
219 | 158 | async def test_langgraph_agent( |
220 | 159 | checkpointer_value, events_list, expected_messages |
221 | 160 | ): |
222 | | - mock_graph = MagicMock(spec=CompiledGraph) |
| 161 | + mock_graph = MagicMock(spec=CompiledStateGraph) |
223 | 162 | mock_graph_state = MagicMock() |
224 | 163 | mock_graph_state.values = {} |
225 | | - mock_graph.get_state.return_value = mock_graph_state |
| 164 | + mock_graph.aget_state = AsyncMock(return_value=mock_graph_state) |
226 | 165 |
|
227 | 166 | mock_graph.checkpointer = checkpointer_value |
228 | | - mock_graph.invoke.return_value = { |
229 | | - "messages": [AIMessage(content="test response")] |
230 | | - } |
| 167 | + mock_graph.ainvoke = AsyncMock( |
| 168 | + return_value={"messages": [AIMessage(content="test response")]} |
| 169 | + ) |
231 | 170 |
|
232 | 171 | mock_parent_context = MagicMock(spec=InvocationContext) |
233 | 172 | mock_parent_context._state_schema = None |
@@ -257,8 +196,57 @@ async def test_langgraph_agent( |
257 | 196 | assert result_event.author == "weather_agent" |
258 | 197 | assert result_event.content.parts[0].text == "test response" |
259 | 198 |
|
260 | | - mock_graph.invoke.assert_called_once() |
261 | | - mock_graph.invoke.assert_called_with( |
| 199 | + if checkpointer_value: |
| 200 | + mock_graph.aget_state.assert_awaited_once_with( |
| 201 | + {"configurable": {"thread_id": mock_session.id}} |
| 202 | + ) |
| 203 | + else: |
| 204 | + mock_graph.aget_state.assert_not_awaited() |
| 205 | + mock_graph.ainvoke.assert_awaited_once_with( |
262 | 206 | {"messages": expected_messages}, |
263 | 207 | {"configurable": {"thread_id": mock_session.id}}, |
264 | 208 | ) |
| 209 | + |
| 210 | + |
| 211 | +@pytest.mark.asyncio |
| 212 | +async def test_langgraph_agent_runs_real_compiled_state_graph(): |
| 213 | + """A real compiled state graph runs through the asynchronous adapter.""" |
| 214 | + observed_messages = [] |
| 215 | + |
| 216 | + def respond(state: MessagesState) -> dict[str, list[AIMessage]]: |
| 217 | + observed_messages.extend(state["messages"]) |
| 218 | + return {"messages": [AIMessage(content="real graph response")]} |
| 219 | + |
| 220 | + graph_builder = StateGraph(MessagesState) |
| 221 | + graph_builder.add_node("respond", respond) |
| 222 | + graph_builder.set_entry_point("respond") |
| 223 | + graph_builder.set_finish_point("respond") |
| 224 | + graph = graph_builder.compile() |
| 225 | + |
| 226 | + parent_context = MagicMock(spec=InvocationContext) |
| 227 | + parent_context._state_schema = None |
| 228 | + mock_session = MagicMock() |
| 229 | + mock_session.id = "session-id" |
| 230 | + mock_session.events = [] |
| 231 | + parent_context.session = mock_session |
| 232 | + parent_context.user_content = types.Content( |
| 233 | + role="user", parts=[types.Part.from_text(text="test prompt")] |
| 234 | + ) |
| 235 | + parent_context.branch = "parent_agent" |
| 236 | + parent_context.end_invocation = False |
| 237 | + parent_context.invocation_id = "test_invocation_id" |
| 238 | + parent_context.model_copy.return_value = parent_context |
| 239 | + parent_context.plugin_manager = PluginManager(plugins=[]) |
| 240 | + agent = LangGraphAgent( |
| 241 | + name="weather_agent", |
| 242 | + instruction="test system prompt", |
| 243 | + graph=graph, |
| 244 | + ) |
| 245 | + |
| 246 | + events = [event async for event in agent.run_async(parent_context)] |
| 247 | + |
| 248 | + assert len(events) == 1 |
| 249 | + assert events[0].content.parts[0].text == "real graph response" |
| 250 | + assert len(observed_messages) == 1 |
| 251 | + assert isinstance(observed_messages[0], SystemMessage) |
| 252 | + assert observed_messages[0].content == "test system prompt" |
0 commit comments