Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
74 changes: 60 additions & 14 deletions webview-ui/src/components/chat/McpExecution.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,33 +60,75 @@
// Only need expanded state for response section (like command output)
const [isResponseExpanded, setIsResponseExpanded] = useState(false)

// Try to parse JSON and return both the result and formatted text
const tryParseJson = useCallback((text: string): { isJson: boolean; formatted: string } => {
if (!text) return { isJson: false, formatted: "" }
const looksLikeJson = useCallback((value: string): boolean => {
const trimmed = value.trim()
return (trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))
}, [])

const tryParseJsonValue = useCallback((value: string): unknown | undefined => {
try {
const parsed = JSON.parse(text)
return {
isJson: true,
formatted: JSON.stringify(parsed, null, 2),
}
return JSON.parse(value)
} catch {
return undefined
}
}, [])

// Try to parse JSON and return both the result and formatted text.
// Handles:
// - minified JSON
// - double-encoded JSON (JSON string containing JSON)
// - escaped JSON blobs (eg `[{"id":1}]`)
const tryParseJson = useCallback(
(text: string): { isJson: boolean; formatted: string } => {
if (!text) return { isJson: false, formatted: "" }

const trimmed = text.trim()
let parsed: unknown | undefined = tryParseJsonValue(trimmed)

// If initial parse fails, try un-escaping common "JSON encoded as a string blob" patterns.
if (parsed === undefined && (trimmed.includes('\\"') || trimmed.includes("\\\\"))) {
const unescaped = trimmed.replaceAll("\\\\", "\\").replaceAll('\\"', '"')
parsed = tryParseJsonValue(unescaped)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unescape fallback (replaceAll(\\\\, \\).replaceAll('\\"', '"')) is fairly aggressive and can change the meaning of responses that contain backslashes but are not actually escaped JSON blobs; consider gating this branch on a stricter pattern (eg starting with {\" or [{\") so we do not pretty-print mutated content.

Fix it with Roo Code or mention @roomote and request a fix.


// If we parsed a string that itself looks like JSON, try parsing again (double-encoded).
for (let i = 0; i < 2; i++) {
if (typeof parsed === "string" && looksLikeJson(parsed)) {
parsed = tryParseJsonValue(parsed)
continue
}
break
}

// Only treat as JSON when we end up with a non-string JSON value.
if (parsed !== undefined && typeof parsed !== "string") {
return {
isJson: true,
formatted: JSON.stringify(parsed, null, 2),
}
}

return {
isJson: false,
formatted: text,
}
}
}, [])
},
[looksLikeJson, tryParseJsonValue],
)

// Only parse response data when expanded AND complete to avoid parsing partial JSON
const responseData = useMemo(() => {
if (!isResponseExpanded) {
return { isJson: false, formatted: responseText }
}
// Only try to parse JSON if the response is complete
if (status && status.status === "completed") {

// If we have streaming status, only parse when completed.
// If we have no status, this is typically a non-streaming "ask" payload, so treat as complete.
const shouldParse = status ? status.status === "completed" : true
if (shouldParse) {
return tryParseJson(responseText)
}

// For partial responses, just return as-is without parsing
return { isJson: false, formatted: responseText }
}, [responseText, isResponseExpanded, tryParseJson, status])
Expand Down Expand Up @@ -125,6 +167,7 @@
const formattedResponseText = responseData.formatted
const formattedArgumentsText = argumentsData.formatted
const responseIsJson = responseData.isJson
const rawResponseText = responseText

const onToggleResponseExpand = useCallback(() => {
setIsResponseExpanded(!isResponseExpanded)
Expand Down Expand Up @@ -277,14 +320,15 @@
"mt-1 pt-1":
!isArguments && (useMcpServer?.type === "use_mcp_tool" || (toolName && serverName)),
})}>
<CodeBlock source={formattedArgumentsText} language="json" />
<CodeBlock source={formattedArgumentsText} rawSource={argumentsText} language="json" />
</div>
)}

{/* Response section - collapsible like command output */}
<ResponseContainer
isExpanded={isResponseExpanded}
response={formattedResponseText}
rawResponse={rawResponseText}
isJson={responseIsJson}
hasArguments={!!(isArguments || useMcpServer?.arguments || argumentsText)}
isPartial={status ? status.status !== "completed" : false}
Expand All @@ -299,12 +343,14 @@
const ResponseContainerInternal = ({
isExpanded,
response,
rawResponse,
isJson,
hasArguments,
isPartial = false,
}: {
isExpanded: boolean
response: string
rawResponse: string
isJson: boolean
hasArguments?: boolean
isPartial?: boolean
Expand All @@ -327,7 +373,7 @@
"max-h-96 overflow-y-auto mt-1 pt-1": !hasArguments,
})}>
{isJson ? (
<CodeBlock source={response} language="json" />
<CodeBlock source={response} rawSource={rawResponse} language="json" />
) : (
<Markdown markdown={response} partial={isPartial} />
)}
Expand Down
147 changes: 147 additions & 0 deletions webview-ui/src/components/chat/__tests__/McpExecution.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"

import { McpExecution } from "../McpExecution"

vi.mock("react-use", () => ({
useEvent: vi.fn(),
}))

vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))

// CodeBlock is heavy (shiki); mock it to just print its props.
vi.mock("../../common/CodeBlock", () => ({
default: ({ source, rawSource, language }: { source: string; rawSource?: string; language: string }) =>
!source || source.length === 0 ? null : (
<div data-testid={`code-block-${language}`} data-raw={rawSource ?? ""}>
{source}
</div>
),
}))

vi.mock("../Markdown", () => ({
Markdown: ({ markdown }: { markdown: string }) => <div data-testid="markdown">{markdown}</div>,
}))

vi.mock("../../mcp/McpToolRow", () => ({
default: ({ tool }: { tool: { name: string } }) => <div data-testid="mcp-tool-row">{tool.name}</div>,
}))

describe("McpExecution", () => {
it("pretty prints minified JSON response when expanded (no streaming status)", () => {
render(
<McpExecution
executionId="1"
serverName="github"
toolName="list_pull_requests"
isArguments={true}
useMcpServer={
{
type: "use_mcp_tool",
serverName: "github",
toolName: "list_pull_requests",
arguments: "{}",
response: '[{"id":1,"title":"PR"}]',
} as any
}
/>,
)

// Expand response
fireEvent.click(screen.getByRole("button"))

const block = screen.getByTestId("code-block-json")
expect(block).toHaveTextContent('"id": 1')
expect(block).toHaveTextContent('"title": "PR"')
// raw output preserved for copy
expect(block.getAttribute("data-raw")).toBe('[{"id":1,"title":"PR"}]')
})

it("pretty prints escaped JSON blobs when expanded", () => {
// Example: JSON content returned as an escaped JSON blob string (common in some MCP servers)
const escapedBlob = '[{\\"id\\":1,\\"title\\":\\"PR\\"}]'

render(
<McpExecution
executionId="1"
serverName="github"
toolName="list_pull_requests"
isArguments={true}
useMcpServer={
{
type: "use_mcp_tool",
serverName: "github",
toolName: "list_pull_requests",
arguments: "{}",
response: escapedBlob,
} as any
}
/>,
)

fireEvent.click(screen.getByRole("button"))

const block = screen.getByTestId("code-block-json")
expect(block).toHaveTextContent('"id": 1')
expect(block).toHaveTextContent('"title": "PR"')
expect(block.getAttribute("data-raw")).toBe(escapedBlob)
})

it("pretty prints double-encoded JSON when expanded", () => {
// Example: response is a JSON string whose content is JSON
const doubleEncoded = JSON.stringify('[{"id":1,"title":"PR"}]')

render(
<McpExecution
executionId="1"
serverName="github"
toolName="list_pull_requests"
isArguments={true}
useMcpServer={
{
type: "use_mcp_tool",
serverName: "github",
toolName: "list_pull_requests",
arguments: "{}",
response: doubleEncoded,
} as any
}
/>,
)

fireEvent.click(screen.getByRole("button"))

const block = screen.getByTestId("code-block-json")
expect(block).toHaveTextContent('"id": 1')
expect(block).toHaveTextContent('"title": "PR"')
expect(block.getAttribute("data-raw")).toBe(doubleEncoded)
})

it("renders non-JSON response as Markdown", () => {
render(
<McpExecution
executionId="1"
serverName="github"
toolName="list_pull_requests"
isArguments={true}
useMcpServer={
{
type: "use_mcp_tool",
serverName: "github",
toolName: "list_pull_requests",
arguments: "{}",
response: "not json",
} as any
}
/>,
)

fireEvent.click(screen.getByRole("button"))

expect(screen.getByTestId("markdown")).toHaveTextContent("not json")
})
})
Loading