mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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:
parent
c47de36b05
commit
023398de91
4 changed files with 261 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -412,6 +412,7 @@ export interface ClineApiReqInfo {
|
|||
cancelReason?: ClineApiReqCancelReason
|
||||
streamingFailedMessage?: string
|
||||
apiProtocol?: "anthropic" | "openai"
|
||||
errorDetails?: string
|
||||
}
|
||||
|
||||
export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
218
webview-ui/src/components/chat/__tests__/ChatRow.spec.tsx
Normal file
218
webview-ui/src/components/chat/__tests__/ChatRow.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue