Skip to content
Open
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
13 changes: 13 additions & 0 deletions packages/types/src/followup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.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.trim() : undefined
}

return undefined
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Zod schema for SuggestionItem
*/
Expand Down
5 changes: 3 additions & 2 deletions src/core/tools/AskFollowupQuestionTool.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/core/tools/__tests__/askFollowupQuestionTool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?",
Expand Down
16 changes: 10 additions & 6 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1336,13 +1335,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro

const switchToMode = useCallback(
(modeSlug: string): void => {
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(
Expand All @@ -1358,12 +1361,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}

// Check if we need to switch modes
if (suggestion.mode) {
const suggestionMode = getSuggestionMode(suggestion.mode)
if (suggestionMode) {
// Only switch modes if it's a manual click (event exists) or auto-approval is allowed
const isManualClick = !!event
if (isManualClick || alwaysAllowModeSwitch) {
// Switch mode without waiting
switchToMode(suggestion.mode)
switchToMode(suggestionMode)
}
}

Expand Down
7 changes: 4 additions & 3 deletions webview-ui/src/components/chat/FollowUpSuggest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Button, StandardTooltip } from "@/components/ui"

import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { SuggestionItem } from "@roo-code/types"
import { getSuggestionMode, type SuggestionItem } from "@roo-code/types"
import { cn } from "@/lib/utils"

const DEFAULT_FOLLOWUP_TIMEOUT_MS = 60000
Expand Down Expand Up @@ -111,6 +111,7 @@ export const FollowUpSuggest = ({
<div className="flex mb-2 flex-col h-full gap-2">
{suggestions.map((suggestion, index) => {
const isFirstSuggestion = index === 0
const suggestionMode = getSuggestionMode(suggestion.mode)

return (
<div key={`${suggestion.answer}-${ts}`} className="w-full relative group">
Expand All @@ -134,10 +135,10 @@ export const FollowUpSuggest = ({
{t("chat:followUpSuggest.timerPrefix", { seconds: countdown })}
</p>
)}
{suggestion.mode && (
{suggestionMode && (
<div className="absolute bottom-0 right-0 text-[10px] text-vscode-badge-foreground pl-1 pr-2.5 pt-0.5 pb-1.5 flex items-center gap-0.5 bg-transparent rounded-xl">
<span className="codicon codicon-arrow-right" style={{ fontSize: "8px" }} />
{suggestion.mode}
{suggestionMode}
</div>
)}
<StandardTooltip content={t("chat:followUpSuggest.copyToInput")}>
Expand Down
120 changes: 119 additions & 1 deletion webview-ui/src/components/chat/__tests__/ChatView.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 (
<div data-testid="chat-row">
{followUp.suggest?.map((suggestion) => (
<button
key={suggestion.answer}
type="button"
onClick={(event) => onSuggestionClick?.(suggestion, event)}>
{suggestion.answer}
</button>
))}
</div>
)
} catch {
// Fall through to the generic row renderer.
}
}

return <div data-testid="chat-row">{JSON.stringify(message)}</div>
},
}))
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<FollowUpSuggest
suggestions={suggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onCancelAutoApproval={mockOnCancelAutoApproval}
/>,
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(
<FollowUpSuggest
Expand Down
Loading