Files
wehub-resource-sync 9201ef759e
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
Harness Compat / harness compat (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 13:27:52 +08:00

6317 lines
230 KiB
Python

"""Tests for AG-UI implementation."""
from __future__ import annotations
import importlib.metadata
import inspect
import json
import uuid
import warnings
from collections.abc import AsyncIterator, MutableMapping
from dataclasses import dataclass
from typing import Any, Literal
import pytest
from pydantic import BaseModel
from pydantic_ai import (
AudioUrl,
BinaryContent,
BinaryImage,
CachePoint,
DocumentUrl,
FilePart,
FunctionToolCallEvent,
FunctionToolResultEvent,
ImageUrl,
ModelMessage,
ModelRequest,
ModelRequestPart,
ModelResponse,
ModelResponsePart,
NativeToolCallPart,
NativeToolReturnPart,
PartDeltaEvent,
PartEndEvent,
PartStartEvent,
RequestUsage,
RetryPromptPart,
SystemPromptPart,
TextContent,
TextPart,
TextPartDelta,
ThinkingPart,
ThinkingPartDelta,
ToolCallPart,
ToolCallPartDelta,
ToolReturn,
ToolReturnPart,
UploadedFile,
UserPromptPart,
VideoUrl,
capture_run_messages,
)
from pydantic_ai._deferred_capabilities import parse_loaded_capabilities
from pydantic_ai._run_context import RunContext
from pydantic_ai.agent import Agent, AgentRunResult
from pydantic_ai.capabilities import Capability, PrepareTools
from pydantic_ai.exceptions import ApprovalRequired, UserError
from pydantic_ai.messages import (
LoadCapabilityCallPart,
LoadCapabilityReturnPart,
NativeToolSearchCallPart,
NativeToolSearchReturnPart,
)
from pydantic_ai.models.function import (
AgentInfo,
BuiltinToolCallsReturns,
DeltaThinkingCalls,
DeltaThinkingPart,
DeltaToolCall,
DeltaToolCalls,
FunctionModel,
)
from pydantic_ai.models.test import TestModel
from pydantic_ai.native_tools import WebSearchTool
from pydantic_ai.output import OutputDataT
from pydantic_ai.tools import (
AgentDepsT,
DeferredToolRequests,
DeferredToolResults,
ToolApproved,
ToolDefinition,
ToolDenied,
)
from pydantic_ai.toolsets._tool_search import parse_discovered_tools
from ._inline_snapshot import snapshot
from .conftest import IsDatetime, IsInt, IsSameStr, IsStr, message, message_part, try_import
with try_import() as imports_successful:
from ag_ui.core import (
ActivityMessage,
AssistantMessage,
AudioInputContent,
BaseEvent,
BinaryInputContent,
CustomEvent,
DeveloperMessage,
DocumentInputContent,
EventType,
FunctionCall,
ImageInputContent,
InputContentDataSource,
InputContentUrlSource,
Message,
ReasoningMessage,
RunAgentInput,
StateSnapshotEvent,
SystemMessage,
TextInputContent,
Tool,
ToolCall,
ToolMessage,
UserMessage,
VideoInputContent,
)
from ag_ui.encoder import EventEncoder
from starlette.requests import Request
from starlette.responses import StreamingResponse
from pydantic_ai.ui import SSE_CONTENT_TYPE, OnCompleteFunc, StateDeps
from pydantic_ai.ui.ag_ui import AGUIAdapter, AGUIEventStream
from pydantic_ai.ui.ag_ui._utils import (
BUILTIN_TOOL_CALL_ID_PREFIX,
detect_ag_ui_version,
parse_ag_ui_version,
)
with try_import() as anthropic_imports_successful:
from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings
from pydantic_ai.providers.anthropic import AnthropicProvider
with try_import() as interrupts_imports_successful:
# `ResumeEntry` and the interrupt-aware run lifecycle were added in ag-ui-protocol 0.1.19
# (PR #1569). On older installs, the dedicated interrupt tests below are skipped.
from ag_ui.core import ResumeEntry
pytestmark = [
pytest.mark.anyio,
pytest.mark.skipif(not imports_successful(), reason='ag-ui-protocol not installed'),
]
def simple_result(*, outcome: dict[str, Any] | None = None) -> Any:
"""Expected event sequence for `simple_stream`.
Pass `outcome={'type': 'success'}` for callers that run against `ag-ui-protocol >= 0.1.19`
(where the adapter emits `RunFinishedEvent.outcome`). Older negotiated versions
(e.g. `ag_ui_version='0.1.10'`) suppress the field, so the default `outcome=None`
matches a bare `RUN_FINISHED`.
"""
thread_id = IsSameStr()
run_id = IsSameStr()
message_id = IsSameStr()
run_finished: dict[str, Any] = {
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
}
if outcome is not None:
run_finished['outcome'] = outcome
return snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': message_id,
'role': 'assistant',
},
{'type': 'TEXT_MESSAGE_CONTENT', 'timestamp': IsInt(), 'messageId': message_id, 'delta': 'success '},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': '(no tool calls)',
},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
run_finished,
]
)
def test_manage_system_prompt_visible_in_ag_ui_from_request_signature() -> None:
from_request_parameters = inspect.signature(AGUIAdapter.from_request).parameters
assert 'manage_system_prompt' in from_request_parameters
assert from_request_parameters['manage_system_prompt'].default == 'server'
async def run_and_collect_events(
agent: Agent[AgentDepsT, OutputDataT],
*run_inputs: RunAgentInput,
deps: AgentDepsT = None,
on_complete: OnCompleteFunc[BaseEvent] | None = None,
ag_ui_version: Literal['0.1.10', '0.1.13'] = '0.1.10',
) -> list[dict[str, Any]]:
events = list[dict[str, Any]]()
for run_input in run_inputs:
adapter = AGUIAdapter(agent=agent, run_input=run_input, ag_ui_version=ag_ui_version)
async for event in adapter.encode_stream(adapter.run_stream(deps=deps, on_complete=on_complete)):
events.append(json.loads(event.removeprefix('data: ')))
return events
class StateInt(BaseModel):
"""Example state class for testing purposes."""
value: int = 0
def get_weather(name: str = 'get_weather') -> Tool:
return Tool(
name=name,
description='Get the weather for a given location',
parameters={
'type': 'object',
'properties': {
'location': {
'type': 'string',
'description': 'The location to get the weather for',
},
},
'required': ['location'],
},
)
def current_time() -> str:
"""Get the current time in ISO format.
Returns:
The current UTC time in ISO format string.
"""
return '2023-06-21T12:08:45.485981+00:00'
async def send_snapshot() -> StateSnapshotEvent:
"""Display the recipe to the user.
Returns:
StateSnapshotEvent.
"""
return StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot={'key': 'value'},
)
async def send_custom() -> ToolReturn:
return ToolReturn(
return_value='Done',
metadata=[
CustomEvent(
type=EventType.CUSTOM,
name='custom_event1',
value={'key1': 'value1'},
),
CustomEvent(
type=EventType.CUSTOM,
name='custom_event2',
value={'key2': 'value2'},
),
],
)
def uuid_str() -> str:
"""Generate a random UUID string."""
return uuid.uuid4().hex
def create_input(
*messages: Message, tools: list[Tool] | None = None, thread_id: str | None = None, state: Any = None
) -> RunAgentInput:
"""Create a RunAgentInput for testing."""
thread_id = thread_id or uuid_str()
return RunAgentInput(
thread_id=thread_id,
run_id=uuid_str(),
messages=list(messages),
state=dict(state) if state else {},
context=[],
tools=tools or [],
forwarded_props=None,
)
async def simple_stream(messages: list[ModelMessage], agent_info: AgentInfo) -> AsyncIterator[str]:
"""A simple function that returns a text response without tool calls."""
yield 'success '
yield '(no tool calls)'
async def test_agui_adapter_state_none() -> None:
"""Ensure adapter exposes `None` state when no frontend state provided."""
agent = Agent(
model=FunctionModel(stream_function=simple_stream),
)
run_input = RunAgentInput(
thread_id=uuid_str(),
run_id=uuid_str(),
messages=[],
state=None,
context=[],
tools=[],
forwarded_props=None,
)
adapter = AGUIAdapter(agent=agent, run_input=run_input, accept=None)
assert adapter.state is None
async def test_basic_user_message() -> None:
"""Test basic user message with text response."""
agent = Agent(
model=FunctionModel(stream_function=simple_stream),
)
run_input = create_input(
UserMessage(
id='msg_1',
content='Hello, how are you?',
)
)
events = await run_and_collect_events(agent, run_input)
assert events == simple_result()
async def test_empty_messages() -> None:
"""Test handling of empty messages."""
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[str]: # pragma: no cover
raise NotImplementedError
yield 'no messages'
agent = Agent(
model=FunctionModel(stream_function=stream_function),
)
run_input = create_input()
events = await run_and_collect_events(agent, run_input)
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': IsStr(),
'runId': IsStr(),
},
{
'type': 'RUN_ERROR',
'timestamp': IsInt(),
'message': 'No message history, user prompt, or instructions provided',
},
]
)
async def test_multiple_messages() -> None:
"""Test with multiple different message types."""
agent = Agent(
model=FunctionModel(stream_function=simple_stream),
)
run_input = create_input(
UserMessage(
id='msg_1',
content='First message',
),
AssistantMessage(
id='msg_2',
content='Assistant response',
),
SystemMessage(
id='msg_3',
content='System message',
),
DeveloperMessage(
id='msg_4',
content='Developer note',
),
UserMessage(
id='msg_5',
content='Second message',
),
ActivityMessage(
id='msg_6',
activity_type='testing',
content={
'test_field': None,
},
),
)
# The frontend-sent `SystemMessage` is stripped by the default server mode; verify
# that doesn't change the event stream (which is driven by the assistant's output).
with pytest.warns(UserWarning, match='manage_system_prompt'):
events = await run_and_collect_events(agent, run_input)
assert events == simple_result()
async def test_messages_with_history() -> None:
"""Test with multiple user messages (conversation history)."""
agent = Agent(
model=FunctionModel(stream_function=simple_stream),
)
run_input = create_input(
UserMessage(
id='msg_1',
content='First message',
),
UserMessage(
id='msg_2',
content='Second message',
),
)
events = await run_and_collect_events(agent, run_input)
assert events == simple_result()
async def test_tool_ag_ui() -> None:
"""Test AG-UI tool call."""
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaToolCalls | str]:
if len(messages) == 1:
# First call - make a tool call
yield {0: DeltaToolCall(name='get_weather', json_args='{"location": ')}
yield {0: DeltaToolCall(json_args='"Paris"}')}
else:
# Second call - return text result
yield '{"get_weather": "Tool result"}'
agent = Agent(
model=FunctionModel(stream_function=stream_function),
tools=[send_snapshot, send_custom, current_time],
)
thread_id = uuid_str()
run_inputs = [
create_input(
UserMessage(
id='msg_1',
content='Please call get_weather for Paris',
),
tools=[get_weather()],
thread_id=thread_id,
),
create_input(
UserMessage(
id='msg_1',
content='Please call get_weather for Paris',
),
AssistantMessage(
id='msg_2',
tool_calls=[
ToolCall(
id='pyd_ai_00000000000000000000000000000003',
type='function',
function=FunctionCall(
name='get_weather',
arguments='{"location": "Paris"}',
),
),
],
),
ToolMessage(
id='msg_3',
content='Tool result',
tool_call_id='pyd_ai_00000000000000000000000000000003',
),
thread_id=thread_id,
),
]
events = await run_and_collect_events(agent, *run_inputs)
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': (run_id := IsSameStr()),
},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': (tool_call_id := IsSameStr()),
'toolCallName': 'get_weather',
'parentMessageId': IsStr(),
},
{
'type': 'TOOL_CALL_ARGS',
'timestamp': IsInt(),
'toolCallId': tool_call_id,
'delta': '{"location": ',
},
{'type': 'TOOL_CALL_ARGS', 'timestamp': IsInt(), 'toolCallId': tool_call_id, 'delta': '"Paris"}'},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': tool_call_id},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': (run_id := IsSameStr()),
},
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': '{"get_weather": "Tool result"}',
},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
]
)
async def test_tool_ag_ui_multiple() -> None:
"""Test multiple AG-UI tool calls in sequence."""
run_count = 0
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaToolCalls | str]:
nonlocal run_count
run_count += 1
if run_count == 1:
# First run - make multiple tool calls
yield {0: DeltaToolCall(name='get_weather')}
yield {0: DeltaToolCall(json_args='{"location": "Paris"}')}
yield {1: DeltaToolCall(name='get_weather_parts')}
yield {1: DeltaToolCall(json_args='{"location": "')}
yield {1: DeltaToolCall(json_args='Paris"}')}
else:
# Second run - process tool results
yield '{"get_weather": "Tool result", "get_weather_parts": "Tool result"}'
agent = Agent(
model=FunctionModel(stream_function=stream_function),
)
tool_call_id1 = uuid_str()
tool_call_id2 = uuid_str()
run_inputs = [
(
first_input := create_input(
UserMessage(
id='msg_1',
content='Please call get_weather and get_weather_parts for Paris',
),
tools=[get_weather(), get_weather('get_weather_parts')],
)
),
create_input(
UserMessage(
id='msg_1',
content='Please call get_weather for Paris',
),
AssistantMessage(
id='msg_2',
tool_calls=[
ToolCall(
id=tool_call_id1,
type='function',
function=FunctionCall(
name='get_weather',
arguments='{"location": "Paris"}',
),
),
],
),
ToolMessage(
id='msg_3',
content='Tool result',
tool_call_id=tool_call_id1,
),
AssistantMessage(
id='msg_4',
tool_calls=[
ToolCall(
id=tool_call_id2,
type='function',
function=FunctionCall(
name='get_weather_parts',
arguments='{"location": "Paris"}',
),
),
],
),
ToolMessage(
id='msg_5',
content='Tool result',
tool_call_id=tool_call_id2,
),
tools=[get_weather(), get_weather('get_weather_parts')],
thread_id=first_input.thread_id,
),
]
events = await run_and_collect_events(agent, *run_inputs)
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': (thread_id := IsSameStr()),
'runId': (run_id := IsSameStr()),
},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': (tool_call_id := IsSameStr()),
'toolCallName': 'get_weather',
'parentMessageId': (parent_message_id := IsSameStr()),
},
{
'type': 'TOOL_CALL_ARGS',
'timestamp': IsInt(),
'toolCallId': tool_call_id,
'delta': '{"location": "Paris"}',
},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': tool_call_id},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': (tool_call_id := IsSameStr()),
'toolCallName': 'get_weather_parts',
'parentMessageId': parent_message_id,
},
{
'type': 'TOOL_CALL_ARGS',
'timestamp': IsInt(),
'toolCallId': tool_call_id,
'delta': '{"location": "',
},
{'type': 'TOOL_CALL_ARGS', 'timestamp': IsInt(), 'toolCallId': tool_call_id, 'delta': 'Paris"}'},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': tool_call_id},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': (run_id := IsSameStr()),
},
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': '{"get_weather": "Tool result", "get_weather_parts": "Tool result"}',
},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
]
)
async def test_tool_ag_ui_parts() -> None:
"""Test AG-UI tool call with streaming/parts (same as tool_call_with_args_streaming)."""
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaToolCalls | str]:
if len(messages) == 1:
# First call - make a tool call with streaming args
yield {0: DeltaToolCall(name='get_weather')}
yield {0: DeltaToolCall(json_args='{"location":"')}
yield {0: DeltaToolCall(json_args='Paris"}')}
else:
# Second call - return text result
yield '{"get_weather": "Tool result"}'
agent = Agent(model=FunctionModel(stream_function=stream_function))
run_inputs = [
(
first_input := create_input(
UserMessage(
id='msg_1',
content='Please call get_weather_parts for Paris',
),
tools=[get_weather('get_weather_parts')],
)
),
create_input(
UserMessage(
id='msg_1',
content='Please call get_weather_parts for Paris',
),
AssistantMessage(
id='msg_2',
tool_calls=[
ToolCall(
id='pyd_ai_00000000000000000000000000000003',
type='function',
function=FunctionCall(
name='get_weather_parts',
arguments='{"location": "Paris"}',
),
),
],
),
ToolMessage(
id='msg_3',
content='Tool result',
tool_call_id='pyd_ai_00000000000000000000000000000003',
),
tools=[get_weather('get_weather_parts')],
thread_id=first_input.thread_id,
),
]
events = await run_and_collect_events(agent, *run_inputs)
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': (thread_id := IsSameStr()),
'runId': (run_id := IsSameStr()),
},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': (tool_call_id := IsSameStr()),
'toolCallName': 'get_weather',
'parentMessageId': IsStr(),
},
{
'type': 'TOOL_CALL_ARGS',
'timestamp': IsInt(),
'toolCallId': tool_call_id,
'delta': '{"location":"',
},
{'type': 'TOOL_CALL_ARGS', 'timestamp': IsInt(), 'toolCallId': tool_call_id, 'delta': 'Paris"}'},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': tool_call_id},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': tool_call_id,
'content': """\
Unknown tool name: 'get_weather'. Available tools: 'get_weather_parts'
Fix the errors and try again.\
""",
'role': 'tool',
},
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': '{"get_weather": "Tool result"}',
},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': (run_id := IsSameStr()),
},
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': '{"get_weather": "Tool result"}',
},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
]
)
async def test_tool_local_single_event() -> None:
"""Test local tool call that returns a single event."""
encoder = EventEncoder()
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaToolCalls | str]:
if len(messages) == 1:
# First call - make a tool call
yield {0: DeltaToolCall(name='send_snapshot')}
yield {0: DeltaToolCall(json_args='{}')}
else:
# Second call - return text result
yield encoder.encode(await send_snapshot())
agent = Agent(
model=FunctionModel(stream_function=stream_function),
tools=[send_snapshot],
)
run_input = create_input(
UserMessage(
id='msg_1',
content='Please call send_snapshot',
),
)
events = await run_and_collect_events(agent, run_input)
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': (thread_id := IsSameStr()),
'runId': (run_id := IsSameStr()),
},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': (tool_call_id := IsSameStr()),
'toolCallName': 'send_snapshot',
'parentMessageId': IsStr(),
},
{'type': 'TOOL_CALL_ARGS', 'timestamp': IsInt(), 'toolCallId': tool_call_id, 'delta': '{}'},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': tool_call_id},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': tool_call_id,
'content': '{"type":"STATE_SNAPSHOT","timestamp":null,"raw_event":null,"snapshot":{"key":"value"}}',
'role': 'tool',
},
{'type': 'STATE_SNAPSHOT', 'timestamp': IsInt(), 'snapshot': {'key': 'value'}},
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': """\
data: {"type":"STATE_SNAPSHOT","snapshot":{"key":"value"}}
""",
},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
]
)
async def test_tool_local_multiple_events() -> None:
"""Test local tool call that returns multiple events."""
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaToolCalls | str]:
if len(messages) == 1:
# First call - make a tool call
yield {0: DeltaToolCall(name='send_custom')}
yield {0: DeltaToolCall(json_args='{}')}
else:
# Second call - return text result
yield 'success send_custom called'
agent = Agent(
model=FunctionModel(stream_function=stream_function),
tools=[send_custom],
)
run_input = create_input(
UserMessage(
id='msg_1',
content='Please call send_custom',
),
)
events = await run_and_collect_events(agent, run_input)
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': (thread_id := IsSameStr()),
'runId': (run_id := IsSameStr()),
},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': (tool_call_id := IsSameStr()),
'toolCallName': 'send_custom',
'parentMessageId': IsStr(),
},
{'type': 'TOOL_CALL_ARGS', 'timestamp': IsInt(), 'toolCallId': tool_call_id, 'delta': '{}'},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': tool_call_id},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': tool_call_id,
'content': 'Done',
'role': 'tool',
},
{'type': 'CUSTOM', 'timestamp': IsInt(), 'name': 'custom_event1', 'value': {'key1': 'value1'}},
{'type': 'CUSTOM', 'timestamp': IsInt(), 'name': 'custom_event2', 'value': {'key2': 'value2'}},
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': 'success send_custom called',
},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
]
)
async def test_tool_local_parts() -> None:
"""Test local tool call with streaming/parts."""
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaToolCalls | str]:
if len(messages) == 1:
# First call - make a tool call with streaming args
yield {0: DeltaToolCall(name='current_time')}
yield {0: DeltaToolCall(json_args='{}')}
else:
# Second call - return text result
yield 'success current_time called'
agent = Agent(
model=FunctionModel(stream_function=stream_function),
tools=[send_snapshot, send_custom, current_time],
)
run_input = create_input(
UserMessage(
id='msg_1',
content='Please call current_time',
),
)
events = await run_and_collect_events(agent, run_input)
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': (thread_id := IsSameStr()),
'runId': (run_id := IsSameStr()),
},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': (tool_call_id := IsSameStr()),
'toolCallName': 'current_time',
'parentMessageId': IsStr(),
},
{'type': 'TOOL_CALL_ARGS', 'timestamp': IsInt(), 'toolCallId': tool_call_id, 'delta': '{}'},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': tool_call_id},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': tool_call_id,
'content': '2023-06-21T12:08:45.485981+00:00',
'role': 'tool',
},
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': 'success current_time called',
},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
]
)
async def test_output_tool() -> None:
"""Output tool calls emit `TOOL_CALL_RESULT` via `handle_output_tool_result`."""
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaToolCalls | str]:
yield {0: DeltaToolCall(name='final_result', json_args='{"query":"hello"}', tool_call_id='out_1')}
def web_search(query: str) -> dict[str, str]:
return {'result': f'Searched for {query}'}
agent = Agent(model=FunctionModel(stream_function=stream_function), output_type=web_search)
run_input = create_input(UserMessage(id='msg_1', content='Tell me about hello'))
events = await run_and_collect_events(agent, run_input)
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': (thread_id := IsSameStr()),
'runId': (run_id := IsSameStr()),
},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': (tool_call_id := IsSameStr()),
'toolCallName': 'final_result',
'parentMessageId': IsStr(),
},
{
'type': 'TOOL_CALL_ARGS',
'timestamp': IsInt(),
'toolCallId': tool_call_id,
'delta': '{"query":"hello"}',
},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': tool_call_id},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': tool_call_id,
'content': 'Final result processed.',
'role': 'tool',
},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
]
)
async def test_thinking() -> None:
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaThinkingCalls | str]:
yield {0: DeltaThinkingPart(content='')}
yield "Let's do some thinking"
yield ''
yield ' and some more'
yield {1: DeltaThinkingPart(content='Thinking ')}
yield {1: DeltaThinkingPart(content='about the weather')}
yield {2: DeltaThinkingPart(content='')}
yield {3: DeltaThinkingPart(content='')}
yield {3: DeltaThinkingPart(content='Thinking about the meaning of life')}
yield {4: DeltaThinkingPart(content='Thinking about the universe')}
agent = Agent(
model=FunctionModel(stream_function=stream_function),
)
run_input = create_input(
UserMessage(
id='msg_1',
content='Think about the weather',
),
)
events = await run_and_collect_events(agent, run_input)
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': (thread_id := IsSameStr()),
'runId': (run_id := IsSameStr()),
},
# Part 0: empty thinking — skipped (no content, no metadata)
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': "Let's do some thinking",
},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': ' and some more',
},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
# Part 1: "Thinking about the weather"
{'type': 'THINKING_START', 'timestamp': IsInt()},
{'type': 'THINKING_TEXT_MESSAGE_START', 'timestamp': IsInt()},
{'type': 'THINKING_TEXT_MESSAGE_CONTENT', 'timestamp': IsInt(), 'delta': 'Thinking '},
{'type': 'THINKING_TEXT_MESSAGE_CONTENT', 'timestamp': IsInt(), 'delta': 'about the weather'},
{'type': 'THINKING_TEXT_MESSAGE_END', 'timestamp': IsInt()},
{'type': 'THINKING_END', 'timestamp': IsInt()},
# Part 2: empty thinking — skipped (no content, no metadata)
# Part 3: "Thinking about the meaning of life"
{'type': 'THINKING_START', 'timestamp': IsInt()},
{'type': 'THINKING_TEXT_MESSAGE_START', 'timestamp': IsInt()},
{
'type': 'THINKING_TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'delta': 'Thinking about the meaning of life',
},
{'type': 'THINKING_TEXT_MESSAGE_END', 'timestamp': IsInt()},
{'type': 'THINKING_END', 'timestamp': IsInt()},
# Part 4: "Thinking about the universe"
{'type': 'THINKING_START', 'timestamp': IsInt()},
{'type': 'THINKING_TEXT_MESSAGE_START', 'timestamp': IsInt()},
{'type': 'THINKING_TEXT_MESSAGE_CONTENT', 'timestamp': IsInt(), 'delta': 'Thinking about the universe'},
{'type': 'THINKING_TEXT_MESSAGE_END', 'timestamp': IsInt()},
{'type': 'THINKING_END', 'timestamp': IsInt()},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
]
)
async def test_thinking_with_signature() -> None:
"""Test that ReasoningEncryptedValueEvent is emitted with thinking metadata."""
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaThinkingCalls | str]:
yield {0: DeltaThinkingPart(content='Thinking deeply', signature='sig_abc123')}
yield 'Here is my response'
agent = Agent(model=FunctionModel(stream_function=stream_function))
run_input = create_input(
UserMessage(id='msg_1', content='Think about something'),
)
events = await run_and_collect_events(agent, run_input, ag_ui_version='0.1.13')
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': (thread_id := IsSameStr()),
'runId': (run_id := IsSameStr()),
},
{'type': 'REASONING_START', 'timestamp': IsInt(), 'messageId': (reasoning_id := IsSameStr())},
{
'type': 'REASONING_MESSAGE_START',
'timestamp': IsInt(),
'messageId': reasoning_id,
'role': 'reasoning',
},
{
'type': 'REASONING_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': reasoning_id,
'delta': 'Thinking deeply',
},
{'type': 'REASONING_MESSAGE_END', 'timestamp': IsInt(), 'messageId': reasoning_id},
{
'type': 'REASONING_ENCRYPTED_VALUE',
'timestamp': IsInt(),
'subtype': 'message',
'entityId': reasoning_id,
'encryptedValue': IsStr(),
},
{'type': 'REASONING_END', 'timestamp': IsInt(), 'messageId': reasoning_id},
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': 'Here is my response',
},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
{'type': 'RUN_FINISHED', 'timestamp': IsInt(), 'threadId': thread_id, 'runId': run_id},
]
)
async def test_thinking_consecutive_signatures() -> None:
"""Test that consecutive ThinkingParts each preserve their own metadata via separate REASONING blocks."""
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaThinkingCalls | str]:
yield {0: DeltaThinkingPart(content='First thought', signature='sig_aaa')}
yield {1: DeltaThinkingPart(content='Second thought', signature='sig_bbb')}
yield {2: DeltaThinkingPart(content='Third thought', signature='sig_ccc')}
yield 'Final answer'
agent = Agent(model=FunctionModel(stream_function=stream_function))
run_input = create_input(
UserMessage(id='msg_1', content='Think deeply'),
)
events = await run_and_collect_events(agent, run_input, ag_ui_version='0.1.13')
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': (thread_id := IsSameStr()),
'runId': (run_id := IsSameStr()),
},
# Part 0: signature=sig_aaa
{'type': 'REASONING_START', 'timestamp': IsInt(), 'messageId': (r0 := IsSameStr())},
{
'type': 'REASONING_MESSAGE_START',
'timestamp': IsInt(),
'messageId': r0,
'role': 'reasoning',
},
{'type': 'REASONING_MESSAGE_CONTENT', 'timestamp': IsInt(), 'messageId': r0, 'delta': 'First thought'},
{'type': 'REASONING_MESSAGE_END', 'timestamp': IsInt(), 'messageId': r0},
{
'type': 'REASONING_ENCRYPTED_VALUE',
'timestamp': IsInt(),
'subtype': 'message',
'entityId': r0,
'encryptedValue': IsStr(),
},
{'type': 'REASONING_END', 'timestamp': IsInt(), 'messageId': r0},
# Part 1: signature=sig_bbb
{'type': 'REASONING_START', 'timestamp': IsInt(), 'messageId': (r1 := IsSameStr())},
{
'type': 'REASONING_MESSAGE_START',
'timestamp': IsInt(),
'messageId': r1,
'role': 'reasoning',
},
{'type': 'REASONING_MESSAGE_CONTENT', 'timestamp': IsInt(), 'messageId': r1, 'delta': 'Second thought'},
{'type': 'REASONING_MESSAGE_END', 'timestamp': IsInt(), 'messageId': r1},
{
'type': 'REASONING_ENCRYPTED_VALUE',
'timestamp': IsInt(),
'subtype': 'message',
'entityId': r1,
'encryptedValue': IsStr(),
},
{'type': 'REASONING_END', 'timestamp': IsInt(), 'messageId': r1},
# Part 2: signature=sig_ccc
{'type': 'REASONING_START', 'timestamp': IsInt(), 'messageId': (r2 := IsSameStr())},
{
'type': 'REASONING_MESSAGE_START',
'timestamp': IsInt(),
'messageId': r2,
'role': 'reasoning',
},
{'type': 'REASONING_MESSAGE_CONTENT', 'timestamp': IsInt(), 'messageId': r2, 'delta': 'Third thought'},
{'type': 'REASONING_MESSAGE_END', 'timestamp': IsInt(), 'messageId': r2},
{
'type': 'REASONING_ENCRYPTED_VALUE',
'timestamp': IsInt(),
'subtype': 'message',
'entityId': r2,
'encryptedValue': IsStr(),
},
{'type': 'REASONING_END', 'timestamp': IsInt(), 'messageId': r2},
# Text response
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': 'Final answer',
},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
{'type': 'RUN_FINISHED', 'timestamp': IsInt(), 'threadId': thread_id, 'runId': run_id},
]
)
def test_reasoning_message_thinking_roundtrip() -> None:
"""Test that ReasoningMessage converts to ThinkingPart with metadata from encrypted_value."""
messages = AGUIAdapter.load_messages(
[
ReasoningMessage(
id='reasoning-1',
content='Let me think about this...',
encrypted_value=json.dumps(
{
'id': 'thinking-1',
'signature': 'sig_abc123',
'provider_name': 'anthropic',
'provider_details': {'some': 'details'},
}
),
),
AssistantMessage(id='msg-1', content='Here is my response'),
]
)
assert messages == snapshot(
[
ModelResponse(
parts=[
ThinkingPart(
content='Let me think about this...',
id='thinking-1',
signature='sig_abc123',
provider_name='anthropic',
provider_details={'some': 'details'},
),
TextPart(content='Here is my response'),
],
timestamp=IsDatetime(),
)
]
)
async def test_reasoning_events_with_all_metadata() -> None:
"""Test that REASONING_* events emit encryptedValue with all metadata fields."""
run_input = create_input(UserMessage(id='msg_1', content='test'))
event_stream = AGUIEventStream(run_input, accept=SSE_CONTENT_TYPE, ag_ui_version='0.1.13')
part = ThinkingPart(
content='Thinking content',
id='thinking-123',
signature='sig_xyz',
provider_name='anthropic',
provider_details={'model': 'claude-sonnet-4-5'},
)
events: list[BaseEvent] = []
async for e in event_stream.handle_thinking_start(part):
events.append(e)
async for e in event_stream.handle_thinking_end(part):
events.append(e)
assert [e.model_dump(exclude_none=True) for e in events] == snapshot(
[
{'type': 'REASONING_START', 'message_id': IsStr()},
{'type': 'REASONING_MESSAGE_START', 'message_id': IsStr(), 'role': 'reasoning'},
{'type': 'REASONING_MESSAGE_CONTENT', 'message_id': IsStr(), 'delta': 'Thinking content'},
{'type': 'REASONING_MESSAGE_END', 'message_id': IsStr()},
{
'type': 'REASONING_ENCRYPTED_VALUE',
'subtype': 'message',
'entity_id': IsStr(),
'encrypted_value': '{"id": "thinking-123", "signature": "sig_xyz", "provider_name": "anthropic", "provider_details": {"model": "claude-sonnet-4-5"}}',
},
{'type': 'REASONING_END', 'message_id': IsStr()},
]
)
def test_activity_message_other_types_ignored() -> None:
"""Test that ActivityMessage with other activity types are ignored."""
messages = AGUIAdapter.load_messages(
[
ActivityMessage(
id='activity-1',
activity_type='some_other_activity',
content={'foo': 'bar'},
),
AssistantMessage(id='msg-1', content='Response'),
]
)
assert messages == snapshot([ModelResponse(parts=[TextPart(content='Response')], timestamp=IsDatetime())])
@pytest.mark.parametrize(
'encrypted_value',
[
pytest.param('not valid json{{{', id='invalid-json'),
pytest.param('"just a string"', id='non-dict-string'),
pytest.param('[1, 2, 3]', id='non-dict-list'),
pytest.param('42', id='non-dict-number'),
],
)
def test_reasoning_message_malformed_encrypted_value(encrypted_value: str) -> None:
"""Test that malformed or non-dict encrypted_value is handled gracefully."""
messages = AGUIAdapter.load_messages(
[
ReasoningMessage(id='r-1', content='Thinking...', encrypted_value=encrypted_value),
AssistantMessage(id='msg-1', content='Done'),
]
)
assert messages == snapshot(
[
ModelResponse(
parts=[ThinkingPart(content='Thinking...'), TextPart(content='Done')],
timestamp=IsDatetime(),
)
]
)
def test_activity_message_file_part_missing_url() -> None:
"""Test that ActivityMessage(pydantic_ai_file) with empty url raises ValueError."""
with pytest.raises(ValueError, match='must have a non-empty url'):
AGUIAdapter.load_messages(
[
ActivityMessage(
id='activity-1',
activity_type='pydantic_ai_file',
content={'url': '', 'media_type': 'image/png'},
),
],
preserve_file_data=True,
)
_TIMESTAMPED_PARTS = (UserPromptPart, RetryPromptPart, ToolReturnPart, NativeToolReturnPart, SystemPromptPart)
def _sync_part_timestamps(
original_part: ModelRequestPart | ModelResponsePart,
new_part: ModelRequestPart | ModelResponsePart,
) -> None:
"""Sync timestamp attribute if both parts are request parts (which carry timestamps)."""
if isinstance(new_part, _TIMESTAMPED_PARTS) and isinstance(original_part, _TIMESTAMPED_PARTS):
object.__setattr__(new_part, 'timestamp', original_part.timestamp)
def _sync_timestamps(original: list[ModelMessage], reloaded: list[ModelMessage]) -> None:
"""Sync timestamps between original and reloaded messages for comparison."""
for o, n in zip(original, reloaded):
if isinstance(n, ModelResponse) and isinstance(o, ModelResponse):
n.timestamp = o.timestamp
for op, np in zip(o.parts, n.parts):
_sync_part_timestamps(op, np)
elif isinstance(n, ModelRequest) and isinstance(o, ModelRequest): # pragma: no branch
for op, np in zip(o.parts, n.parts):
_sync_part_timestamps(op, np)
def test_dump_load_roundtrip_basic() -> None:
"""Test that load_messages(dump_messages(msgs)) preserves basic messages."""
original: list[ModelMessage] = [
ModelRequest(parts=[SystemPromptPart(content='You are helpful'), UserPromptPart(content='Hello')]),
ModelResponse(parts=[TextPart(content='Hi!')]),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original)
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
_sync_timestamps(original, reloaded)
assert reloaded == original
def test_dump_load_roundtrip_thinking() -> None:
"""Test full round-trip for thinking parts with all metadata."""
original: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Think about this')]),
ModelResponse(
parts=[
ThinkingPart(
content='Deep thoughts...',
id='think-001',
signature='sig_xyz',
provider_name='anthropic',
provider_details={'model': 'claude-sonnet-4-5'},
),
TextPart(content='Conclusion'),
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original, ag_ui_version='0.1.13')
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
_sync_timestamps(original, reloaded)
assert reloaded == original
def test_dump_load_roundtrip_tools() -> None:
"""Test full round-trip for tool calls and returns."""
original: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Call tool')]),
ModelResponse(parts=[ToolCallPart(tool_name='my_tool', tool_call_id='call_abc', args='{"x": 1}')]),
ModelRequest(parts=[ToolReturnPart(tool_name='my_tool', tool_call_id='call_abc', content='result')]),
ModelResponse(parts=[TextPart(content='Done')]),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original)
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
_sync_timestamps(original, reloaded)
assert reloaded == original
def test_dump_load_roundtrip_load_capability() -> None:
"""Typed `load_capability` parts keep their identity through dump/load on >= 0.1.11.
The `encrypted_value` carrier landed in 0.1.11, so `tool_kind` survives from there; without it a
resuming agent would forget its loaded capabilities. Dumped at exactly 0.1.11 to pin the floor.
"""
original: list[ModelMessage] = [
ModelResponse(parts=[LoadCapabilityCallPart(tool_call_id='load-foobar', args='{"id": "foobar"}')]),
ModelRequest(
parts=[LoadCapabilityReturnPart(tool_call_id='load-foobar', content={'instructions': '# Foo Bar'})]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original, ag_ui_version='0.1.11')
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
_sync_timestamps(original, reloaded)
assert reloaded == original
assert parse_loaded_capabilities(reloaded) == {'foobar'}
def test_dump_load_roundtrip_load_capability_invalid_args() -> None:
"""Invalid `load_capability` args never count as loaded after a roundtrip.
AG-UI args are JSON strings, which always satisfy the typed part's `str` arm — unlike
Vercel's structured args the call still promotes, and invalidity surfaces as
`capability_id is None`.
"""
original: list[ModelMessage] = [
ModelResponse(parts=[LoadCapabilityCallPart(tool_call_id='load-foobar', args='{"name": "foobar"}')]),
ModelRequest(
parts=[
RetryPromptPart(
tool_name='load_capability',
tool_call_id='load-foobar',
content='Field required: id',
)
]
),
ModelResponse(parts=[LoadCapabilityCallPart(tool_call_id='load-foobar', args='{"id": "foobar"}')]),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original, ag_ui_version='0.1.13')
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
reloaded_call = message_part(reloaded, LoadCapabilityCallPart)
assert reloaded_call.capability_id is None
assert parse_loaded_capabilities(reloaded) == set()
def test_dump_load_roundtrip_load_capability_old_version() -> None:
"""On < 0.1.11, `tool_kind` is skipped (no `encrypted_value` field) and typed parts reload as base classes.
Dumping a typed part below the floor warns, since the round-trip silently forgets loaded state.
"""
original: list[ModelMessage] = [
ModelResponse(parts=[LoadCapabilityCallPart(tool_call_id='load-foobar', args='{"id": "foobar"}')]),
ModelRequest(
parts=[LoadCapabilityReturnPart(tool_call_id='load-foobar', content={'instructions': '# Foo Bar'})]
),
]
with pytest.warns(UserWarning, match=r'ag-ui-protocol 0\.1\.10 predates the `encrypted_value` field'):
ag_ui_msgs = AGUIAdapter.dump_messages(original, ag_ui_version='0.1.10')
assistant_msg = ag_ui_msgs[0]
assert isinstance(assistant_msg, AssistantMessage)
assert assistant_msg.tool_calls is not None
# Omitted entirely (not set to `None`), so a pre-0.1.11 client never sees an unexpected field.
assert 'encrypted_value' not in assistant_msg.tool_calls[0].model_fields_set
tool_msg = ag_ui_msgs[1]
assert isinstance(tool_msg, ToolMessage)
assert 'encrypted_value' not in tool_msg.model_fields_set
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
assert type(reloaded[0].parts[0]) is ToolCallPart
assert type(reloaded[1].parts[0]) is ToolReturnPart
assert parse_loaded_capabilities(reloaded) == set()
def test_dump_omits_encrypted_value_without_tool_kind() -> None:
"""On a supported version, a plain call/return with no `tool_kind` still omits `encrypted_value`
rather than emitting a bare `null` — the field is only set when there's a claim to carry."""
original: list[ModelMessage] = [
ModelResponse(parts=[ToolCallPart(tool_name='regular', tool_call_id='c1', args='{}')]),
ModelRequest(parts=[ToolReturnPart(tool_name='regular', tool_call_id='c1', content='ok')]),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original, ag_ui_version='0.1.13')
assistant_msg = ag_ui_msgs[0]
assert isinstance(assistant_msg, AssistantMessage)
assert assistant_msg.tool_calls is not None
assert 'encrypted_value' not in assistant_msg.tool_calls[0].model_fields_set
tool_msg = ag_ui_msgs[1]
assert isinstance(tool_msg, ToolMessage)
assert 'encrypted_value' not in tool_msg.model_fields_set
def test_dump_load_roundtrip_native_tool_search() -> None:
"""Native tool-search parts keep their typed identity through dump/load on >= 0.1.11."""
original: list[ModelMessage] = [
ModelResponse(
parts=[
NativeToolSearchCallPart(tool_call_id='search-1', args='{"queries": ["refund"]}'),
NativeToolSearchReturnPart(
tool_call_id='search-1',
content={'discovered_tools': [{'name': 'refund_tool'}]},
),
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original, ag_ui_version='0.1.13')
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
assert parse_discovered_tools(reloaded) == {'refund_tool'}
# A non-empty result proves the return part kept its typed identity; the call part's
# identity matters to Anthropic history replay, so pin it too.
assert isinstance(reloaded[0].parts[0], NativeToolSearchCallPart)
def test_load_tool_kind_falls_back_to_call_claim() -> None:
"""A ToolMessage without its own `encrypted_value` narrows via the paired call's claim.
Streamed results have no metadata slot, so client-assembled histories only carry
the claim on the `ToolCall`.
"""
loaded = AGUIAdapter.load_messages(
[
AssistantMessage(
id='msg-1',
tool_calls=[
ToolCall(
id='load-foobar',
function=FunctionCall(name='load_capability', arguments='{"id": "foobar"}'),
encrypted_value='{"pydantic_ai": {"tool_kind": "capability-load"}}',
)
],
),
ToolMessage(id='msg-2', tool_call_id='load-foobar', content='{"instructions": "# Foo Bar"}'),
]
)
assert isinstance(loaded[0].parts[0], LoadCapabilityCallPart)
assert isinstance(loaded[1].parts[0], LoadCapabilityReturnPart)
assert parse_loaded_capabilities(loaded) == {'foobar'}
def test_load_tool_kind_error_result_stays_plain() -> None:
"""A ToolMessage with `error` set never narrows: typed return parts imply success to their readers."""
loaded = AGUIAdapter.load_messages(
[
AssistantMessage(
id='msg-1',
tool_calls=[
ToolCall(
id='load-foobar',
function=FunctionCall(name='load_capability', arguments='{"name": "foobar"}'),
encrypted_value='{"pydantic_ai": {"tool_kind": "capability-load"}}',
)
],
),
ToolMessage(
id='msg-2',
tool_call_id='load-foobar',
content='Field required: id',
error='Field required: id',
),
]
)
assert type(loaded[1].parts[0]) is ToolReturnPart
assert parse_loaded_capabilities(loaded) == set()
@pytest.mark.parametrize(
'encrypted_value',
[
'not json',
'"a string"',
'[1]',
# A genuine provider blob or an un-namespaced claim (no `pydantic_ai` key) is never honored.
'{"tool_kind": "capability-load"}',
# A namespaced claim with an unknown kind is rejected by the `ToolPartKind` filter.
'{"pydantic_ai": {"tool_kind": "unknown-kind"}}',
],
)
def test_load_tool_kind_garbage_encrypted_value(encrypted_value: str) -> None:
"""`encrypted_value` is client-supplied: anything malformed or un-namespaced loads as a plain part."""
loaded = AGUIAdapter.load_messages(
[
AssistantMessage(
id='msg-1',
tool_calls=[
ToolCall(
id='call-1',
function=FunctionCall(name='load_capability', arguments='{"id": "foobar"}'),
encrypted_value=encrypted_value,
)
],
),
]
)
assert type(loaded[0].parts[0]) is ToolCallPart
def test_load_tool_kind_unparseable_result_content_stays_plain() -> None:
"""A claimed return whose content isn't valid JSON degrades to the plain string part."""
loaded = AGUIAdapter.load_messages(
[
AssistantMessage(
id='msg-1',
tool_calls=[
ToolCall(
id='load-foobar',
function=FunctionCall(name='load_capability', arguments='{"id": "foobar"}'),
encrypted_value='{"pydantic_ai": {"tool_kind": "capability-load"}}',
)
],
),
ToolMessage(id='msg-2', tool_call_id='load-foobar', content='not json'),
]
)
return_part = loaded[1].parts[0]
assert type(return_part) is ToolReturnPart
assert return_part.content == 'not json'
def test_load_malformed_builtin_tool_call_id_degrades_to_plain() -> None:
"""A client-supplied id starting with the builtin prefix but missing its `|` segments
degrades to plain tool call/return parts instead of raising on the tuple unpack."""
loaded = AGUIAdapter.load_messages(
[
AssistantMessage(
id='msg-1',
tool_calls=[
ToolCall(
id=BUILTIN_TOOL_CALL_ID_PREFIX,
function=FunctionCall(name='web_search', arguments='{}'),
)
],
),
ToolMessage(id='msg-2', tool_call_id=BUILTIN_TOOL_CALL_ID_PREFIX, content='{}'),
]
)
assert type(loaded[0].parts[0]) is ToolCallPart
assert type(loaded[1].parts[0]) is ToolReturnPart
@pytest.mark.parametrize('ag_ui_version', ['0.1.10', '0.1.13'])
async def test_run_stream_load_capability_tool_kind_encrypted_value(
ag_ui_version: Literal['0.1.10', '0.1.13'],
) -> None:
"""Streamed `load_capability` calls carry `tool_kind` via `REASONING_ENCRYPTED_VALUE`.
Clients build their `ToolCall` history from streamed events, echoing this back as
`encrypted_value` — without it, streaming-built histories reload as plain parts.
The event doesn't exist before 0.1.13, so it's skipped there.
"""
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaToolCalls | str]:
if len(messages) == 1:
yield {0: DeltaToolCall(name='load_capability', json_args='{"id": "refunds"}', tool_call_id='load-1')}
else:
yield 'done'
agent = Agent(
model=FunctionModel(stream_function=stream_function),
capabilities=[
Capability[object](
id='refunds',
description='Refund tools.',
instructions='Refund instructions.',
defer_loading=True,
)
],
)
run_input = create_input(UserMessage(id='msg_1', content='Help me with a refund'))
events = await run_and_collect_events(agent, run_input, ag_ui_version=ag_ui_version)
tool_events = [e for e in events if e['type'].startswith('TOOL_CALL') or e['type'] == 'REASONING_ENCRYPTED_VALUE']
encrypted_value_event = {
'type': 'REASONING_ENCRYPTED_VALUE',
'timestamp': IsInt(),
'subtype': 'tool-call',
'entityId': 'load-1',
'encryptedValue': '{"pydantic_ai": {"tool_kind": "capability-load"}}',
}
expected: list[dict[str, Any]] = [
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': 'load-1',
'toolCallName': 'load_capability',
'parentMessageId': IsStr(),
},
*([encrypted_value_event] if ag_ui_version == '0.1.13' else []),
{'type': 'TOOL_CALL_ARGS', 'timestamp': IsInt(), 'toolCallId': 'load-1', 'delta': '{"id": "refunds"}'},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': 'load-1'},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': 'load-1',
'content': '{"instructions":"Refund instructions."}',
'role': 'tool',
},
]
assert tool_events == expected
@pytest.mark.parametrize('ag_ui_version', ['0.1.10', '0.1.13'])
async def test_run_stream_native_tool_search_tool_kind_encrypted_value(
ag_ui_version: Literal['0.1.10', '0.1.13'],
) -> None:
"""Streamed native `tool_search` calls carry `tool_kind` via `REASONING_ENCRYPTED_VALUE`.
Mirrors `test_run_stream_load_capability_tool_kind_encrypted_value`, but for the builtin
(`provider_executed`) streaming path, which is a distinct code path. Clients build their
`ToolCall` history from streamed events, echoing this back as `encrypted_value` — without
it, streaming-built histories reload as plain parts and `parse_discovered_tools()` is empty
on resume. The event doesn't exist before 0.1.13, so it's skipped there.
"""
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[BuiltinToolCallsReturns | DeltaToolCalls | str]:
if len(messages) == 1:
yield {
0: NativeToolSearchCallPart(
tool_call_id='search-1', args='{"queries": ["refund"]}', provider_name='function'
)
}
yield {
1: NativeToolSearchReturnPart(
tool_call_id='search-1',
content={'discovered_tools': [{'name': 'refund_tool'}]},
provider_name='function',
)
}
else:
yield 'done'
agent = Agent(model=FunctionModel(stream_function=stream_function))
run_input = create_input(UserMessage(id='msg_1', content='Find me a refund tool'))
events = await run_and_collect_events(agent, run_input, ag_ui_version=ag_ui_version)
builtin_id = 'pyd_ai_builtin|function|search-1'
tool_events = [e for e in events if e['type'].startswith('TOOL_CALL') or e['type'] == 'REASONING_ENCRYPTED_VALUE']
encrypted_value_event = {
'type': 'REASONING_ENCRYPTED_VALUE',
'timestamp': IsInt(),
'subtype': 'tool-call',
'entityId': builtin_id,
'encryptedValue': '{"pydantic_ai": {"tool_kind": "tool-search"}}',
}
expected: list[dict[str, Any]] = [
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': builtin_id,
'toolCallName': 'tool_search',
'parentMessageId': IsStr(),
},
*([encrypted_value_event] if ag_ui_version == '0.1.13' else []),
{'type': 'TOOL_CALL_ARGS', 'timestamp': IsInt(), 'toolCallId': builtin_id, 'delta': '{"queries": ["refund"]}'},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': builtin_id},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': builtin_id,
'content': '{"discovered_tools":[{"name":"refund_tool"}]}',
'role': 'tool',
},
]
assert tool_events == expected
def test_dump_load_roundtrip_multiple_thinking_parts() -> None:
"""Test round-trip preserves multiple ThinkingParts with their metadata."""
original: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Think hard')]),
ModelResponse(
parts=[
ThinkingPart(content='First thought', id='think-1', signature='sig_1'),
ThinkingPart(content='Second thought', id='think-2', signature='sig_2'),
TextPart(content='Final answer'),
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original, ag_ui_version='0.1.13')
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
_sync_timestamps(original, reloaded)
assert reloaded == original
def test_dump_load_roundtrip_binary_content() -> None:
"""Test round-trip for binary content in user prompts (images, documents, etc.)."""
original: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'Describe this image',
ImageUrl(url='https://example.com/image.png', media_type='image/png'),
BinaryContent(data=b'raw image data', media_type='image/jpeg'),
]
),
]
),
ModelResponse(parts=[TextPart(content='I see an image.')]),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original)
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
_sync_timestamps(original, reloaded)
assert reloaded == original
@pytest.mark.parametrize(
'original',
[
pytest.param(
[
ModelRequest(parts=[UserPromptPart(content='Generate an image')]),
ModelResponse(
parts=[
FilePart(
content=BinaryImage(data=b'generated file content', media_type='image/png'),
id='file-001',
provider_name='openai',
provider_details={'model': 'gpt-image'},
),
TextPart(content='Here is your generated image.'),
]
),
],
id='full-attrs',
),
pytest.param(
[
ModelRequest(parts=[UserPromptPart(content='Generate')]),
ModelResponse(
parts=[
FilePart(content=BinaryImage(data=b'minimal file', media_type='image/png')),
TextPart(content='Done'),
]
),
],
id='minimal-attrs',
),
pytest.param(
[
ModelRequest(parts=[UserPromptPart(content='Generate image only')]),
ModelResponse(parts=[FilePart(content=BinaryImage(data=b'only file', media_type='image/png'))]),
],
id='file-only',
),
pytest.param(
[
ModelRequest(parts=[UserPromptPart(content='Generate an image')]),
ModelResponse(
parts=[
FilePart(
content=BinaryImage(
data=b'generated file content',
media_type='image/png',
vendor_metadata={'detail': 'high'},
),
),
]
),
],
id='vendor-metadata',
),
],
)
def test_dump_load_roundtrip_file_part(original: list[ModelMessage]) -> None:
"""Test round-trip for FilePart variants: full attributes, minimal, and file-only response.
Note: BinaryImage is used because from_data_uri() returns BinaryImage for image/* media types.
"""
ag_ui_msgs = AGUIAdapter.dump_messages(original, preserve_file_data=True)
reloaded = AGUIAdapter.load_messages(ag_ui_msgs, preserve_file_data=True)
_sync_timestamps(original, reloaded)
assert reloaded == original
def test_dump_load_roundtrip_builtin_tool_return() -> None:
"""Test round-trip for builtin tool calls with their return values.
Note: The round-trip reorders parts within ModelResponse because AG-UI's AssistantMessage
has separate content and tool_calls fields. TextPart comes first (from content), then
NativeToolCallPart (from tool_calls), then NativeToolReturnPart (from subsequent ToolMessage).
"""
original: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Search for info')]),
ModelResponse(
parts=[
TextPart(content='Based on the search...'),
NativeToolCallPart(
tool_name='web_search',
tool_call_id='call_123',
args='{"query": "test"}',
provider_name='anthropic',
),
NativeToolReturnPart(
tool_name='web_search',
tool_call_id='call_123',
content='Search results here',
provider_name='anthropic',
),
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original)
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
_sync_timestamps(original, reloaded)
assert reloaded == original
def test_dump_builtin_tool_call_without_return() -> None:
"""Test that NativeToolCallPart without a matching NativeToolReturnPart still dumps correctly."""
messages: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Search for info')]),
ModelResponse(
parts=[
NativeToolCallPart(
tool_name='web_search',
tool_call_id='call_orphan',
args='{"query": "test"}',
provider_name='anthropic',
),
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(messages)
assert len(ag_ui_msgs) == 2
assistant_msg = ag_ui_msgs[1]
assert isinstance(assistant_msg, AssistantMessage)
assert assistant_msg.tool_calls is not None
assert len(assistant_msg.tool_calls) == 1
assert assistant_msg.tool_calls[0].id == 'pyd_ai_builtin|anthropic|call_orphan'
def test_dump_load_roundtrip_cache_point() -> None:
"""Test that CachePoint is filtered out during round-trip (it's metadata only)."""
original: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(content=['Hello', CachePoint(), 'world']),
]
),
ModelResponse(parts=[TextPart(content='Hi!')]),
]
expected: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content=['Hello', 'world'])]),
ModelResponse(parts=[TextPart(content='Hi!')]),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original)
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
_sync_timestamps(expected, reloaded)
assert reloaded == expected
def test_dump_load_roundtrip_uploaded_file() -> None:
"""Test that UploadedFile is filtered out during round-trip (opaque provider file_id)."""
original: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=['Hello', UploadedFile(file_id='file-abc123', provider_name='anthropic'), 'world']
),
]
),
ModelResponse(parts=[TextPart(content='Hi!')]),
]
expected: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content=['Hello', 'world'])]),
ModelResponse(parts=[TextPart(content='Hi!')]),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original)
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
_sync_timestamps(expected, reloaded)
assert reloaded == expected
def test_dump_load_roundtrip_retry_prompt_with_tool() -> None:
"""Test round-trip for RetryPromptPart with tool_name (converted to ToolMessage with error)."""
original: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Call tool')]),
ModelResponse(parts=[ToolCallPart(tool_name='my_tool', tool_call_id='call_1', args='{}')]),
ModelRequest(
parts=[
RetryPromptPart(
tool_name='my_tool',
tool_call_id='call_1',
content='Invalid args',
)
]
),
ModelResponse(parts=[TextPart(content='OK')]),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original)
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
_sync_timestamps(original, reloaded)
# RetryPromptPart becomes ToolReturnPart on reload (same tool_call_id mapping)
assert len(reloaded) == 4
retry_part = message_part(reloaded, ToolReturnPart, message_index=2)
assert retry_part.tool_name == 'my_tool'
assert retry_part.tool_call_id == 'call_1'
def test_dump_load_roundtrip_retry_prompt_without_tool() -> None:
"""Test round-trip for RetryPromptPart without tool_name (converted to UserMessage)."""
original: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Do something')]),
ModelResponse(parts=[TextPart(content='Done')]),
ModelRequest(parts=[RetryPromptPart(content='Please try again')]),
ModelResponse(parts=[TextPart(content='OK')]),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original)
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
_sync_timestamps(original, reloaded)
# RetryPromptPart without tool becomes UserPromptPart on reload
# Content is formatted by RetryPromptPart.model_response()
assert len(reloaded) == 4
retry_part = message_part(reloaded, UserPromptPart, message_index=2)
assert 'Please try again' in str(retry_part.content)
def test_dump_messages_preserves_part_order() -> None:
"""Dumping a `ModelRequest` keeps `ToolReturnPart`s interleaved with user prompts (regression for #5964).
User content was previously buffered and emitted as a single `UserMessage` at the end, so a
`ToolReturnPart` following a `UserPromptPart` would be reordered after it. That produces a
`tool_use` block without an immediately-following `tool_result`, which providers like Anthropic
reject on the next request. The buffer must be flushed before each tool message so the original
part order survives, including user prompts on both sides of a tool return.
"""
original: list[ModelMessage] = [
ModelResponse(
parts=[
ToolCallPart(tool_name='suggest', args={'suggestions': ['Yes', 'No']}, tool_call_id='call_1'),
]
),
ModelRequest(
parts=[
UserPromptPart(content='Before the tool return.'),
ToolReturnPart(tool_name='suggest', tool_call_id='call_1', content='suggested'),
UserPromptPart(content='After the tool return.'),
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original)
assert [type(msg).__name__ for msg in ag_ui_msgs] == snapshot(
['AssistantMessage', 'UserMessage', 'ToolMessage', 'UserMessage']
)
tool_msg = ag_ui_msgs[2]
assert isinstance(tool_msg, ToolMessage)
assert tool_msg.tool_call_id == 'call_1'
assert [getattr(msg, 'content', None) for msg in ag_ui_msgs[1:]] == snapshot(
['Before the tool return.', 'suggested', 'After the tool return.']
)
def test_dump_messages_preserves_uploaded_file_order() -> None:
"""Text straddling an `UploadedFile` keeps its order around the emitted `ActivityMessage`.
An `UploadedFile` is emitted as an `ActivityMessage` directly, so buffered user text must be
flushed before it, otherwise text that precedes the file would be reordered after it (the same
reordering class as #5964). The text on either side is therefore split into separate
`UserMessage`s rather than combined into one.
"""
original: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'Before the file.',
UploadedFile(file_id='file-abc123', provider_name='anthropic'),
'After the file.',
]
),
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original, preserve_file_data=True)
assert [type(msg).__name__ for msg in ag_ui_msgs] == snapshot(['UserMessage', 'ActivityMessage', 'UserMessage'])
before, activity, after = ag_ui_msgs
assert before.content == snapshot('Before the file.')
assert isinstance(activity, ActivityMessage)
assert activity.content['file_id'] == 'file-abc123'
assert after.content == snapshot('After the file.')
def test_file_part_dropped_by_default() -> None:
"""Test that FilePart is silently dropped when preserve_file_data=False (default).
dump_messages drops FilePart from output, and load_messages ignores
ActivityMessage(pydantic_ai_file) — both without raising errors.
"""
messages_with_file: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Generate an image')]),
ModelResponse(
parts=[
FilePart(content=BinaryImage(data=b'image data', media_type='image/png')),
TextPart(content='Here is your image.'),
]
),
]
# dump_messages drops FilePart by default
ag_ui_msgs = AGUIAdapter.dump_messages(messages_with_file)
assert not any(isinstance(m, ActivityMessage) and m.activity_type == 'pydantic_ai_file' for m in ag_ui_msgs)
# load_messages ignores ActivityMessage(pydantic_ai_file) by default
ag_ui_msgs_with_activity = AGUIAdapter.dump_messages(messages_with_file, preserve_file_data=True)
reloaded = AGUIAdapter.load_messages(ag_ui_msgs_with_activity)
assert not any(isinstance(part, FilePart) for msg in reloaded for part in msg.parts)
def test_dump_load_roundtrip_interleaved_text_and_tools() -> None:
"""Test round-trip for response with text interleaved around tool calls.
When text appears after tool calls, the flush pattern splits them into
separate AssistantMessages to preserve ordering on round-trip.
"""
original: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Do things')]),
ModelResponse(
parts=[
TextPart(content='Before tools'),
ToolCallPart(tool_name='search', args='{"q": "test"}', tool_call_id='call_1'),
TextPart(content='After tools'),
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original)
# Text before tools shares an AssistantMessage with the tool call;
# text after tools gets its own AssistantMessage.
assert [m.model_dump(exclude={'id'}, exclude_none=True) for m in ag_ui_msgs] == snapshot(
[
{'role': 'user', 'content': 'Do things'},
{
'role': 'assistant',
'content': 'Before tools',
'tool_calls': [
{
'id': 'call_1',
'type': 'function',
'function': {'name': 'search', 'arguments': '{"q": "test"}'},
},
],
},
{'role': 'assistant', 'content': 'After tools'},
]
)
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
_sync_timestamps(original, reloaded)
# Round-trip splits into two ModelResponses due to the two AssistantMessages
assert reloaded == snapshot(
[
ModelRequest(parts=[UserPromptPart(content='Do things', timestamp=IsDatetime())]),
ModelResponse(
parts=[
TextPart(content='Before tools'),
ToolCallPart(tool_name='search', args='{"q": "test"}', tool_call_id='call_1'),
TextPart(content='After tools'),
],
timestamp=IsDatetime(),
),
]
)
async def test_reasoning_events_empty_content_with_metadata() -> None:
"""Test REASONING_* events for ThinkingPart with no content but with metadata.
This exercises the path in handle_thinking_end where _reasoning_started is False
(no content was streamed) but encrypted metadata is present — e.g. redacted thinking.
"""
run_input = create_input(UserMessage(id='msg_1', content='test'))
event_stream = AGUIEventStream(run_input, accept=SSE_CONTENT_TYPE, ag_ui_version='0.1.13')
part = ThinkingPart(
content='',
id='think_redacted',
signature='sig_redacted',
)
events: list[BaseEvent] = [e async for e in event_stream.handle_thinking_start(part)]
async for e in event_stream.handle_thinking_end(part):
events.append(e)
assert [e.model_dump(exclude_none=True) for e in events] == snapshot(
[
{'type': 'REASONING_START', 'message_id': IsStr()},
{
'type': 'REASONING_ENCRYPTED_VALUE',
'subtype': 'message',
'entity_id': IsStr(),
'encrypted_value': '{"id": "think_redacted", "signature": "sig_redacted"}',
},
{'type': 'REASONING_END', 'message_id': IsStr()},
]
)
@pytest.mark.vcr()
@pytest.mark.skipif(not anthropic_imports_successful(), reason='anthropic not installed')
async def test_thinking_roundtrip_anthropic(allow_model_requests: None, anthropic_api_key: str) -> None:
"""Test that pydantic -> AG-UI -> pydantic round-trip preserves thinking metadata with real Anthropic responses."""
m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(api_key=anthropic_api_key))
settings: AnthropicModelSettings = {'anthropic_thinking': {'type': 'enabled', 'budget_tokens': 1024}}
agent = Agent(m, model_settings=settings)
result = await agent.run('What is 1+1? Reply in one word.')
original = result.all_messages()
ag_ui_msgs = AGUIAdapter.dump_messages(original, ag_ui_version='0.1.13')
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
_sync_timestamps(original, reloaded)
assert reloaded == snapshot(
[
ModelRequest(parts=[UserPromptPart(content='What is 1+1? Reply in one word.', timestamp=IsDatetime())]),
ModelResponse(
parts=[
ThinkingPart(
content='The user is asking what 1+1 equals and wants a one-word reply. The answer is 2, which is one word.',
signature='EooCCkYICxgCKkDYW6Ka+Mo73ZE34HVijmFbdV6QH/iRdv+3WuisH3pR8D5aSFASMBsF1F1bZRQFQXuM0+G4H83czthKvHqdqWriEgwB0eJaWoXZWU18NKoaDMH4nN8ZwJ6W9DnYLyIwrdTWmfc5QTqDr8gye3/yrPpV2YPeZnUBoHBLOGl8MUaC6SuGmxcm8rGqf2s+P+ZtKnJPJJzQiTrvPcEkF3ij22w3bXC9yoyZCyJVPcibR2ZZpLYF/UOoZ+BRBs0FCdm/QFXUUe8W1tcQ/ZQgBaW44LTcdzwOSP5hJb25UrPiGWuTytGMxIr7QyG7INpVbmm8JRBIIEzj3gs2zlxdbl17yZ/yZXcYAQ==',
provider_name='anthropic',
),
TextPart(content='Two'),
],
timestamp=IsDatetime(),
),
]
)
async def test_tool_local_then_ag_ui() -> None:
"""Test mixed local and AG-UI tool calls."""
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaToolCalls | str]:
if len(messages) == 1:
# First - call local tool (current_time)
yield {0: DeltaToolCall(name='current_time')}
yield {0: DeltaToolCall(json_args='{}')}
# Then - call AG-UI tool (get_weather)
yield {1: DeltaToolCall(name='get_weather')}
yield {1: DeltaToolCall(json_args='{"location": "Paris"}')}
else:
# Final response with results
yield 'current time is 2023-06-21T12:08:45.485981+00:00 and the weather in Paris is bright and sunny'
tool_call_id1 = uuid_str()
tool_call_id2 = uuid_str()
agent = Agent(
model=FunctionModel(stream_function=stream_function),
tools=[current_time],
)
run_inputs = [
(
first_input := create_input(
UserMessage(
id='msg_1',
content='Please tell me the time and then call get_weather for Paris',
),
tools=[get_weather()],
)
),
create_input(
UserMessage(
id='msg_1',
content='Please call get_weather for Paris',
),
AssistantMessage(
id='msg_2',
tool_calls=[
ToolCall(
id=tool_call_id1,
type='function',
function=FunctionCall(
name='current_time',
arguments='{}',
),
),
],
),
ToolMessage(
id='msg_3',
content='Tool result',
tool_call_id=tool_call_id1,
),
AssistantMessage(
id='msg_4',
tool_calls=[
ToolCall(
id=tool_call_id2,
type='function',
function=FunctionCall(
name='get_weather',
arguments='{"location": "Paris"}',
),
),
],
),
ToolMessage(
id='msg_5',
content='Bright and sunny',
tool_call_id=tool_call_id2,
),
tools=[get_weather()],
thread_id=first_input.thread_id,
),
]
events = await run_and_collect_events(agent, *run_inputs)
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': (thread_id := IsSameStr()),
'runId': (run_id := IsSameStr()),
},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': (first_tool_call_id := IsSameStr()),
'toolCallName': 'current_time',
'parentMessageId': (parent_message_id := IsSameStr()),
},
{'type': 'TOOL_CALL_ARGS', 'timestamp': IsInt(), 'toolCallId': first_tool_call_id, 'delta': '{}'},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': first_tool_call_id},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': (second_tool_call_id := IsSameStr()),
'toolCallName': 'get_weather',
'parentMessageId': parent_message_id,
},
{
'type': 'TOOL_CALL_ARGS',
'timestamp': IsInt(),
'toolCallId': second_tool_call_id,
'delta': '{"location": "Paris"}',
},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': second_tool_call_id},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': first_tool_call_id,
'content': '2023-06-21T12:08:45.485981+00:00',
'role': 'tool',
},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': (run_id := IsSameStr()),
},
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': 'current time is 2023-06-21T12:08:45.485981+00:00 and the weather in Paris is bright and sunny',
},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
]
)
async def test_request_with_state() -> None:
"""Test request with state modification."""
seen_states: list[int] = []
async def store_state(
ctx: RunContext[StateDeps[StateInt]], tool_defs: list[ToolDefinition]
) -> list[ToolDefinition]:
seen_states.append(ctx.deps.state.value)
ctx.deps.state.value += 1
return tool_defs
agent: Agent[StateDeps[StateInt], str] = Agent(
model=FunctionModel(stream_function=simple_stream),
deps_type=StateDeps[StateInt],
capabilities=[PrepareTools(store_state)],
)
run_inputs = [
create_input(
UserMessage(
id='msg_1',
content='Hello, how are you?',
),
state=StateInt(value=41),
),
create_input(
UserMessage(
id='msg_2',
content='Hello, how are you?',
),
),
create_input(
UserMessage(
id='msg_3',
content='Hello, how are you?',
),
),
create_input(
UserMessage(
id='msg_4',
content='Hello, how are you?',
),
state=StateInt(value=42),
),
]
seen_deps_states: list[int] = []
for run_input in run_inputs:
events = list[dict[str, Any]]()
deps = StateDeps(StateInt(value=0))
async def on_complete(result: AgentRunResult[Any]):
seen_deps_states.append(deps.state.value)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
async for event in adapter.encode_stream(adapter.run_stream(deps=deps, on_complete=on_complete)):
events.append(json.loads(event.removeprefix('data: ')))
assert events == simple_result(outcome={'type': 'success'})
assert seen_states == snapshot([41, 0, 0, 42])
assert seen_deps_states == snapshot([42, 1, 1, 43])
async def test_request_with_state_without_handler() -> None:
agent = Agent(model=FunctionModel(stream_function=simple_stream))
run_input = create_input(
UserMessage(
id='msg_1',
content='Hello, how are you?',
),
state=StateInt(value=41),
)
with pytest.warns(
UserWarning,
match='State was provided but `deps` of type `NoneType` does not implement the `StateHandler` protocol, so the state was ignored. Use `StateDeps\\[\\.\\.\\.\\]` or implement `StateHandler` to receive AG-UI state.',
):
events = list[dict[str, Any]]()
adapter = AGUIAdapter(agent=agent, run_input=run_input)
async for event in adapter.encode_stream(adapter.run_stream()):
events.append(json.loads(event.removeprefix('data: ')))
assert events == simple_result(outcome={'type': 'success'})
async def test_request_with_empty_state_without_handler() -> None:
agent = Agent(model=FunctionModel(stream_function=simple_stream))
run_input = create_input(
UserMessage(
id='msg_1',
content='Hello, how are you?',
),
state={},
)
events = list[dict[str, Any]]()
adapter = AGUIAdapter(agent=agent, run_input=run_input)
async for event in adapter.encode_stream(adapter.run_stream()):
events.append(json.loads(event.removeprefix('data: ')))
assert events == simple_result(outcome={'type': 'success'})
async def test_request_with_state_with_custom_handler() -> None:
@dataclass
class CustomStateDeps:
state: dict[str, Any]
seen_states: list[dict[str, Any]] = []
async def store_state(ctx: RunContext[CustomStateDeps], tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
seen_states.append(ctx.deps.state)
return tool_defs
agent: Agent[CustomStateDeps, str] = Agent(
model=FunctionModel(stream_function=simple_stream),
deps_type=CustomStateDeps,
capabilities=[PrepareTools(store_state)],
)
run_input = create_input(
UserMessage(
id='msg_1',
content='Hello, how are you?',
),
state={'value': 42},
)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
async for _ in adapter.encode_stream(adapter.run_stream(deps=CustomStateDeps(state={'value': 0}))):
pass
assert seen_states[-1] == {'value': 42}
async def test_concurrent_runs() -> None:
"""Test concurrent execution of multiple runs."""
import asyncio
agent: Agent[StateDeps[StateInt], str] = Agent(
model=TestModel(),
deps_type=StateDeps[StateInt],
)
@agent.tool
async def get_state(ctx: RunContext[StateDeps[StateInt]]) -> int:
return ctx.deps.state.value
concurrent_tasks: list[asyncio.Task[list[dict[str, Any]]]] = []
for i in range(5): # Test with 5 concurrent runs
run_input = create_input(
UserMessage(
id=f'msg_{i}',
content=f'Message {i}',
),
state=StateInt(value=i),
thread_id=f'test_thread_{i}',
)
task = asyncio.create_task(run_and_collect_events(agent, run_input, deps=StateDeps(StateInt())))
concurrent_tasks.append(task)
results = await asyncio.gather(*concurrent_tasks)
# Verify all runs completed successfully
for i, events in enumerate(results):
assert events == [
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': f'test_thread_{i}',
'runId': (run_id := IsSameStr()),
},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': (tool_call_id := IsSameStr()),
'toolCallName': 'get_state',
'parentMessageId': IsStr(),
},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': tool_call_id},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': tool_call_id,
'content': str(i),
'role': 'tool',
},
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{'type': 'TEXT_MESSAGE_CONTENT', 'timestamp': IsInt(), 'messageId': message_id, 'delta': '{"get_s'},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': 'tate":' + str(i) + '}',
},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
{'type': 'RUN_FINISHED', 'timestamp': IsInt(), 'threadId': f'test_thread_{i}', 'runId': run_id},
]
async def test_callback_sync() -> None:
"""Test that sync callbacks work correctly."""
captured_results: list[AgentRunResult[Any]] = []
def sync_callback(run_result: AgentRunResult[Any]) -> None:
captured_results.append(run_result)
agent = Agent(TestModel())
run_input = create_input(
UserMessage(
id='msg1',
content='Hello!',
)
)
events = await run_and_collect_events(agent, run_input, on_complete=sync_callback)
# Verify callback was called
assert len(captured_results) == 1
run_result = captured_results[0]
# Verify we can access messages
messages = run_result.all_messages()
assert len(messages) >= 1
req = message(messages, ModelRequest)
assert req.run_id == run_result.run_id
# Verify events were still streamed normally
assert len(events) > 0
assert events[0]['type'] == 'RUN_STARTED'
assert events[-1]['type'] == 'RUN_FINISHED'
async def test_adapter_sets_current_run_id_on_trailing_mapped_request() -> None:
"""The adapter sets `run_id` on the current run's mapped request, not older history."""
captured_results: list[AgentRunResult[Any]] = []
def sync_callback(run_result: AgentRunResult[Any]) -> None:
captured_results.append(run_result)
agent = Agent(TestModel())
run_input = create_input(
UserMessage(id='msg0', content='Previous question'),
AssistantMessage(id='msg1', content='Previous response'),
UserMessage(id='msg2', content='Hello!'),
)
await run_and_collect_events(agent, run_input, on_complete=sync_callback)
assert len(captured_results) == 1
run_result = captured_results[0]
messages = run_result.all_messages()
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Previous question', timestamp=IsDatetime())],
),
ModelResponse(
parts=[TextPart(content='Previous response')],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[UserPromptPart(content='Hello!', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=(run_id := IsSameStr()),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=IsInt(), output_tokens=IsInt()),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=run_id,
conversation_id=IsStr(),
),
]
)
assert messages[0].run_id is None
assert messages[1].run_id is None
assert messages[2].run_id == run_result.run_id
assert messages[3].run_id == run_result.run_id
assert run_result.new_messages() == messages[-1:]
async def test_adapter_uses_run_input_thread_id_as_conversation_id() -> None:
"""`RunAgentInput.threadId` is wired through to `gen_ai.conversation.id`."""
captured_results: list[AgentRunResult[Any]] = []
agent = Agent(TestModel())
run_input = create_input(UserMessage(id='msg0', content='Hello!'), thread_id='thread-abc')
await run_and_collect_events(agent, run_input, on_complete=captured_results.append)
assert captured_results[0].conversation_id == 'thread-abc'
assert captured_results[0].all_messages()[-1].conversation_id == 'thread-abc'
async def test_adapter_explicit_conversation_id_overrides_thread_id() -> None:
"""Passing `conversation_id` explicitly to `run_stream_native` overrides `RunAgentInput.threadId`."""
captured_results: list[AgentRunResult[Any]] = []
agent = Agent(TestModel())
run_input = create_input(UserMessage(id='msg0', content='Hello!'), thread_id='thread-abc')
adapter = AGUIAdapter(agent=agent, run_input=run_input, accept=None)
async for _ in adapter.transform_stream(
adapter.run_stream_native(conversation_id='explicit-conv-id'),
on_complete=captured_results.append,
):
pass
assert captured_results[0].conversation_id == 'explicit-conv-id'
async def test_adapter_run_stream_native_capabilities_kwarg_merged_into_run() -> None:
"""`AGUIAdapter.run_stream_native(capabilities=[...])` extends the run-level capability
list passed through to `Agent.run_stream_events`."""
seen_tool_defs: list[ToolDefinition] = []
async def prep(_ctx: RunContext[Any], tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
seen_tool_defs.extend(tool_defs)
return tool_defs
def my_tool() -> str:
return 'ok'
agent = Agent(TestModel(), tools=[my_tool])
run_input = create_input(UserMessage(id='msg0', content='Hello!'))
adapter = AGUIAdapter(agent=agent, run_input=run_input, accept=None)
async for _ in adapter.transform_stream(adapter.run_stream_native(capabilities=[PrepareTools(prep)])):
pass
assert seen_tool_defs, 'PrepareTools capability passed via run_stream_native(capabilities=...) should fire'
async def test_callback_async() -> None:
"""Test that async callbacks work correctly."""
captured_results: list[AgentRunResult[Any]] = []
async def async_callback(run_result: AgentRunResult[Any]) -> None:
captured_results.append(run_result)
agent = Agent(TestModel())
run_input = create_input(
UserMessage(
id='msg1',
content='Hello!',
)
)
events = await run_and_collect_events(agent, run_input, on_complete=async_callback)
# Verify callback was called
assert len(captured_results) == 1
run_result = captured_results[0]
# Verify we can access messages
messages = run_result.all_messages()
assert len(messages) >= 1
# Verify events were still streamed normally
assert len(events) > 0
assert events[0]['type'] == 'RUN_STARTED'
assert events[-1]['type'] == 'RUN_FINISHED'
async def test_messages(image_content: BinaryContent, document_content: BinaryContent) -> None:
messages = [
SystemMessage(
id='msg_1',
content='System message',
),
DeveloperMessage(
id='msg_2',
content='Developer message',
),
UserMessage(
id='msg_3',
content='User message',
),
UserMessage(
id='msg_4',
content='User message',
),
UserMessage(
id='msg_1',
content=[
TextInputContent(text='this is an image:'),
BinaryInputContent(url=image_content.data_uri, mime_type=image_content.media_type),
],
),
UserMessage(
id='msg2',
content=[BinaryInputContent(url='http://example.com/image.png', mime_type='image/png')],
),
UserMessage(
id='msg3',
content=[BinaryInputContent(url='http://example.com/video.mp4', mime_type='video/mp4')],
),
UserMessage(
id='msg4',
content=[BinaryInputContent(url='http://example.com/audio.mp3', mime_type='audio/mpeg')],
),
UserMessage(
id='msg5',
content=[BinaryInputContent(url='http://example.com/doc.pdf', mime_type='application/pdf')],
),
UserMessage(
id='msg6', content=[BinaryInputContent(data=document_content.base64, mime_type=document_content.media_type)]
),
AssistantMessage(
id='msg_5',
tool_calls=[
ToolCall(
id='pyd_ai_builtin|function|search_1',
function=FunctionCall(
name='web_search',
arguments='{"query": "Hello, world!"}',
),
),
],
),
ToolMessage(
id='msg_6',
content='{"results": [{"title": "Hello, world!", "url": "https://en.wikipedia.org/wiki/Hello,_world!"}]}',
tool_call_id='pyd_ai_builtin|function|search_1',
),
AssistantMessage(
id='msg_7',
content='Assistant message',
),
AssistantMessage(
id='msg_8',
tool_calls=[
ToolCall(
id='tool_call_1',
function=FunctionCall(
name='tool_call_1',
arguments='{}',
),
),
],
),
AssistantMessage(
id='msg_9',
tool_calls=[
ToolCall(
id='tool_call_2',
function=FunctionCall(
name='tool_call_2',
arguments='{}',
),
),
],
),
ToolMessage(
id='msg_10',
content='Tool message',
tool_call_id='tool_call_1',
),
ToolMessage(
id='msg_11',
content='Tool message',
tool_call_id='tool_call_2',
),
UserMessage(
id='msg_12',
content='User message',
),
AssistantMessage(
id='msg_13',
content='Assistant message',
),
]
assert AGUIAdapter.load_messages(messages) == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(
content='System message',
timestamp=IsDatetime(),
),
SystemPromptPart(
content='Developer message',
timestamp=IsDatetime(),
),
UserPromptPart(
content='User message',
timestamp=IsDatetime(),
),
UserPromptPart(
content='User message',
timestamp=IsDatetime(),
),
UserPromptPart(
content=['this is an image:', image_content],
timestamp=IsDatetime(),
),
UserPromptPart(
content=[ImageUrl(url='http://example.com/image.png', _media_type='image/png')],
timestamp=IsDatetime(),
),
UserPromptPart(
content=[VideoUrl(url='http://example.com/video.mp4', _media_type='video/mp4')],
timestamp=IsDatetime(),
),
UserPromptPart(
content=[AudioUrl(url='http://example.com/audio.mp3', _media_type='audio/mpeg')],
timestamp=IsDatetime(),
),
UserPromptPart(
content=[DocumentUrl(url='http://example.com/doc.pdf', _media_type='application/pdf')],
timestamp=IsDatetime(),
),
UserPromptPart(
content=[document_content],
timestamp=IsDatetime(),
),
]
),
ModelResponse(
parts=[
NativeToolCallPart(
tool_name='web_search',
args='{"query": "Hello, world!"}',
tool_call_id='search_1',
provider_name='function',
),
NativeToolReturnPart(
tool_name='web_search',
content={
'results': [
{'title': 'Hello, world!', 'url': 'https://en.wikipedia.org/wiki/Hello,_world!'}
]
},
tool_call_id='search_1',
timestamp=IsDatetime(),
provider_name='function',
),
TextPart(content='Assistant message'),
ToolCallPart(tool_name='tool_call_1', args='{}', tool_call_id='tool_call_1'),
ToolCallPart(tool_name='tool_call_2', args='{}', tool_call_id='tool_call_2'),
],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='tool_call_1',
content='Tool message',
tool_call_id='tool_call_1',
timestamp=IsDatetime(),
),
ToolReturnPart(
tool_name='tool_call_2',
content='Tool message',
tool_call_id='tool_call_2',
timestamp=IsDatetime(),
),
UserPromptPart(
content='User message',
timestamp=IsDatetime(),
),
]
),
ModelResponse(
parts=[TextPart(content='Assistant message')],
timestamp=IsDatetime(),
),
]
)
async def test_builtin_tool_return_json_string_content_parsed() -> None:
"""Regression test for https://github.com/pydantic/pydantic-ai/issues/4623.
AG-UI ToolMessage.content is always a string. For built-in tools the original
dict content gets JSON-serialized on the way out. The adapter must parse it
back so downstream model code (which checks isinstance(content, dict)) doesn't
silently drop the tool result.
"""
messages: list[Message] = [
AssistantMessage(
id='msg_1',
tool_calls=[
ToolCall(
id='pyd_ai_builtin|anthropic|srvtoolu_abc123',
function=FunctionCall(
name='web_fetch',
arguments='{"url": "https://example.com"}',
),
),
],
),
ToolMessage(
id='msg_2',
content='{"type": "web_fetch_result", "url": "https://example.com", "page_content": "hello"}',
tool_call_id='pyd_ai_builtin|anthropic|srvtoolu_abc123',
),
]
result = AGUIAdapter.load_messages(messages)
return_part = message_part(result, NativeToolReturnPart, part_index=1)
assert return_part.tool_name == 'web_fetch'
assert return_part.tool_call_id == 'srvtoolu_abc123'
assert return_part.provider_name == 'anthropic'
content = return_part.content
assert content == {'type': 'web_fetch_result', 'url': 'https://example.com', 'page_content': 'hello'}
async def test_builtin_tool_return_plain_string_content_preserved() -> None:
"""Plain string content that isn't valid JSON stays as-is."""
messages: list[Message] = [
AssistantMessage(
id='msg_1',
tool_calls=[
ToolCall(
id='pyd_ai_builtin|anthropic|srvtoolu_abc456',
function=FunctionCall(
name='web_fetch',
arguments='{"url": "https://example.com"}',
),
),
],
),
ToolMessage(
id='msg_2',
content='just a plain string, not JSON',
tool_call_id='pyd_ai_builtin|anthropic|srvtoolu_abc456',
),
]
result = AGUIAdapter.load_messages(messages)
return_part = message_part(result, NativeToolReturnPart, part_index=1)
assert return_part.content == 'just a plain string, not JSON'
async def test_builtin_tool_return_non_string_content_passthrough() -> None:
"""When ToolMessage.content is already a non-string (e.g. dict), it passes through without JSON parsing."""
tool_msg = ToolMessage.model_construct(
id='msg_2',
content={'type': 'web_fetch_result', 'url': 'https://example.com'},
tool_call_id='pyd_ai_builtin|anthropic|srvtoolu_abc789',
)
messages: list[Message] = [
AssistantMessage(
id='msg_1',
tool_calls=[
ToolCall(
id='pyd_ai_builtin|anthropic|srvtoolu_abc789',
function=FunctionCall(
name='web_fetch',
arguments='{"url": "https://example.com"}',
),
),
],
),
tool_msg,
]
result = AGUIAdapter.load_messages(messages)
return_part = message_part(result, NativeToolReturnPart, part_index=1)
assert return_part.content == {'type': 'web_fetch_result', 'url': 'https://example.com'}
async def test_builtin_tool_return_non_string_scalar_content_passthrough() -> None:
"""A non-string, non-mapping/sequence `content` (a scalar) passes through untouched.
Only mappings/sequences can nest multimodal items, so the discriminator is skipped for a scalar and
the value is returned as-is rather than coerced.
"""
tool_msg = ToolMessage.model_construct(
id='msg_2',
content=42,
tool_call_id='pyd_ai_builtin|anthropic|srvtoolu_scalar',
)
messages: list[Message] = [
AssistantMessage(
id='msg_1',
tool_calls=[
ToolCall(
id='pyd_ai_builtin|anthropic|srvtoolu_scalar',
function=FunctionCall(name='web_fetch', arguments='{"url": "https://example.com"}'),
),
],
),
tool_msg,
]
result = AGUIAdapter.load_messages(messages)
return_part = message_part(result, NativeToolReturnPart, part_index=1)
assert return_part.content == 42
async def test_user_message_empty_content_list_skipped() -> None:
"""A UserMessage with an empty content list produces no UserPromptPart."""
messages: list[Message] = [
UserMessage(id='msg_1', content=[]),
]
result = AGUIAdapter.load_messages(messages)
assert result == []
async def test_builtin_tool_call() -> None:
"""Test back-to-back builtin tool calls share the same parent_message_id.
Regression test for https://github.com/pydantic/pydantic-ai/issues/4098:
When a model performs multiple builtin tool calls (e.g. web searches) in
the same response, the BuiltinToolReturn handling would mutate the shared
message_id, causing subsequent tool calls to reference a non-existent
parent message.
"""
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[BuiltinToolCallsReturns | DeltaToolCalls | str]:
yield {
0: NativeToolCallPart(
tool_name=WebSearchTool.kind,
args='{"query":',
tool_call_id='search_1',
provider_name='function',
)
}
yield {
0: DeltaToolCall(
json_args='"Hello world"}',
tool_call_id='search_1',
)
}
yield {
1: NativeToolReturnPart(
tool_name=WebSearchTool.kind,
content={
'results': [
{
'title': '"Hello, World!" program',
'url': 'https://en.wikipedia.org/wiki/%22Hello,_World!%22_program',
}
]
},
tool_call_id='search_1',
provider_name='function',
)
}
yield {
2: NativeToolCallPart(
tool_name=WebSearchTool.kind,
args='{"query": "Hello world history"}',
tool_call_id='search_2',
provider_name='function',
)
}
yield {
3: NativeToolReturnPart(
tool_name=WebSearchTool.kind,
content={
'results': [
{
'title': 'History of Hello World',
'url': 'https://en.wikipedia.org/wiki/Hello_World_history',
}
]
},
tool_call_id='search_2',
provider_name='function',
)
}
yield 'A "Hello, World!" program is usually a simple computer program that emits (or displays) to the screen (often the console) a message similar to "Hello, World!". '
agent = Agent(
model=FunctionModel(stream_function=stream_function),
)
run_input = create_input(
UserMessage(
id='msg_1',
content='Tell me about Hello World',
),
)
events = await run_and_collect_events(agent, run_input)
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': (thread_id := IsSameStr()),
'runId': (run_id := IsSameStr()),
},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': 'pyd_ai_builtin|function|search_1',
'toolCallName': 'web_search',
'parentMessageId': (parent_message_id := IsSameStr()),
},
{
'type': 'TOOL_CALL_ARGS',
'timestamp': IsInt(),
'toolCallId': 'pyd_ai_builtin|function|search_1',
'delta': '{"query":',
},
{
'type': 'TOOL_CALL_ARGS',
'timestamp': IsInt(),
'toolCallId': 'pyd_ai_builtin|function|search_1',
'delta': '"Hello world"}',
},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': 'pyd_ai_builtin|function|search_1'},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': 'pyd_ai_builtin|function|search_1',
'content': '{"results":[{"title":"\\"Hello, World!\\" program","url":"https://en.wikipedia.org/wiki/%22Hello,_World!%22_program"}]}',
'role': 'tool',
},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': 'pyd_ai_builtin|function|search_2',
'toolCallName': 'web_search',
'parentMessageId': parent_message_id,
},
{
'type': 'TOOL_CALL_ARGS',
'timestamp': IsInt(),
'toolCallId': 'pyd_ai_builtin|function|search_2',
'delta': '{"query": "Hello world history"}',
},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': 'pyd_ai_builtin|function|search_2'},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': 'pyd_ai_builtin|function|search_2',
'content': '{"results":[{"title":"History of Hello World","url":"https://en.wikipedia.org/wiki/Hello_World_history"}]}',
'role': 'tool',
},
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': 'A "Hello, World!" program is usually a simple computer program that emits (or displays) to the screen (often the console) a message similar to "Hello, World!". ',
},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
},
]
)
async def test_event_stream_back_to_back_text():
async def event_generator():
yield PartStartEvent(index=0, part=TextPart(content='Hello'))
yield PartDeltaEvent(index=0, delta=TextPartDelta(content_delta=' world'))
yield PartEndEvent(index=0, part=TextPart(content='Hello world'), next_part_kind='text')
yield PartStartEvent(index=1, part=TextPart(content='Goodbye'), previous_part_kind='text')
yield PartDeltaEvent(index=1, delta=TextPartDelta(content_delta=' world'))
yield PartEndEvent(index=1, part=TextPart(content='Goodbye world'))
run_input = create_input(
UserMessage(
id='msg_1',
content='Tell me about Hello World',
),
)
event_stream = AGUIEventStream(run_input=run_input)
events = [
json.loads(event.removeprefix('data: '))
async for event in event_stream.encode_stream(event_stream.transform_stream(event_generator()))
]
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': (thread_id := IsSameStr()),
'runId': (run_id := IsSameStr()),
},
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{'type': 'TEXT_MESSAGE_CONTENT', 'timestamp': IsInt(), 'messageId': message_id, 'delta': 'Hello'},
{'type': 'TEXT_MESSAGE_CONTENT', 'timestamp': IsInt(), 'messageId': message_id, 'delta': ' world'},
{'type': 'TEXT_MESSAGE_CONTENT', 'timestamp': IsInt(), 'messageId': message_id, 'delta': 'Goodbye'},
{'type': 'TEXT_MESSAGE_CONTENT', 'timestamp': IsInt(), 'messageId': message_id, 'delta': ' world'},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
'outcome': {'type': 'success'},
},
]
)
async def test_event_stream_multiple_responses_with_tool_calls():
async def event_generator():
yield PartStartEvent(index=0, part=TextPart(content='Hello'))
yield PartDeltaEvent(index=0, delta=TextPartDelta(content_delta=' world'))
yield PartEndEvent(index=0, part=TextPart(content='Hello world'), next_part_kind='tool-call')
yield PartStartEvent(
index=1,
part=ToolCallPart(tool_name='tool_call_1', args='{}', tool_call_id='tool_call_1'),
previous_part_kind='text',
)
yield PartDeltaEvent(
index=1, delta=ToolCallPartDelta(args_delta='{"query": "Hello world"}', tool_call_id='tool_call_1')
)
yield PartEndEvent(
index=1,
part=ToolCallPart(tool_name='tool_call_1', args='{"query": "Hello world"}', tool_call_id='tool_call_1'),
next_part_kind='tool-call',
)
yield PartStartEvent(
index=2,
part=ToolCallPart(tool_name='tool_call_2', args='{}', tool_call_id='tool_call_2'),
previous_part_kind='tool-call',
)
yield PartDeltaEvent(
index=2, delta=ToolCallPartDelta(args_delta='{"query": "Goodbye world"}', tool_call_id='tool_call_2')
)
yield PartEndEvent(
index=2,
part=ToolCallPart(tool_name='tool_call_2', args='{"query": "Hello world"}', tool_call_id='tool_call_2'),
next_part_kind=None,
)
yield FunctionToolCallEvent(
part=ToolCallPart(tool_name='tool_call_1', args='{"query": "Hello world"}', tool_call_id='tool_call_1'),
args_valid=True,
)
yield FunctionToolCallEvent(
part=ToolCallPart(tool_name='tool_call_2', args='{"query": "Goodbye world"}', tool_call_id='tool_call_2'),
args_valid=True,
)
yield FunctionToolResultEvent(
part=ToolReturnPart(tool_name='tool_call_1', content='Hi!', tool_call_id='tool_call_1')
)
yield FunctionToolResultEvent(
part=ToolReturnPart(tool_name='tool_call_2', content='Bye!', tool_call_id='tool_call_2')
)
yield PartStartEvent(
index=0,
part=ToolCallPart(tool_name='tool_call_3', args='{}', tool_call_id='tool_call_3'),
previous_part_kind=None,
)
yield PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='{"query": "Hello world"}', tool_call_id='tool_call_3')
)
yield PartEndEvent(
index=0,
part=ToolCallPart(tool_name='tool_call_3', args='{"query": "Hello world"}', tool_call_id='tool_call_3'),
next_part_kind='tool-call',
)
yield PartStartEvent(
index=1,
part=ToolCallPart(tool_name='tool_call_4', args='{}', tool_call_id='tool_call_4'),
previous_part_kind='tool-call',
)
yield PartDeltaEvent(
index=1, delta=ToolCallPartDelta(args_delta='{"query": "Goodbye world"}', tool_call_id='tool_call_4')
)
yield PartEndEvent(
index=1,
part=ToolCallPart(tool_name='tool_call_4', args='{"query": "Goodbye world"}', tool_call_id='tool_call_4'),
next_part_kind=None,
)
yield FunctionToolCallEvent(
part=ToolCallPart(tool_name='tool_call_3', args='{"query": "Hello world"}', tool_call_id='tool_call_3'),
args_valid=True,
)
yield FunctionToolCallEvent(
part=ToolCallPart(tool_name='tool_call_4', args='{"query": "Goodbye world"}', tool_call_id='tool_call_4'),
args_valid=True,
)
yield FunctionToolResultEvent(
part=ToolReturnPart(tool_name='tool_call_3', content='Hi!', tool_call_id='tool_call_3')
)
yield FunctionToolResultEvent(
part=ToolReturnPart(tool_name='tool_call_4', content='Bye!', tool_call_id='tool_call_4')
)
run_input = create_input(
UserMessage(
id='msg_1',
content='Tell me about Hello World',
),
)
event_stream = AGUIEventStream(run_input=run_input)
events = [
json.loads(event.removeprefix('data: '))
async for event in event_stream.encode_stream(event_stream.transform_stream(event_generator()))
]
assert events == snapshot(
[
{
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': (thread_id := IsSameStr()),
'runId': (run_id := IsSameStr()),
},
{
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
{'type': 'TEXT_MESSAGE_CONTENT', 'timestamp': IsInt(), 'messageId': message_id, 'delta': 'Hello'},
{'type': 'TEXT_MESSAGE_CONTENT', 'timestamp': IsInt(), 'messageId': message_id, 'delta': ' world'},
{'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': 'tool_call_1',
'toolCallName': 'tool_call_1',
'parentMessageId': message_id,
},
{'type': 'TOOL_CALL_ARGS', 'timestamp': IsInt(), 'toolCallId': 'tool_call_1', 'delta': '{}'},
{
'type': 'TOOL_CALL_ARGS',
'timestamp': IsInt(),
'toolCallId': 'tool_call_1',
'delta': '{"query": "Hello world"}',
},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': 'tool_call_1'},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': 'tool_call_2',
'toolCallName': 'tool_call_2',
'parentMessageId': message_id,
},
{'type': 'TOOL_CALL_ARGS', 'timestamp': IsInt(), 'toolCallId': 'tool_call_2', 'delta': '{}'},
{
'type': 'TOOL_CALL_ARGS',
'timestamp': IsInt(),
'toolCallId': 'tool_call_2',
'delta': '{"query": "Goodbye world"}',
},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': 'tool_call_2'},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': 'tool_call_1',
'content': 'Hi!',
'role': 'tool',
},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': (result_message_id := IsSameStr()),
'toolCallId': 'tool_call_2',
'content': 'Bye!',
'role': 'tool',
},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': 'tool_call_3',
'toolCallName': 'tool_call_3',
'parentMessageId': (new_message_id := IsSameStr()),
},
{'type': 'TOOL_CALL_ARGS', 'timestamp': IsInt(), 'toolCallId': 'tool_call_3', 'delta': '{}'},
{
'type': 'TOOL_CALL_ARGS',
'timestamp': IsInt(),
'toolCallId': 'tool_call_3',
'delta': '{"query": "Hello world"}',
},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': 'tool_call_3'},
{
'type': 'TOOL_CALL_START',
'timestamp': IsInt(),
'toolCallId': 'tool_call_4',
'toolCallName': 'tool_call_4',
'parentMessageId': new_message_id,
},
{'type': 'TOOL_CALL_ARGS', 'timestamp': IsInt(), 'toolCallId': 'tool_call_4', 'delta': '{}'},
{
'type': 'TOOL_CALL_ARGS',
'timestamp': IsInt(),
'toolCallId': 'tool_call_4',
'delta': '{"query": "Goodbye world"}',
},
{'type': 'TOOL_CALL_END', 'timestamp': IsInt(), 'toolCallId': 'tool_call_4'},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': 'tool_call_3',
'content': 'Hi!',
'role': 'tool',
},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': 'tool_call_4',
'content': 'Bye!',
'role': 'tool',
},
{
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
'outcome': {'type': 'success'},
},
]
)
assert result_message_id != new_message_id
async def test_timestamps_are_set():
"""Test that all AG-UI events have timestamps set."""
agent = Agent(
model=FunctionModel(stream_function=simple_stream),
)
run_input = create_input(
UserMessage(
id='msg_1',
content='Hello, how are you?',
)
)
events = await run_and_collect_events(agent, run_input)
# All events should have timestamps
for event in events:
assert 'timestamp' in event, f'Event {event["type"]} missing timestamp'
assert isinstance(event['timestamp'], int), (
f'Event {event["type"]} timestamp should be int, got {type(event["timestamp"])}'
)
assert event['timestamp'] > 0, f'Event {event["type"]} timestamp should be positive'
async def test_tool_returns_event_with_timestamp_preserved():
"""Test that tools can return BaseEvents with pre-set timestamps that are preserved."""
custom_timestamp = 1234567890000
async def event_generator():
yield FunctionToolResultEvent(
part=ToolReturnPart(
tool_name='get_status',
content='Status retrieved',
tool_call_id='call_1',
metadata=CustomEvent(name='status_update', value={'status': 'ok'}, timestamp=custom_timestamp),
)
)
run_input = create_input(UserMessage(id='msg_1', content='Check status'))
event_stream = AGUIEventStream(run_input=run_input)
events = [
json.loads(event.removeprefix('data: '))
async for event in event_stream.encode_stream(event_stream.transform_stream(event_generator()))
]
custom_event = next((e for e in events if e.get('type') == 'CUSTOM'), None)
assert custom_event is not None
assert custom_event['timestamp'] == custom_timestamp
async def test_dispatch_request():
agent = Agent(model=TestModel())
run_input = create_input(
UserMessage(
id='msg_1',
content='Tell me about Hello World',
),
)
async def receive() -> dict[str, Any]:
return {'type': 'http.request', 'body': run_input.model_dump_json().encode('utf-8')}
starlette_request = Request(
scope={
'type': 'http',
'method': 'POST',
'headers': [
(b'content-type', b'application/json'),
],
},
receive=receive,
)
response = await AGUIAdapter.dispatch_request(starlette_request, agent=agent)
assert isinstance(response, StreamingResponse)
chunks: list[MutableMapping[str, Any]] = []
async def send(data: MutableMapping[str, Any]) -> None:
if body := data.get('body'):
data['body'] = json.loads(body.decode('utf-8').removeprefix('data: '))
chunks.append(data)
await response.stream_response(send)
assert chunks == snapshot(
[
{
'type': 'http.response.start',
'status': 200,
'headers': [(b'content-type', b'text/event-stream; charset=utf-8')],
},
{
'type': 'http.response.body',
'body': {
'type': 'RUN_STARTED',
'timestamp': IsInt(),
'threadId': (thread_id := IsSameStr()),
'runId': (run_id := IsSameStr()),
},
'more_body': True,
},
{
'type': 'http.response.body',
'body': {
'type': 'TEXT_MESSAGE_START',
'timestamp': IsInt(),
'messageId': (message_id := IsSameStr()),
'role': 'assistant',
},
'more_body': True,
},
{
'type': 'http.response.body',
'body': {
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': 'success ',
},
'more_body': True,
},
{
'type': 'http.response.body',
'body': {
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': '(no ',
},
'more_body': True,
},
{
'type': 'http.response.body',
'body': {
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': 'tool ',
},
'more_body': True,
},
{
'type': 'http.response.body',
'body': {
'type': 'TEXT_MESSAGE_CONTENT',
'timestamp': IsInt(),
'messageId': message_id,
'delta': 'calls)',
},
'more_body': True,
},
{
'type': 'http.response.body',
'body': {'type': 'TEXT_MESSAGE_END', 'timestamp': IsInt(), 'messageId': message_id},
'more_body': True,
},
{
'type': 'http.response.body',
'body': {
'type': 'RUN_FINISHED',
'timestamp': IsInt(),
'threadId': thread_id,
'runId': run_id,
'outcome': {'type': 'success'},
},
'more_body': True,
},
{'type': 'http.response.body', 'body': b'', 'more_body': False},
]
)
def test_dump_load_roundtrip_uploaded_file_preserved() -> None:
"""Test UploadedFile round-trips via ActivityMessage when preserve_file_data=True."""
original: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'Describe this file',
UploadedFile(
file_id='file-abc123',
provider_name='anthropic',
media_type='application/pdf',
vendor_metadata={'source': 'upload'},
identifier='my-doc.pdf',
),
]
),
]
),
ModelResponse(parts=[TextPart(content='I see a PDF.')]),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original, preserve_file_data=True)
# Verify ActivityMessage was emitted
activity_msgs = [m for m in ag_ui_msgs if isinstance(m, ActivityMessage)]
assert len(activity_msgs) == 1
assert activity_msgs[0].activity_type == 'pydantic_ai_uploaded_file'
assert activity_msgs[0].content['file_id'] == 'file-abc123'
reloaded = AGUIAdapter.load_messages(ag_ui_msgs, preserve_file_data=True)
# The text and UploadedFile come back as separate UserPromptParts
request_parts = [p for msg in reloaded if isinstance(msg, ModelRequest) for p in msg.parts]
user_parts = [p for p in request_parts if isinstance(p, UserPromptPart)]
assert len(user_parts) == 2
# First UserPromptPart has the text
assert user_parts[0].content == 'Describe this file'
# Second UserPromptPart has the UploadedFile
assert isinstance(user_parts[1].content, list)
uploaded = user_parts[1].content[0]
assert isinstance(uploaded, UploadedFile)
assert uploaded.file_id == 'file-abc123'
assert uploaded.provider_name == 'anthropic'
assert uploaded.media_type == 'application/pdf'
assert uploaded.vendor_metadata == {'source': 'upload'}
assert uploaded.identifier == 'my-doc.pdf'
@pytest.mark.parametrize(
'case_id',
[
'plain-string',
'structured-dict',
'structured-list',
'single-image',
'text-then-audio',
'image-and-video',
'document-url',
'dict-with-nested-image',
],
)
def test_dump_load_roundtrip_tool_return_multimodal(
case_id: str,
tiny_image: BinaryImage,
tiny_audio: BinaryContent,
tiny_video: BinaryContent,
) -> None:
"""Multimodal and structured `ToolReturnPart.content` ride inline in `ToolMessage.content` and round-trip with no flag.
`ag_ui.core.ToolMessage.content` is a plain `str`, but it already carries JSON for structured returns,
so the full content — files serialized as base64/URL dicts included — is written inline and rehydrated
on load via the `ToolReturnContent` discriminator. No sidecar `ActivityMessage` and no `preserve_file_data`
flag are involved: inline content round-trips verbatim through any frontend, whereas a custom sidecar
only round-trips if the frontend echoes it back. A file nested in a mapping (unreachable by
`BaseToolReturnPart.files`) round-trips too.
"""
contents: dict[str, Any] = {
'plain-string': 'just some text',
'structured-dict': {'temperature': 21, 'unit': 'C'},
'structured-list': [1, 2, 3],
'single-image': tiny_image,
'text-then-audio': ['the audio narration says...', tiny_audio],
'image-and-video': [tiny_image, tiny_video],
'document-url': DocumentUrl(url='https://example.com/doc.pdf', media_type='application/pdf'),
'dict-with-nested-image': {'caption': 'see image', 'attachment': tiny_image},
}
content = contents[case_id]
original: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Call tool')]),
ModelResponse(parts=[ToolCallPart(tool_name='get_files', tool_call_id='tc-1', args='{}')]),
ModelRequest(parts=[ToolReturnPart(tool_name='get_files', tool_call_id='tc-1', content=content)]),
ModelResponse(parts=[TextPart(content='Done')]),
]
# No flag: tool-return files always ride inline in the tool message, never a sidecar.
ag_ui_msgs = AGUIAdapter.dump_messages(original)
assert [m for m in ag_ui_msgs if isinstance(m, ActivityMessage)] == []
tool_msgs = [m for m in ag_ui_msgs if isinstance(m, ToolMessage)]
assert len(tool_msgs) == 1
assert isinstance(tool_msgs[0].content, str)
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
tool_returns = [
p for m in reloaded if isinstance(m, ModelRequest) for p in m.parts if isinstance(p, ToolReturnPart)
]
assert tool_returns == snapshot(
[ToolReturnPart(tool_name='get_files', tool_call_id='tc-1', content=content, timestamp=IsDatetime())]
)
def test_dump_tool_return_none_content_becomes_empty_string() -> None:
"""A `None` tool-return content dumps to an empty string, since AG-UI `ToolMessage.content` is text-only.
On reload the empty string is not valid JSON, so it stays `''` rather than round-tripping back to `None`.
"""
original: list[ModelMessage] = [
ModelResponse(parts=[ToolCallPart(tool_name='noop', tool_call_id='tc-1', args='{}')]),
ModelRequest(parts=[ToolReturnPart(tool_name='noop', tool_call_id='tc-1', content=None)]),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original)
tool_msgs = [m for m in ag_ui_msgs if isinstance(m, ToolMessage)]
assert len(tool_msgs) == 1
assert tool_msgs[0].content == ''
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
tool_returns = [
p for m in reloaded if isinstance(m, ModelRequest) for p in m.parts if isinstance(p, ToolReturnPart)
]
assert tool_returns == snapshot(
[ToolReturnPart(tool_name='noop', tool_call_id='tc-1', content='', timestamp=IsDatetime())]
)
@pytest.mark.parametrize('content', ['123', 'true', 'null'])
def test_tool_return_json_scalar_string_stays_string(content: str) -> None:
"""A string return that happens to be a valid JSON *scalar* must not change type on the round-trip.
`ToolMessage.content` is text-only on the AG-UI wire, so a string return is dumped verbatim. Re-parsing it
through the discriminator would turn `'123'` into `123`, `'true'` into `True`, etc. The rehydrator only runs the
discriminator on a parsed mapping/sequence (where nested multimodal items can live), leaving scalars as strings.
A container-shaped string (`'[1, 2]'`) is wire-indistinguishable from a real list return, so it does rehydrate —
that ambiguity is inherent to the text-only wire and only the scalar coercion is recoverable.
"""
original: list[ModelMessage] = [
ModelResponse(parts=[ToolCallPart(tool_name='get_value', tool_call_id='tc-1', args='{}')]),
ModelRequest(parts=[ToolReturnPart(tool_name='get_value', tool_call_id='tc-1', content=content)]),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original)
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
tool_returns = [
p for m in reloaded if isinstance(m, ModelRequest) for p in m.parts if isinstance(p, ToolReturnPart)
]
assert tool_returns == snapshot(
[ToolReturnPart(tool_name='get_value', tool_call_id='tc-1', content=content, timestamp=IsDatetime())]
)
def test_dump_load_roundtrip_builtin_tool_return_multimodal(tiny_image: BinaryImage) -> None:
"""Multimodal `NativeToolReturnPart.content` rides inline in `ToolMessage.content` and round-trips with no flag."""
original: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Search')]),
ModelResponse(
parts=[
NativeToolCallPart(
tool_name='web_search',
tool_call_id='call_1',
args='{"q": "test"}',
provider_name='anthropic',
),
NativeToolReturnPart(
tool_name='web_search',
tool_call_id='call_1',
content=['Search results', tiny_image],
provider_name='anthropic',
),
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(original)
assert [m for m in ag_ui_msgs if isinstance(m, ActivityMessage)] == []
reloaded = AGUIAdapter.load_messages(ag_ui_msgs)
returns = [
p for m in reloaded if isinstance(m, ModelResponse) for p in m.parts if isinstance(p, NativeToolReturnPart)
]
assert returns == snapshot(
[
NativeToolReturnPart(
tool_name='web_search',
tool_call_id='call_1',
content=['Search results', tiny_image],
timestamp=IsDatetime(),
provider_name='anthropic',
)
]
)
def test_load_messages_builtin_tool_return_json_content_rehydrates() -> None:
"""A builtin tool's JSON `ToolMessage.content` rehydrates to structured content (inline, no sidecar)."""
prefixed_id = 'pyd_ai_builtin|anthropic|call_1'
raw = [
AssistantMessage(
id='msg-1',
tool_calls=[
ToolCall(
id=prefixed_id,
type='function',
function=FunctionCall(name='web_search', arguments='{"q": "test"}'),
),
],
),
ToolMessage(
id='msg-2',
content='[{"a": 1}, {"b": 2}]',
tool_call_id=prefixed_id,
),
]
reloaded = AGUIAdapter.load_messages(raw)
returns = [
p for m in reloaded if isinstance(m, ModelResponse) for p in m.parts if isinstance(p, NativeToolReturnPart)
]
assert returns == snapshot(
[
NativeToolReturnPart(
tool_name='web_search',
tool_call_id='call_1',
content=[{'a': 1}, {'b': 2}],
timestamp=IsDatetime(),
provider_name='anthropic',
)
]
)
@pytest.mark.parametrize(
'version,expected_reasoning',
[
pytest.param('0.1.10', snapshot([]), id='v010-drops-thinking'),
pytest.param(
'0.1.13',
snapshot(
[{'content': 'Deep thoughts...', 'encrypted_value': '{"signature": "sig_xyz"}', 'role': 'reasoning'}]
),
id='v013-includes-reasoning',
),
],
)
def test_dump_messages_thinking_version_gated(version: str, expected_reasoning: list[Any]) -> None:
"""Test that dump_messages drops ThinkingPart at <0.1.13 and emits ReasoningMessage at >=0.1.13."""
messages: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Think about this')]),
ModelResponse(
parts=[
ThinkingPart(content='Deep thoughts...', signature='sig_xyz'),
TextPart(content='Conclusion'),
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(messages, ag_ui_version=version)
reasoning_msgs = [m for m in ag_ui_msgs if isinstance(m, ReasoningMessage)]
assert [m.model_dump(exclude={'id'}) for m in reasoning_msgs] == expected_reasoning
assert any(isinstance(m, AssistantMessage) and m.content == 'Conclusion' for m in ag_ui_msgs)
async def test_tool_return_with_files():
"""A tool return carrying files streams its full content inline in `ToolCallResultEvent.content`.
Files are serialized (base64 for `BinaryContent`, URL for `ImageUrl`) using the same dump as history
serialization, not collapsed to a `[File: ...]` placeholder, so a streaming frontend can echo the
content back and have the files rehydrated and re-sent to the model on the next step.
"""
async def event_generator():
# Content with text and file - files property extracts BinaryContent from the list
yield FunctionToolResultEvent(
part=ToolReturnPart(
tool_name='get_image',
content=['Image analysis result', BinaryContent(data=b'img', media_type='image/png')],
tool_call_id='call_1',
)
)
# Content with only a FileUrl - files property returns [ImageUrl]
yield FunctionToolResultEvent(
part=ToolReturnPart(
tool_name='get_url',
content=ImageUrl(url='https://example.com/image.jpg'),
tool_call_id='call_2',
)
)
run_input = create_input(UserMessage(id='msg_1', content='Analyze images'))
event_stream = AGUIEventStream(run_input=run_input)
events = [
json.loads(event.removeprefix('data: '))
async for event in event_stream.encode_stream(event_stream.transform_stream(event_generator()))
]
tool_results = [e for e in events if e.get('type') == 'TOOL_CALL_RESULT']
assert tool_results == snapshot(
[
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': 'call_1',
'content': '["Image analysis result",{"data":"aW1n","media_type":"image/png","vendor_metadata":null,"kind":"binary","identifier":"978ea7"}]',
'role': 'tool',
},
{
'type': 'TOOL_CALL_RESULT',
'timestamp': IsInt(),
'messageId': IsStr(),
'toolCallId': 'call_2',
'content': '{"url":"https://example.com/image.jpg","force_download":false,"vendor_metadata":null,"kind":"image-url","media_type":"image/jpeg","identifier":"39cfc4"}',
'role': 'tool',
},
]
)
async def test_stream_tool_return_files_roundtrip_to_history() -> None:
"""The content a tool return streams can be replayed as history and rehydrates to the original file.
This is the round-trip that matters for a streaming frontend: the file a tool produced during the run
is streamed inline in `ToolCallResultEvent.content`, and when the frontend echoes that content back on
the next request it is recovered as a `BinaryImage` — so it can be sent to the model again (preserving
prompt-cache prefixes) instead of degrading to a text placeholder.
"""
image = BinaryImage(data=b'img', media_type='image/png')
async def event_generator():
yield FunctionToolResultEvent(
part=ToolReturnPart(tool_name='get_image', content=['here it is', image], tool_call_id='call_1')
)
run_input = create_input(UserMessage(id='msg_1', content='Analyze'))
event_stream = AGUIEventStream(run_input=run_input)
events = [
json.loads(event.removeprefix('data: '))
async for event in event_stream.encode_stream(event_stream.transform_stream(event_generator()))
]
result_content = next(e['content'] for e in events if e.get('type') == 'TOOL_CALL_RESULT')
# Replay the streamed content back as client-submitted history, paired with its tool call.
reloaded = AGUIAdapter.load_messages(
[
AssistantMessage(
id='msg_2',
tool_calls=[
ToolCall(id='call_1', type='function', function=FunctionCall(name='get_image', arguments='{}')),
],
),
ToolMessage(id='msg_3', content=result_content, tool_call_id='call_1'),
]
)
tool_returns = [
p for m in reloaded if isinstance(m, ModelRequest) for p in m.parts if isinstance(p, ToolReturnPart)
]
assert tool_returns == snapshot(
[
ToolReturnPart(
tool_name='get_image', content=['here it is', image], tool_call_id='call_1', timestamp=IsDatetime()
)
]
)
# region: Coverage — event_stream thinking version branches
async def test_thinking_events_v010_with_content() -> None:
"""Test v0.1.10 THINKING_* events for ThinkingPart with content."""
run_input = create_input(UserMessage(id='msg_1', content='test'))
event_stream = AGUIEventStream(run_input, accept=SSE_CONTENT_TYPE, ag_ui_version='0.1.10')
part = ThinkingPart(content='Some thoughts', signature='sig_abc')
events: list[BaseEvent] = []
async for e in event_stream.handle_thinking_start(part):
events.append(e)
async for e in event_stream.handle_thinking_end(part):
events.append(e)
assert [e.model_dump(exclude_none=True) for e in events] == snapshot(
[
{'type': 'THINKING_START'},
{'type': 'THINKING_TEXT_MESSAGE_START'},
{'type': 'THINKING_TEXT_MESSAGE_CONTENT', 'delta': 'Some thoughts'},
{'type': 'THINKING_TEXT_MESSAGE_END'},
{'type': 'THINKING_END'},
]
)
async def test_thinking_events_v010_empty_content() -> None:
"""Test v0.1.10 early return when ThinkingPart has no content."""
run_input = create_input(UserMessage(id='msg_1', content='test'))
event_stream = AGUIEventStream(run_input, accept=SSE_CONTENT_TYPE, ag_ui_version='0.1.10')
part = ThinkingPart(content='', signature='sig_abc')
events = [e async for e in event_stream.handle_thinking_start(part)]
events.extend([e async for e in event_stream.handle_thinking_end(part)])
assert events == []
async def test_thinking_delta_v013() -> None:
"""Test v0.1.13 REASONING_* events emitted via handle_thinking_delta."""
run_input = create_input(UserMessage(id='msg_1', content='test'))
event_stream = AGUIEventStream(run_input, accept=SSE_CONTENT_TYPE, ag_ui_version='0.1.13')
start_part = ThinkingPart(content='')
events: list[BaseEvent] = [e async for e in event_stream.handle_thinking_start(start_part)]
delta = ThinkingPartDelta(content_delta='chunk1')
async for e in event_stream.handle_thinking_delta(delta):
events.append(e)
assert [e.model_dump(exclude_none=True) for e in events] == snapshot(
[
{'type': 'REASONING_START', 'message_id': IsStr()},
{'type': 'REASONING_MESSAGE_START', 'message_id': IsStr(), 'role': 'reasoning'},
{'type': 'REASONING_MESSAGE_CONTENT', 'message_id': IsStr(), 'delta': 'chunk1'},
]
)
async def test_thinking_end_v013_no_content_no_metadata() -> None:
"""Test v0.1.13 early return when ThinkingPart has no content and no encrypted metadata."""
run_input = create_input(UserMessage(id='msg_1', content='test'))
event_stream = AGUIEventStream(run_input, accept=SSE_CONTENT_TYPE, ag_ui_version='0.1.13')
part = ThinkingPart(content='')
events = [e async for e in event_stream.handle_thinking_start(part)]
events.extend([e async for e in event_stream.handle_thinking_end(part)])
assert events == []
async def test_thinking_delta_v013_after_content_start() -> None:
"""Test v0.1.13 delta skips START/MESSAGE_START when reasoning already started."""
run_input = create_input(UserMessage(id='msg_1', content='test'))
event_stream = AGUIEventStream(run_input, accept=SSE_CONTENT_TYPE, ag_ui_version='0.1.13')
start_part = ThinkingPart(content='initial')
events = [e async for e in event_stream.handle_thinking_start(start_part)]
delta = ThinkingPartDelta(content_delta='more')
events.extend([e async for e in event_stream.handle_thinking_delta(delta)])
assert [e.model_dump(exclude_none=True) for e in events] == snapshot(
[
{'type': 'REASONING_START', 'message_id': IsStr()},
{'type': 'REASONING_MESSAGE_START', 'message_id': IsStr(), 'role': 'reasoning'},
{'type': 'REASONING_MESSAGE_CONTENT', 'message_id': IsStr(), 'delta': 'initial'},
{'type': 'REASONING_MESSAGE_CONTENT', 'message_id': IsStr(), 'delta': 'more'},
]
)
async def test_thinking_end_v010_with_content() -> None:
"""Test v0.1.10 end emits TextMessageEnd when content was streamed, and ThinkingStart when not started."""
run_input = create_input(UserMessage(id='msg_1', content='test'))
# Case 1: start with content → _reasoning_started=True, _reasoning_text=True
# end should emit TextMessageEnd + ThinkingEnd
event_stream = AGUIEventStream(run_input, accept=SSE_CONTENT_TYPE, ag_ui_version='0.1.10')
part = ThinkingPart(content='text')
events = [e async for e in event_stream.handle_thinking_start(part)]
events.extend([e async for e in event_stream.handle_thinking_end(part)])
assert [e.model_dump(exclude_none=True) for e in events] == snapshot(
[
{'type': 'THINKING_START'},
{'type': 'THINKING_TEXT_MESSAGE_START'},
{'type': 'THINKING_TEXT_MESSAGE_CONTENT', 'delta': 'text'},
{'type': 'THINKING_TEXT_MESSAGE_END'},
{'type': 'THINKING_END'},
]
)
# Case 2: start with empty content → _reasoning_started=False
# end with content → hits ThinkingStartEvent at line 246
event_stream2 = AGUIEventStream(run_input, accept=SSE_CONTENT_TYPE, ag_ui_version='0.1.10')
empty_part = ThinkingPart(content='')
events2 = [e async for e in event_stream2.handle_thinking_start(empty_part)]
full_part = ThinkingPart(content='non-empty')
events2.extend([e async for e in event_stream2.handle_thinking_end(full_part)])
assert [e.model_dump(exclude_none=True) for e in events2] == snapshot(
[
{'type': 'THINKING_START'},
{'type': 'THINKING_END'},
]
)
async def test_thinking_end_v013_no_encrypted_metadata() -> None:
"""Test v0.1.13 end skips encrypted_value event when part has no signature or metadata."""
run_input = create_input(UserMessage(id='msg_1', content='test'))
event_stream = AGUIEventStream(run_input, accept=SSE_CONTENT_TYPE, ag_ui_version='0.1.13')
part = ThinkingPart(content='text')
events = [e async for e in event_stream.handle_thinking_start(part)]
events.extend([e async for e in event_stream.handle_thinking_end(part)])
assert [e.model_dump(exclude_none=True) for e in events] == snapshot(
[
{'type': 'REASONING_START', 'message_id': IsStr()},
{'type': 'REASONING_MESSAGE_START', 'message_id': IsStr(), 'role': 'reasoning'},
{'type': 'REASONING_MESSAGE_CONTENT', 'message_id': IsStr(), 'delta': 'text'},
{'type': 'REASONING_MESSAGE_END', 'message_id': IsStr()},
{'type': 'REASONING_END', 'message_id': IsStr()},
]
)
# endregion
# region: Coverage — encrypted_metadata branch gap
async def test_thinking_encrypted_metadata_partial_fields() -> None:
"""Test thinking_encrypted_metadata with signature but no provider_name."""
run_input = create_input(UserMessage(id='msg_1', content='test'))
event_stream = AGUIEventStream(run_input, accept=SSE_CONTENT_TYPE, ag_ui_version='0.1.13')
part = ThinkingPart(content='Thoughts', signature='sig_only')
events: list[BaseEvent] = []
async for e in event_stream.handle_thinking_start(part):
events.append(e)
async for e in event_stream.handle_thinking_end(part):
events.append(e)
assert [e.model_dump(exclude_none=True) for e in events] == snapshot(
[
{'type': 'REASONING_START', 'message_id': IsStr()},
{'type': 'REASONING_MESSAGE_START', 'message_id': IsStr(), 'role': 'reasoning'},
{'type': 'REASONING_MESSAGE_CONTENT', 'message_id': IsStr(), 'delta': 'Thoughts'},
{'type': 'REASONING_MESSAGE_END', 'message_id': IsStr()},
{
'type': 'REASONING_ENCRYPTED_VALUE',
'subtype': 'message',
'entity_id': IsStr(),
'encrypted_value': '{"signature": "sig_only"}',
},
{'type': 'REASONING_END', 'message_id': IsStr()},
]
)
# endregion
# region: Coverage — adapter uploaded file edge cases
def test_load_messages_uploaded_file_missing_fields() -> None:
"""Test load_messages raises ValueError for malformed pydantic_ai_uploaded_file ActivityMessage."""
with pytest.raises(ValueError, match='must have non-empty file_id and provider_name'):
AGUIAdapter.load_messages(
[ActivityMessage(id='msg_1', activity_type='pydantic_ai_uploaded_file', content={})],
preserve_file_data=True,
)
def test_load_messages_uploaded_file_dropped_by_default() -> None:
"""AG-UI is default-safe: a client `pydantic_ai_uploaded_file` activity is ignored unless
`preserve_file_data=True`, so a client-supplied `file_id` is never honored by default."""
activity = ActivityMessage(
id='msg_1',
activity_type='pydantic_ai_uploaded_file',
content={'file_id': 's3://private-bucket/payroll.pdf', 'provider_name': 'bedrock'},
)
# Default (preserve_file_data=False): the activity is ignored, no UploadedFile is produced.
assert AGUIAdapter.load_messages([activity]) == []
# Opt-in (preserve_file_data=True): the UploadedFile is reconstructed.
reloaded = AGUIAdapter.load_messages([activity], preserve_file_data=True)
uploaded = [
item
for msg in reloaded
if isinstance(msg, ModelRequest)
for part in msg.parts
if isinstance(part, UserPromptPart)
for item in (part.content if isinstance(part.content, list) else [part.content])
if isinstance(item, UploadedFile)
]
assert len(uploaded) == 1
assert uploaded[0].file_id == 's3://private-bucket/payroll.pdf'
def test_agui_adapter_allow_uploaded_files_end_to_end() -> None:
"""End-to-end: with `preserve_file_data=True` the adapter reconstructs a client `UploadedFile`, and
with `allow_uploaded_files=True` it survives `sanitize_messages` into the agent-visible messages.
`preserve_file_data` controls the AG-UI wire representation (reconstructing the `UploadedFile` from a
`pydantic_ai_uploaded_file` activity message), while `allow_uploaded_files` is the inbound security
gate; both must be set for a client-submitted uploaded file to reach the agent.
"""
activity = ActivityMessage(
id='activity_1',
activity_type='pydantic_ai_uploaded_file',
content={'file_id': 's3://private-bucket/payroll.pdf', 'provider_name': 'bedrock'},
)
run_input = create_input(activity)
agent: Agent[None, str] = Agent(model=TestModel())
adapter = AGUIAdapter(agent=agent, run_input=run_input, preserve_file_data=True, allow_uploaded_files=True)
# `preserve_file_data=True` reconstructs the `UploadedFile` from the activity message.
loaded_uploaded = [
item
for msg in adapter.messages
if isinstance(msg, ModelRequest)
for part in msg.parts
if isinstance(part, UserPromptPart)
for item in (part.content if isinstance(part.content, list) else [part.content])
if isinstance(item, UploadedFile)
]
assert len(loaded_uploaded) == 1
assert loaded_uploaded[0].file_id == 's3://private-bucket/payroll.pdf'
# `allow_uploaded_files=True` lets it survive sanitization with no warning.
with warnings.catch_warnings():
warnings.simplefilter('error')
sanitized = adapter.sanitize_messages(adapter.messages)
sanitized_uploaded = [
item
for msg in sanitized
if isinstance(msg, ModelRequest)
for part in msg.parts
if isinstance(part, UserPromptPart)
for item in (part.content if isinstance(part.content, list) else [part.content])
if isinstance(item, UploadedFile)
]
assert len(sanitized_uploaded) == 1
assert sanitized_uploaded[0].file_id == 's3://private-bucket/payroll.pdf'
def test_agui_preserve_file_data_and_allow_uploaded_files_independent() -> None:
"""`preserve_file_data` (representation) and `allow_uploaded_files` (security) are independent.
With `preserve_file_data=True, allow_uploaded_files=False`, `load_messages` still reconstructs the
`UploadedFile` from the activity message, but `sanitize_messages` DROPS it with the uploaded-file
warning because the security gate is closed.
"""
activity = ActivityMessage(
id='activity_1',
activity_type='pydantic_ai_uploaded_file',
content={'file_id': 's3://private-bucket/payroll.pdf', 'provider_name': 'bedrock'},
)
run_input = create_input(activity)
agent: Agent[None, str] = Agent(model=TestModel())
adapter = AGUIAdapter(agent=agent, run_input=run_input, preserve_file_data=True, allow_uploaded_files=False)
# Representation opt-in still reconstructs the `UploadedFile`.
loaded_uploaded = [
item
for msg in adapter.messages
if isinstance(msg, ModelRequest)
for part in msg.parts
if isinstance(part, UserPromptPart)
for item in (part.content if isinstance(part.content, list) else [part.content])
if isinstance(item, UploadedFile)
]
assert len(loaded_uploaded) == 1
# But the closed security gate drops it during sanitization with a warning.
with pytest.warns(UserWarning, match=r"uploaded file\(s\) for provider\(s\) \['bedrock'\]"):
sanitized = adapter.sanitize_messages(adapter.messages)
sanitized_uploaded = [
item
for msg in sanitized
if isinstance(msg, ModelRequest)
for part in msg.parts
if isinstance(part, UserPromptPart)
for item in (part.content if isinstance(part.content, list) else [part.content])
if isinstance(item, UploadedFile)
]
assert sanitized_uploaded == []
def test_dump_messages_uploaded_file_with_vendor_metadata() -> None:
"""Test dump_messages includes vendor_metadata in ActivityMessage when present on UploadedFile."""
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
UploadedFile(
file_id='file-xyz',
provider_name='openai',
media_type='text/plain',
vendor_metadata={'custom': 'data'},
),
]
),
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(messages, preserve_file_data=True)
activity_msgs = [m for m in ag_ui_msgs if isinstance(m, ActivityMessage)]
assert [m.model_dump() for m in activity_msgs] == snapshot(
[
{
'id': IsStr(),
'role': 'activity',
'activity_type': 'pydantic_ai_uploaded_file',
'content': {
'file_id': 'file-xyz',
'provider_name': 'openai',
'media_type': 'text/plain',
'identifier': '6f0bbc',
'vendor_metadata': {'custom': 'data'},
},
}
]
)
def test_dump_messages_uploaded_file_without_vendor_metadata() -> None:
"""Test dump_messages omits vendor_metadata from ActivityMessage when None on UploadedFile."""
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
UploadedFile(
file_id='file-xyz',
provider_name='openai',
media_type='text/plain',
),
]
),
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(messages, preserve_file_data=True)
activity_msgs = [m for m in ag_ui_msgs if isinstance(m, ActivityMessage)]
assert [m.model_dump() for m in activity_msgs] == snapshot(
[
{
'id': IsStr(),
'role': 'activity',
'activity_type': 'pydantic_ai_uploaded_file',
'content': {
'file_id': 'file-xyz',
'provider_name': 'openai',
'media_type': 'text/plain',
'identifier': '6f0bbc',
},
}
]
)
def test_dump_messages_file_part_with_vendor_metadata() -> None:
"""Test dump_messages includes vendor_metadata in the file ActivityMessage when present on a FilePart."""
messages: list[ModelMessage] = [
ModelResponse(
parts=[
FilePart(
content=BinaryImage(
data=b'generated file content',
media_type='image/png',
vendor_metadata={'detail': 'high'},
),
),
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(messages, preserve_file_data=True)
activity_msgs = [m for m in ag_ui_msgs if isinstance(m, ActivityMessage)]
assert len(activity_msgs) == 1
assert activity_msgs[0].activity_type == 'pydantic_ai_file'
assert activity_msgs[0].content.get('vendor_metadata') == {'detail': 'high'}
def test_dump_messages_file_part_without_vendor_metadata() -> None:
"""Test dump_messages omits vendor_metadata from the file ActivityMessage when None on a FilePart."""
messages: list[ModelMessage] = [
ModelResponse(
parts=[
FilePart(content=BinaryImage(data=b'generated file content', media_type='image/png')),
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(messages, preserve_file_data=True)
activity_msgs = [m for m in ag_ui_msgs if isinstance(m, ActivityMessage)]
assert len(activity_msgs) == 1
assert 'vendor_metadata' not in activity_msgs[0].content
def test_load_messages_file_part_ignores_non_dict_vendor_metadata() -> None:
"""Test load_messages ignores client-supplied vendor_metadata that isn't a dict.
`vendor_metadata` is typed `Any` in the wire schema and set on a `BinaryContent` that
doesn't validate on assignment, so a malformed value must not reach the model.
"""
data_uri = BinaryImage(data=b'generated file content', media_type='image/png').data_uri
content: dict[str, Any] = {'url': data_uri, 'media_type': 'image/png', 'vendor_metadata': 'not-a-dict'}
reloaded = AGUIAdapter.load_messages(
[ActivityMessage(id='activity-1', activity_type='pydantic_ai_file', content=content)],
preserve_file_data=True,
)
file_parts = [part for message in reloaded for part in message.parts if isinstance(part, FilePart)]
assert len(file_parts) == 1
assert file_parts[0].content.vendor_metadata is None
# endregion
# region: Coverage — parse_ag_ui_version validation + TextContent + detect fallback
def test_parse_ag_ui_version_invalid() -> None:
"""Test that parse_ag_ui_version raises UserError for malformed input."""
with pytest.raises(UserError, match="Invalid AG-UI version 'latest'"):
parse_ag_ui_version('latest')
with pytest.raises(UserError, match="Invalid AG-UI version ''"):
parse_ag_ui_version('')
def test_parse_ag_ui_version_prerelease() -> None:
"""Test that parse_ag_ui_version strips pre-release suffixes."""
assert parse_ag_ui_version('0.1.13a1') == snapshot((0, 1, 13))
assert parse_ag_ui_version('0.1.13b2') == snapshot((0, 1, 13))
assert parse_ag_ui_version('0.1.13rc1') == snapshot((0, 1, 13))
assert parse_ag_ui_version('0.1.13.dev0') == snapshot((0, 1, 13))
assert parse_ag_ui_version('0.1.x') == snapshot((0, 1))
def test_detect_ag_ui_version_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that detect_ag_ui_version returns '0.1.10' when package is not found."""
def _raise_not_found(_name: str) -> str:
raise importlib.metadata.PackageNotFoundError()
monkeypatch.setattr('pydantic_ai.ui.ag_ui._utils.importlib.metadata.version', _raise_not_found)
assert detect_ag_ui_version() == snapshot('0.1.10')
def test_detect_ag_ui_version_old(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that detect_ag_ui_version returns the raw installed version string."""
def _return_old_version(_name: str) -> str:
return '0.1.10'
monkeypatch.setattr('pydantic_ai.ui.ag_ui._utils.importlib.metadata.version', _return_old_version)
assert detect_ag_ui_version() == snapshot('0.1.10')
def test_dump_messages_text_content() -> None:
"""Test that TextContent in UserPromptPart is converted to TextInputContent."""
messages: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content=[TextContent(content='hello')])]),
]
result = AGUIAdapter.dump_messages(messages)
assert [m.model_dump(exclude={'id'}, exclude_none=True) for m in result] == snapshot(
[{'role': 'user', 'content': 'hello'}]
)
# region multimodal and coverage tests
@pytest.mark.parametrize(
'input_content,expected_type',
[
pytest.param(
ImageInputContent(
source=InputContentUrlSource(type='url', value='https://example.com/photo.jpg', mime_type='image/jpeg')
),
ImageUrl(url='https://example.com/photo.jpg', media_type='image/jpeg'),
id='image-url',
),
pytest.param(
AudioInputContent(
source=InputContentUrlSource(type='url', value='https://example.com/clip.mp3', mime_type='audio/mpeg')
),
AudioUrl(url='https://example.com/clip.mp3', media_type='audio/mpeg'),
id='audio-url',
),
pytest.param(
VideoInputContent(
source=InputContentUrlSource(type='url', value='https://example.com/vid.mp4', mime_type='video/mp4')
),
VideoUrl(url='https://example.com/vid.mp4', media_type='video/mp4'),
id='video-url',
),
pytest.param(
DocumentInputContent(
source=InputContentUrlSource(
type='url', value='https://example.com/doc.pdf', mime_type='application/pdf'
)
),
DocumentUrl(url='https://example.com/doc.pdf', media_type='application/pdf'),
id='document-url',
),
]
if imports_successful()
else [],
)
def test_load_multimodal_url_sources(
input_content: ImageInputContent | AudioInputContent | VideoInputContent | DocumentInputContent,
expected_type: ImageUrl | AudioUrl | VideoUrl | DocumentUrl,
) -> None:
"""Test that typed multimodal URL input content is converted to the correct Pydantic AI URL type."""
messages = AGUIAdapter.load_messages([UserMessage(id='msg-1', content=[input_content])])
assert len(messages) == 1
request = message(messages, ModelRequest)
assert len(request.parts) == 1
part = message_part(messages, UserPromptPart)
assert isinstance(part.content, list)
assert len(part.content) == 1
assert part.content[0] == expected_type
def test_load_multimodal_data_source() -> None:
"""Test that multimodal data source input content is converted to BinaryContent."""
messages = AGUIAdapter.load_messages(
[
UserMessage(
id='msg-1',
content=[
ImageInputContent(
source=InputContentDataSource(type='data', value='aGVsbG8=', mime_type='image/png')
)
],
)
]
)
assert messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content=[BinaryContent(data=b'hello', media_type='image/png')], timestamp=IsDatetime()
),
]
)
]
)
def test_dump_messages_multimodal_url() -> None:
"""Test that media URLs are dumped as typed multimodal content with ag_ui_version >= 0.1.15."""
messages: list[ModelMessage] = [
ModelRequest(
parts=[UserPromptPart(content=[ImageUrl(url='https://example.com/img.png', media_type='image/png')])]
),
]
result = AGUIAdapter.dump_messages(messages, ag_ui_version='0.1.15')
assert [m.model_dump(exclude={'id'}, exclude_none=True) for m in result] == snapshot(
[
{
'role': 'user',
'content': [
{
'source': {
'type': 'url',
'value': 'https://example.com/img.png',
'mime_type': 'image/png',
},
'type': 'image',
}
],
}
]
)
def test_dump_messages_legacy_binary_content() -> None:
"""Test that media URLs and BinaryContent are dumped as BinaryInputContent with ag_ui_version < 0.1.15."""
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
ImageUrl(url='https://example.com/img.png', media_type='image/png'),
BinaryContent(data=b'raw data', media_type='image/jpeg'),
]
)
]
),
]
result = AGUIAdapter.dump_messages(messages, ag_ui_version='0.1.10')
assert [m.model_dump(exclude={'id'}, exclude_none=True) for m in result] == snapshot(
[
{
'role': 'user',
'content': [
{'type': 'binary', 'url': 'https://example.com/img.png', 'mime_type': 'image/png'},
{'type': 'binary', 'data': 'cmF3IGRhdGE=', 'mime_type': 'image/jpeg'},
],
}
]
)
def test_multimodal_roundtrip_preserves_file_vendor_metadata() -> None:
"""`vendor_metadata` on `FileUrl`/`BinaryContent` survives a dump -> load round-trip (ag-ui >= 0.1.15).
Regression test for #5764: the AG-UI adapter dropped `vendor_metadata`
(e.g. OpenAI/xAI image `detail`, Google `video_metadata`) for every
`ImageUrl`/`AudioUrl`/`VideoUrl`/`DocumentUrl`/`BinaryContent`, even though the adjacent
`UploadedFile` branch already round-tripped it. Multimodal input content carries it under
a `vendor_metadata` key in the typed part's `metadata` field.
"""
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
ImageUrl(
url='https://example.com/image.png',
media_type='image/png',
vendor_metadata={'detail': 'high'},
),
AudioUrl(
url='https://example.com/audio.mp3',
media_type='audio/mpeg',
vendor_metadata={'foo': 'bar'},
),
VideoUrl(
url='https://example.com/video.mp4',
media_type='video/mp4',
vendor_metadata={'fps': 5},
),
DocumentUrl(
url='https://example.com/doc.pdf',
media_type='application/pdf',
vendor_metadata={'foo': 'baz'},
),
BinaryContent(
data=b'fake_doc',
media_type='application/pdf',
vendor_metadata={'detail': 'low'},
),
]
)
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(messages, ag_ui_version='0.1.15')
# The dumped `metadata` is the external contract a frontend persists and re-sends.
assert [m.model_dump(exclude={'id'}, exclude_none=True) for m in ag_ui_msgs] == snapshot(
[
{
'role': 'user',
'content': [
{
'type': 'image',
'source': {'type': 'url', 'value': 'https://example.com/image.png', 'mime_type': 'image/png'},
'metadata': {'vendor_metadata': {'detail': 'high'}},
},
{
'type': 'audio',
'source': {'type': 'url', 'value': 'https://example.com/audio.mp3', 'mime_type': 'audio/mpeg'},
'metadata': {'vendor_metadata': {'foo': 'bar'}},
},
{
'type': 'video',
'source': {'type': 'url', 'value': 'https://example.com/video.mp4', 'mime_type': 'video/mp4'},
'metadata': {'vendor_metadata': {'fps': 5}},
},
{
'type': 'document',
'source': {
'type': 'url',
'value': 'https://example.com/doc.pdf',
'mime_type': 'application/pdf',
},
'metadata': {'vendor_metadata': {'foo': 'baz'}},
},
{
'type': 'document',
'source': {'type': 'data', 'value': 'ZmFrZV9kb2M=', 'mime_type': 'application/pdf'},
'metadata': {'vendor_metadata': {'detail': 'low'}},
},
],
}
]
)
loaded = AGUIAdapter.load_messages(ag_ui_msgs)
assert loaded == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content=[
ImageUrl(
url='https://example.com/image.png',
media_type='image/png',
identifier='01a7df',
vendor_metadata={'detail': 'high'},
),
AudioUrl(
url='https://example.com/audio.mp3',
vendor_metadata={'foo': 'bar'},
_media_type='audio/mpeg',
),
VideoUrl(
url='https://example.com/video.mp4',
media_type='video/mp4',
identifier='8cb95e',
vendor_metadata={'fps': 5},
),
DocumentUrl(
url='https://example.com/doc.pdf',
media_type='application/pdf',
identifier='e3337d',
vendor_metadata={'foo': 'baz'},
),
BinaryContent(
data=b'fake_doc',
media_type='application/pdf',
identifier='42a9bb',
vendor_metadata={'detail': 'low'},
),
],
timestamp=IsDatetime(),
)
]
)
]
)
@pytest.mark.parametrize(
'content',
[
pytest.param(
ImageUrl(url='https://example.com/image.png', media_type='image/png', force_download=True),
id='image-true',
),
pytest.param(
AudioUrl(url='https://example.com/audio.mp3', media_type='audio/mpeg', force_download='allow-local'),
id='audio-allow-local',
),
pytest.param(
VideoUrl(url='https://example.com/video.mp4', media_type='video/mp4', force_download=True),
id='video-true',
),
pytest.param(
DocumentUrl(url='https://example.com/doc.pdf', media_type='application/pdf', force_download='allow-local'),
id='document-allow-local',
),
],
)
def test_multimodal_roundtrip_preserves_file_url_force_download(
content: ImageUrl | AudioUrl | VideoUrl | DocumentUrl,
) -> None:
"""`FileUrl.force_download` survives an AG-UI multimodal dump -> load round-trip."""
messages: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart(content=[content])])]
ag_ui_msgs = AGUIAdapter.dump_messages(messages, ag_ui_version='0.1.15')
user_msg = ag_ui_msgs[0]
assert isinstance(user_msg, UserMessage)
assert isinstance(user_msg.content, list)
dumped_content = user_msg.content[0]
assert isinstance(dumped_content, ImageInputContent | AudioInputContent | VideoInputContent | DocumentInputContent)
assert dumped_content.metadata == {'force_download': content.force_download}
loaded = AGUIAdapter.load_messages(ag_ui_msgs)
_sync_timestamps(messages, loaded)
assert loaded == messages
def test_multimodal_roundtrip_drops_file_url_force_download_before_0_1_15() -> None:
"""`FileUrl.force_download` is dropped when dumping to AG-UI versions before `0.1.15`.
Legacy `BinaryInputContent` (emitted for `ag_ui_version < '0.1.15'`) has no `metadata`
carrier, so there is nowhere to stash `force_download` and it silently resets to `False`
on reload — the security-conservative default, matching the other legacy-version drops.
"""
content = ImageUrl(url='https://example.com/image.png', media_type='image/png', force_download=True)
messages: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart(content=[content])])]
ag_ui_msgs = AGUIAdapter.dump_messages(messages, ag_ui_version='0.1.14')
user_msg = ag_ui_msgs[0]
assert isinstance(user_msg, UserMessage)
assert isinstance(user_msg.content, list)
dumped_content = user_msg.content[0]
assert isinstance(dumped_content, BinaryInputContent)
assert not hasattr(dumped_content, 'metadata')
loaded = AGUIAdapter.load_messages(ag_ui_msgs)
user_part = message_part(loaded, UserPromptPart)
assert isinstance(user_part.content, list)
loaded_content = user_part.content[0]
assert isinstance(loaded_content, ImageUrl)
assert loaded_content.force_download is False
def test_multimodal_roundtrip_file_without_vendor_metadata_stays_none() -> None:
"""A file with no `vendor_metadata` round-trips to `None` (no spurious `metadata`)."""
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
ImageUrl(url='https://example.com/image.png', media_type='image/png'),
BinaryContent(data=b'fake_image', media_type='image/png'),
]
)
]
),
]
ag_ui_msgs = AGUIAdapter.dump_messages(messages, ag_ui_version='0.1.15')
# `exclude_none` drops the `metadata` key entirely when no vendor_metadata is present.
assert [m.model_dump(exclude={'id'}, exclude_none=True) for m in ag_ui_msgs] == snapshot(
[
{
'role': 'user',
'content': [
{
'type': 'image',
'source': {'type': 'url', 'value': 'https://example.com/image.png', 'mime_type': 'image/png'},
},
{
'type': 'image',
'source': {'type': 'data', 'value': 'ZmFrZV9pbWFnZQ==', 'mime_type': 'image/png'},
},
],
}
]
)
loaded = AGUIAdapter.load_messages(ag_ui_msgs)
user_part = message_part(loaded, UserPromptPart)
assert isinstance(user_part.content, list)
for item in user_part.content:
assert getattr(item, 'vendor_metadata', None) is None
def test_load_multimodal_rejects_invalid_vendor_metadata() -> None:
"""A malformed `vendor_metadata` on multimodal input content is rejected on load.
The `metadata` field is typed as `Any`, so a non-`dict` client value is passed to the file
constructor which raises `ValidationError` here (matching the Vercel adapter), instead of
being stored unvalidated and crashing a provider model later.
"""
from pydantic import ValidationError
with pytest.raises(ValidationError):
AGUIAdapter.load_messages(
[
UserMessage(
id='msg-1',
content=[
ImageInputContent(
source=InputContentUrlSource(
type='url', value='https://example.com/image.png', mime_type='image/png'
),
metadata={'vendor_metadata': 'not-a-dict'},
)
],
)
]
)
def test_load_messages_unknown_type_warns() -> None:
"""Test that an unknown AG-UI message type emits a warning and is skipped."""
class UnknownMessage(BaseModel):
id: str
role: str = 'unknown'
with pytest.warns(UserWarning, match='AG-UI message type UnknownMessage is not yet implemented; skipping.'):
messages = AGUIAdapter.load_messages([UnknownMessage(id='msg-1')]) # pyright: ignore[reportArgumentType]
assert messages == []
# endregion
# region: System prompt tests
async def test_system_prompt_with_ag_ui_adapter():
"""Test that system prompts are included when using AGUIAdapter on first message."""
system_prompt = 'You are a helpful assistant'
agent = Agent(model=TestModel(), system_prompt=system_prompt)
run_input = create_input(
UserMessage(
id='msg_1',
content='Hello',
),
)
with capture_run_messages() as messages:
adapter = AGUIAdapter(agent=agent, run_input=run_input)
async for _ in adapter.encode_stream(adapter.run_stream()):
pass
assert messages == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='You are a helpful assistant', timestamp=IsDatetime()),
UserPromptPart(content='Hello', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=56, output_tokens=4),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_dynamic_system_prompt_with_ag_ui_adapter():
"""Test that dynamic system prompts are included when using AGUIAdapter on first message."""
agent = Agent(model=TestModel())
@agent.system_prompt
def dynamic_prompt(ctx: RunContext) -> str:
return 'Dynamic system prompt'
run_input = create_input(
UserMessage(
id='msg_1',
content='Hello',
),
)
with capture_run_messages() as messages:
adapter = AGUIAdapter(agent=agent, run_input=run_input)
async for _ in adapter.encode_stream(adapter.run_stream()):
pass
assert messages == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Dynamic system prompt', timestamp=IsDatetime()),
UserPromptPart(content='Hello', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=54, output_tokens=4),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_frontend_system_prompt_stripped_by_default():
"""Test that frontend system prompts are stripped and a warning emitted when `manage_system_prompt='server'`."""
agent = Agent(model=TestModel(), system_prompt='Agent system prompt')
run_input = create_input(
SystemMessage(
id='msg_sys',
content='Frontend system prompt',
),
UserMessage(
id='msg_1',
content='Hello',
),
)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
with capture_run_messages() as messages:
with pytest.warns(UserWarning, match='manage_system_prompt'):
async for _ in adapter.encode_stream(adapter.run_stream()):
pass
assert messages == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Agent system prompt', timestamp=IsDatetime()),
UserPromptPart(content='Hello', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=54, output_tokens=4),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_frontend_system_prompt_stripped_no_agent_prompt():
"""Test that frontend system prompts are stripped even when there's no agent system prompt."""
agent = Agent(model=TestModel())
run_input = create_input(
SystemMessage(
id='msg_sys',
content='Frontend system prompt',
),
UserMessage(
id='msg_1',
content='Hello',
),
)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
with capture_run_messages() as messages:
with pytest.warns(UserWarning, match='manage_system_prompt'):
async for _ in adapter.encode_stream(adapter.run_stream()):
pass
assert messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(content='Hello', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_frontend_system_prompt_only_request_dropped():
"""Test that a `ModelRequest` containing only `SystemPromptParts` is dropped entirely when filtering."""
agent = Agent(model=TestModel())
run_input = create_input(
SystemMessage(
id='msg_sys',
content='Frontend system prompt',
),
AssistantMessage(
id='msg_assistant',
content='Previous response',
),
UserMessage(
id='msg_1',
content='Hello',
),
)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
with capture_run_messages() as messages:
with pytest.warns(UserWarning, match='manage_system_prompt'):
async for _ in adapter.encode_stream(adapter.run_stream()):
pass
assert messages == snapshot(
[
ModelResponse(
parts=[TextPart(content='Previous response')],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[
UserPromptPart(content='Hello', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=51, output_tokens=6),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_client_mode_keeps_frontend_system_prompt():
"""Test that frontend system prompts are kept and agent prompt skipped when `manage_system_prompt='client'`."""
agent = Agent(model=TestModel(), system_prompt='Agent system prompt')
run_input = create_input(
SystemMessage(
id='msg_sys',
content='Frontend system prompt',
),
UserMessage(
id='msg_1',
content='Hello',
),
)
adapter = AGUIAdapter(agent=agent, run_input=run_input, manage_system_prompt='client')
with capture_run_messages() as messages:
async for _ in adapter.encode_stream(adapter.run_stream()):
pass
assert messages == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Frontend system prompt', timestamp=IsDatetime()),
UserPromptPart(content='Hello', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=54, output_tokens=4),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_client_mode_keeps_frontend_system_prompt_no_agent_prompt():
"""Test that frontend system prompts are used when `manage_system_prompt='client'` and agent has no system_prompt."""
agent = Agent(model=TestModel())
run_input = create_input(
SystemMessage(
id='msg_sys',
content='Frontend system prompt',
),
UserMessage(
id='msg_1',
content='Hello',
),
)
adapter = AGUIAdapter(agent=agent, run_input=run_input, manage_system_prompt='client')
with capture_run_messages() as messages:
async for _ in adapter.encode_stream(adapter.run_stream()):
pass
assert messages == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Frontend system prompt', timestamp=IsDatetime()),
UserPromptPart(content='Hello', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=54, output_tokens=4),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_client_mode_keeps_frontend_system_prompt_multi_turn():
"""Test that client-managed frontend system prompts are preserved across multi-turn conversations."""
agent = Agent(model=TestModel(), system_prompt='Agent system prompt')
run_input = RunAgentInput(
thread_id=uuid_str(),
run_id=uuid_str(),
messages=[
SystemMessage(
id='msg_sys',
content='Frontend system prompt',
),
UserMessage(
id='msg_1',
content='First message',
),
AssistantMessage(
id='msg_2',
content='First response',
),
UserMessage(
id='msg_3',
content='Second message',
),
],
state=None,
context=[],
tools=[],
forwarded_props=None,
)
adapter = AGUIAdapter(agent=agent, run_input=run_input, manage_system_prompt='client')
with capture_run_messages() as messages:
async for _ in adapter.encode_stream(adapter.run_stream()):
pass
assert messages == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Frontend system prompt', timestamp=IsDatetime()),
UserPromptPart(content='First message', timestamp=IsDatetime()),
],
),
ModelResponse(parts=[TextPart(content='First response')], timestamp=IsDatetime()),
ModelRequest(
parts=[
UserPromptPart(content='Second message', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=57, output_tokens=6),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_client_mode_does_not_reinject_agent_system_prompt():
"""In `manage_system_prompt='client'`, the agent's configured prompt is not injected when
the frontend sends none — frontend ownership means the frontend is responsible for any
system prompt. To get fallback-to-configured behavior anyway, callers can add the
[`ReinjectSystemPrompt`][pydantic_ai.capabilities.ReinjectSystemPrompt] capability to the
agent.
"""
agent = Agent(model=TestModel(), system_prompt='Agent system prompt')
run_input = create_input(
UserMessage(
id='msg_1',
content='Hello',
),
)
adapter = AGUIAdapter(agent=agent, run_input=run_input, manage_system_prompt='client')
with capture_run_messages() as messages:
async for _ in adapter.encode_stream(adapter.run_stream()):
pass
assert messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(content='Hello', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_system_prompt_reinjected_with_ag_ui_history():
"""Test that system prompts ARE reinjected on followup messages via UI adapters."""
system_prompt = 'You are a helpful assistant'
agent = Agent(model=TestModel(), system_prompt=system_prompt)
run_input = RunAgentInput(
thread_id=uuid_str(),
run_id=uuid_str(),
messages=[
UserMessage(
id='msg_1',
content='First message',
),
AssistantMessage(
id='msg_2',
content='First response',
),
UserMessage(
id='msg_3',
content='Second message',
),
],
state=None,
context=[],
tools=[],
forwarded_props=None,
)
with capture_run_messages() as messages:
adapter = AGUIAdapter(agent=agent, run_input=run_input)
async for _ in adapter.encode_stream(adapter.run_stream()):
pass
assert messages == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='You are a helpful assistant', timestamp=IsDatetime()),
UserPromptPart(content='First message', timestamp=IsDatetime()),
]
),
ModelResponse(parts=[TextPart(content='First response')], timestamp=IsDatetime()),
ModelRequest(
parts=[UserPromptPart(content='Second message', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=59, output_tokens=6),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_client_submitted_dangling_tool_calls_not_executed() -> None:
"""A client-submitted history ending with an unresolved tool call has that tool call
stripped before the agent sees the history, so the agent never has the chance to
execute it.
"""
captured: list[list[ModelMessage]] = []
async def stream_function(messages: list[ModelMessage], _info: AgentInfo) -> AsyncIterator[str]:
captured.append(list(messages))
yield 'done'
agent = Agent(model=FunctionModel(stream_function=stream_function))
run_input = create_input(
UserMessage(id='msg_1', content='Hi'),
AssistantMessage(
id='msg_2',
tool_calls=[
ToolCall(
id='client-call-1',
type='function',
function=FunctionCall(name='refresh_cache', arguments='{"key": "prod"}'),
)
],
),
)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
with pytest.warns(UserWarning, match=r'unresolved tool call.*refresh_cache'):
async for _ in adapter.encode_stream(adapter.run_stream()):
pass
assert len(captured) == 1
history_seen_by_model = captured[0]
assert not any(
isinstance(message, ModelResponse) and any(isinstance(part, ToolCallPart) for part in message.parts)
for message in history_seen_by_model
), 'dangling client-submitted tool call leaked into the agent run'
async def test_client_submitted_tool_call_resolved_by_deferred_results_runs() -> None:
"""Tool calls matched by caller-supplied `deferred_tool_results` survive sanitization,
so human-in-the-loop resumption still works.
"""
executed: list[dict[str, Any]] = []
agent = Agent(model=TestModel(), output_type=[str, DeferredToolRequests])
@agent.tool_plain(requires_approval=True)
def refresh_cache(key: str) -> str:
executed.append({'key': key})
return 'refreshed'
run_input = create_input(
UserMessage(id='msg_1', content='Hi'),
AssistantMessage(
id='msg_2',
tool_calls=[
ToolCall(
id='approved-call-1',
type='function',
function=FunctionCall(name='refresh_cache', arguments='{"key": "prod"}'),
)
],
),
)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
with warnings.catch_warnings():
warnings.simplefilter('error')
async for _ in adapter.encode_stream(
adapter.run_stream(deferred_tool_results=DeferredToolResults(approvals={'approved-call-1': True}))
):
pass
assert executed == [{'key': 'prod'}], 'approval-resumed tool call must execute'
async def test_client_submitted_file_url_disallowed_scheme_stripped() -> None:
"""An AG-UI `AGUIAdapter.sanitize_messages` call drops `FileUrl` parts whose URL
scheme isn't in `allowed_file_url_schemes`, matching the base `UIAdapter` contract.
"""
agent = Agent(model=TestModel())
adapter = AGUIAdapter(
agent=agent,
run_input=create_input(UserMessage(id='msg_1', content='Hi')),
)
crafted: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'See attached',
ImageUrl(url='s3://some-bucket/internal.png'),
ImageUrl(url='https://example.com/ok.png'),
]
)
]
)
]
with pytest.warns(UserWarning, match=r"scheme\(s\).*'s3'"):
sanitized = adapter.sanitize_messages(crafted)
assert len(sanitized) == 1
user_part = message_part(sanitized, UserPromptPart)
assert user_part.content == ['See attached', ImageUrl(url='https://example.com/ok.png')]
# endregion
# region: Interrupts — AG-UI RunFinished outcome and resume[] translation
pytestmark_interrupts = pytest.mark.skipif(
not interrupts_imports_successful(),
reason='ag-ui-protocol < 0.1.19 — interrupt types unavailable',
)
_INTERRUPTS_AG_UI_VERSION = '0.1.19'
async def _collect_adapter_events(
agent: Agent[Any, Any],
run_input: RunAgentInput,
*,
ag_ui_version: str = _INTERRUPTS_AG_UI_VERSION,
deferred_tool_results: DeferredToolResults | None = None,
) -> list[dict[str, Any]]:
"""Drive `AGUIAdapter` directly so we can pin `ag_ui_version` to the interrupt-aware release."""
adapter = AGUIAdapter(agent=agent, run_input=run_input, ag_ui_version=ag_ui_version)
events: list[dict[str, Any]] = []
async for encoded in adapter.encode_stream(adapter.run_stream(deferred_tool_results=deferred_tool_results)):
events.append(json.loads(encoded.removeprefix('data: ')))
return events
@pytestmark_interrupts
async def test_run_finished_success_outcome_on_modern_version() -> None:
"""On ag-ui-protocol >= 0.1.19, a normal run ends with `outcome.type == 'success'`."""
agent = Agent(model=FunctionModel(stream_function=simple_stream))
events = await _collect_adapter_events(agent, create_input(UserMessage(id='m1', content='hi')))
run_finished = next(e for e in events if e['type'] == 'RUN_FINISHED')
assert run_finished['outcome'] == snapshot({'type': 'success'})
@pytestmark_interrupts
async def test_run_finished_no_outcome_on_legacy_version() -> None:
"""On ag-ui-protocol < 0.1.19, no `outcome` field is emitted — matches today's behavior."""
agent = Agent(model=FunctionModel(stream_function=simple_stream))
events = await _collect_adapter_events(
agent, create_input(UserMessage(id='m1', content='hi')), ag_ui_version='0.1.10'
)
run_finished = next(e for e in events if e['type'] == 'RUN_FINISHED')
assert 'outcome' not in run_finished
@pytestmark_interrupts
async def test_run_finished_no_outcome_when_sdk_lacks_interrupts(monkeypatch: pytest.MonkeyPatch) -> None:
"""When the installed ag-ui-protocol SDK predates interrupts, `after_stream` emits a bare
`RUN_FINISHED` with no `outcome` field — even on a modern negotiated version. This is the
import-gated path (`HAS_INTERRUPTS` is False), distinct from the version-gated path.
"""
monkeypatch.setattr('pydantic_ai.ui.ag_ui._event_stream.HAS_INTERRUPTS', False)
agent = Agent(model=FunctionModel(stream_function=simple_stream))
events = await _collect_adapter_events(agent, create_input(UserMessage(id='m1', content='hi')))
run_finished = next(e for e in events if e['type'] == 'RUN_FINISHED')
assert 'outcome' not in run_finished
@pytestmark_interrupts
async def test_run_finished_interrupt_outcome_for_pending_approval() -> None:
"""When the run ends with `DeferredToolRequests.approvals`, the adapter emits an
interrupt outcome carrying one `Interrupt` per pending approval, with `reason='tool_call'`
and the original `tool_call_id` bound for resume.
"""
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaToolCalls | str]:
yield {0: DeltaToolCall(name='delete_file', json_args='{"path": ".env"}')}
agent = Agent(model=FunctionModel(stream_function=stream_function), output_type=[str, DeferredToolRequests])
@agent.tool_plain(requires_approval=True)
def delete_file(path: str) -> str: # pragma: no cover
# Body never runs: the tool is deferred for approval and this test asserts the
# pre-execution interrupt outcome. The roundtrip test covers an approved execution.
return f'deleted {path}'
events = await _collect_adapter_events(agent, create_input(UserMessage(id='m1', content='delete .env')))
run_finished = next(e for e in events if e['type'] == 'RUN_FINISHED')
assert run_finished['outcome'] == snapshot(
{
'type': 'interrupt',
'interrupts': [
{
'id': IsStr(),
'reason': 'tool_call',
'message': IsStr(),
'toolCallId': IsStr(),
'responseSchema': {
'type': 'object',
'properties': {
'approved': {'type': 'boolean'},
'editedArgs': {'type': 'object'},
'reason': {'type': 'string'},
},
'required': ['approved'],
},
}
],
}
)
interrupt = run_finished['outcome']['interrupts'][0]
# The `id` must let the client round-trip back to the original tool_call_id.
assert interrupt['id'].endswith(interrupt['toolCallId'])
@pytestmark_interrupts
async def test_run_finished_interrupt_outcome_carries_metadata() -> None:
"""Per-call `DeferredToolRequests.metadata` surfaces on `Interrupt.metadata`.
A tool raising `ApprovalRequired(metadata=...)` attaches metadata keyed by `tool_call_id`;
the adapter must forward it so a frontend can render context-specific approval UI.
"""
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaToolCalls | str]:
yield {0: DeltaToolCall(name='delete_file', json_args='{"path": ".env"}')}
agent = Agent(model=FunctionModel(stream_function=stream_function), output_type=[str, DeferredToolRequests])
@agent.tool_plain
def delete_file(path: str) -> str:
raise ApprovalRequired(metadata={'risk': 'high', 'scope': 'filesystem'})
events = await _collect_adapter_events(agent, create_input(UserMessage(id='m1', content='delete .env')))
run_finished = next(e for e in events if e['type'] == 'RUN_FINISHED')
interrupt = run_finished['outcome']['interrupts'][0]
assert interrupt['metadata'] == snapshot({'risk': 'high', 'scope': 'filesystem'})
@pytestmark_interrupts
async def test_resume_resolved_approves_tool() -> None:
"""`status='resolved'` + `payload.approved=True` → `ToolApproved()` for the bound tool call."""
agent = Agent(model=TestModel())
run_input = RunAgentInput(
thread_id=uuid_str(),
run_id=uuid_str(),
state={},
messages=[],
tools=[],
context=[],
forwarded_props=None,
resume=[ResumeEntry(interrupt_id='int-tc-001', status='resolved', payload={'approved': True})],
)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
assert adapter.deferred_tool_results == snapshot(DeferredToolResults(approvals={'tc-001': ToolApproved()}))
@pytestmark_interrupts
async def test_resume_resolved_with_edited_args_passes_override_args() -> None:
"""`payload.editedArgs` on an approval translates to `ToolApproved(override_args=...)`."""
agent = Agent(model=TestModel())
run_input = RunAgentInput(
thread_id=uuid_str(),
run_id=uuid_str(),
state={},
messages=[],
tools=[],
context=[],
forwarded_props=None,
resume=[
ResumeEntry(
interrupt_id='int-tc-001',
status='resolved',
payload={'approved': True, 'editedArgs': {'path': '/tmp/safer.env'}},
)
],
)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
assert adapter.deferred_tool_results == snapshot(
DeferredToolResults(approvals={'tc-001': ToolApproved(override_args={'path': '/tmp/safer.env'})})
)
@pytestmark_interrupts
async def test_resume_resolved_with_approved_false_denies_tool() -> None:
"""`payload.approved=False` with a `reason` → `ToolDenied(reason)`."""
agent = Agent(model=TestModel())
run_input = RunAgentInput(
thread_id=uuid_str(),
run_id=uuid_str(),
state={},
messages=[],
tools=[],
context=[],
forwarded_props=None,
resume=[
ResumeEntry(
interrupt_id='int-tc-001',
status='resolved',
payload={'approved': False, 'reason': 'too destructive'},
)
],
)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
assert adapter.deferred_tool_results == snapshot(
DeferredToolResults(approvals={'tc-001': ToolDenied(message='too destructive')})
)
@pytestmark_interrupts
async def test_resume_cancelled_denies_tool_regardless_of_payload() -> None:
"""`status='cancelled'` → `ToolDenied('Cancelled by user.')` regardless of payload contents."""
agent = Agent(model=TestModel())
run_input = RunAgentInput(
thread_id=uuid_str(),
run_id=uuid_str(),
state={},
messages=[],
tools=[],
context=[],
forwarded_props=None,
resume=[
# Even an apparently-approving payload is overridden by `status='cancelled'`.
ResumeEntry(interrupt_id='int-tc-001', status='cancelled', payload={'approved': True}),
],
)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
assert adapter.deferred_tool_results == snapshot(
DeferredToolResults(approvals={'tc-001': ToolDenied(message='Cancelled by user.')})
)
@pytestmark_interrupts
@pytest.mark.parametrize(
'payload',
[
pytest.param({}, id='missing-approved'),
pytest.param({'approved': None}, id='approved-null'),
pytest.param({'approved': 'true'}, id='approved-string-true'),
pytest.param({'approved': 1}, id='approved-truthy-int'),
pytest.param('approved', id='payload-is-string'),
pytest.param([{'approved': True}], id='payload-is-list'),
pytest.param(None, id='payload-null'),
],
)
async def test_resume_deny_by_default_for_ambiguous_payload(payload: Any) -> None:
"""Approval requires an explicit `payload.approved == True`.
Anything else (missing, null, non-bool, non-dict payload) must deny so a malformed or
hostile client cannot bypass the `requires_approval=True` gate by omitting the field.
"""
agent = Agent(model=TestModel())
run_input = RunAgentInput(
thread_id=uuid_str(),
run_id=uuid_str(),
state={},
messages=[],
tools=[],
context=[],
forwarded_props=None,
resume=[ResumeEntry(interrupt_id='int-tc-001', status='resolved', payload=payload)],
)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
assert adapter.deferred_tool_results == snapshot(DeferredToolResults(approvals={'tc-001': ToolDenied()}))
@pytestmark_interrupts
async def test_resume_unknown_interrupt_id_prefix_raises() -> None:
"""An `interrupt_id` that doesn't carry the adapter's prefix is a protocol error
we surface as `UserError` rather than silently mapping to a wrong tool call.
"""
agent = Agent(model=TestModel())
run_input = RunAgentInput(
thread_id=uuid_str(),
run_id=uuid_str(),
state={},
messages=[],
tools=[],
context=[],
forwarded_props=None,
resume=[ResumeEntry(interrupt_id='bogus-123', status='resolved', payload={'approved': True})],
)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
with pytest.raises(UserError, match=r'does not start with the expected'):
_ = adapter.deferred_tool_results
@pytestmark_interrupts
async def test_deferred_tool_results_none_when_sdk_lacks_interrupts(monkeypatch: pytest.MonkeyPatch) -> None:
"""When the installed ag-ui-protocol SDK predates interrupts, `resume[]` is ignored entirely
(`deferred_tool_results` returns `None`) so the old SDK path stays byte-for-byte unchanged.
"""
monkeypatch.setattr('pydantic_ai.ui.ag_ui._adapter.HAS_INTERRUPTS', False)
agent = Agent(model=TestModel())
run_input = RunAgentInput(
thread_id=uuid_str(),
run_id=uuid_str(),
state={},
messages=[],
tools=[],
context=[],
forwarded_props=None,
resume=[ResumeEntry(interrupt_id='int-tc-001', status='resolved', payload={'approved': True})],
)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
assert adapter.deferred_tool_results is None
@pytestmark_interrupts
async def test_resume_well_formed_but_nonexistent_interrupt_id_is_noop() -> None:
"""A well-formed `int-` id that binds to no pending approval is accepted, not an error.
Unlike a missing prefix (which raises `UserError`), a correctly-prefixed id that matches no
pending call maps to its own stripped `tool_call_id` — never to a different pending call — so
the agent simply finds no call to resolve and the entry is a harmless no-op.
"""
agent = Agent(model=TestModel())
run_input = RunAgentInput(
thread_id=uuid_str(),
run_id=uuid_str(),
state={},
messages=[],
tools=[],
context=[],
forwarded_props=None,
resume=[ResumeEntry(interrupt_id='int-tc-does-not-exist', status='resolved', payload={'approved': True})],
)
adapter = AGUIAdapter(agent=agent, run_input=run_input)
assert adapter.deferred_tool_results == snapshot(
DeferredToolResults(approvals={'tc-does-not-exist': ToolApproved()})
)
@pytestmark_interrupts
async def test_interrupt_resume_roundtrip_executes_approved_tool() -> None:
"""End-to-end: turn 1 ends with an interrupt outcome; turn 2 supplies `resume[]` and the
tool actually runs. The resumed turn must NOT re-emit `TOOL_CALL_START` for the same
`tool_call_id` (per AG-UI spec) — the agent loops back into execution with the original id
and we only see `TOOL_CALL_RESULT`.
"""
executed: list[dict[str, Any]] = []
async def stream_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaToolCalls | str]:
# Whenever the agent has just a user prompt + system prompts, propose the call;
# after the tool result is in history, emit a final text response.
if not any(isinstance(p, ToolReturnPart) for m in messages for p in getattr(m, 'parts', [])):
yield {0: DeltaToolCall(name='delete_file', json_args='{"path": ".env"}')}
else:
yield 'done'
agent = Agent(model=FunctionModel(stream_function=stream_function), output_type=[str, DeferredToolRequests])
@agent.tool_plain(requires_approval=True)
def delete_file(path: str) -> str:
executed.append({'path': path})
return f'deleted {path}'
# Turn 1: collect the interrupt outcome.
turn1_input = create_input(UserMessage(id='m1', content='delete .env'))
turn1_events = await _collect_adapter_events(agent, turn1_input)
run_finished_1 = next(e for e in turn1_events if e['type'] == 'RUN_FINISHED')
interrupt = run_finished_1['outcome']['interrupts'][0]
interrupt_id = interrupt['id']
tool_call_id = interrupt['toolCallId']
assert executed == [], 'turn 1 must not execute the deferred tool'
# Turn 2: resume with approval; the message history must include the proposed call so the
# agent's tool manager can match the resume back to it.
turn2_input = RunAgentInput(
thread_id=turn1_input.thread_id,
run_id=uuid_str(),
state={},
messages=[
UserMessage(id='m1', content='delete .env'),
AssistantMessage(
id='m2',
tool_calls=[
ToolCall(
id=tool_call_id,
type='function',
function=FunctionCall(name='delete_file', arguments='{"path": ".env"}'),
)
],
),
],
tools=[],
context=[],
forwarded_props=None,
resume=[ResumeEntry(interrupt_id=interrupt_id, status='resolved', payload={'approved': True})],
)
turn2_events = await _collect_adapter_events(agent, turn2_input)
assert executed == [{'path': '.env'}], 'turn 2 must execute the approved tool'
tool_starts_for_call = [
e for e in turn2_events if e['type'] == 'TOOL_CALL_START' and e.get('toolCallId') == tool_call_id
]
tool_results_for_call = [
e for e in turn2_events if e['type'] == 'TOOL_CALL_RESULT' and e.get('toolCallId') == tool_call_id
]
assert tool_starts_for_call == [], 'spec: resumed turn must not re-emit TOOL_CALL_START for the same tool_call_id'
assert len(tool_results_for_call) == 1, 'resumed turn must emit exactly one TOOL_CALL_RESULT'
run_finished_2 = next(e for e in turn2_events if e['type'] == 'RUN_FINISHED')
assert run_finished_2['outcome'] == snapshot({'type': 'success'})
# endregion