diff --git a/frontend/src/components/chat/chat-display.tsx b/frontend/src/components/chat/chat-display.tsx index 249ded2685d..9e4896e389e 100644 --- a/frontend/src/components/chat/chat-display.tsx +++ b/frontend/src/components/chat/chat-display.tsx @@ -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 ( @@ -49,6 +52,7 @@ export const renderUIMessage = ({ approval={part.approval} onApprove={addToolApprovalResponse} isLive={isLast} + isActive={isActive} /> ); } @@ -102,6 +106,7 @@ export const renderUIMessage = ({ approval={part.approval} onApprove={addToolApprovalResponse} isLive={isLast} + isActive={isActive} /> ); case "source-document": diff --git a/frontend/src/components/chat/chat-panel.tsx b/frontend/src/components/chat/chat-panel.tsx index 70bbd86b304..9d91ba7fae7 100644 --- a/frontend/src/components/chat/chat-panel.tsx +++ b/frontend/src/components/chat/chat-panel.tsx @@ -143,6 +143,7 @@ interface ChatMessageProps { onEdit: (index: number, newValue: string) => void; isStreamingReasoning: boolean; isLast: boolean; + isActive: boolean; addToolApprovalResponse?: ChatAddToolApproveResponseFunction; } @@ -153,6 +154,7 @@ const ChatMessageDisplay: React.FC = memo( onEdit, isStreamingReasoning, isLast, + isActive, addToolApprovalResponse, }) => { const renderUserMessage = (message: UIMessage) => { @@ -205,6 +207,7 @@ const ChatMessageDisplay: React.FC = memo( message, isStreamingReasoning, isLast, + isActive, addToolApprovalResponse, })} @@ -780,6 +783,7 @@ const ChatPanelBody = () => { onEdit={handleMessageEdit} isStreamingReasoning={isStreamingReasoning} isLast={idx === messages.length - 1} + isActive={isLoading} addToolApprovalResponse={addToolApprovalResponse} /> ))} diff --git a/frontend/src/components/chat/tool-call/tool-call-view.tsx b/frontend/src/components/chat/tool-call/tool-call-view.tsx index 6aa9ecf48a1..0068bbef36f 100644 --- a/frontend/src/components/chat/tool-call/tool-call-view.tsx +++ b/frontend/src/components/chat/tool-call/tool-call-view.tsx @@ -24,6 +24,7 @@ interface ToolCallViewProps { * (the user has moved on). */ isLive?: boolean; + isActive?: boolean; } export const ToolCallView: React.FC = ({ @@ -37,6 +38,7 @@ export const ToolCallView: React.FC = ({ index, className, isLive = true, + isActive = true, }) => { switch (state) { case "approval-requested": @@ -61,6 +63,7 @@ export const ToolCallView: React.FC = ({ input={input} index={index} className={className} + isActive={isActive} /> ); @@ -89,6 +92,7 @@ export const ToolCallView: React.FC = ({ approval={approval} index={index} className={className} + isActive={isActive} /> ); diff --git a/frontend/src/components/chat/tool-call/tool-history-row.tsx b/frontend/src/components/chat/tool-call/tool-history-row.tsx index a4a940ac9de..c82c3c1ac26 100644 --- a/frontend/src/components/chat/tool-call/tool-history-row.tsx +++ b/frontend/src/components/chat/tool-call/tool-history-row.tsx @@ -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, @@ -29,7 +35,19 @@ const STATUS_LABEL: Record = { "output-denied": "Denied", }; -const StatusIcon: React.FC<{ state: HistoryState }> = ({ state }) => { +const PENDING_STATES = new Set([ + "input-streaming", + "input-available", + "approval-responded", +]); + +const StatusIcon: React.FC<{ state: HistoryState; interrupted: boolean }> = ({ + state, + interrupted, +}) => { + if (interrupted) { + return ; + } switch (state) { case "input-streaming": case "input-available": @@ -69,6 +87,7 @@ interface ToolHistoryRowProps { approval?: ToolApproval; index?: number; className?: string; + isActive?: boolean; } export const ToolHistoryRow: React.FC = ({ @@ -79,7 +98,9 @@ export const ToolHistoryRow: React.FC = ({ approval, index = 0, className, + isActive = true, }) => { + const interrupted = !isActive && PENDING_STATES.has(state); return ( = ({ svg]:rotate-180 hover:no-underline", - getTriggerToneClass(state), + interrupted ? "text-muted-foreground" : getTriggerToneClass(state), )} > - - {STATUS_LABEL[state]}: + + {interrupted ? "Interrupted" : STATUS_LABEL[state]}: {formatToolName(toolName)} diff --git a/frontend/src/plugins/impl/chat/chat-ui.tsx b/frontend/src/plugins/impl/chat/chat-ui.tsx index f4fabb3b8db..7a77a491abe 100644 --- a/frontend/src/plugins/impl/chat/chat-ui.tsx +++ b/frontend/src/plugins/impl/chat/chat-ui.tsx @@ -445,6 +445,7 @@ export const Chatbot: React.FC = (props) => { message, isStreamingReasoning: status === "streaming", isLast, + isActive: isLoading, addToolApprovalResponse: isLast ? addToolApprovalResponse : undefined, diff --git a/marimo/_ai/_pydantic_ai_utils.py b/marimo/_ai/_pydantic_ai_utils.py index 2b37f7e0e2c..9e267eb20e9 100644 --- a/marimo/_ai/_pydantic_ai_utils.py +++ b/marimo/_ai/_pydantic_ai_utils.py @@ -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 = [ @@ -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. diff --git a/tests/_ai/test_pydantic_utils.py b/tests/_ai/test_pydantic_utils.py index e3b79441611..050c073000c 100644 --- a/tests/_ai/test_pydantic_utils.py +++ b/tests/_ai/test_pydantic_utils.py @@ -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 @@ -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, + )