1111from typing import Any , Literal
1212
1313from .. import core
14+ from ..core import hooks
1415from . import protocol , ui_message
1516
1617# ============================================================================
@@ -69,6 +70,7 @@ def __init__(self) -> None:
6970 self .started_tool_calls : set [str ] = set ()
7071 self .emitted_tool_results : set [str ] = set ()
7172 self .pending_tool_calls : set [str ] = set ()
73+ self .emitted_approval_requests : set [str ] = set ()
7274
7375 def close_open_blocks (self ) -> list [protocol .UIMessageStreamPart ]:
7476 """Close any open reasoning/text blocks, returning parts to emit."""
@@ -94,6 +96,7 @@ def reset_tool_tracking(self) -> None:
9496 self .started_tool_calls = set ()
9597 self .emitted_tool_results = set ()
9698 self .pending_tool_calls = set ()
99+ self .emitted_approval_requests = set ()
97100
98101 def begin_message (
99102 self , msg : core .messages .Message
@@ -130,6 +133,22 @@ def begin_message(
130133 return parts
131134
132135
136+ def _tool_call_id_from_approval_hook (
137+ hook_part : core .messages .HookPart ,
138+ ) -> str | None :
139+ """Extract tool_call_id from a ToolApproval HookPart.
140+
141+ Returns the tool_call_id if this is a ToolApproval hook whose hook_id
142+ follows the ``approve_{tool_call_id}`` convention, otherwise None.
143+ """
144+ if hook_part .hook_type != hooks .ToolApproval .hook_type : # type: ignore[attr-defined]
145+ return None
146+ prefix = "approve_"
147+ if hook_part .hook_id .startswith (prefix ):
148+ return hook_part .hook_id [len (prefix ) :]
149+ return None
150+
151+
133152async def to_ui_message_stream (
134153 messages : AsyncIterable [core .messages .Message ],
135154) -> AsyncGenerator [protocol .UIMessageStreamPart ]:
@@ -256,6 +275,33 @@ async def to_ui_message_stream(
256275 output = result ,
257276 )
258277
278+ # Pass 3: Hook-based tool approvals
279+ for msg_part in msg .parts :
280+ if not isinstance (msg_part , core .messages .HookPart ):
281+ continue
282+ approval_tc_id = _tool_call_id_from_approval_hook (msg_part )
283+ if approval_tc_id is None :
284+ continue
285+
286+ if msg_part .status == "pending" :
287+ if approval_tc_id not in state .emitted_approval_requests :
288+ state .emitted_approval_requests .add (approval_tc_id )
289+ yield protocol .ToolApprovalRequestPart (
290+ approval_id = msg_part .hook_id ,
291+ tool_call_id = approval_tc_id ,
292+ )
293+ elif msg_part .status == "resolved" :
294+ resolution = msg_part .resolution or {}
295+ if not resolution .get ("granted" , False ):
296+ yield protocol .ToolOutputDeniedPart (
297+ tool_call_id = approval_tc_id ,
298+ )
299+ elif msg_part .status == "cancelled" :
300+ yield protocol .ToolOutputErrorPart (
301+ tool_call_id = approval_tc_id ,
302+ error_text = "Hook cancelled" ,
303+ )
304+
259305 # Final cleanup
260306 for part in state .finish_step ():
261307 yield part
@@ -333,6 +379,10 @@ def to_messages(
333379) -> list [core .messages .Message ]:
334380 """Convert AI SDK v6 UI messages to internal Message format.
335381
382+ As a side-effect, tool parts in ``approval-responded`` state trigger
383+ ``ToolApproval.resolve()`` so the agent loop can resume execution
384+ without the caller needing to handle approval routing explicitly.
385+
336386 Args:
337387 ui_messages: List of UIMessage objects from the AI SDK v6 frontend.
338388
@@ -375,6 +425,20 @@ def to_messages(
375425 result = _normalize_tool_result (tp .output ),
376426 )
377427 )
428+ # Side-effect: resolve ToolApproval hooks from approval
429+ # responses so the agent loop can resume execution.
430+ if (
431+ tp .state == "approval-responded"
432+ and tp .approval is not None
433+ and tp .approval .approved is not None
434+ ):
435+ hooks .ToolApproval .resolve ( # type: ignore[attr-defined]
436+ tp .approval .id ,
437+ {
438+ "granted" : tp .approval .approved ,
439+ "reason" : tp .approval .reason ,
440+ },
441+ )
378442
379443 case (
380444 ui_message .UIStepStartPart ()
0 commit comments