|
| 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