Skip to content

Commit 9f8126e

Browse files
declan-scaleclaude
andcommitted
test(pydantic-ai): cross-channel conformance fixtures
Register 4 pydantic-ai conformance fixtures (text-only, single tool call, reasoning block, multi-step) that drive both yield_events and auto_send channels and assert logical-delivery + span-signal equivalence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 023e0d0 commit 9f8126e

1 file changed

Lines changed: 199 additions & 0 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""Cross-channel conformance fixtures derived from real pydantic-ai event sequences.
2+
3+
Each fixture is built by running a pydantic_ai event stream through PydanticAITurn
4+
(default coalesce_tool_requests=False) and collecting the canonical StreamTaskMessage*
5+
output. These canonical event lists are then registered with the conformance runner and
6+
exercised by the cross-channel test (yield_events vs auto_send).
7+
8+
AGX1-377 NOTE
9+
-------------
10+
The pydantic-ai stream emits a tool REQUEST as Start + ToolRequestDelta + Done (not a
11+
Full event). The runner's current normalization does NOT produce a logical delivery for
12+
Start+Delta+Done(tool_request): _yield_logical_deliveries only produces a delivery for
13+
Full(tool_request) or Full(tool_response), and Start+Done for text/reasoning content.
14+
auto_send likewise drops the Start+Delta+Done(tool_request) shape. Both channels handle
15+
it consistently (both ignore it), so the cross-channel test PASSES, but it does NOT yet
16+
assert that the streamed tool-request is actually delivered. Full delivery-equivalence
17+
coverage for streamed tool requests will land once AGX1-377 fixes the normalization.
18+
The fixtures below retain the ToolRequestDelta events so they become valid test inputs
19+
automatically once AGX1-377 lands.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import asyncio
25+
from typing import Any, AsyncIterator
26+
27+
import pytest
28+
from pydantic_ai.messages import (
29+
TextPart,
30+
PartEndEvent,
31+
ThinkingPart,
32+
ToolCallPart,
33+
TextPartDelta,
34+
PartDeltaEvent,
35+
PartStartEvent,
36+
ToolReturnPart,
37+
ThinkingPartDelta,
38+
ToolCallPartDelta,
39+
FunctionToolResultEvent,
40+
)
41+
42+
from tests.lib.core.harness.conformance.runner import (
43+
Fixture,
44+
register,
45+
derive_all,
46+
run_cross_channel_conformance,
47+
)
48+
from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn
49+
50+
# ---------------------------------------------------------------------------
51+
# Helpers
52+
# ---------------------------------------------------------------------------
53+
54+
55+
async def _aiter(events: list[Any]) -> AsyncIterator[Any]:
56+
for e in events:
57+
yield e
58+
59+
60+
async def _canonical(pydantic_events: list[Any]) -> list[Any]:
61+
"""Run pydantic_ai events through PydanticAITurn and collect the output.
62+
63+
Default coalesce_tool_requests=False means the output equals the bare
64+
convert_pydantic_ai_to_agentex_events output.
65+
"""
66+
turn = PydanticAITurn(_aiter(pydantic_events), model=None)
67+
return [e async for e in turn.events]
68+
69+
70+
def _build_fixtures() -> list[Fixture]:
71+
"""Build all pydantic-ai conformance fixtures synchronously via asyncio.run."""
72+
73+
# ------------------------------------------------------------------ #
74+
# 1. Text-only run: simple streaming text response.
75+
# ------------------------------------------------------------------ #
76+
text_only_pydantic = [
77+
PartStartEvent(index=0, part=TextPart(content="")),
78+
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Hello, ")),
79+
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="world!")),
80+
PartEndEvent(index=0, part=TextPart(content="Hello, world!")),
81+
]
82+
83+
# ------------------------------------------------------------------ #
84+
# 2. Single tool call + tool response.
85+
# The canonical stream emits Start+ToolRequestDelta+Done for the request
86+
# and Full(ToolResponseContent) for the response. See AGX1-377 note above
87+
# for why the request delivery is not yet asserted cross-channel.
88+
# ------------------------------------------------------------------ #
89+
tool_call_pydantic = [
90+
PartStartEvent(
91+
index=0,
92+
part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="call_01"),
93+
),
94+
PartDeltaEvent(
95+
index=0,
96+
delta=ToolCallPartDelta(args_delta='{"city":"Paris"}', tool_call_id="call_01"),
97+
),
98+
PartEndEvent(
99+
index=0,
100+
part=ToolCallPart(tool_name="get_weather", args='{"city":"Paris"}', tool_call_id="call_01"),
101+
),
102+
FunctionToolResultEvent(
103+
part=ToolReturnPart(tool_name="get_weather", content="Sunny, 22C", tool_call_id="call_01"),
104+
),
105+
]
106+
107+
# ------------------------------------------------------------------ #
108+
# 3. Reasoning/thinking block: produces ReasoningContent Start+Delta+Done.
109+
# ------------------------------------------------------------------ #
110+
reasoning_pydantic = [
111+
PartStartEvent(index=0, part=ThinkingPart(content="")),
112+
PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="First, let me think...")),
113+
PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta=" Then conclude.")),
114+
PartEndEvent(index=0, part=ThinkingPart(content="First, let me think... Then conclude.")),
115+
]
116+
117+
# ------------------------------------------------------------------ #
118+
# 4. Multi-step run: text -> tool call + response -> text.
119+
# Pydantic AI restarts part indices at 0 for each model response; the
120+
# converter assigns globally-monotonic indices to Agentex messages.
121+
# ------------------------------------------------------------------ #
122+
multi_step_pydantic = [
123+
# First model turn: text then tool call
124+
PartStartEvent(index=0, part=TextPart(content="")),
125+
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Let me check the weather.")),
126+
PartEndEvent(index=0, part=TextPart(content="Let me check the weather.")),
127+
PartStartEvent(
128+
index=1,
129+
part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="call_ms1"),
130+
),
131+
PartDeltaEvent(
132+
index=1,
133+
delta=ToolCallPartDelta(args_delta='{"city":"London"}', tool_call_id="call_ms1"),
134+
),
135+
PartEndEvent(
136+
index=1,
137+
part=ToolCallPart(tool_name="get_weather", args='{"city":"London"}', tool_call_id="call_ms1"),
138+
),
139+
FunctionToolResultEvent(
140+
part=ToolReturnPart(tool_name="get_weather", content="Cloudy, 15C", tool_call_id="call_ms1"),
141+
),
142+
# Second model turn: text response (pydantic restarts index at 0)
143+
PartStartEvent(index=0, part=TextPart(content="")),
144+
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="It's cloudy and 15C in London.")),
145+
PartEndEvent(index=0, part=TextPart(content="It's cloudy and 15C in London.")),
146+
]
147+
148+
text_only_events = asyncio.run(_canonical(text_only_pydantic))
149+
tool_call_events = asyncio.run(_canonical(tool_call_pydantic))
150+
reasoning_events = asyncio.run(_canonical(reasoning_pydantic))
151+
multi_step_events = asyncio.run(_canonical(multi_step_pydantic))
152+
153+
return [
154+
Fixture(name="pydantic-ai-text-only", events=text_only_events),
155+
Fixture(name="pydantic-ai-single-tool-call", events=tool_call_events),
156+
Fixture(name="pydantic-ai-reasoning-block", events=reasoning_events),
157+
Fixture(name="pydantic-ai-multi-step", events=multi_step_events),
158+
]
159+
160+
161+
_FIXTURES: list[Fixture] = _build_fixtures()
162+
163+
for _f in _FIXTURES:
164+
register(_f)
165+
166+
167+
# ---------------------------------------------------------------------------
168+
# Cross-channel conformance: logical equivalence + span equivalence
169+
# ---------------------------------------------------------------------------
170+
171+
172+
@pytest.mark.parametrize("fixture", _FIXTURES, ids=lambda f: f.name)
173+
@pytest.mark.asyncio
174+
async def test_cross_channel_equivalence(fixture: Fixture) -> None:
175+
"""Assert that yield_events and auto_send produce equivalent logical
176+
deliveries and identical span signals for each pydantic-ai fixture.
177+
178+
See runner.py for the full contract. The AGX1-377 note at the top of this
179+
module explains why streamed-tool-request delivery is not yet asserted.
180+
"""
181+
yield_deliveries, auto_deliveries, yield_spans, auto_spans = await run_cross_channel_conformance(fixture)
182+
183+
assert yield_deliveries == auto_deliveries, (
184+
f"[{fixture.name}] logical deliveries differ:\n yield: {yield_deliveries}\n auto_send: {auto_deliveries}"
185+
)
186+
assert yield_spans == auto_spans, (
187+
f"[{fixture.name}] span signals differ:\n yield: {yield_spans}\n auto_send: {auto_spans}"
188+
)
189+
190+
191+
# ---------------------------------------------------------------------------
192+
# Backward-compatible determinism guard
193+
# ---------------------------------------------------------------------------
194+
195+
196+
@pytest.mark.parametrize("fixture", _FIXTURES, ids=lambda f: f.name)
197+
def test_span_derivation_is_deterministic(fixture: Fixture) -> None:
198+
"""Span derivation over the same event list is idempotent."""
199+
assert derive_all(fixture.events) == derive_all(fixture.events)

0 commit comments

Comments
 (0)