fix(x): surface coding tool errors

This commit is contained in:
Ramnique Singh 2026-07-06 10:56:23 +05:30
parent 36c0cca3f9
commit 4a2fe30153
11 changed files with 138 additions and 18 deletions

View file

@ -22,7 +22,7 @@ import {
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation";
import { getToolActionsSummary, getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
import { getToolActionsSummary, getToolDisplayName, getToolErrorText, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
const formatToolValue = (value: unknown) => {
if (typeof value === "string") return value;
@ -329,7 +329,7 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool
<ToolTabbedContent
input={tool.input as ToolUIPart["input"]}
output={tool.result as ToolUIPart["output"]}
errorText={tool.status === 'error' ? 'Tool error' : undefined}
errorText={getToolErrorText(tool)}
/>
</ToolContent>
</Tool>

View file

@ -51,6 +51,7 @@ import {
getWebSearchCardData,
getComposioConnectCardData,
getToolDisplayName,
getToolErrorText,
groupConversationItems,
isChatMessage,
isErrorMessage,
@ -484,7 +485,7 @@ export function ChatSidebar({
)
}
const toolTitle = getToolDisplayName(item)
const errorText = item.status === 'error' ? 'Tool error' : ''
const errorText = getToolErrorText(item)
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
return (

View file

@ -10,7 +10,7 @@ import { Conversation, ConversationContent, ConversationScrollButton } from '@/c
import { MessageResponse } from '@/components/ai-elements/message'
import { Shimmer } from '@/components/ai-elements/shimmer'
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
import { toToolState, getToolDisplayName, getWebSearchCardData, type ToolCall } from '@/lib/chat-conversation'
import { toToolState, getToolDisplayName, getToolErrorText, getWebSearchCardData, type ToolCall } from '@/lib/chat-conversation'
import { CodeRunPermissionRequest, CodingRunTimeline } from '@/components/coding-run'
import { PermissionRequest } from '@/components/ai-elements/permission-request'
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
@ -84,7 +84,7 @@ function RowboatToolCall({ item, onOpenDiff }: { item: ToolCall; onOpenDiff: (pa
<Tool open={open || item.status === 'running'} onOpenChange={setOpen}>
<ToolHeader title={AGENT_LABEL[agent ?? ''] ?? 'Coding agent'} type="tool-code_agent_run" state={toToolState(item.status)} />
<ToolContent>
<CodingRunTimeline events={item.codeRunEvents ?? []} onOpenDiff={onOpenDiff} />
<CodingRunTimeline events={item.codeRunEvents ?? []} error={getToolErrorText(item)} onOpenDiff={onOpenDiff} />
</ToolContent>
</Tool>
)

View file

@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
import {
AlertCircle,
CheckCircle2,
Circle,
CircleDot,
@ -17,7 +18,7 @@ import {
import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js'
import { cn } from '@/lib/utils'
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
import { toToolState, type ToolCall } from '@/lib/chat-conversation'
import { getToolErrorText, toToolState, type ToolCall } from '@/lib/chat-conversation'
// ── Timeline reduction ──────────────────────────────────────────────
// The raw ACP stream is a flat list of events; collapse it into ordered rows,
@ -111,14 +112,26 @@ const basename = (p: string) => p.split(/[\\/]/).pop() || p
export function CodingRunTimeline({
events,
error,
onOpenDiff,
}: {
events: CodeRunEvent[]
error?: string
// When set, changed-file names become clickable (the Code section opens the diff).
onOpenDiff?: (path: string) => void
}) {
const rows = useMemo(() => reduceEvents(events), [events])
if (rows.length === 0) {
if (error) {
return (
<div className="px-4 py-3">
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 whitespace-pre-wrap break-words">{error}</span>
</div>
</div>
)
}
return <div className="px-4 py-3 text-xs text-muted-foreground">Starting the agent</div>
}
return (
@ -133,12 +146,17 @@ export function CodingRunTimeline({
}
if (row.kind === 'tool') {
const running = row.status !== 'completed' && row.status !== 'failed'
const failed = row.status === 'failed'
return (
<div key={row.id} className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm">
{running
? <Loader className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
: <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />}
{running ? (
<Loader className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
) : failed ? (
<AlertCircle className="size-3.5 shrink-0 text-destructive" />
) : (
<CheckCircle2 className="size-3.5 shrink-0 text-green-600" />
)}
{toolKindIcon(row.toolKind, row.title)}
<span className="truncate text-foreground/90">{row.title ?? row.toolKind ?? 'Tool call'}</span>
</div>
@ -189,6 +207,12 @@ export function CodingRunTimeline({
</div>
)
})}
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 whitespace-pre-wrap break-words">{error}</span>
</div>
)}
</div>
)
}
@ -258,12 +282,13 @@ export function CodingRunBlock({
(item.result as { agent?: string } | undefined)?.agent ??
(item.input as { agent?: string } | undefined)?.agent
const title = AGENT_LABEL[agent ?? ''] ?? 'Coding agent'
const error = getToolErrorText(item)
return (
<>
<Tool open={open} onOpenChange={onOpenChange}>
<ToolHeader title={title} type="tool-code_agent_run" state={toToolState(item.status)} />
<ToolContent>
<CodingRunTimeline events={item.codeRunEvents ?? []} />
<CodingRunTimeline events={item.codeRunEvents ?? []} error={error} />
</ToolContent>
</Tool>
{item.pendingCodePermission && (

View file

@ -7,6 +7,7 @@ import {
isErrorMessage,
isToolCall,
getToolDisplayName,
getToolErrorText,
toToolState,
normalizeToolOutput,
} from '@/lib/chat-conversation'
@ -62,7 +63,7 @@ function CompactToolRow({ tool }: { tool: ToolCall }) {
const [open, setOpen] = useState(false)
const title = getToolDisplayName(tool)
const state = toToolState(tool.status)
const errorText = tool.status === 'error' && typeof tool.result === 'string' ? tool.result : undefined
const errorText = getToolErrorText(tool)
return (
<Tool open={open} onOpenChange={setOpen} className="mb-0 text-xs">
<ToolHeader title={title} type={`tool-${tool.name}` as `tool-${string}`} state={state} />

View file

@ -121,6 +121,19 @@ export const normalizeToolOutput = (
return output
}
export const getToolErrorText = (tool: ToolCall): string | undefined => {
if (tool.status !== 'error') return undefined
if (typeof tool.result === 'string' && tool.result.trim()) return tool.result
if (tool.result !== undefined) {
try {
return JSON.stringify(tool.result, null, 2)
} catch {
// Fall through to the generic label for non-serializable legacy values.
}
}
return 'Tool error'
}
export type WebSearchCardResult = { title: string; url: string; description: string }
export type WebSearchCardData = {

View file

@ -190,6 +190,35 @@ describe('buildTurnConversation', () => {
expect(items.map((i) => i.status)).toEqual(['pending', 'running'])
})
it('uses the tool result envelope to determine error status', () => {
const state = reduceTurn([
created(T1, S1),
requested(T1, 0),
completed(
T1,
0,
assistantCalls(
toolCallPart('flagged', 'echo'),
toolCallPart('normal', 'echo'),
),
),
invocation(T1, 'flagged', 'echo'),
{
type: 'tool_result',
turnId: T1,
ts: TS,
toolCallId: 'flagged',
toolName: 'echo',
source: 'sync',
result: { output: 'boom', isError: true },
},
invocation(T1, 'normal', 'echo'),
toolResult(T1, 'normal', 'echo', { success: false, message: 'payload data' }),
])
const items = buildTurnConversation(state).filter(isToolCall)
expect(items.map((i) => i.status)).toEqual(['error', 'completed'])
})
it('renders user attachments and a failed turn as an error item', () => {
const input = {
role: 'user' as const,

View file

@ -200,7 +200,7 @@ function extractAttachments(content: UserContent): MessageAttachment[] | undefin
}
function toolStatus(tc: ToolCallState): ToolCall['status'] {
if (tc.result) return 'completed'
if (tc.result) return tc.result.result.isError ? 'error' : 'completed'
if (tc.permission && !tc.permission.resolved) return 'pending'
return 'running'
}