Skip to content

Commit e5328ba

Browse files
declan-scaleclaude
andcommitted
test(langgraph): unified sync path — passthrough and span derivation
Verifies yield_turn(LangGraphTurn) produces identical events to direct iteration, and documents the AGX1-377 behavior (LangGraph Full tool events don't produce SpanDeriver spans today; cross-channel equivalence comes with AGX1-373). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b1079a5 commit e5328ba

1 file changed

Lines changed: 218 additions & 0 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""Unified sync path tests for LangGraphTurn + UnifiedEmitter.
2+
3+
Verifies:
4+
1. Passthrough: events from emitter.yield_turn(LangGraphTurn(stream)) equal
5+
LangGraphTurn(stream).events collected directly.
6+
2. Span derivation: with trace_id + fake tracer, tool spans are derived from
7+
the event stream.
8+
9+
NOTE: langchain_core imports are deferred to test scope because conftest.py
10+
stubs ``langchain_core.messages`` with MagicMock.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import sys
16+
from typing import Any
17+
from dataclasses import field, dataclass
18+
19+
import pytest
20+
21+
from agentex.lib.core.harness.tracer import SpanTracer
22+
from agentex.lib.core.harness.emitter import UnifiedEmitter
23+
from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn
24+
25+
# ---------------------------------------------------------------------------
26+
# Remove conftest stubs so real langchain_core types are used
27+
# ---------------------------------------------------------------------------
28+
29+
30+
@pytest.fixture(autouse=True)
31+
def _real_langchain_core():
32+
stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")]
33+
saved = {k: sys.modules.pop(k) for k in stub_keys}
34+
import importlib
35+
36+
importlib.import_module("langchain_core.messages")
37+
yield
38+
sys.modules.update(saved)
39+
40+
41+
# ---------------------------------------------------------------------------
42+
# Helpers
43+
# ---------------------------------------------------------------------------
44+
45+
46+
def _make_stream(events: list[tuple[str, Any]]):
47+
async def _gen():
48+
for e in events:
49+
yield e
50+
51+
return _gen()
52+
53+
54+
# ---------------------------------------------------------------------------
55+
# Fake SpanTracer
56+
# ---------------------------------------------------------------------------
57+
58+
59+
@dataclass
60+
class _FakeTracingBackend:
61+
spans_started: list[dict[str, Any]] = field(default_factory=list)
62+
spans_ended: list[str] = field(default_factory=list)
63+
64+
async def start_span(self, **kw) -> Any:
65+
from agentex.types.span import Span
66+
67+
sp = Span(
68+
id=f"span-{len(self.spans_started) + 1}",
69+
trace_id=kw.get("trace_id", "trace1"),
70+
name=kw.get("name", ""),
71+
)
72+
self.spans_started.append(kw)
73+
return sp
74+
75+
async def end_span(self, *, trace_id: str, span: Any) -> None:
76+
self.spans_ended.append(span.id if span else "")
77+
78+
79+
# ---------------------------------------------------------------------------
80+
# Tests
81+
# ---------------------------------------------------------------------------
82+
83+
84+
class TestPassthrough:
85+
async def test_yield_turn_events_equal_direct_events(self):
86+
"""Events from emitter.yield_turn(LangGraphTurn(stream)) must equal
87+
LangGraphTurn(stream).events collected directly — the emitter must not
88+
add, drop, or reorder events in yield mode."""
89+
from langchain_core.messages import AIMessage, AIMessageChunk
90+
91+
chunk = AIMessageChunk(content="Hello!")
92+
ai_msg = AIMessage(content="Hello!")
93+
94+
# Build two identical streams
95+
events_raw = [
96+
("messages", (chunk, {})),
97+
("updates", {"agent": {"messages": [ai_msg]}}),
98+
]
99+
100+
# Direct collection
101+
direct = [e async for e in LangGraphTurn(_make_stream(events_raw)).events]
102+
103+
# Via emitter.yield_turn
104+
emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None)
105+
via_emitter = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))]
106+
107+
assert len(direct) == len(via_emitter), "yield_turn must not add or drop events relative to direct iteration"
108+
for a, b in zip(direct, via_emitter, strict=True):
109+
assert type(a) == type(b), f"Event type mismatch: {type(a).__name__} vs {type(b).__name__}"
110+
111+
async def test_yield_turn_passes_all_event_types(self):
112+
"""Start, Delta, Done, Full — each type is preserved."""
113+
from langchain_core.messages import AIMessage, AIMessageChunk
114+
115+
chunk = AIMessageChunk(content="hi")
116+
tc = {"id": "c1", "name": "t", "args": {}}
117+
ai_msg = AIMessage(content="hi", tool_calls=[tc])
118+
119+
events_raw = [
120+
("messages", (chunk, {})),
121+
("updates", {"agent": {"messages": [ai_msg]}}),
122+
]
123+
emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None)
124+
out = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))]
125+
types = {type(e).__name__ for e in out}
126+
# text chunk emits Start + Delta
127+
assert "StreamTaskMessageStart" in types
128+
assert "StreamTaskMessageDelta" in types
129+
# tool call emits Full
130+
assert "StreamTaskMessageFull" in types
131+
132+
async def test_empty_stream_yields_no_events(self):
133+
emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None)
134+
out = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream([])))]
135+
assert out == []
136+
137+
138+
class TestSpanDerivation:
139+
@pytest.fixture
140+
def fake_tracer(self):
141+
backend = _FakeTracingBackend()
142+
tracer = SpanTracer(
143+
trace_id="trace1",
144+
parent_span_id=None,
145+
task_id="t",
146+
tracing=backend, # type: ignore[arg-type]
147+
)
148+
return tracer, backend
149+
150+
async def test_tool_span_not_derived_from_full_events(self, fake_tracer):
151+
"""AGX1-377: LangGraph emits tool calls as Full events (not Start+Done).
152+
The SpanDeriver opens tool spans from Start(ToolRequestContent)+Done
153+
sequences. Since LangGraph uses Full, no tool span is opened by the
154+
SpanDeriver -- this is the documented AGX1-377 gap resolved by the
155+
unified surface (Full events are emitted identically; cross-channel
156+
span equivalence arrives with AGX1-373).
157+
158+
The tracer must still be invoked (SpanDeriver.observe is called for each
159+
event); it just produces no open-span signals for LangGraph Full tool events.
160+
"""
161+
from langchain_core.messages import AIMessage, ToolMessage
162+
163+
tracer, backend = fake_tracer
164+
tc = {"id": "c1", "name": "get_weather", "args": {"city": "Paris"}}
165+
ai_msg = AIMessage(content="", tool_calls=[tc])
166+
tool_msg = ToolMessage(content="Sunny", tool_call_id="c1", name="get_weather")
167+
168+
events_raw = [
169+
("updates", {"agent": {"messages": [ai_msg]}}),
170+
("updates", {"tools": {"messages": [tool_msg]}}),
171+
]
172+
173+
emitter = UnifiedEmitter(task_id="t", trace_id="trace1", parent_span_id=None, tracer=tracer)
174+
_ = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))]
175+
176+
# AGX1-377: Full events don't produce tool spans via SpanDeriver today.
177+
# This is the documented gap; full cross-channel equivalence arrives with AGX1-373.
178+
assert backend.spans_started == [], (
179+
"Expected no tool spans for LangGraph Full events (AGX1-377); if this "
180+
"assertion fails it means SpanDeriver now handles Full events — update "
181+
"the test to assert the new span names."
182+
)
183+
184+
async def test_no_spans_when_no_tool_calls(self, fake_tracer):
185+
"""yield_turn with tracer but no tool calls emits no spans."""
186+
from langchain_core.messages import AIMessage, AIMessageChunk
187+
188+
tracer, backend = fake_tracer
189+
chunk = AIMessageChunk(content="Hello!")
190+
ai_msg = AIMessage(content="Hello!")
191+
192+
events_raw = [
193+
("messages", (chunk, {})),
194+
("updates", {"agent": {"messages": [ai_msg]}}),
195+
]
196+
197+
emitter = UnifiedEmitter(task_id="t", trace_id="trace1", parent_span_id=None, tracer=tracer)
198+
_ = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))]
199+
200+
assert backend.spans_started == [], "No tool spans when there are no tool calls"
201+
202+
async def test_tracer_none_means_no_spans(self):
203+
"""With tracer=False, no spans should be emitted."""
204+
from langchain_core.messages import AIMessage, ToolMessage
205+
206+
tc = {"id": "c1", "name": "t", "args": {}}
207+
ai_msg = AIMessage(content="", tool_calls=[tc])
208+
tool_msg = ToolMessage(content="ok", tool_call_id="c1", name="t")
209+
210+
events_raw = [
211+
("updates", {"agent": {"messages": [ai_msg]}}),
212+
("updates", {"tools": {"messages": [tool_msg]}}),
213+
]
214+
215+
emitter = UnifiedEmitter(task_id="t", trace_id="trace1", parent_span_id=None, tracer=False)
216+
_ = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))]
217+
# No assertion on spans since tracer=False means emitter.tracer is None
218+
assert emitter.tracer is None

0 commit comments

Comments
 (0)