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
98 changes: 98 additions & 0 deletions src/core/auto-approval/__tests__/followup.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { ExtensionState } from "@roo-code/types"
import { checkAutoApproval, type AutoApprovalState, type AutoApprovalStateOptions } from ".."

type AutoApprovalFields = Pick<ExtensionState, AutoApprovalState | AutoApprovalStateOptions>

describe("Follow-up question auto-approval", () => {
const baseState: AutoApprovalFields = {
autoApprovalEnabled: true,
alwaysAllowFollowupQuestions: true,
followupAutoApproveTimeoutMs: 10_000,
}

const followupText = (suggest: unknown) => JSON.stringify({ question: "Pick one?", suggest }) as string

const run = (state: AutoApprovalFields, text: string) => checkAutoApproval({ state, ask: "followup", text })

it("schedules a timeout that auto-answers with the first valid suggestion", async () => {
const result = await run(baseState, followupText([{ answer: "Yes, proceed" }]))

expect(result.decision).toBe("timeout")
if (result.decision === "timeout") {
expect(result.timeout).toBe(10_000)
expect(result.fn()).toEqual({
askResponse: "messageResponse",
text: "Yes, proceed",
})
}
})

it("falls back to asking when the follow-up has no text payload", async () => {
// Exercises the `text || "{}"` fallback: a follow-up without any payload must
// not schedule an auto-answer timeout.
const result = await checkAutoApproval({ state: baseState, ask: "followup" })

expect(result).toEqual({ decision: "ask" })
})

it("skips a blank or missing first answer and uses the next valid suggestion (issue #1226)", async () => {
// Mirrors a malformed model response where JSON round-tripping drops
// `answer: undefined` and the first item is unusable.
const result = await run(baseState, followupText([{}, { answer: " " }, { answer: "Valid answer" }]))

expect(result.decision).toBe("timeout")
if (result.decision === "timeout") {
expect(result.fn()).toEqual({
askResponse: "messageResponse",
text: "Valid answer",
})
}
})

it("falls back to asking when every suggestion answer is blank or missing (issue #1226)", async () => {
// Before the #1226 fix this scheduled a timeout that auto-answered the
// follow-up with `undefined` text, silently accepting an empty answer.
const result = await run(baseState, followupText([{ answer: "" }, { answer: " \n\t " }, {}]))

expect(result).toEqual({ decision: "ask" })
})

it("falls back to asking when the suggestion answer is not a string", async () => {
const result = await run(baseState, followupText([{ answer: 42 }]))

expect(result).toEqual({ decision: "ask" })
})

it("falls back to asking when the follow-up has no suggestions", async () => {
const result = await run(baseState, JSON.stringify({ question: "Pick one?" }))

expect(result).toEqual({ decision: "ask" })
})

it("falls back to asking when the follow-up text is not valid JSON", async () => {
const result = await run(baseState, "not-json")

expect(result).toEqual({ decision: "ask" })
})

it("falls back to asking when the auto-approve timeout is not positive", async () => {
const result = await run({ ...baseState, followupAutoApproveTimeoutMs: 0 }, followupText([{ answer: "Yes" }]))

expect(result).toEqual({ decision: "ask" })
})

it("does not auto-approve when follow-up auto-approval is disabled", async () => {
const result = await run(
{ ...baseState, alwaysAllowFollowupQuestions: false },
followupText([{ answer: "Yes" }]),
)

expect(result).toEqual({ decision: "ask" })
})

it("does not auto-approve when global auto-approval is disabled", async () => {
const result = await run({ ...baseState, autoApprovalEnabled: false }, followupText([{ answer: "Yes" }]))

expect(result).toEqual({ decision: "ask" })
})
})
10 changes: 9 additions & 1 deletion src/core/auto-approval/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,15 @@ export async function checkAutoApproval({
if (ask === "followup") {
if (state.alwaysAllowFollowupQuestions === true) {
try {
const suggestion = (JSON.parse(text || "{}") as FollowUpData).suggest?.[0]
const suggestions = (JSON.parse(text || "{}") as FollowUpData).suggest ?? []

// A missing or blank answer would auto-approve the follow-up with no
// content after the timeout (issue #1226), so pick the first suggestion
// with a usable answer. This mirrors the webview's visible-suggestions
// filter in FollowUpSuggest.
const suggestion = suggestions.find(
(item) => typeof item?.answer === "string" && item.answer.trim().length > 0,
)

if (
suggestion &&
Expand Down
54 changes: 31 additions & 23 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
},
ref,
) => {
// A malformed follow-up answer can push a non-string value into the input state
// (issue #1226). Normalize once so every string operation below (trim, slice,
// indexing, paste, drop, and the textarea value) is safe.
const normalizedInputValue = typeof inputValue === "string" ? inputValue : ""

const { t } = useAppTranslation()
const {
filePaths,
Expand Down Expand Up @@ -159,7 +164,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
if (message.text && textAreaRef.current) {
// Insert the command text at the current cursor position
const textarea = textAreaRef.current
const currentValue = inputValue
const currentValue = normalizedInputValue
const cursorPos = textarea.selectionStart || 0

// Check if we need to add a space before the command
Expand Down Expand Up @@ -205,7 +210,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(

window.addEventListener("message", messageHandler)
return () => window.removeEventListener("message", messageHandler)
}, [setInputValue, searchRequestId, inputValue])
}, [setInputValue, searchRequestId, normalizedInputValue])

const [isDraggingOver, setIsDraggingOver] = useState(false)
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
Expand All @@ -228,7 +233,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
clineMessages,
taskHistory,
cwd,
inputValue,
inputValue: normalizedInputValue,
setInputValue,
})

Expand All @@ -244,22 +249,22 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}, [selectedType, searchQuery])

const handleEnhancePrompt = useCallback(() => {
const trimmedInput = inputValue.trim()
const trimmedInput = normalizedInputValue.trim()

if (trimmedInput) {
setIsEnhancingPrompt(true)
vscode.postMessage({ type: "enhancePrompt" as const, text: trimmedInput })
} else {
setInputValue(t("chat:enhancePromptDescription"))
}
}, [inputValue, setInputValue, t])
}, [normalizedInputValue, setInputValue, t])

const allModes = useMemo(() => getAllModes(customModes), [customModes])

// Memoized check for whether the input has content (text or images)
const hasInputContent = useMemo(() => {
return inputValue.trim().length > 0 || selectedImages.length > 0
}, [inputValue, selectedImages])
return normalizedInputValue.trim().length > 0 || selectedImages.length > 0
}, [normalizedInputValue, selectedImages])

// Compute the key combination text for the send button tooltip based on enterBehavior
const sendKeyCombination = useMemo(() => {
Expand Down Expand Up @@ -508,8 +513,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}

if (event.key === "Backspace" && !isComposing) {
const charBeforeCursor = inputValue[cursorPosition - 1]
const charAfterCursor = inputValue[cursorPosition + 1]
const charBeforeCursor = normalizedInputValue[cursorPosition - 1]
const charAfterCursor = normalizedInputValue[cursorPosition + 1]

const charBeforeIsWhitespace =
charBeforeCursor === " " || charBeforeCursor === "\n" || charBeforeCursor === "\r\n"
Expand All @@ -521,7 +526,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
if (
charBeforeIsWhitespace &&
// "$" is added to ensure the match occurs at the end of the string.
inputValue.slice(0, cursorPosition - 1).match(new RegExp(mentionRegex.source + "$"))
normalizedInputValue.slice(0, cursorPosition - 1).match(new RegExp(mentionRegex.source + "$"))
) {
const newCursorPosition = cursorPosition - 1
// If mention is followed by another word, then instead
Expand All @@ -536,9 +541,9 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setCursorPosition(newCursorPosition)
setJustDeletedSpaceAfterMention(true)
} else if (justDeletedSpaceAfterMention) {
const { newText, newPosition } = removeMention(inputValue, cursorPosition)
const { newText, newPosition } = removeMention(normalizedInputValue, cursorPosition)

if (newText !== inputValue) {
if (newText !== normalizedInputValue) {
event.preventDefault()
setInputValue(newText)
setIntendedCursorPosition(newPosition) // Store the new cursor position in state
Expand All @@ -558,7 +563,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
selectedMenuIndex,
handleMentionSelect,
selectedType,
inputValue,
normalizedInputValue,
cursorPosition,
setInputValue,
justDeletedSpaceAfterMention,
Expand All @@ -577,7 +582,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
textAreaRef.current.setSelectionRange(intendedCursorPosition, intendedCursorPosition)
setIntendedCursorPosition(null) // Reset the state.
}
}, [inputValue, intendedCursorPosition])
}, [normalizedInputValue, intendedCursorPosition])

// Ref to store the search timeout.
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
Expand Down Expand Up @@ -677,7 +682,10 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
e.preventDefault()
const trimmedUrl = pastedText.trim()
const newValue =
inputValue.slice(0, cursorPosition) + trimmedUrl + " " + inputValue.slice(cursorPosition)
normalizedInputValue.slice(0, cursorPosition) +
trimmedUrl +
" " +
normalizedInputValue.slice(cursorPosition)
setInputValue(newValue)
const newCursorPosition = cursorPosition + trimmedUrl.length + 1
setCursorPosition(newCursorPosition)
Expand Down Expand Up @@ -740,7 +748,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
}
},
[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t],
[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, normalizedInputValue, t],
)

const handleMenuMouseDown = useCallback(() => {
Expand Down Expand Up @@ -790,7 +798,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(

useLayoutEffect(() => {
updateHighlights()
}, [inputValue, updateHighlights])
}, [normalizedInputValue, updateHighlights])

const updateCursorPosition = useCallback(() => {
if (textAreaRef.current) {
Expand Down Expand Up @@ -822,7 +830,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(

if (lines.length > 0) {
// Process each line as a separate file path
let newValue = inputValue.slice(0, cursorPosition)
let newValue = normalizedInputValue.slice(0, cursorPosition)
let totalLength = 0

// Using a standard for loop instead of forEach for potential performance gains.
Expand All @@ -841,7 +849,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}

// Add space after the last mention and append the rest of the input
newValue += " " + inputValue.slice(cursorPosition)
newValue += " " + normalizedInputValue.slice(cursorPosition)
totalLength += 1

setInputValue(newValue)
Expand Down Expand Up @@ -902,7 +910,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
[
cursorPosition,
cwd,
inputValue,
normalizedInputValue,
setInputValue,
setCursorPosition,
setIntendedCursorPosition,
Expand Down Expand Up @@ -995,7 +1003,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
<ContextMenu
onSelect={handleMentionSelect}
searchQuery={searchQuery}
inputValue={inputValue}
inputValue={normalizedInputValue}
onMouseDown={handleMenuMouseDown}
selectedIndex={selectedMenuIndex}
setSelectedIndex={setSelectedMenuIndex}
Expand Down Expand Up @@ -1058,7 +1066,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
textAreaRef.current = el
}}
value={inputValue}
value={normalizedInputValue}
onChange={(e) => {
handleInputChange(e)
updateHighlights()
Expand Down Expand Up @@ -1264,7 +1272,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</StandardTooltip>
</div>

{!inputValue && (
{!normalizedInputValue && (
<div
className={cn(
"absolute left-2 z-30 flex items-center h-8 font-vscode-font-family text-vscode-editor-font-size leading-vscode-editor-line-height",
Expand Down
12 changes: 10 additions & 2 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1431,6 +1431,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro

const handleSuggestionClickInRow = useCallback(
(suggestion: SuggestionItem, event?: React.MouseEvent) => {
// The model may emit suggestions with missing or blank answers (issue #1226).
// Ignore them instead of pushing an undefined value into the input, which
// would crash the text area (inputValue.trim on undefined).
const answer = typeof suggestion?.answer === "string" ? suggestion.answer.trim() : ""
if (!answer) {
return
}

// Mark that user has responded if this is a manual click (not auto-approval)
if (event) {
userRespondedRef.current = true
Expand All @@ -1455,13 +1463,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
if (event?.shiftKey) {
// Always append to existing text, don't overwrite
setInputValue((currentValue: string) => {
return currentValue !== "" ? `${currentValue} \n${suggestion.answer}` : suggestion.answer
return currentValue !== "" ? `${currentValue} \n${answer}` : answer
})
} else {
// Don't clear the input value when sending a follow-up choice
// The message should be sent but the text area should preserve what the user typed
const preservedInput = inputValueRef.current
handleSendMessage(suggestion.answer, [])
handleSendMessage(answer, [])
// Restore the input value after sending
setInputValue(preservedInput)
}
Expand Down
Loading
Loading