Skip to content

feat: Make API error messages expandable to show full details #6143

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
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
41 changes: 32 additions & 9 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ export interface ClineApiReqInfo {
cancelReason?: ClineApiReqCancelReason
streamingFailedMessage?: string
apiProtocol?: "anthropic" | "openai"
errorDetails?: string
}

export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled"
15 changes: 10 additions & 5 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
/>
Expand Down
218 changes: 218 additions & 0 deletions webview-ui/src/components/chat/__tests__/ChatRow.spec.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
})