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
5 changes: 5 additions & 0 deletions frontend/src/components/chat/chat-display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@ export const renderUIMessage = ({
message,
isStreamingReasoning,
isLast,
isActive,
addToolApprovalResponse,
}: {
message: UIMessage;
isStreamingReasoning: boolean;
isLast: boolean;
/** Whether the chat is currently streaming/submitting a response. */
isActive: boolean;
addToolApprovalResponse?: ChatAddToolApproveResponseFunction;
}) => {
return (
Expand All @@ -49,6 +52,7 @@ export const renderUIMessage = ({
approval={part.approval}
onApprove={addToolApprovalResponse}
isLive={isLast}
isActive={isActive}
/>
);
}
Expand Down Expand Up @@ -102,6 +106,7 @@ export const renderUIMessage = ({
approval={part.approval}
onApprove={addToolApprovalResponse}
isLive={isLast}
isActive={isActive}
/>
);
case "source-document":
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/chat/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ interface ChatMessageProps {
onEdit: (index: number, newValue: string) => void;
isStreamingReasoning: boolean;
isLast: boolean;
isActive: boolean;
addToolApprovalResponse?: ChatAddToolApproveResponseFunction;
}

Expand All @@ -153,6 +154,7 @@ const ChatMessageDisplay: React.FC<ChatMessageProps> = memo(
onEdit,
isStreamingReasoning,
isLast,
isActive,
addToolApprovalResponse,
}) => {
const renderUserMessage = (message: UIMessage) => {
Expand Down Expand Up @@ -205,6 +207,7 @@ const ChatMessageDisplay: React.FC<ChatMessageProps> = memo(
message,
isStreamingReasoning,
isLast,
isActive,
addToolApprovalResponse,
})}
</div>
Expand Down Expand Up @@ -780,6 +783,7 @@ const ChatPanelBody = () => {
onEdit={handleMessageEdit}
isStreamingReasoning={isStreamingReasoning}
isLast={idx === messages.length - 1}
isActive={isLoading}
addToolApprovalResponse={addToolApprovalResponse}
/>
))}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/chat/tool-call/tool-call-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface ToolCallViewProps {
* (the user has moved on).
*/
isLive?: boolean;
isActive?: boolean;
}

export const ToolCallView: React.FC<ToolCallViewProps> = ({
Expand All @@ -37,6 +38,7 @@ export const ToolCallView: React.FC<ToolCallViewProps> = ({
index,
className,
isLive = true,
isActive = true,
}) => {
switch (state) {
case "approval-requested":
Expand All @@ -61,6 +63,7 @@ export const ToolCallView: React.FC<ToolCallViewProps> = ({
input={input}
index={index}
className={className}
isActive={isActive}
/>
);

Expand Down Expand Up @@ -89,6 +92,7 @@ export const ToolCallView: React.FC<ToolCallViewProps> = ({
approval={approval}
index={index}
className={className}
isActive={isActive}
/>
);

Expand Down
31 changes: 26 additions & 5 deletions frontend/src/components/chat/tool-call/tool-history-row.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { BanIcon, CheckCircleIcon, Loader2, WrenchIcon } from "lucide-react";
import {
BanIcon,
CheckCircleIcon,
CircleSlashIcon,
Loader2,
WrenchIcon,
} from "lucide-react";
import React from "react";
import {
Accordion,
Expand Down Expand Up @@ -29,7 +35,19 @@ const STATUS_LABEL: Record<HistoryState, string> = {
"output-denied": "Denied",
};

const StatusIcon: React.FC<{ state: HistoryState }> = ({ state }) => {
const PENDING_STATES = new Set<HistoryState>([
"input-streaming",
"input-available",
"approval-responded",
]);

const StatusIcon: React.FC<{ state: HistoryState; interrupted: boolean }> = ({
state,
interrupted,
}) => {
if (interrupted) {
return <CircleSlashIcon className="h-3 w-3 text-muted-foreground" />;
}
switch (state) {
case "input-streaming":
case "input-available":
Expand Down Expand Up @@ -69,6 +87,7 @@ interface ToolHistoryRowProps {
approval?: ToolApproval;
index?: number;
className?: string;
isActive?: boolean;
}

export const ToolHistoryRow: React.FC<ToolHistoryRowProps> = ({
Expand All @@ -79,7 +98,9 @@ export const ToolHistoryRow: React.FC<ToolHistoryRowProps> = ({
approval,
index = 0,
className,
isActive = true,
}) => {
const interrupted = !isActive && PENDING_STATES.has(state);
return (
<Accordion
key={`tool-${index}`}
Expand All @@ -91,12 +112,12 @@ export const ToolHistoryRow: React.FC<ToolHistoryRowProps> = ({
<AccordionTrigger
className={cn(
"h-6 text-xs border-border shadow-none! ring-0! bg-muted/60 hover:bg-muted py-0 px-2 gap-1 rounded-sm [&[data-state=open]>svg]:rotate-180 hover:no-underline",
getTriggerToneClass(state),
interrupted ? "text-muted-foreground" : getTriggerToneClass(state),
)}
>
<span className="flex items-center gap-1">
<StatusIcon state={state} />
{STATUS_LABEL[state]}:
<StatusIcon state={state} interrupted={interrupted} />
{interrupted ? "Interrupted" : STATUS_LABEL[state]}:
<code className="font-mono text-xs">
{formatToolName(toolName)}
</code>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/plugins/impl/chat/chat-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ export const Chatbot: React.FC<Props> = (props) => {
message,
isStreamingReasoning: status === "streaming",
isLast,
isActive: isLoading,
addToolApprovalResponse: isLast
? addToolApprovalResponse
: undefined,
Expand Down
53 changes: 53 additions & 0 deletions marimo/_ai/_pydantic_ai_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ def safe_part_processor(
id=message_id, role=role, parts=parts, metadata=metadata
)

ui_message.parts = [
repair_incomplete_tool_call(part) for part in ui_message.parts
]

# Process parts after casting so the processor will work on typed parts
if ui_message.parts and part_processor:
new_parts = [
Expand Down Expand Up @@ -147,6 +151,55 @@ def _tool_part_allowed_fields() -> dict[tuple[bool, str], frozenset[str]]:
return result


_INTERRUPTED_TOOL_MESSAGE = "Tool call was interrupted and did not complete."


def repair_incomplete_tool_call(part: UIMessagePart) -> UIMessagePart:
"""Give an interrupted tool call a terminal `output-error` result.

A tool part left in `input-streaming`/`input-available` is a tool call with no result.
Some providers like Anthropic expect a tool result, so stopping a stream mid-call would break the conversation.
We rewrite the part to the matching `output-error` model so the conversion to pydantic-ai produces a tool result.

A deferred call (approval-requested/approval-responded) is left alone.
"""
from pydantic_ai.ui.vercel_ai.request_types import (
DynamicToolInputAvailablePart,
DynamicToolInputStreamingPart,
DynamicToolOutputErrorPart,
ToolInputAvailablePart,
ToolInputStreamingPart,
ToolOutputErrorPart,
)

if isinstance(part, (ToolInputStreamingPart, ToolInputAvailablePart)):
return ToolOutputErrorPart(
type=part.type,
tool_call_id=part.tool_call_id,
title=part.title,
input=part.input,
error_text=_INTERRUPTED_TOOL_MESSAGE,
provider_executed=part.provider_executed,
call_provider_metadata=part.call_provider_metadata,
approval=part.approval,
)
if isinstance(
part,
(DynamicToolInputStreamingPart, DynamicToolInputAvailablePart),
):
return DynamicToolOutputErrorPart(
tool_name=part.tool_name,
tool_call_id=part.tool_call_id,
title=part.title,
input=part.input,
error_text=_INTERRUPTED_TOOL_MESSAGE,
provider_executed=part.provider_executed,
call_provider_metadata=part.call_provider_metadata,
approval=part.approval,
)
return part


def sanitize_part(part: Any) -> Any:
"""Drop fields the AI SDK spread onto a tool part during a state transition.

Expand Down
141 changes: 141 additions & 0 deletions tests/_ai/test_pydantic_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
create_simple_prompt,
form_toolsets,
generate_id,
repair_incomplete_tool_call,
sanitize_part,
)
from marimo._server.ai.tools.types import ToolDefinition
Expand Down Expand Up @@ -635,3 +636,143 @@ def test_stale_output_on_approval_responded_part_validates(self):
result = convert_to_pydantic_messages(messages)
assert len(result) == 1
assert isinstance(result[0].parts[0], ToolApprovalRespondedPart)


class TestRepairIncompleteToolCall:
"""Tests for repairing tool calls interrupted before producing a result.

Stopping a stream mid tool-call leaves the part in `input-streaming` or
`input-available`. Anthropic rejects a `tool_use` without a following
`tool_result`, so we rewrite the part to a terminal `output-error` part.
"""

def test_static_tool_incomplete_state_becomes_output_error(self):
from pydantic_ai.ui.vercel_ai.request_types import (
ToolInputAvailablePart,
ToolInputStreamingPart,
ToolOutputErrorPart,
)

for part_cls in (ToolInputStreamingPart, ToolInputAvailablePart):
part = part_cls(
type="tool-execute_code",
tool_call_id="toolu_01S47YeQUgc4ydHC15aVk5yq",
input={"code": "print(1)"},
)
repaired = repair_incomplete_tool_call(part)
assert repaired == ToolOutputErrorPart(
type="tool-execute_code",
tool_call_id="toolu_01S47YeQUgc4ydHC15aVk5yq",
input={"code": "print(1)"},
error_text="Tool call was interrupted and did not complete.",
)

def test_dynamic_tool_input_streaming_without_input(self):
from pydantic_ai.ui.vercel_ai.request_types import (
DynamicToolInputStreamingPart,
DynamicToolOutputErrorPart,
)

part = DynamicToolInputStreamingPart(
tool_name="mcp_search",
tool_call_id="c1",
)
repaired = repair_incomplete_tool_call(part)
# `input` is preserved as-is; a streaming part without input stays None.
assert repaired == DynamicToolOutputErrorPart(
type="dynamic-tool",
tool_name="mcp_search",
tool_call_id="c1",
title=None,
state="output-error",
input=None,
error_text="Tool call was interrupted and did not complete.",
provider_executed=None,
call_provider_metadata=None,
approval=None,
)

def test_terminal_approval_and_non_tool_parts_pass_through_unchanged(
self,
):
from pydantic_ai.ui.vercel_ai.request_types import (
ReasoningUIPart,
TextUIPart,
ToolApprovalResponded,
ToolApprovalRespondedPart,
ToolOutputAvailablePart,
ToolOutputErrorPart,
)

parts = [
ToolOutputAvailablePart(
type="tool-foo", tool_call_id="c1", input={}, output="ok"
),
ToolOutputErrorPart(
type="tool-foo",
tool_call_id="c1",
input={},
error_text="boom",
),
ToolApprovalRespondedPart(
type="tool-foo",
tool_call_id="c1",
input={},
approval=ToolApprovalResponded(id="c1", approved=True),
),
TextUIPart(text="hello"),
ReasoningUIPart(text="thinking..."),
]
for part in parts:
assert repair_incomplete_tool_call(part) is part

def test_convert_repairs_orphaned_tool_call_from_stopped_stream(self):
"""Regression for the Anthropic 400 after stopping a stream mid tool-call.

Mirrors the observed history: assistant emits a tool call that never
completed, followed by a user `continue`. Without repair, pydantic-ai
sends a `tool_use` with no `tool_result` and Anthropic rejects it.
"""
from pydantic_ai.ui.vercel_ai.request_types import (
ToolOutputErrorPart,
)

messages = [
{
"id": "msg_user",
"role": "user",
"parts": [{"type": "text", "text": "edit akshay's cell"}],
},
{
"id": "msg_assistant",
"role": "assistant",
"parts": [
{"type": "reasoning", "text": "I'll edit the cell"},
{
"type": "tool-execute_code",
"toolCallId": "toolu_01S47YeQUgc4ydHC15aVk5yq",
"state": "input-available",
"input": {"code": "..."},
},
],
},
{
"id": "msg_continue",
"role": "user",
"parts": [{"type": "text", "text": "continue"}],
},
]
result = convert_to_pydantic_messages(messages)
assert len(result) == 3
tool_part = result[1].parts[1]
# The original tool input is preserved; only a terminal error result
# is added so the `tool_use` stays paired with a `tool_result`.
assert tool_part == ToolOutputErrorPart(
type="tool-execute_code",
tool_call_id="toolu_01S47YeQUgc4ydHC15aVk5yq",
input={"code": "..."},
error_text="Tool call was interrupted and did not complete.",
provider_executed=None,
call_provider_metadata=None,
approval=None,
)
Loading