diff --git a/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_adapter.py b/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_adapter.py index 356dd83ee2..395aafa792 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_adapter.py +++ b/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_adapter.py @@ -61,8 +61,9 @@ SourceUrlUIPart, StepStartUIPart, TextUIPart, + ToolApprovalRequested, + ToolApprovalRequestedPart, ToolApprovalResponded, - ToolInputAvailablePart, ToolOutputAvailablePart, ToolOutputDeniedPart, ToolOutputErrorPart, @@ -469,7 +470,9 @@ def _dump_request_message(msg: ModelRequest) -> tuple[list[UIMessagePart], list[ @classmethod def _dump_response_message( - cls, msg: ModelResponse, tool_results: dict[str, ToolReturnPart | RetryPromptPart] + cls, + msg: ModelResponse, + tool_results: dict[str, ToolReturnPart | RetryPromptPart], ) -> list[UIMessagePart]: """Convert a ModelResponse into a UIMessage.""" ui_parts: list[UIMessagePart] = [] @@ -580,13 +583,18 @@ def _dump_response_message( call_provider_metadata = dump_provider_metadata( id=part.id, provider_name=part.provider_name, provider_details=part.provider_details ) + # No result found → the tool call is deferred (awaiting approval or external result). + # Emit approval-requested so the frontend renders approve/reject buttons on reload. + # approval.id is not used for matching (tool_call_id is the match key), + # so we use tool_call_id for a stable, deterministic value in dump output. ui_parts.append( - ToolInputAvailablePart( + ToolApprovalRequestedPart( type=tool_name, tool_call_id=part.tool_call_id, input=part.args_as_dict(), provider_executed=True, call_provider_metadata=call_provider_metadata, + approval=ToolApprovalRequested(id=part.tool_call_id), ) ) elif isinstance(part, ToolCallPart): @@ -598,7 +606,8 @@ def _dump_response_message( @staticmethod def _dump_tool_call_part( - part: ToolCallPart, tool_results: dict[str, ToolReturnPart | RetryPromptPart] + part: ToolCallPart, + tool_results: dict[str, ToolReturnPart | RetryPromptPart], ) -> list[UIMessagePart]: """Convert a ToolCallPart (with optional result) into UIMessageParts.""" tool_result = tool_results.get(part.tool_call_id) @@ -660,13 +669,18 @@ def _dump_tool_call_part( ) ) else: + # No result found → the tool call is deferred (awaiting approval or external result). + # Emit approval-requested so the frontend renders approve/reject buttons on reload. + # approval.id is not used for matching (tool_call_id is the match key), + # so we use tool_call_id for a stable, deterministic value in dump output. ui_parts.append( - ToolInputAvailablePart( + ToolApprovalRequestedPart( type=tool_type, tool_call_id=part.tool_call_id, input=part.args_as_dict(), provider_executed=False, call_provider_metadata=call_provider_metadata, + approval=ToolApprovalRequested(id=part.tool_call_id), ) ) @@ -682,6 +696,10 @@ def dump_messages( ) -> list[UIMessage]: """Transform Pydantic AI messages into Vercel AI messages. + Tool calls that have no corresponding result in the message history are automatically + detected as deferred and emitted with ``state='approval-requested'``, so the frontend + can render approve/reject buttons on reload. + Args: messages: A sequence of ModelMessage objects to convert generate_message_id: Optional custom function to generate message IDs. If provided, diff --git a/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_event_stream.py b/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_event_stream.py index 611e71d103..6de9b0c36f 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_event_stream.py +++ b/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_event_stream.py @@ -5,7 +5,6 @@ from collections.abc import AsyncIterator, Mapping from dataclasses import KW_ONLY, dataclass from typing import Any, Literal -from uuid import uuid4 from pydantic_core import to_json @@ -128,7 +127,7 @@ async def handle_run_result(self, event: AgentRunResultEvent) -> AsyncIterator[B if self.sdk_version >= 6 and isinstance(output, DeferredToolRequests): for tool_call in output.approvals: yield ToolApprovalRequestChunk( - approval_id=str(uuid4()), + approval_id=tool_call.tool_call_id, tool_call_id=tool_call.tool_call_id, ) return diff --git a/tests/test_vercel_ai.py b/tests/test_vercel_ai.py index c37f795f04..f1ef5f34d2 100644 --- a/tests/test_vercel_ai.py +++ b/tests/test_vercel_ai.py @@ -2482,7 +2482,7 @@ def capture_result(r: AgentRunResult[Any]) -> None: 'toolName': 'delete_file', 'input': {'path': 'test.txt'}, }, - {'type': 'tool-approval-request', 'toolCallId': 'delete_1', 'approvalId': IsStr()}, + {'type': 'tool-approval-request', 'toolCallId': 'delete_1', 'approvalId': 'delete_1'}, {'type': 'finish-step'}, {'type': 'finish'}, '[DONE]', @@ -3140,7 +3140,7 @@ async def send(data: MutableMapping[str, Any]) -> None: 'toolName': 'delete_file', 'input': {'path': 'test.txt'}, }, - {'type': 'tool-approval-request', 'toolCallId': 'delete_1', 'approvalId': IsStr()}, + {'type': 'tool-approval-request', 'toolCallId': 'delete_1', 'approvalId': 'delete_1'}, {'type': 'finish-step'}, {'type': 'finish'}, '[DONE]', @@ -3970,11 +3970,11 @@ async def test_adapter_dump_messages_with_builtin_tool_without_return(): { 'type': 'tool-web_search', 'tool_call_id': 'orphan_tool_id', - 'state': 'input-available', + 'state': 'approval-requested', 'input': {'query': 'orphan query'}, 'provider_executed': True, 'call_provider_metadata': {'pydantic_ai': {'provider_name': 'openai'}}, - 'approval': None, + 'approval': {'id': 'orphan_tool_id'}, } ], }, @@ -4525,11 +4525,11 @@ async def test_adapter_dump_messages_with_invalid_json_args(): { 'type': 'tool-test', 'tool_call_id': 'call_1', - 'state': 'input-available', + 'state': 'approval-requested', 'provider_executed': False, 'input': {'INVALID_JSON': '{invalid json'}, 'call_provider_metadata': None, - 'approval': None, + 'approval': {'id': 'call_1'}, } ], } @@ -4598,11 +4598,11 @@ async def test_adapter_dump_messages_tool_call_without_return(): { 'type': 'tool-get_weather', 'tool_call_id': 'tool_abc', - 'state': 'input-available', + 'state': 'approval-requested', 'provider_executed': False, 'input': {'city': 'New York'}, 'call_provider_metadata': None, - 'approval': None, + 'approval': {'id': 'tool_abc'}, } ], } @@ -4610,6 +4610,99 @@ async def test_adapter_dump_messages_tool_call_without_return(): ) +async def test_adapter_dump_messages_deferred_tool_approval(): + """Test that dump_messages automatically emits approval-requested for tool calls without results.""" + messages: list[ModelMessage] = [ + ModelRequest(parts=[UserPromptPart(content='Do something')]), + ModelResponse( + parts=[ + ToolCallPart( + tool_name='dangerous_action', + args={'target': 'production'}, + tool_call_id='deferred_tc1', + ), + ] + ), + ] + + # Tool call without a result is automatically detected as deferred + ui_messages = VercelAIAdapter.dump_messages(messages) + dicts = [msg.model_dump() for msg in ui_messages] + tool_part = dicts[1]['parts'][0] + assert tool_part == snapshot( + { + 'type': 'tool-dangerous_action', + 'tool_call_id': 'deferred_tc1', + 'state': 'approval-requested', + 'input': {'target': 'production'}, + 'provider_executed': False, + 'call_provider_metadata': None, + 'approval': {'id': 'deferred_tc1'}, + } + ) + + # Verify roundtrip — load_messages should reconstruct a ToolCallPart without a result + reloaded = VercelAIAdapter.load_messages(ui_messages) + assert len(reloaded) == 2 + tool_call_part = reloaded[1].parts[0] + assert isinstance(tool_call_part, ToolCallPart) + assert tool_call_part.tool_name == 'dangerous_action' + assert tool_call_part.tool_call_id == 'deferred_tc1' + + +async def test_adapter_dump_messages_deferred_tool_with_resolved_result(): + """Test that tool calls with results are shown as completed, not deferred.""" + messages: list[ModelMessage] = [ + ModelRequest(parts=[UserPromptPart(content='Do something')]), + ModelResponse( + parts=[ + ToolCallPart( + tool_name='dangerous_action', + args={'target': 'production'}, + tool_call_id='resolved_tc1', + ), + ] + ), + ModelRequest( + parts=[ + ToolReturnPart( + tool_name='dangerous_action', + content='Action completed', + tool_call_id='resolved_tc1', + ), + ] + ), + ] + + # Tool call with a result is shown as completed + ui_messages = VercelAIAdapter.dump_messages(messages) + dicts = [msg.model_dump() for msg in ui_messages] + tool_part = dicts[1]['parts'][0] + assert tool_part['state'] == 'output-available' + assert tool_part['output'] == 'Action completed' + + +async def test_adapter_dump_messages_deferred_builtin_tool(): + """Test that builtin tool calls without results are automatically detected as deferred.""" + messages: list[ModelMessage] = [ + ModelResponse( + parts=[ + BuiltinToolCallPart( + tool_name='web_search', + args={'query': 'test'}, + tool_call_id='builtin_deferred_tc1', + ), + ] + ), + ] + + ui_messages = VercelAIAdapter.dump_messages(messages) + dicts = [msg.model_dump() for msg in ui_messages] + tool_part = dicts[0]['parts'][0] + assert tool_part['state'] == 'approval-requested' + assert tool_part['approval'] == {'id': 'builtin_deferred_tc1'} + + async def test_adapter_dump_messages_assistant_starts_with_tool(): """Test an assistant message that starts with a tool call instead of text.""" messages = [ @@ -4633,11 +4726,11 @@ async def test_adapter_dump_messages_assistant_starts_with_tool(): { 'type': 'tool-t', 'tool_call_id': 'tc1', - 'state': 'input-available', + 'state': 'approval-requested', 'provider_executed': False, 'input': {}, 'call_provider_metadata': None, - 'approval': None, + 'approval': {'id': 'tc1'}, }, { 'type': 'text',