@@ -108,6 +108,7 @@ class ApprovalResponse(NamedTuple):
108108 hook_id : str
109109 granted : bool
110110 reason : str | None
111+ tool_call_id : str
111112
112113
113114def extract_approvals (
@@ -132,6 +133,7 @@ def extract_approvals(
132133 hook_id = part .approval .id ,
133134 granted = part .approval .approved ,
134135 reason = part .approval .reason ,
136+ tool_call_id = part .tool_call_id ,
135137 )
136138 )
137139 return approvals
@@ -203,8 +205,10 @@ def to_messages(
203205
204206 Pure: normalizes stale tool states, extracts approval responses,
205207 parses UIMessages into an ``ai.messages.Message`` list (split at
206- tool boundaries), and drops the internal tombstones for approval
207- responses.
208+ tool boundaries), drops the internal tombstones for approval
209+ responses, and patches the trailing tool message with
210+ ``is_hook_abort`` placeholders for tool calls whose approval was
211+ just responded to but never recorded a real tool result.
208212
209213 Returns ``(messages, approvals)``. The caller can pre-register
210214 resolutions via :func:`apply_approvals` before calling
@@ -213,9 +217,61 @@ def to_messages(
213217 normalized = _normalize_ui_messages (ui_messages )
214218 approvals = extract_approvals (normalized )
215219 messages = [m for m in _parse (normalized ) if not _is_approval_response (m )]
220+ _patch_pending_hook_aborts (messages , approvals )
216221 return messages , approvals
217222
218223
224+ def _patch_pending_hook_aborts (
225+ messages : list [messages_ .Message ],
226+ approvals : list [ApprovalResponse ],
227+ ) -> None :
228+ """Inject ``is_hook_abort=True`` placeholders for tool calls whose
229+ approval was responded to but whose tool result is still missing.
230+
231+ This deals with the case where a prior run emitted multiple tool
232+ calls, some of which succeeded and some of which aborted on an
233+ approval hook.
234+
235+ In that case, there will be an assistant message with multiple
236+ tool calls, a tool result with fewer results (some are missing),
237+ and then also some hook approvals.
238+
239+ This synthesizes `ToolResultPart`s with `is_hook_abort=True` in
240+ order to be able to feed things back into the agent protocol.
241+ """
242+ if len (messages ) < 2 :
243+ return
244+
245+ tool_msg = messages [- 1 ]
246+ assistant_msg = messages [- 2 ]
247+ if tool_msg .role != "tool" or assistant_msg .role != "assistant" :
248+ return
249+ if not assistant_msg .tool_calls :
250+ return
251+
252+ hooks = {a .tool_call_id : a for a in approvals }
253+ completed_ids = {r .tool_call_id for r in tool_msg .tool_results }
254+
255+ new_parts : list [messages_ .Part ] = list (tool_msg .parts )
256+ for tc in assistant_msg .tool_calls :
257+ if tc .tool_call_id in completed_ids :
258+ continue
259+ if not (hook := hooks .get (tc .tool_call_id )):
260+ continue
261+ new_parts .append (
262+ messages_ .ToolResultPart (
263+ tool_call_id = tc .tool_call_id ,
264+ tool_name = tc .tool_name ,
265+ result = f"Pending on hook '{ hook .hook_id } '" ,
266+ is_error = True ,
267+ is_hook_abort = True ,
268+ )
269+ )
270+
271+ if len (new_parts ) != len (tool_msg .parts ):
272+ messages [- 1 ] = tool_msg .model_copy (update = {"parts" : new_parts })
273+
274+
219275def _is_approval_response (msg : messages_ .Message ) -> bool :
220276 """Internal message that records a resolved tool-approval hook."""
221277 if msg .role != "internal" or len (msg .parts ) != 1 :
0 commit comments