diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d12c0a2ffe9..2a792088231 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1222,7 +1222,7 @@ export class Task extends EventEmitter { request: userContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n") + "\n\nLoading...", apiProtocol, - }), + } satisfies ClineApiReqInfo), ) const { @@ -1279,7 +1279,11 @@ export class Task extends EventEmitter { // 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 { ), 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 { // 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 { // 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 { } 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 { 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 diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index bfcf70deb86..10c07563d87 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -412,6 +412,7 @@ export interface ClineApiReqInfo { cancelReason?: ClineApiReqCancelReason streamingFailedMessage?: string apiProtocol?: "anthropic" | "openai" + errorDetails?: string } export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 4fa921f4435..134efda1cba 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -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(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 && (
(message.text)?.request} - language="markdown" + code={ + errorDetails || + apiRequestFailedMessage || + apiReqStreamingFailedMessage || + safeJsonParse(message.text)?.request + } + language={errorDetails ? "json" : "markdown"} isExpanded={true} onToggleExpand={handleToggleExpand} /> diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.spec.tsx new file mode 100644 index 00000000000..5547a81267b --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.spec.tsx @@ -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) => {i18nKey || children}, + 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({ui}) + } + + 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( + , + ) + + // Should not show error details when collapsed + expect(screen.queryByText(/invalid_request_error/)).not.toBeInTheDocument() + + // Expand the message + rerender( + + + , + ) + + // 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( + , + ) + + // 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() + + 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() + + // 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() + + // Should show the request content + expect(screen.getByText(/Test API request content/)).toBeInTheDocument() + }) + }) +})