|
1 | 1 | """Tests for LangGraphAgentGraphRunner and LangChainRunnerFactory.create_agent_graph().""" |
2 | 2 |
|
| 3 | +from uuid import uuid4 |
3 | 4 | from unittest.mock import AsyncMock, MagicMock, patch |
4 | 5 |
|
5 | 6 | import pytest |
@@ -156,3 +157,72 @@ async def test_langgraph_runner_run_success(): |
156 | 157 | tracker.track_path.assert_not_called() |
157 | 158 | tracker.track_invocation_success.assert_not_called() |
158 | 159 | tracker.track_duration.assert_not_called() |
| 160 | + |
| 161 | + |
| 162 | +@pytest.mark.asyncio |
| 163 | +async def test_langgraph_runner_run_resets_node_metrics_between_runs(): |
| 164 | + """Successive runs do not leak stale node metrics from a previous run. |
| 165 | +
|
| 166 | + Mirrors ``test_openai_agent_graph_runner_run_resets_node_metrics_between_runs`` |
| 167 | + in the OpenAI provider tests. Each ``run()`` invocation must produce its |
| 168 | + own fresh ``node_metrics`` rather than a union of all prior runs' metrics. |
| 169 | +
|
| 170 | + Strategy: bypass ``_build_graph()`` by pre-populating ``_compiled`` and |
| 171 | + ``_node_keys`` on the runner. The mock compiled graph's ``ainvoke`` is a |
| 172 | + side-effect coroutine that fires callbacks on the handler passed in via |
| 173 | + ``config['callbacks']`` — the same handler the real LangGraph executor |
| 174 | + would invoke. Each call fires events for only ``root-agent`` so we can |
| 175 | + assert the second result's ``node_metrics`` reflects only the second run. |
| 176 | + """ |
| 177 | + graph = _make_graph() |
| 178 | + |
| 179 | + mock_message = MagicMock() |
| 180 | + mock_message.content = "answer" |
| 181 | + mock_message.usage_metadata = None |
| 182 | + mock_message.response_metadata = None |
| 183 | + |
| 184 | + async def fire_callbacks(_payload, *, config): |
| 185 | + handler = config['callbacks'][0] |
| 186 | + # If state leaked across runs, the handler passed in here on the |
| 187 | + # second call would already contain entries from the first run before |
| 188 | + # any callback fires. We assert below that this is not the case. |
| 189 | + run_id = uuid4() |
| 190 | + handler.on_chain_start({}, {}, run_id=run_id, name='root-agent') |
| 191 | + handler.on_chain_end({}, run_id=run_id) |
| 192 | + return {'messages': [mock_message]} |
| 193 | + |
| 194 | + mock_compiled = MagicMock() |
| 195 | + mock_compiled.ainvoke = AsyncMock(side_effect=fire_callbacks) |
| 196 | + |
| 197 | + mock_human_message = MagicMock() |
| 198 | + mock_lc_core_messages = MagicMock() |
| 199 | + mock_lc_core_messages.HumanMessage = MagicMock(return_value=mock_human_message) |
| 200 | + |
| 201 | + runner = LangGraphAgentGraphRunner(graph, {}) |
| 202 | + # Bypass _build_graph(): provide a pre-compiled graph and the node keys |
| 203 | + # that the callback handler would otherwise be initialised with. |
| 204 | + runner._compiled = mock_compiled |
| 205 | + runner._node_keys = {'root-agent'} |
| 206 | + runner._fn_name_to_config_key = {} |
| 207 | + |
| 208 | + with patch.dict('sys.modules', { |
| 209 | + 'langchain_core': MagicMock(), |
| 210 | + 'langchain_core.messages': mock_lc_core_messages, |
| 211 | + }): |
| 212 | + first = await runner.run("attempt 1") |
| 213 | + assert first.metrics.success is True |
| 214 | + assert 'root-agent' in first.metrics.node_metrics |
| 215 | + first_metrics = first.metrics.node_metrics['root-agent'] |
| 216 | + |
| 217 | + second = await runner.run("attempt 2") |
| 218 | + |
| 219 | + assert second.metrics.success is True |
| 220 | + assert 'root-agent' in second.metrics.node_metrics |
| 221 | + # The second run's per-node metrics must be a fresh object, not the |
| 222 | + # accumulated state from the first run. If the runner leaked the |
| 223 | + # callback handler (or its state dict) across invocations, the second |
| 224 | + # run would return the same LDAIMetrics instance with cumulative values. |
| 225 | + assert second.metrics.node_metrics['root-agent'] is not first_metrics |
| 226 | + # Path and node_metrics keys reflect only the second invocation. |
| 227 | + assert second.metrics.path == ['root-agent'] |
| 228 | + assert set(second.metrics.node_metrics.keys()) == {'root-agent'} |
0 commit comments