From 77a61df0650c5dc91b885ee532ac5dd26478014c Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 30 May 2026 13:22:48 +0000 Subject: [PATCH 1/2] fix(ChatView): follow-up suggestion mode rendering crash --- packages/types/src/followup.ts | 13 ++ src/core/tools/AskFollowupQuestionTool.ts | 5 +- .../__tests__/askFollowupQuestionTool.spec.ts | 16 +++ webview-ui/src/components/chat/ChatView.tsx | 16 ++- .../src/components/chat/FollowUpSuggest.tsx | 7 +- .../chat/__tests__/ChatView.spec.tsx | 120 +++++++++++++++++- .../chat/__tests__/FollowUpSuggest.spec.tsx | 17 +++ 7 files changed, 182 insertions(+), 12 deletions(-) diff --git a/packages/types/src/followup.ts b/packages/types/src/followup.ts index 1a5424cd11..254fd16d95 100644 --- a/packages/types/src/followup.ts +++ b/packages/types/src/followup.ts @@ -22,6 +22,19 @@ export interface SuggestionItem { mode?: string } +export const getSuggestionMode = (mode: unknown): string | undefined => { + if (typeof mode === "string" && mode.trim().length > 0) { + return mode + } + + if (mode && typeof mode === "object" && "mode_slug" in mode) { + const modeSlug = (mode as { mode_slug?: unknown }).mode_slug + return typeof modeSlug === "string" && modeSlug.trim().length > 0 ? modeSlug : undefined + } + + return undefined +} + /** * Zod schema for SuggestionItem */ diff --git a/src/core/tools/AskFollowupQuestionTool.ts b/src/core/tools/AskFollowupQuestionTool.ts index c45acc824b..857ee7c152 100644 --- a/src/core/tools/AskFollowupQuestionTool.ts +++ b/src/core/tools/AskFollowupQuestionTool.ts @@ -1,12 +1,13 @@ import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" import type { ToolUse } from "../../shared/tools" +import { getSuggestionMode } from "@roo-code/types" import { BaseTool, ToolCallbacks } from "./BaseTool" interface Suggestion { text: string - mode?: string + mode?: unknown } interface AskFollowupQuestionParams { @@ -42,7 +43,7 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> { // Transform follow_up suggestions to the format expected by task.ask const follow_up_json = { question, - suggest: follow_up.map((s) => ({ answer: s.text, mode: s.mode })), + suggest: follow_up.map((s) => ({ answer: s.text, mode: getSuggestionMode(s.mode) })), } task.consecutiveMistakeCount = 0 diff --git a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts index 08a8394ac7..9bac90067b 100644 --- a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts +++ b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts @@ -137,6 +137,22 @@ describe("AskFollowupQuestionTool", () => { expect(mockTask.ask).toHaveBeenCalledWith("followup", expectedJson, false) }) + it("should normalize malformed object mode values", async () => { + const params = { + question: "Switch mode?", + follow_up: [{ text: "Use code mode", mode: { mode_slug: "code" } }], + } as any + + await tool.execute(params, mockTask, mockCallbacks) + + const expectedJson = JSON.stringify({ + question: "Switch mode?", + suggest: [{ answer: "Use code mode", mode: "code" }], + }) + + expect(mockTask.ask).toHaveBeenCalledWith("followup", expectedJson, false) + }) + it("should say user_feedback and push tool result after user answers", async () => { const params = { question: "Which approach?", diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 805293aa2d..c44e54c783 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -10,11 +10,10 @@ import { appendImages } from "@src/utils/imageUtils" import { getCostBreakdownIfNeeded } from "@src/utils/costFormatting" import { batchConsecutive } from "@src/utils/batchConsecutive" -import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType } from "@roo-code/types" -import { isRetiredProvider } from "@roo-code/types" +import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType, SuggestionItem } from "@roo-code/types" +import { getSuggestionMode, isRetiredProvider } from "@roo-code/types" import { findLast } from "@roo/array" -import { SuggestionItem } from "@roo-code/types" import { combineApiRequests } from "@roo/combineApiRequests" import { combineCommandSequences } from "@roo/combineCommandSequences" import { getApiMetrics } from "@roo/getApiMetrics" @@ -1336,13 +1335,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + if (!getAllModes(customModes).some((modeConfig) => modeConfig.slug === modeSlug)) { + return + } + // Update local state and notify extension to sync mode change. setMode(modeSlug) // Send the mode switch message. vscode.postMessage({ type: "mode", text: modeSlug }) }, - [setMode], + [customModes, setMode], ) const handleSuggestionClickInRow = useCallback( @@ -1358,12 +1361,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction {suggestions.map((suggestion, index) => { const isFirstSuggestion = index === 0 + const suggestionMode = getSuggestionMode(suggestion.mode) return (
@@ -134,10 +135,10 @@ export const FollowUpSuggest = ({ {t("chat:followUpSuggest.timerPrefix", { seconds: countdown })}

)} - {suggestion.mode && ( + {suggestionMode && (
- {suggestion.mode} + {suggestionMode}
)} diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index 3fba140222..3cc60dba04 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" import { vscode } from "@src/utils/vscode" +import type { SuggestionItem } from "@roo-code/types" import ChatView, { ChatViewProps } from "../ChatView" @@ -46,7 +47,33 @@ vi.mock("use-sound", () => ({ // Mock components that use ESM dependencies vi.mock("../ChatRow", () => ({ - default: function MockChatRow({ message }: { message: ClineMessage }) { + default: function MockChatRow({ + message, + onSuggestionClick, + }: { + message: ClineMessage + onSuggestionClick?: (suggestion: SuggestionItem, event?: React.MouseEvent) => void + }) { + if (message.type === "ask" && message.ask === "followup" && message.text) { + try { + const followUp = JSON.parse(message.text) as { suggest?: SuggestionItem[] } + return ( +
+ {followUp.suggest?.map((suggestion) => ( + + ))} +
+ ) + } catch { + // Fall through to the generic row renderer. + } + } + return
{JSON.stringify(message)}
}, })) @@ -1050,6 +1077,97 @@ describe("ChatView - Message Queueing Tests", () => { }) }) +describe("ChatView - Follow-up Suggestions", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(vscode.postMessage).mockClear() + }) + + it("switches to a known mode from a malformed object mode suggestion", async () => { + const { getByRole } = renderChatView() + + mockPostMessage({ + mode: "ask", + customModes: [], + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 1000, + text: "Initial task", + }, + { + type: "ask", + ask: "followup", + ts: Date.now(), + text: JSON.stringify({ + question: "Switch mode?", + suggest: [{ answer: "Use code mode", mode: { mode_slug: "code" } }], + }), + partial: false, + }, + ], + }) + + const suggestion = await waitFor(() => getByRole("button", { name: "Use code mode" })) + vi.mocked(vscode.postMessage).mockClear() + + fireEvent.click(suggestion) + + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "mode", text: "code" }) + }) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "messageResponse", + text: "Use code mode", + images: [], + }) + }) + + it("does not switch modes for an unknown malformed object mode suggestion", async () => { + const { getByRole } = renderChatView() + + mockPostMessage({ + mode: "ask", + customModes: [], + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 1000, + text: "Initial task", + }, + { + type: "ask", + ask: "followup", + ts: Date.now(), + text: JSON.stringify({ + question: "Switch mode?", + suggest: [{ answer: "Use invalid mode", mode: { mode_slug: "not-a-mode" } }], + }), + partial: false, + }, + ], + }) + + const suggestion = await waitFor(() => getByRole("button", { name: "Use invalid mode" })) + vi.mocked(vscode.postMessage).mockClear() + + fireEvent.click(suggestion) + + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "messageResponse", + text: "Use invalid mode", + images: [], + }) + }) + expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "mode" })) + }) +}) + describe("ChatView - Context Condensing Indicator Tests", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx b/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx index e489911268..a46df75b80 100644 --- a/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx @@ -243,6 +243,23 @@ describe("FollowUpSuggest", () => { expect(container.firstChild).toBeNull() }) + it("should render malformed object mode values without crashing", () => { + const suggestions = [{ answer: "Use code mode", mode: { mode_slug: "code" } }] as any + + renderWithTestProviders( + , + defaultTestState, + ) + + expect(screen.getByText("Use code mode")).toBeInTheDocument() + expect(screen.getByText("code")).toBeInTheDocument() + }) + it("should stop countdown when user manually responds (isAnswered becomes true)", () => { const { rerender } = renderWithTestProviders( Date: Sat, 30 May 2026 14:32:38 +0000 Subject: [PATCH 2/2] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --- packages/types/src/followup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/types/src/followup.ts b/packages/types/src/followup.ts index 254fd16d95..b3990c2cee 100644 --- a/packages/types/src/followup.ts +++ b/packages/types/src/followup.ts @@ -24,12 +24,12 @@ export interface SuggestionItem { export const getSuggestionMode = (mode: unknown): string | undefined => { if (typeof mode === "string" && mode.trim().length > 0) { - return mode + return mode.trim() } if (mode && typeof mode === "object" && "mode_slug" in mode) { const modeSlug = (mode as { mode_slug?: unknown }).mode_slug - return typeof modeSlug === "string" && modeSlug.trim().length > 0 ? modeSlug : undefined + return typeof modeSlug === "string" && modeSlug.trim().length > 0 ? modeSlug.trim() : undefined } return undefined