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
121 changes: 104 additions & 17 deletions web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {useChat} from "@ai-sdk/react"
import {Attachments, Bubble, Sender} from "@ant-design/x"
import {ArrowDown, Paperclip} from "@phosphor-icons/react"
import {DefaultChatTransport, type UIMessage} from "ai"
import {Alert, Button, Modal, Tabs, Tag, Tooltip} from "antd"
import {Button, Modal, Tabs, Tag, Tooltip} from "antd"
import type {UploadFile} from "antd"
import {useAtomValue, useSetAtom, useStore} from "jotai"

Expand All @@ -14,17 +14,18 @@ import {messageText, sideEffectingToolsInRange} from "./assets/rewind"
import AgentMessage from "./components/AgentMessage"
import SessionHistoryMenu from "./components/SessionHistoryMenu"
import SessionTabLabel from "./components/SessionTabLabel"
import {useChatScopeKey} from "./state/scope"
import {
type AgentChatSession,
activeSessionIdAtom,
addSessionAtom,
closeSessionAtom,
activeSessionIdAtomFamily,
addSessionAtomFamily,
closeSessionAtomFamily,
persistSessionMessagesAtom,
renameSessionAtom,
renameSessionAtomFamily,
sessionFirstUserTextAtomFamily,
sessionMessagesAtom,
sessionsListAtom,
setActiveSessionAtom,
sessionsListAtomFamily,
setActiveSessionAtomFamily,
} from "./state/sessions"

/** A stream error/abort is already surfaced via `useChat`'s `onError` + the in-chat `error`
Expand All @@ -45,6 +46,57 @@ const ignoreStreamRejection = () => {}
* - DT4 autoscroll: stick to bottom while streaming; pause when scrolled up; "jump to latest".
* - DT5 a11y: the message log is an aria-live region; controls are keyboard-operable.
*/

/** A settled assistant turn with no content at all — no answer, reasoning, tool, file, or
* source part. Mirrors AgentMessage's `!hasContent`; used to collapse a run of "no response"
* bubbles (e.g. repeated failed runs) down to the first one. */
const isEmptyAssistantTurn = (m: UIMessage): boolean =>
m.role === "assistant" &&
!m.parts.some(
(p) =>
(p.type === "text" && Boolean((p as {text?: string}).text?.trim())) ||
(p.type === "reasoning" && Boolean((p as {text?: string}).text?.trim())) ||
p.type === "file" ||
p.type === "source-url" ||
p.type.startsWith("tool-") ||
p.type === "dynamic-tool",
)

interface ParsedRunError {
message: string
code?: number
}

/**
* Best-effort human reason from a useChat stream error. The server may hand us a clean string
* ("Agent run failed: …") or a JSON envelope (`{status:{code,message,…}}` / `{message}`) — pull
* the message out of either and drop the stacktrace / docs-url noise so it reads cleanly inline.
*/
const parseAgentRunError = (err: unknown): ParsedRunError => {
const raw =
err instanceof Error ? err.message : typeof err === "string" ? err : String(err ?? "")
const fallback = raw.trim() || "The agent run failed."
try {
const obj = JSON.parse(raw) as Record<string, unknown>
const status = (obj?.status && typeof obj.status === "object" ? obj.status : obj) as Record<
string,
unknown
>
const message =
typeof status?.message === "string"
? status.message
: typeof obj?.message === "string"
? (obj.message as string)
: null
if (message) {
return {message, code: typeof status?.code === "number" ? status.code : undefined}
}
} catch {
// raw isn't JSON — it's already the human message.
}
return {message: fallback}
}

const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: string}) => {
const store = useStore()
const persistMessages = useSetAtom(persistSessionMessagesAtom)
Expand Down Expand Up @@ -109,6 +161,39 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId:

const busy = status === "submitted" || status === "streaming"

// Surface a stream failure inline: stamp the parsed error onto the failing assistant turn so
// it renders as a red error bubble with the real reason (and persists with the session via the
// effect below), instead of a transient top banner + a generic "no response". FE-only — it
// uses the error useChat already has; the backend doesn't need to attach it to the trace.
useEffect(() => {
if (!error) return
const parsed = parseAgentRunError(error)
setMessages((prev) => {
const last = prev.length > 0 ? prev[prev.length - 1] : undefined
const existing = (last?.metadata as {runError?: {message?: string}} | undefined)
?.runError
if (last?.role === "assistant") {
if (existing?.message === parsed.message) return prev // already stamped
const next = [...prev]
next[next.length - 1] = {
...last,
metadata: {...(last.metadata as object | undefined), runError: parsed},
}
return next
}
// No trailing assistant turn (failed before one existed) — add a minimal carrier.
return [
...prev,
{
id: `run-error-${crypto.randomUUID()}`,
role: "assistant",
parts: [],
metadata: {runError: parsed},
} as (typeof prev)[number],
]
})
}, [error, setMessages])

// Persist the conversation whenever its stream settles (skip mid-stream).
useEffect(() => {
if (status === "streaming") return
Expand Down Expand Up @@ -219,10 +304,8 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId:

return (
<div className="flex h-full min-h-0 w-full flex-col gap-3">
{error && (
<Alert type="error" showIcon message="Stream error" description={error.message} />
)}

{/* Stream errors are surfaced inline on the failing turn (red error bubble with the
real reason), stamped in the effect above — no separate top-level banner. */}
<div className="relative flex min-h-0 flex-1 flex-col">
<div
ref={(el) => {
Expand All @@ -247,6 +330,9 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId:
isStreaming={busy && index === messages.length - 1}
onRewind={() => handleRewind(message)}
onApprovalResponse={addToolApprovalResponse}
precededByEmptyAssistant={
index > 0 && isEmptyAssistantTurn(messages[index - 1])
}
/>
{stoppedIds.has(message.id) && (
<div className="flex items-center gap-2 self-start pl-1">
Expand Down Expand Up @@ -387,12 +473,13 @@ const TabLabel = ({
}

const AgentChatPanel = ({entityId}: {entityId: string}) => {
const sessions = useAtomValue(sessionsListAtom)
const rawActiveId = useAtomValue(activeSessionIdAtom)
const addSession = useSetAtom(addSessionAtom)
const closeSession = useSetAtom(closeSessionAtom)
const renameSession = useSetAtom(renameSessionAtom)
const setActiveSession = useSetAtom(setActiveSessionAtom)
const scope = useChatScopeKey()
const sessions = useAtomValue(sessionsListAtomFamily(scope))
const rawActiveId = useAtomValue(activeSessionIdAtomFamily(scope))
const addSession = useSetAtom(addSessionAtomFamily(scope))
const closeSession = useSetAtom(closeSessionAtomFamily(scope))
const renameSession = useSetAtom(renameSessionAtomFamily(scope))
const setActiveSession = useSetAtom(setActiveSessionAtomFamily(scope))

// Always keep at least one tab. Re-arms when the list drains without double-firing
// under StrictMode.
Expand Down
12 changes: 12 additions & 0 deletions web/oss/src/components/AgentChatSlice/assets/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ export const getMessageTraceId = (message: UIMessage): string | undefined => {
return tracePart.data.traceId || parseTraceIdFromUrl(tracePart.data.url)
}

/**
* A run failure stamped onto an assistant turn's metadata (FE-side, when the stream errors —
* see AgentChatPanel). The backend doesn't always record the error on the trace, but useChat
* surfaces it; persisting it here lets the failed turn render the real reason inline (a red
* error bubble) instead of a generic "no response", and survives a reload with the session.
*/
export const getMessageRunError = (message: UIMessage): string | undefined => {
const runError = (message.metadata as {runError?: {message?: string}} | undefined)?.runError
const msg = runError?.message
return typeof msg === "string" && msg.trim() ? msg : undefined
}

/** Token/cost fields in `ExecutionMetricsDisplay`'s shape. */
export interface MessageUsageMetrics {
promptTokens?: number
Expand Down
8 changes: 7 additions & 1 deletion web/oss/src/components/AgentChatSlice/assets/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,13 @@ async function requestMeta(track: AgentChatTrack, appId?: string | null) {
// `Accept: text/event-stream` makes the agent `/messages` endpoint serve the v6 SSE
// stream useChat consumes; without it the endpoint negotiates down to batch JSON
// (the AI-SDK transport sets no Accept), which useChat can't render.
const headers: Record<string, string> = {Accept: "text/event-stream"}
// `x-ag-messages-format` declares the request body's message format (AI-SDK / Vercel
// UIMessages) so `/messages` picks the right adapter; "vercel" matches the backend's
// VERCEL_MESSAGE_PROTOCOL identity (sdk/agents/adapters/vercel/routing.py).
const headers: Record<string, string> = {
Accept: "text/event-stream",
"x-ag-messages-format": "vercel",
}
if (jwt) headers.Authorization = `Bearer ${jwt}`
const projectId = getDefaultStore().get(projectIdAtom) || undefined
const api = withQuery(trackApi(track), {
Expand Down
93 changes: 81 additions & 12 deletions web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import {openTraceDrawerAtom} from "@/oss/components/SharedDrawers/TraceDrawer/st

import {fileKind, filePartName} from "../assets/files"
import Markdown from "../assets/markdown"
import {getMessageTraceId, getMessageUsage, type MessageUsageMetrics} from "../assets/trace"
import {
getMessageRunError,
getMessageTraceId,
getMessageUsage,
type MessageUsageMetrics,
} from "../assets/trace"

import ToolPart from "./ToolPart"

Expand All @@ -45,6 +50,9 @@ interface AgentMessageProps {
isStreaming?: boolean
onRewind: () => void
onApprovalResponse: (args: {id: string; approved: boolean}) => void
/** The previous turn was also an empty (content-less) assistant turn. Used to collapse a
* run of "no response" bubbles down to the first one. */
precededByEmptyAssistant?: boolean
}

const isToolPart = (type: string) => type.startsWith("tool-") || type === "dynamic-tool"
Expand Down Expand Up @@ -99,6 +107,48 @@ const ReasoningPart = ({text, streaming}: {text: string; streaming: boolean}) =>
)
}

/**
* Failed-run body: the icon + "The agent run failed" + the reason. The reason is clamped to a few
* lines by default so a long message (or a stacktrace that slipped past parsing) can't drown the
* chat; when it's long, a "Show more" toggle expands it into a scrollable, whitespace-preserving
* block so it stays readable.
*/
const RunErrorBody = ({text}: {text: string}) => {
const [expanded, setExpanded] = useState(false)
const isLong = text.length > 220 || text.includes("\n")

return (
<div className="flex items-start gap-2">
<XCircle size={16} weight="fill" className="mt-px shrink-0 text-colorError" />
<div className="flex min-w-0 flex-col items-start gap-0.5">
<Text className="!text-xs !font-medium !text-colorError">The agent run failed</Text>
{expanded ? (
<pre className="m-0 max-h-60 w-full overflow-auto whitespace-pre-wrap break-words rounded border border-solid border-colorErrorBorder bg-[var(--ant-color-error-bg)] p-2 font-mono text-[11px] !text-colorErrorText">
{text}
</pre>
) : (
<Text
className="line-clamp-3 !text-xs break-words !text-colorErrorText"
title={isLong ? text : undefined}
>
{text}
</Text>
)}
{isLong && (
<button
type="button"
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
className="-ml-1 cursor-pointer rounded border-0 bg-transparent px-1 py-0.5 text-[11px] font-medium text-colorError transition-colors hover:bg-[var(--ant-color-error-bg)]"
>
{expanded ? "Show less" : "Show more"}
</button>
)}
</div>
</div>
)
}

const avatarFor = (isUser: boolean) => (
<Avatar size="small" icon={isUser ? <User size={16} /> : <Robot size={16} />} />
)
Expand All @@ -114,6 +164,7 @@ const AgentMessage = ({
isStreaming = false,
onRewind,
onApprovalResponse,
precededByEmptyAssistant = false,
}: AgentMessageProps) => {
const openTraceDrawer = useSetAtom(openTraceDrawerAtom)
const isUser = message.role === "user"
Expand All @@ -123,6 +174,14 @@ const AgentMessage = ({
// A failed run (e.g. a quota error the runner swallowed into an empty turn) lands as an
// error on the trace; read it so the bubble can render as a failure.
const traceError = useAtomValue(traceDataSummaryAtomFamily(traceId ?? null)).error
// A failure can reach us two ways: recorded on the trace (backend), or stamped onto the turn
// FE-side from the useChat stream error (AgentChatPanel). Prefer whichever is present.
const runError = getMessageRunError(message)
const errorText = traceError || runError
Comment on lines +177 to +180

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep showing the failure when the stream dies after partial output.

AgentChatPanel now stamps runError onto any failing assistant turn, but this component only renders that error when noResponse is true. If the model emitted text/tool output before the stream failed, the top banner is gone and the turn now looks successful even though it terminated in error.

💡 Minimal fix
     const traceError = useAtomValue(traceDataSummaryAtomFamily(traceId ?? null)).error
     // A failure can reach us two ways: recorded on the trace (backend), or stamped onto the turn
     // FE-side from the useChat stream error (AgentChatPanel). Prefer whichever is present.
     const runError = getMessageRunError(message)
     const errorText = traceError || runError
+    const showError = !isStreaming && !!errorText
@@
-    const isError = noResponse && !!errorText
+    const isError = noResponse && showError
 
-    if (noResponse && !isError && !hasContent && precededByEmptyAssistant) return null
+    if (noResponse && !showError && !hasContent && precededByEmptyAssistant) return null
@@
-    const body = isError ? errorBody : defaultBody
+    const body =
+        showError && !isError ? (
+            <div className="flex min-w-0 max-w-full flex-col gap-2">
+                {defaultBody}
+                {errorBody}
+            </div>
+        ) : isError ? (
+            errorBody
+        ) : (
+            defaultBody
+        )

Also applies to: 214-219, 327-328

// Surface a settled-turn error even when the model emitted partial output before the
// stream died — not only when the turn is answer-less. (`isError` stays answer-less-only
// so the *whole* bubble only turns red when there's nothing else to show.)
const showError = !isStreaming && !!errorText
const fullText = message.parts
.filter((p) => p.type === "text")
.map((p) => (p as {text: string}).text)
Expand Down Expand Up @@ -154,7 +213,12 @@ const AgentMessage = ({
const noResponse = !isUser && !isStreaming && !hasAnswer
// A settled no-answer turn whose trace recorded an error → render the bubble itself as a
// failure (red), with the message inline — not a nested alert box.
const isError = noResponse && !!traceError
const isError = noResponse && showError

// #3: collapse a run of empty "no response" turns to just the first. A turn with ANY content
// (answer or reasoning) and any error turn (isError, which shows the real failure) always
// render; only a truly-empty, non-error turn that follows another empty turn is hidden.
if (noResponse && !showError && !hasContent && precededByEmptyAssistant) return null

// Only the message being generated shows the loading state, and only until it has content.
if (!isUser && isStreaming && !hasContent) {
Expand Down Expand Up @@ -261,17 +325,22 @@ const AgentMessage = ({
)

// Failed run: the whole bubble reads as the error (red), message inline — no nested box.
const errorBody = (
<div className="flex items-start gap-2">
<XCircle size={16} weight="fill" className="mt-px shrink-0 text-colorError" />
<div className="flex min-w-0 flex-col gap-0.5">
<Text className="!text-xs !font-medium !text-colorError">The agent run failed</Text>
<Text className="!text-xs break-words !text-colorErrorText">{traceError}</Text>
</div>
</div>
)
// RunErrorBody truncates a long reason so it can't drown the chat (expand to read it all).
const errorBody = <RunErrorBody text={errorText || "The agent run failed."} />

const body = isError ? errorBody : defaultBody
// Partial output then failure: show the content AND the error. Answer-less failure: the
// whole bubble is the error. Otherwise: just the content.
const body =
showError && !isError ? (
<div className="flex min-w-0 max-w-full flex-col gap-2">
{defaultBody}
{errorBody}
</div>
) : isError ? (
errorBody
) : (
defaultBody
)

// Control toolbar — an X `Actions` row that FLOATS over the bubble's bottom edge. It is
// absolutely positioned (out of flow), so it adds no height: bubbles sit tight with no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import {ClockCounterClockwise, Trash} from "@phosphor-icons/react"
import {Button, Empty, Popover, Tag, Tooltip, Typography} from "antd"
import {useAtomValue, useSetAtom} from "jotai"

import {useChatScopeKey} from "../state/scope"
import {
deleteSessionAtom,
deleteSessionAtomFamily,
firstUserText,
openSessionAtom,
openSessionIdsAtom,
sessionHistoryAtom,
openSessionAtomFamily,
openSessionIdsAtomFamily,
sessionHistoryAtomFamily,
sessionMessagesAtom,
} from "../state/sessions"

Expand All @@ -34,11 +35,12 @@ const timeAgo = (ts?: number): string => {
* permanently (tab + history + messages).
*/
const SessionHistoryList = ({onPicked}: {onPicked: () => void}) => {
const history = useAtomValue(sessionHistoryAtom)
const openIds = useAtomValue(openSessionIdsAtom)
const scope = useChatScopeKey()
const history = useAtomValue(sessionHistoryAtomFamily(scope))
const openIds = useAtomValue(openSessionIdsAtomFamily(scope))
const allMessages = useAtomValue(sessionMessagesAtom)
const openSession = useSetAtom(openSessionAtom)
const deleteSession = useSetAtom(deleteSessionAtom)
const openSession = useSetAtom(openSessionAtomFamily(scope))
const deleteSession = useSetAtom(deleteSessionAtomFamily(scope))

if (history.length === 0) {
return (
Expand Down
Loading
Loading