feat: make API error messages expandable to show full details

- Added errorDetails field to ClineApiReqInfo interface to store full error information
- Modified ChatRow component to make failed API requests clickable/expandable
- Updated Task.ts to capture and store complete error details when API requests fail
- Added comprehensive tests for the new functionality

Fixes #6139
This commit is contained in:
Roo Code 2025-07-23 22:46:22 +00:00
parent c47de36b05
commit 023398de91
4 changed files with 261 additions and 14 deletions

View file

@ -1222,7 +1222,7 @@ export class Task extends EventEmitter<ClineEvents> {
request:
userContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n") + "\n\nLoading...",
apiProtocol,
}),
} satisfies ClineApiReqInfo),
)
const {
@ -1279,7 +1279,11 @@ export class Task extends EventEmitter<ClineEvents> {
// anyways, so it remains solely for legacy purposes to keep track
// of prices in tasks from history (it's worth removing a few months
// from now).
const updateApiReqMsg = (cancelReason?: ClineApiReqCancelReason, streamingFailedMessage?: string) => {
const updateApiReqMsg = (
cancelReason?: ClineApiReqCancelReason,
streamingFailedMessage?: string,
errorDetails?: string,
) => {
const existingData = JSON.parse(this.clineMessages[lastApiReqIndex].text || "{}")
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
...existingData,
@ -1298,10 +1302,15 @@ export class Task extends EventEmitter<ClineEvents> {
),
cancelReason,
streamingFailedMessage,
errorDetails,
} satisfies ClineApiReqInfo)
}
const abortStream = async (cancelReason: ClineApiReqCancelReason, streamingFailedMessage?: string) => {
const abortStream = async (
cancelReason: ClineApiReqCancelReason,
streamingFailedMessage?: string,
errorDetails?: string,
) => {
if (this.diffViewProvider.isEditing) {
await this.diffViewProvider.revertChanges() // closes diff view
}
@ -1336,7 +1345,7 @@ export class Task extends EventEmitter<ClineEvents> {
// Update `api_req_started` to have cancelled and cost, so that
// we can display the cost of the partial stream.
updateApiReqMsg(cancelReason, streamingFailedMessage)
updateApiReqMsg(cancelReason, streamingFailedMessage, errorDetails)
await this.saveClineMessages()
// Signals to provider that it can retrieve the saved messages
@ -1460,7 +1469,9 @@ export class Task extends EventEmitter<ClineEvents> {
// Now call abortTask after determining the cancel reason
await this.abortTask()
await abortStream(cancelReason, streamingFailedMessage)
// Include full error details for display
const fullErrorDetails = JSON.stringify(serializeError(error), null, 2)
await abortStream(cancelReason, streamingFailedMessage, fullErrorDetails)
const history = await provider?.getTaskWithId(this.taskId)
@ -1816,6 +1827,20 @@ export class Task extends EventEmitter<ClineEvents> {
} catch (error) {
this.isWaitingForFirstChunk = false
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
// Store the full error details in the api_req_started message
const fullErrorDetails = JSON.stringify(serializeError(error), null, 2)
const lastApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
if (lastApiReqIndex !== -1) {
const existingData = JSON.parse(this.clineMessages[lastApiReqIndex].text || "{}")
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
...existingData,
errorDetails: fullErrorDetails,
} satisfies ClineApiReqInfo)
await this.saveClineMessages()
await this.providerRef.deref()?.postStateToWebview()
}
if (autoApprovalEnabled && alwaysApproveResubmit) {
let errorMsg
@ -1873,10 +1898,8 @@ export class Task extends EventEmitter<ClineEvents> {
return
} else {
const { response } = await this.ask(
"api_req_failed",
error.message ?? JSON.stringify(serializeError(error), null, 2),
)
const errorMessage = error.message ?? JSON.stringify(serializeError(error), null, 2)
const { response } = await this.ask("api_req_failed", errorMessage)
if (response !== "yesButtonClicked") {
// This will never happen since if noButtonClicked, we will

View file

@ -412,6 +412,7 @@ export interface ClineApiReqInfo {
cancelReason?: ClineApiReqCancelReason
streamingFailedMessage?: string
apiProtocol?: "anthropic" | "openai"
errorDetails?: string
}
export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled"

View file

@ -177,13 +177,13 @@ export const ChatRowContent = ({
vscode.postMessage({ type: "selectImages", context: "edit", messageTs: message.ts })
}, [message.ts])
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage, errorDetails] = useMemo(() => {
if (message.text !== null && message.text !== undefined && message.say === "api_req_started") {
const info = safeJsonParse<ClineApiReqInfo>(message.text)
return [info?.cost, info?.cancelReason, info?.streamingFailedMessage]
return [info?.cost, info?.cancelReason, info?.streamingFailedMessage, info?.errorDetails]
}
return [undefined, undefined, undefined]
return [undefined, undefined, undefined, undefined]
}, [message.text, message.say])
// When resuming task, last wont be api_req_failed but a resume_task
@ -1041,8 +1041,13 @@ export const ChatRowContent = ({
{isExpanded && (
<div style={{ marginTop: "10px" }}>
<CodeAccordian
code={safeJsonParse<any>(message.text)?.request}
language="markdown"
code={
errorDetails ||
apiRequestFailedMessage ||
apiReqStreamingFailedMessage ||
safeJsonParse<any>(message.text)?.request
}
language={errorDetails ? "json" : "markdown"}
isExpanded={true}
onToggleExpand={handleToggleExpand}
/>

View file

@ -0,0 +1,218 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { ChatRowContent } from "../ChatRow"
import { ClineMessage } from "@roo-code/types"
import { TooltipProvider } from "@src/components/ui/tooltip"
// Mock dependencies
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ i18nKey, children }: any) => <span>{i18nKey || children}</span>,
initReactI18next: {
type: "3rdParty",
init: () => {},
},
}))
vi.mock("@src/context/ExtensionStateContext", () => ({
useExtensionState: () => ({
mcpServers: [],
alwaysAllowMcp: false,
currentCheckpoint: null,
mode: "code",
}),
}))
vi.mock("@src/utils/clipboard", () => ({
useCopyToClipboard: () => ({
copyWithFeedback: vi.fn().mockResolvedValue(true),
}),
}))
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
describe("ChatRow", () => {
const mockProps = {
message: {} as ClineMessage,
lastModifiedMessage: undefined,
isExpanded: false,
isLast: false,
isStreaming: false,
onToggleExpand: vi.fn(),
onHeightChange: vi.fn(),
onSuggestionClick: vi.fn(),
onBatchFileResponse: vi.fn(),
onFollowUpUnmount: vi.fn(),
isFollowUpAnswered: false,
editable: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe("API Request Error Display", () => {
const renderWithProviders = (ui: React.ReactElement) => {
return render(<TooltipProvider>{ui}</TooltipProvider>)
}
it("should display error details when API request fails and is expanded", () => {
const errorDetails = JSON.stringify(
{
error: {
type: "invalid_request_error",
message: "Invalid API key provided",
},
status: 401,
},
null,
2,
)
const failedApiMessage: ClineMessage = {
ts: Date.now(),
type: "say",
say: "api_req_started",
text: JSON.stringify({
request: "Test API request",
errorDetails,
}),
}
const { rerender } = renderWithProviders(
<ChatRowContent {...mockProps} message={failedApiMessage} isExpanded={false} />,
)
// Should not show error details when collapsed
expect(screen.queryByText(/invalid_request_error/)).not.toBeInTheDocument()
// Expand the message
rerender(
<TooltipProvider>
<ChatRowContent {...mockProps} message={failedApiMessage} isExpanded={true} />
</TooltipProvider>,
)
// Should show error details when expanded
expect(screen.getByText(/invalid_request_error/)).toBeInTheDocument()
expect(screen.getByText(/Invalid API key provided/)).toBeInTheDocument()
})
it("should make failed API requests expandable", () => {
const failedApiMessage: ClineMessage = {
ts: Date.now(),
type: "say",
say: "api_req_started",
text: JSON.stringify({
request: "Test API request",
streamingFailedMessage: "Connection timeout",
}),
}
renderWithProviders(
<ChatRowContent
{...mockProps}
message={failedApiMessage}
lastModifiedMessage={{
ts: Date.now(),
type: "ask",
ask: "api_req_failed",
text: "API request failed",
}}
isLast={true}
/>,
)
// Find the header div that should be clickable
const headerDiv = screen.getByText("chat:apiRequest.failed").closest("div")?.parentElement
expect(headerDiv).toBeTruthy()
// Click to expand
fireEvent.click(headerDiv!)
expect(mockProps.onToggleExpand).toHaveBeenCalledWith(failedApiMessage.ts)
})
it("should show streaming failed message when present", () => {
const streamingFailedMessage = "Stream interrupted: Network error"
const failedApiMessage: ClineMessage = {
ts: Date.now(),
type: "say",
say: "api_req_started",
text: JSON.stringify({
request: "Test API request",
cancelReason: "streaming_failed",
streamingFailedMessage,
}),
}
renderWithProviders(<ChatRowContent {...mockProps} message={failedApiMessage} />)
expect(screen.getByText(streamingFailedMessage)).toBeInTheDocument()
expect(screen.getByText("chat:apiRequest.streamingFailed")).toBeInTheDocument()
})
it("should show error details in expanded view when available", () => {
const errorDetails = JSON.stringify(
{
error: {
type: "rate_limit_error",
message: "Rate limit exceeded",
code: "rate_limit_exceeded",
},
status: 429,
headers: {
"retry-after": "60",
},
},
null,
2,
)
const failedApiMessage: ClineMessage = {
ts: Date.now(),
type: "say",
say: "api_req_started",
text: JSON.stringify({
request: "Test API request",
errorDetails,
streamingFailedMessage: "Rate limit exceeded",
}),
}
renderWithProviders(<ChatRowContent {...mockProps} message={failedApiMessage} isExpanded={true} />)
// Should show the error details in the code accordion
expect(screen.getByText(/rate_limit_error/)).toBeInTheDocument()
// Use getAllByText since the error message appears in multiple places
const errorMessages = screen.getAllByText(/Rate limit exceeded/)
expect(errorMessages.length).toBeGreaterThan(0)
expect(screen.getByText(/retry-after/)).toBeInTheDocument()
})
it("should show request details for successful API requests when expanded", () => {
const successfulApiMessage: ClineMessage = {
ts: Date.now(),
type: "say",
say: "api_req_started",
text: JSON.stringify({
request: "Test API request content",
cost: 0.005,
tokensIn: 100,
tokensOut: 200,
}),
}
renderWithProviders(<ChatRowContent {...mockProps} message={successfulApiMessage} isExpanded={true} />)
// Should show the request content
expect(screen.getByText(/Test API request content/)).toBeInTheDocument()
})
})
})