-
Notifications
You must be signed in to change notification settings - Fork 1.1k
support tool approvals for pydantic-ai chatbot #9621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Light2Dark
merged 10 commits into
sham/fix-pydantic-ai-chat-interruption
from
sham/pydantic-chat-tool-approvals
May 22, 2026
Merged
Changes from 3 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
c3aa6d3
support tool approvals
Light2Dark 92417be
remove override
Light2Dark 319f270
fix from comment
Light2Dark 53f63ab
fix mypy type for ui_message_part_cls
Light2Dark 12efb1a
address PR review feedback
Light2Dark 7b3da27
restore _EQ_EXCLUDE infrastructure on ChatMessage
Light2Dark 3f3a639
preserve raw dict snapshots in mixed-input ChatMessage
Light2Dark 39b5a16
refactor and fix types
Light2Dark 8158753
stronger typing
Light2Dark 1474735
fix type
Light2Dark File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
269 changes: 269 additions & 0 deletions
269
frontend/src/components/chat/__tests__/chat-utils.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,269 @@ | ||
| /* Copyright 2026 Marimo. All rights reserved. */ | ||
|
|
||
| import type { UIMessage } from "ai"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { hasPendingToolCalls } from "../chat-utils"; | ||
|
|
||
| /** | ||
| * `hasPendingToolCalls` powers `sendAutomaticallyWhen` in `mo.ui.chat`: | ||
| * returns true only when the last assistant message *ends* with a tool | ||
| * call in a ready-to-round-trip state. Any trailing non-tool part (text, | ||
| * file, source-*, reasoning, data-*, new step-start) means the assistant | ||
| * has already answered and we leave the next turn to the user. The | ||
| * approval flow relies on this firing for `approval-responded`. | ||
| */ | ||
|
|
||
| const userMessage = (text: string): UIMessage => ({ | ||
| id: `user-${text}`, | ||
| role: "user", | ||
| parts: [{ type: "text", text }], | ||
| }); | ||
|
|
||
| const assistantToolMessage = ( | ||
| parts: UIMessage["parts"], | ||
| id = "assistant-1", | ||
| ): UIMessage => ({ | ||
| id, | ||
| role: "assistant", | ||
| parts, | ||
| }); | ||
|
|
||
| describe("hasPendingToolCalls", () => { | ||
| it("returns false when there are no messages", () => { | ||
| expect(hasPendingToolCalls([])).toBe(false); | ||
| }); | ||
|
|
||
| it("returns false when the last message is a user message", () => { | ||
| expect(hasPendingToolCalls([userMessage("hi")])).toBe(false); | ||
| }); | ||
|
|
||
| it("returns false when the last assistant message has no tool parts", () => { | ||
| expect( | ||
| hasPendingToolCalls([ | ||
| userMessage("hi"), | ||
| assistantToolMessage([{ type: "text", text: "hello!" }]), | ||
| ]), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("returns false while a tool is still streaming or awaiting approval", () => { | ||
| expect( | ||
| hasPendingToolCalls([ | ||
| userMessage("delete it"), | ||
| assistantToolMessage([ | ||
| { | ||
| type: "tool-delete_file", | ||
| toolCallId: "call-1", | ||
| state: "approval-requested", | ||
| input: { path: "secrets.env" }, | ||
| approval: { id: "approval-1" }, | ||
| } as unknown as UIMessage["parts"][number], | ||
| ]), | ||
| ]), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("returns true when the user has responded to an approval request", () => { | ||
| // The chat must auto-resume as soon as Approve/Deny is clicked. | ||
| expect( | ||
| hasPendingToolCalls([ | ||
| userMessage("delete it"), | ||
| assistantToolMessage([ | ||
| { | ||
| type: "tool-delete_file", | ||
| toolCallId: "call-1", | ||
| state: "approval-responded", | ||
| input: { path: "secrets.env" }, | ||
| approval: { id: "approval-1", approved: true }, | ||
| } as unknown as UIMessage["parts"][number], | ||
| ]), | ||
| ]), | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it("returns true when a tool reached a terminal output state", () => { | ||
| expect( | ||
| hasPendingToolCalls([ | ||
| userMessage("run it"), | ||
| assistantToolMessage([ | ||
| { | ||
| type: "tool-run_query", | ||
| toolCallId: "call-1", | ||
| state: "output-available", | ||
| input: { sql: "select 1" }, | ||
| output: 1, | ||
| } as unknown as UIMessage["parts"][number], | ||
| ]), | ||
| ]), | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it("returns false when only some tool calls are ready", () => { | ||
| expect( | ||
| hasPendingToolCalls([ | ||
| userMessage("two things"), | ||
| assistantToolMessage([ | ||
| { | ||
| type: "tool-first", | ||
| toolCallId: "call-1", | ||
| state: "output-available", | ||
| input: {}, | ||
| output: 1, | ||
| } as unknown as UIMessage["parts"][number], | ||
| { | ||
| type: "tool-second", | ||
| toolCallId: "call-2", | ||
| state: "input-available", | ||
| input: {}, | ||
| } as unknown as UIMessage["parts"][number], | ||
| ]), | ||
| ]), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("returns false once the assistant has appended text after the tool result", () => { | ||
| expect( | ||
| hasPendingToolCalls([ | ||
| userMessage("run it"), | ||
| assistantToolMessage([ | ||
| { | ||
| type: "tool-run_query", | ||
| toolCallId: "call-1", | ||
| state: "output-available", | ||
| input: {}, | ||
| output: 1, | ||
| } as unknown as UIMessage["parts"][number], | ||
| { type: "text", text: "The query returned 1." }, | ||
| ]), | ||
| ]), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("returns false when a file part trails the completed tool call", () => { | ||
| // Regression: tool β text β file used to loop because only trailing | ||
| // text counted as "the assistant has answered". | ||
| expect( | ||
| hasPendingToolCalls([ | ||
| userMessage("show me Starry Night"), | ||
| assistantToolMessage([ | ||
| { type: "step-start" }, | ||
| { | ||
| type: "tool-search_artwork", | ||
| toolCallId: "call-1", | ||
| state: "output-available", | ||
| input: { artist: "Van Gogh" }, | ||
| output: { title: "The Starry Night" }, | ||
| } as unknown as UIMessage["parts"][number], | ||
| { type: "text", text: "Here is the painting:" }, | ||
| { | ||
| type: "file", | ||
| mediaType: "image/jpeg", | ||
| url: "https://example.com/starry-night.jpg", | ||
| } as unknown as UIMessage["parts"][number], | ||
| ]), | ||
| ]), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("returns false when a source-url part trails the completed tool call", () => { | ||
| expect( | ||
| hasPendingToolCalls([ | ||
| userMessage("cite your sources"), | ||
| assistantToolMessage([ | ||
| { | ||
| type: "tool-web_search", | ||
| toolCallId: "call-1", | ||
| state: "output-available", | ||
| input: { q: "marimo notebook" }, | ||
| output: "found", | ||
| } as unknown as UIMessage["parts"][number], | ||
| { type: "text", text: "marimo is a reactive notebook." }, | ||
| { | ||
| type: "source-url", | ||
| sourceId: "src-1", | ||
| url: "https://marimo.io", | ||
| } as unknown as UIMessage["parts"][number], | ||
| ]), | ||
| ]), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("returns false when a reasoning part trails the completed tool call", () => { | ||
| expect( | ||
| hasPendingToolCalls([ | ||
| userMessage("explain"), | ||
| assistantToolMessage([ | ||
| { | ||
| type: "tool-lookup", | ||
| toolCallId: "call-1", | ||
| state: "output-available", | ||
| input: {}, | ||
| output: 1, | ||
| } as unknown as UIMessage["parts"][number], | ||
| { | ||
| type: "reasoning", | ||
| text: "Now I'll summarize.", | ||
| } as unknown as UIMessage["parts"][number], | ||
| ]), | ||
| ]), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("returns false when a new step-start follows the completed tool call", () => { | ||
| expect( | ||
| hasPendingToolCalls([ | ||
| userMessage("multi-step"), | ||
| assistantToolMessage([ | ||
| { type: "step-start" }, | ||
| { | ||
| type: "tool-run_query", | ||
| toolCallId: "call-1", | ||
| state: "output-available", | ||
| input: {}, | ||
| output: 1, | ||
| } as unknown as UIMessage["parts"][number], | ||
| { type: "step-start" }, | ||
| ]), | ||
| ]), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("ignores providerExecuted tools", () => { | ||
| // Provider-side tools are resolved by the model, not the runtime, so | ||
| // they must not drive an auto-resume. | ||
| expect( | ||
| hasPendingToolCalls([ | ||
| userMessage("hi"), | ||
| assistantToolMessage([ | ||
| { | ||
| type: "tool-web_search", | ||
| toolCallId: "call-1", | ||
| state: "output-available", | ||
| input: {}, | ||
| output: 1, | ||
| providerExecuted: true, | ||
| } as unknown as UIMessage["parts"][number], | ||
| ]), | ||
| ]), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("returns true for dynamic-tool parts in a terminal state", () => { | ||
| // `dynamic-tool` parts must drive auto-resume alongside `tool-*`. | ||
| expect( | ||
| hasPendingToolCalls([ | ||
| userMessage("run it"), | ||
| assistantToolMessage([ | ||
| { | ||
| type: "dynamic-tool", | ||
| toolName: "run_query", | ||
| toolCallId: "call-1", | ||
| state: "output-available", | ||
| input: {}, | ||
| output: 1, | ||
| } as unknown as UIMessage["parts"][number], | ||
| ]), | ||
| ]), | ||
| ).toBe(true); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.