Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
315 changes: 273 additions & 42 deletions examples/ai/chat/pydantic-ai-chat.py

Large diffs are not rendered by default.

269 changes: 269 additions & 0 deletions frontend/src/components/chat/__tests__/chat-utils.test.ts
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);
});
});
72 changes: 14 additions & 58 deletions frontend/src/components/chat/chat-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
type ChatAddToolOutputFunction,
type FileUIPart,
isToolUIPart,
type ToolUIPart,
lastAssistantMessageIsCompleteWithApprovalResponses,
lastAssistantMessageIsCompleteWithToolCalls,
type UIMessage,
} from "ai";
import { useState } from "react";
Expand All @@ -17,7 +18,6 @@ import type {
InvokeAiToolRequest,
InvokeAiToolResponse,
} from "@/core/network/types";
import { logNever } from "@/utils/assertNever";
import { blobToString } from "@/utils/fileToBase64";
import { Logger } from "@/utils/Logger";
import { getAICompletionBodyWithAttachments } from "../editor/ai/completion-utils";
Expand Down Expand Up @@ -169,69 +169,25 @@ export async function handleToolCall({
}

/**
* Returns true if a tool call is "ready to be sent back to the server" β€” i.e.
* either it has reached a terminal output state, or the user has just supplied
* an approval response that the server hasn't seen yet.
*/
function isToolCallReadyToSend(state: ToolUIPart["state"]): boolean {
switch (state) {
case "output-available":
case "output-error":
case "output-denied":
case "approval-responded":
return true;
case "input-streaming":
case "input-available":
case "approval-requested":
return false;
default:
logNever(state);
return false;
}
}

/**
* Checks if we should send a message automatically based on the messages.
* We auto-send when every tool call on the last assistant message has either
* finished (output-available/error/denied) or has just received a user
* approval response, and the assistant hasn't replied yet.
* Auto-send the next turn when the last assistant message ends with a
* tool call ready to round-trip. Any non-tool trailing part (text, file,
* source-*, reasoning, data-*, new step-start) means the assistant has
* already answered, so we leave the next turn to the user. State checks
* are delegated to the SDK to stay in sync with upstream.
*/
export function hasPendingToolCalls(messages: UIMessage[]): boolean {
if (messages.length === 0) {
return false;
}

const lastMessage = messages[messages.length - 1];
const parts = lastMessage.parts;

if (parts.length === 0) {
return false;
}

// Only auto-send if the last message is an assistant message
// Because assistant messages are the ones that can have tool calls
if (lastMessage.role !== "assistant") {
const lastMessage = messages.at(-1);
if (!lastMessage || lastMessage.role !== "assistant") {
return false;
}

const toolParts = parts.filter(isToolUIPart);

if (toolParts.length === 0) {
const lastPart = lastMessage.parts.at(-1);
if (!lastPart || !isToolUIPart(lastPart)) {
return false;
}

const allToolCallsReady = toolParts.every((part) =>
isToolCallReadyToSend(part.state),
return (
lastAssistantMessageIsCompleteWithToolCalls({ messages }) ||
lastAssistantMessageIsCompleteWithApprovalResponses({ messages })
);

// Check if the last part has any text content
const lastPart = parts[parts.length - 1];
const hasTextContent =
lastPart.type === "text" && lastPart.text?.trim().length > 0;

Logger.debug("All tool calls ready to send: %s", allToolCallsReady);

return allToolCallsReady && !hasTextContent;
}

export function useFileState() {
Expand Down
Loading
Loading