Files
2026-07-13 13:32:05 +08:00

461 lines
18 KiB
Python

"""Unit tests for ``with next_*_span(...)`` support in the LangChain
``CallbackHandler``.
The handler was wired to call ``pop_pending_for(span_type)`` +
``apply_pending_to_span(...)`` at the start of every span it opens —
``on_chat_model_start`` / ``on_llm_start`` (llm), ``on_tool_start``
(tool), ``on_retriever_start`` (retriever) — so users can stage
metric collections, metrics, metadata, etc. on the next span the
handler creates without baking them into ``with_config(metadata=...)``.
These tests pin down the contracts that surface flips would silently
break (one-shot consumption, cross-type isolation, override of the
metadata path), exercised through the public LangChain runnable
surface with ``FakeListLLM`` so no API key / network call is needed.
"""
import asyncio
from typing import Any, List, Optional, Type
from unittest.mock import MagicMock
import pytest
from langchain_core.callbacks import (
AsyncCallbackManagerForRetrieverRun,
CallbackManagerForRetrieverRun,
)
from langchain_core.documents import Document
from langchain_core.language_models.fake import FakeListLLM
from langchain_core.retrievers import BaseRetriever
from langchain_core.runnables import RunnableLambda
from langchain_core.tools import BaseTool
from pydantic import BaseModel
from deepeval.integrations.langchain import CallbackHandler
from deepeval.metrics import BaseMetric
from deepeval.tracing import (
next_llm_span,
next_retriever_span,
next_span,
next_tool_span,
trace_manager,
)
from deepeval.tracing.types import LlmSpan, RetrieverSpan, ToolSpan
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _RecordingCallbackHandler(CallbackHandler):
"""Capture span object refs the moment they're created so tests can
inspect them after the trace has ended.
``trace_manager.remove_span(...)`` clears the active-spans map at
span end but the span object itself stays parented in the trace
tree, so we take the reference at start (after super() applied
pending) and assert against it post-run.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.llm_spans: List[LlmSpan] = []
self.tool_spans: List[ToolSpan] = []
self.retriever_spans: List[RetrieverSpan] = []
def on_chat_model_start(self, serialized, messages, *, run_id, **kwargs):
res = super().on_chat_model_start(
serialized, messages, run_id=run_id, **kwargs
)
span = trace_manager.get_span_by_uuid(str(run_id))
if span is not None:
self.llm_spans.append(span)
return res
def on_llm_start(self, serialized, prompts, *, run_id, **kwargs):
res = super().on_llm_start(serialized, prompts, run_id=run_id, **kwargs)
span = trace_manager.get_span_by_uuid(str(run_id))
if span is not None:
self.llm_spans.append(span)
return res
def on_tool_start(self, serialized, input_str, *, run_id, **kwargs):
res = super().on_tool_start(
serialized, input_str, run_id=run_id, **kwargs
)
span = trace_manager.get_span_by_uuid(str(run_id))
if span is not None:
self.tool_spans.append(span)
return res
def on_retriever_start(self, serialized, query, *, run_id, **kwargs):
res = super().on_retriever_start(
serialized, query, run_id=run_id, **kwargs
)
span = trace_manager.get_span_by_uuid(str(run_id))
if span is not None:
self.retriever_spans.append(span)
return res
class _EchoToolInput(BaseModel):
text: str
class _EchoTool(BaseTool):
"""Minimal tool that drives ``on_tool_start`` / ``on_tool_end`` with
no LLM dependency."""
name: str = "echo"
description: str = "Echoes the input back."
args_schema: Type[BaseModel] = _EchoToolInput
def _run(self, text: str, **_kwargs: Any) -> str:
return text
class _StaticRetriever(BaseRetriever):
"""Retriever returning a fixed list of docs — drives
``on_retriever_start`` / ``on_retriever_end`` deterministically.
We deliberately do NOT plumb metadata through ``with_config(...)``
on the retriever in tests below so the staged value from
``next_retriever_span(...)`` isn't masked by a metadata fallback.
"""
docs: List[Document] = [Document(page_content="hello")]
def _get_relevant_documents(
self, query: str, *, run_manager: CallbackManagerForRetrieverRun
) -> List[Document]:
return list(self.docs)
async def _aget_relevant_documents(
self,
query: str,
*,
run_manager: AsyncCallbackManagerForRetrieverRun,
) -> List[Document]:
return list(self.docs)
def _fake_metric(name: str = "fake") -> BaseMetric:
"""A throwaway metric stand-in. The handler only stores it on the
span — it never runs ``measure(...)`` here — so a ``MagicMock``
typed as ``BaseMetric`` is enough to assert the wiring."""
metric = MagicMock(spec=BaseMetric)
metric.__name__ = name
return metric
# ---------------------------------------------------------------------------
# next_llm_span → on_chat_model_start / on_llm_start
# ---------------------------------------------------------------------------
class TestNextLlmSpanWiring:
"""``with next_llm_span(...)`` stages defaults that get drained by
the FIRST LLM span the handler opens inside the scope. Verifies the
handler's ``pop_pending_for("llm")`` + ``apply_pending_to_span(...)``
plumbing for both ``on_llm_start`` (string-prompt LLMs like
``FakeListLLM``) and ``on_chat_model_start`` (chat models)."""
def test_metric_collection_lands_on_llm_span(self):
callback = _RecordingCallbackHandler()
llm = FakeListLLM(responses=["pong"])
with next_llm_span(metric_collection="llm_quality_v1"):
llm.invoke("ping", config={"callbacks": [callback]})
assert len(callback.llm_spans) == 1
assert callback.llm_spans[0].metric_collection == "llm_quality_v1"
def test_metrics_list_lands_on_llm_span(self):
callback = _RecordingCallbackHandler()
llm = FakeListLLM(responses=["pong"])
metric = _fake_metric()
with next_llm_span(metrics=[metric]):
llm.invoke("ping", config={"callbacks": [callback]})
assert callback.llm_spans[0].metrics == [metric]
def test_metadata_lands_on_llm_span(self):
callback = _RecordingCallbackHandler()
llm = FakeListLLM(responses=["pong"])
with next_llm_span(metadata={"trace_phase": "warmup"}):
llm.invoke("ping", config={"callbacks": [callback]})
assert callback.llm_spans[0].metadata == {"trace_phase": "warmup"}
def test_one_shot_only_first_llm_span_consumes(self):
"""One-shot semantics: a SECOND ``llm.invoke(...)`` inside the
same ``with`` block does NOT inherit the staged value. This is
the "gotcha" the docs call out for ``create_agent`` /
``StateGraph`` loops where the tool-call retry creates a second
LLM span — and is exactly what should happen given
``pop_pending_for`` drains the slot."""
callback = _RecordingCallbackHandler()
llm = FakeListLLM(responses=["pong-1", "pong-2"])
with next_llm_span(metric_collection="only-first"):
llm.invoke("ping-1", config={"callbacks": [callback]})
llm.invoke("ping-2", config={"callbacks": [callback]})
assert len(callback.llm_spans) == 2
assert callback.llm_spans[0].metric_collection == "only-first"
assert callback.llm_spans[1].metric_collection is None
def test_unconsumed_payload_does_not_leak_to_next_with(self):
"""Token-based reset on scope exit: a payload that nobody
popped must NOT carry into a subsequent ``with`` block."""
callback = _RecordingCallbackHandler()
llm = FakeListLLM(responses=["pong"])
with next_llm_span(metric_collection="leaked"):
pass # no LLM call → nothing pops
with next_llm_span(metric_collection="fresh"):
llm.invoke("ping", config={"callbacks": [callback]})
assert callback.llm_spans[0].metric_collection == "fresh"
def test_outside_with_block_no_staging(self):
"""Sanity floor: an LLM call outside any ``next_llm_span(...)``
leaves ``metric_collection`` / ``metrics`` / ``metadata`` at
their natural defaults (None, since no metadata baseline is
provided either)."""
callback = _RecordingCallbackHandler()
llm = FakeListLLM(responses=["pong"])
llm.invoke("ping", config={"callbacks": [callback]})
span = callback.llm_spans[0]
assert span.metric_collection is None
assert span.metrics is None
# metadata is left untouched (no metadata baseline → None).
assert span.metadata is None
def test_overrides_with_config_metadata_metric_collection(self):
"""``apply_pending_to_span(...)`` runs AFTER the metadata
baseline is read in ``on_llm_start`` (see comment in
``callback.py``: "more specific wins"). So a staged
``next_llm_span(metric_collection=...)`` MUST override
``with_config(metadata={"metric_collection": ...})``."""
callback = _RecordingCallbackHandler()
llm = FakeListLLM(responses=["pong"]).with_config(
metadata={"metric_collection": "from_metadata"}
)
with next_llm_span(metric_collection="from_next_span"):
llm.invoke("ping", config={"callbacks": [callback]})
assert callback.llm_spans[0].metric_collection == "from_next_span"
def test_does_not_override_metadata_when_only_metric_collection_staged(
self,
):
"""Negative guard for the override path: only fields PRESENT in
the pending payload should overwrite. ``metadata`` is left
alone when the staging block doesn't pass it."""
callback = _RecordingCallbackHandler()
llm = FakeListLLM(responses=["pong"]).with_config(
metadata={
"metric_collection": "from_metadata",
"extra_key": "preserved",
}
)
with next_llm_span(metric_collection="staged"):
llm.invoke("ping", config={"callbacks": [callback]})
# metric_collection got overridden, but the metadata-driven
# baseline (which the handler does NOT copy onto span.metadata
# in on_llm_start) is unaffected — span.metadata stays None
# because the staging block didn't pass metadata either.
assert callback.llm_spans[0].metric_collection == "staged"
assert callback.llm_spans[0].metadata is None
# ---------------------------------------------------------------------------
# next_tool_span → on_tool_start
# ---------------------------------------------------------------------------
class TestNextToolSpanWiring:
def test_metric_collection_lands_on_tool_span(self):
callback = _RecordingCallbackHandler()
tool = _EchoTool()
with next_tool_span(metric_collection="tool_quality_v1"):
tool.invoke({"text": "hi"}, config={"callbacks": [callback]})
assert len(callback.tool_spans) == 1
assert callback.tool_spans[0].metric_collection == "tool_quality_v1"
def test_metadata_lands_on_tool_span(self):
callback = _RecordingCallbackHandler()
tool = _EchoTool()
with next_tool_span(metadata={"layer": "outer"}):
tool.invoke({"text": "hi"}, config={"callbacks": [callback]})
assert callback.tool_spans[0].metadata == {"layer": "outer"}
def test_one_shot_only_first_tool_span_consumes(self):
callback = _RecordingCallbackHandler()
tool = _EchoTool()
with next_tool_span(metric_collection="only-first-tool"):
tool.invoke({"text": "hi-1"}, config={"callbacks": [callback]})
tool.invoke({"text": "hi-2"}, config={"callbacks": [callback]})
assert len(callback.tool_spans) == 2
assert callback.tool_spans[0].metric_collection == "only-first-tool"
assert callback.tool_spans[1].metric_collection is None
# ---------------------------------------------------------------------------
# next_retriever_span → on_retriever_start
# ---------------------------------------------------------------------------
class TestNextRetrieverSpanWiring:
def test_metric_collection_lands_on_retriever_span(self):
callback = _RecordingCallbackHandler()
retriever = _StaticRetriever()
with next_retriever_span(metric_collection="retriever_quality_v1"):
retriever.invoke("query", config={"callbacks": [callback]})
assert len(callback.retriever_spans) == 1
assert (
callback.retriever_spans[0].metric_collection
== "retriever_quality_v1"
)
def test_top_k_and_embedder_land_on_retriever_span(self):
"""Retriever-specific kwargs flow through
``apply_pending_to_span(...)`` because the popped dict is
setattr'd onto a ``RetrieverSpan`` placeholder which declares
``embedder`` / ``top_k`` / ``chunk_size``."""
callback = _RecordingCallbackHandler()
retriever = _StaticRetriever()
with next_retriever_span(top_k=5, embedder="text-embedding-3-small"):
retriever.invoke("query", config={"callbacks": [callback]})
span = callback.retriever_spans[0]
assert span.top_k == 5
assert span.embedder == "text-embedding-3-small"
# ---------------------------------------------------------------------------
# Cross-type isolation between typed slots
# ---------------------------------------------------------------------------
class TestCrossTypeIsolation:
"""Each typed slot is independent. The handler pops only the slot
matching the span it's about to open, so staging one type never
leaks onto a different span type opened in the same scope."""
def test_next_tool_span_does_not_leak_to_llm_span(self):
callback = _RecordingCallbackHandler()
llm = FakeListLLM(responses=["pong"])
with next_tool_span(metric_collection="tool-only"):
llm.invoke("ping", config={"callbacks": [callback]})
assert callback.llm_spans[0].metric_collection is None
def test_next_llm_span_does_not_leak_to_tool_span(self):
callback = _RecordingCallbackHandler()
tool = _EchoTool()
with next_llm_span(metric_collection="llm-only"):
tool.invoke({"text": "hi"}, config={"callbacks": [callback]})
assert callback.tool_spans[0].metric_collection is None
# ---------------------------------------------------------------------------
# Base ``next_span(...)`` slot
# ---------------------------------------------------------------------------
class TestNextSpanBaseSlotWiring:
"""``next_span(...)`` sets defaults for the FIRST span of any type.
Verifies the base slot also flows through the handler's
``pop_pending_for(...)`` call (which merges base + typed slots
before applying)."""
def test_base_slot_lands_on_first_llm_span(self):
callback = _RecordingCallbackHandler()
llm = FakeListLLM(responses=["pong"])
with next_span(metric_collection="from_base"):
llm.invoke("ping", config={"callbacks": [callback]})
assert callback.llm_spans[0].metric_collection == "from_base"
def test_typed_slot_overrides_base_slot_on_overlap(self):
"""When both ``next_span`` and ``next_llm_span`` set the same
key, the typed slot wins (more specific > base)."""
callback = _RecordingCallbackHandler()
llm = FakeListLLM(responses=["pong"])
with next_span(metric_collection="from_base"), next_llm_span(
metric_collection="from_typed"
):
llm.invoke("ping", config={"callbacks": [callback]})
assert callback.llm_spans[0].metric_collection == "from_typed"
# ---------------------------------------------------------------------------
# Async path — the handler's pop happens inside the same async task
# as the runnable, so ``ainvoke`` must behave like ``invoke``.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.filterwarnings(
"ignore:The 'config' parameter should be typed as 'RunnableConfig' or 'RunnableConfig \\| None'"
)
async def test_next_llm_span_lands_on_async_llm_call():
"""``await llm.ainvoke(...)`` exercises the async callback path. The
pending slot still pops because ``with next_llm_span(...)`` propagates
via contextvars into the async task created by ``ainvoke``."""
callback = _RecordingCallbackHandler()
llm = FakeListLLM(responses=["pong"])
with next_llm_span(metric_collection="async_v1"):
await llm.ainvoke("ping", config={"callbacks": [callback]})
assert len(callback.llm_spans) == 1
assert callback.llm_spans[0].metric_collection == "async_v1"
@pytest.mark.asyncio
@pytest.mark.filterwarnings(
"ignore:The 'config' parameter should be typed as 'RunnableConfig' or 'RunnableConfig \\| None'"
)
async def test_next_llm_span_lands_inside_runnable_lambda_async():
"""Stage outside, invoke a ``RunnableLambda`` that calls the LLM
inside its async body — verifies the ContextVar carries through
LangChain's task-spawning machinery to the LLM callback."""
callback = _RecordingCallbackHandler()
llm = FakeListLLM(responses=["pong"])
async def call_llm(_input, config=None):
return await llm.ainvoke("ping", config=config)
with next_llm_span(metric_collection="lambda_async_v1"):
await RunnableLambda(call_llm).ainvoke(
"unused", config={"callbacks": [callback]}
)
assert callback.llm_spans[0].metric_collection == "lambda_async_v1"