Skip to content

fix(vercel-ai): auto-detect deferred tool approval state in dump_messages()#4831

Open
tijmenhammer wants to merge 7 commits intopydantic:mainfrom
tijmenhammer:fix/dump-messages-deferred-approval
Open

fix(vercel-ai): auto-detect deferred tool approval state in dump_messages()#4831
tijmenhammer wants to merge 7 commits intopydantic:mainfrom
tijmenhammer:fix/dump-messages-deferred-approval

Conversation

@tijmenhammer
Copy link
Copy Markdown

@tijmenhammer tijmenhammer commented Mar 24, 2026

Pre-Review Checklist

  • Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
  • No breaking changes in accordance with the version policy.
  • Linting and type checking pass per make format and make typecheck.
  • PR title is fit for the release changelog.

Pre-Merge Checklist

  • New tests for any fix or new behavior, maintaining 100% coverage.
  • Updated documentation for new features and behaviors, including docstrings for API docs.

Summary

Fixes #4830

dump_messages() now automatically detects deferred tool calls by checking which ToolCallParts have no corresponding result in the message history. These are emitted with state='approval-requested' instead of state='input-available', enabling the frontend to render approve/reject buttons on page reload.

No new API surface — deferred status is inferred from the messages, not passed in by the caller.

Also uses tool_call_id as approval_id in the streaming path for consistency with the dump path (was uuid4() before).

Changes

  • _adapter.py: Tool calls without results automatically emit approval-requested with approval={id: tool_call_id} (both ToolCallPart and BuiltinToolCallPart)
  • _event_stream.py: Use tool_call_id for approval_id (consistent with dump path)
  • test_vercel_ai.py: Updated snapshot tests, added deferred tool coverage

Test plan

  • All 124 test_vercel_ai.py tests pass
  • All 15 test_ui.py tests pass
  • ruff check + ruff format clean
  • pyright clean on changed files (0 errors)
  • Backward compatible — no new parameters, existing behavior unchanged for tool calls with results

…ext in dump_messages()

1. Add `deferred_tool_call_ids` parameter to `dump_messages()` so callers
   can specify which tool calls are deferred. These are emitted with
   `state='approval-requested'` and `approval={id: tool_call_id}` instead
   of `state='input-available'` with `approval=null`, enabling the frontend
   to render approve/reject buttons on reload.

2. Use raw `RetryPromptPart.content` (when it's a string) instead of
   `model_response()` for the UI error_text. `model_response()` appends
   "Fix the errors and try again." which is intended for the model, not
   for UI display, and mangles custom error markers like "Cancelled".

Fixes pydantic#4830
@github-actions github-actions bot added size: M Medium PR (101-500 weighted lines) bug Report that something isn't working, or PR implementing a fix labels Mar 24, 2026
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 4 additional findings.

Open in Devin Review

devin-ai-integration[bot]

This comment was marked as resolved.

…t error text

The streaming handler in _event_stream.py used part.model_response() which
appends "Fix the errors and try again." to string content. Now uses raw
content for strings (matching _adapter.py dump path), so the same error
shows identical text whether streamed in real-time or reconstructed from
persisted messages.
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

input=part.args_as_dict(),
provider_executed=True,
call_provider_metadata=call_provider_metadata,
approval=ToolApprovalRequested(id=part.tool_call_id),
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: approval_id and tool_call_id are intentionally identical

In _event_stream.py:130-131, both approval_id and tool_call_id are set to tool_call.tool_call_id. Similarly in the dump path, ToolApprovalRequested(id=part.tool_call_id) reuses the tool call ID. I verified that iter_tool_approval_responses in _utils.py:147-151 uses part.tool_call_id (not part.approval.id) for matching, confirming that approval.id is not used as a matching key. This makes the output fully deterministic, which is desirable for snapshot testing and idempotent renders. The only potential concern would be if the Vercel AI SDK frontend uses approval_id for deduplication across multiple approval requests for the same tool call, but the comments explicitly note this is not the case.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inconsistency is harmless approval.id is never used for matching anywhere. All approval pairing goes through tool_call_id (see iter_tool_approval_responses in _utils.py). Using tool_call_id in the dump path is intentional for deterministic output. Added a clarifying comment.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tijmenhammer If we use tool_call_id in the dump case, let's do it in the streaming case as well.

@DouweM
Copy link
Copy Markdown
Collaborator

DouweM commented Mar 25, 2026

@tijmenhammer Bedankt Tijmen :) Can you please look at that Devin comment? I'm not sure if the approval ID actually has to match the tool call ID, or if any UUID is fine.

@tijmenhammer
Copy link
Copy Markdown
Author

Hey @DouweM geen probleem ;)

Looked into it approval ID can be anything, it's not used for matching. All pairing goes through toolCallId on the message part (iter_tool_approval_responses in _utils.py). Went with tool_call_id in the dump path for deterministic output (better for snapshots), the streaming path uses uuid4() which is fine there since it's ephemeral. Added a comment explaining why.

input=part.args_as_dict(),
provider_executed=True,
call_provider_metadata=call_provider_metadata,
approval=ToolApprovalRequested(id=part.tool_call_id),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tijmenhammer If we use tool_call_id in the dump case, let's do it in the streaming case as well.

elif isinstance(tool_result, RetryPromptPart):
# Use the raw content string to avoid model_response() appending
# "Fix the errors and try again." — that suffix is intended for the
# model, not for UI display. For structured validation errors (list
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means that these RetryPromptParts are now lossy in a round-trip right? Because on the next turn (after dump and load), the LLM would not see the "Fix the errors..." bit anymore, breaking the cache, and potentially being less clear to it than the message with the prompt.

Either way I think this is a more controversial change than the one about approval state so would like to see this in a separate PR, if you insist we need it :)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point about the lossy round-trip. Reverted will open a separate PR if I find the time.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #4869 for this

…_id in streaming, revert RetryPromptPart change

- Remove deferred_tool_call_ids param; dump_messages() now auto-detects deferred
  tool calls (no result in history) and emits approval-requested
- Use tool_call_id for approval_id in streaming path (consistent with dump path)
- Revert RetryPromptPart raw content change (separate PR per reviewer request)
@github-actions github-actions bot added size: S Small PR (≤100 weighted lines) and removed size: M Medium PR (101-500 weighted lines) labels Mar 26, 2026
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 4 additional findings in Devin Review.

Open in Devin Review

@tijmenhammer tijmenhammer changed the title fix(vercel-ai): preserve deferred tool approval state and raw error text in dump_messages() fix(vercel-ai): auto-detect deferred tool approval state in dump_messages() Mar 26, 2026
@tijmenhammer tijmenhammer requested a review from DouweM March 26, 2026 22:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Report that something isn't working, or PR implementing a fix size: S Small PR (≤100 weighted lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dump_messages() doesn't preserve deferred tool approval state for Vercel AI SDK v6

3 participants