@@ -1792,6 +1792,19 @@ def delete_file(path: str) -> str:
17921792 id = 'assistant-1' ,
17931793 role = 'assistant' ,
17941794 parts = [
1795+ # Text part - covers continue branch for non-tool parts in _denied_tool_ids
1796+ TextUIPart (text = 'I will delete the file for you.' ),
1797+ # Approved tool - covers branch 100->97 (approved=True) in _denied_tool_ids
1798+ DynamicToolInputAvailablePart (
1799+ tool_name = 'delete_file' ,
1800+ tool_call_id = 'delete_approved' ,
1801+ input = {'path' : 'approved.txt' },
1802+ approval = ToolApprovalResponded (
1803+ id = 'approval-456' ,
1804+ approved = True ,
1805+ ),
1806+ ),
1807+ # Denied tool - should be processed
17951808 DynamicToolInputAvailablePart (
17961809 tool_name = 'delete_file' ,
17971810 tool_call_id = 'delete_1' ,
@@ -1832,6 +1845,144 @@ def mock_header_get(key: str) -> str | None:
18321845 assert denied_event ['toolCallId' ] == 'delete_1'
18331846
18341847
1848+ @pytest .mark .skipif (not starlette_import_successful , reason = 'Starlette is not installed' )
1849+ async def test_tool_approval_extraction_with_edge_cases ():
1850+ """Test that approval extraction correctly skips non-tool parts and non-responded approvals."""
1851+ from unittest .mock import AsyncMock
1852+
1853+ from starlette .requests import Request
1854+
1855+ from pydantic_ai .tools import DeferredToolRequests
1856+ from pydantic_ai .ui .vercel_ai .request_types import (
1857+ DynamicToolInputAvailablePart ,
1858+ ToolApprovalRequested ,
1859+ ToolApprovalResponded ,
1860+ )
1861+
1862+ agent = Agent (TestModel (), output_type = [str , DeferredToolRequests ])
1863+
1864+ @agent .tool_plain (requires_approval = True )
1865+ def some_tool (x : str ) -> str :
1866+ return x # pragma: no cover
1867+
1868+ # Request with edge cases: TextUIPart (non-tool part) and ToolApprovalRequested (non-responded)
1869+ request = SubmitMessage (
1870+ id = 'foo' ,
1871+ messages = [
1872+ UIMessage (
1873+ id = 'user-1' ,
1874+ role = 'user' ,
1875+ parts = [TextUIPart (text = 'Test' )],
1876+ ),
1877+ UIMessage (
1878+ id = 'assistant-1' ,
1879+ role = 'assistant' ,
1880+ parts = [
1881+ # Text part in assistant message - should be skipped
1882+ TextUIPart (text = 'Here is my response.' ),
1883+ # Tool with pending approval (not yet responded) - should be skipped
1884+ DynamicToolInputAvailablePart (
1885+ tool_name = 'some_tool' ,
1886+ tool_call_id = 'pending_tool' ,
1887+ input = {'x' : 'pending' },
1888+ approval = ToolApprovalRequested (id = 'pending-approval' ),
1889+ ),
1890+ # Tool with no approval at all - should be skipped
1891+ DynamicToolInputAvailablePart (
1892+ tool_name = 'some_tool' ,
1893+ tool_call_id = 'no_approval_tool' ,
1894+ input = {'x' : 'no_approval' },
1895+ approval = None ,
1896+ ),
1897+ # Tool with responded approval - should be processed
1898+ DynamicToolInputAvailablePart (
1899+ tool_name = 'some_tool' ,
1900+ tool_call_id = 'approved_tool' ,
1901+ input = {'x' : 'approved' },
1902+ approval = ToolApprovalResponded (id = 'approved-id' , approved = True ),
1903+ ),
1904+ ],
1905+ ),
1906+ ],
1907+ )
1908+
1909+ def mock_header_get (key : str ) -> str | None :
1910+ return None
1911+
1912+ request_body = request .model_dump_json ().encode ()
1913+ mock_request = AsyncMock (spec = Request )
1914+ mock_request .body = AsyncMock (return_value = request_body )
1915+ mock_request .headers .get = mock_header_get
1916+
1917+ adapter = await VercelAIAdapter [None , str | DeferredToolRequests ].from_request (
1918+ mock_request , agent = agent , tool_approval = True
1919+ )
1920+
1921+ # Verify that only the responded approval was extracted
1922+ assert adapter .deferred_tool_results is not None
1923+ assert adapter .deferred_tool_results .approvals == {'approved_tool' : True }
1924+
1925+
1926+ @pytest .mark .skipif (not starlette_import_successful , reason = 'Starlette is not installed' )
1927+ async def test_tool_approval_no_approvals_extracted ():
1928+ """Test that deferred_tool_results is None when no approvals are responded."""
1929+ from unittest .mock import AsyncMock
1930+
1931+ from starlette .requests import Request
1932+
1933+ from pydantic_ai .tools import DeferredToolRequests
1934+ from pydantic_ai .ui .vercel_ai .request_types import (
1935+ DynamicToolInputAvailablePart ,
1936+ ToolApprovalRequested ,
1937+ )
1938+
1939+ agent = Agent (TestModel (), output_type = [str , DeferredToolRequests ])
1940+
1941+ @agent .tool_plain (requires_approval = True )
1942+ def some_tool (x : str ) -> str :
1943+ return x # pragma: no cover
1944+
1945+ # Request with only pending approvals (no ToolApprovalResponded)
1946+ request = SubmitMessage (
1947+ id = 'foo' ,
1948+ messages = [
1949+ UIMessage (
1950+ id = 'user-1' ,
1951+ role = 'user' ,
1952+ parts = [TextUIPart (text = 'Test' )],
1953+ ),
1954+ UIMessage (
1955+ id = 'assistant-1' ,
1956+ role = 'assistant' ,
1957+ parts = [
1958+ # Only pending approvals - should not be extracted
1959+ DynamicToolInputAvailablePart (
1960+ tool_name = 'some_tool' ,
1961+ tool_call_id = 'pending_tool' ,
1962+ input = {'x' : 'pending' },
1963+ approval = ToolApprovalRequested (id = 'pending-approval' ),
1964+ ),
1965+ ],
1966+ ),
1967+ ],
1968+ )
1969+
1970+ def mock_header_get (key : str ) -> str | None :
1971+ return None
1972+
1973+ request_body = request .model_dump_json ().encode ()
1974+ mock_request = AsyncMock (spec = Request )
1975+ mock_request .body = AsyncMock (return_value = request_body )
1976+ mock_request .headers .get = mock_header_get
1977+
1978+ adapter = await VercelAIAdapter [None , str | DeferredToolRequests ].from_request (
1979+ mock_request , agent = agent , tool_approval = True
1980+ )
1981+
1982+ # No approvals extracted, so deferred_tool_results should be None
1983+ assert adapter .deferred_tool_results is None
1984+
1985+
18351986@pytest .mark .skipif (not starlette_import_successful , reason = 'Starlette is not installed' )
18361987async def test_adapter_dispatch_request ():
18371988 agent = Agent (model = TestModel ())
0 commit comments