feat: enhance ask_followup_question to support multiple questions

This commit is contained in:
ScDor 2026-01-16 18:30:15 +02:00
parent 9bf7173725
commit b7ea247f45
8 changed files with 178 additions and 17 deletions

View file

@ -8,6 +8,8 @@ import { z } from "zod"
export interface FollowUpData {
/** The question being asked by the LLM */
question?: string
/** Array of questions being asked by the LLM */
questions?: string[]
/** Array of suggested answers that the user can select */
suggest?: Array<SuggestionItem>
}
@ -35,6 +37,7 @@ export const suggestionItemSchema = z.object({
*/
export const followUpDataSchema = z.object({
question: z.string().optional(),
questions: z.array(z.string()).optional(),
suggest: z.array(suggestionItemSchema).optional(),
})

View file

@ -1,10 +1,11 @@
export function getAskFollowupQuestionDescription(): string {
return `## ask_followup_question
Description: Ask the user a question to gather additional information needed to complete the task. Use when you need clarification or more details to proceed effectively.
Description: Ask the user a question to gather additional information needed to complete the task. Use when you need clarification or more details to proceed effectively. You may ask multiple questions at once.
Parameters:
- question: (required) A clear, specific question addressing the information needed
- follow_up: (required) A list of 2-4 suggested answers, each in its own <suggest> tag. Suggestions must be complete, actionable answers without placeholders. Optionally include mode attribute to switch modes (code/architect/etc.)
- question: (required) A clear, specific question addressing the information needed.
- questions: (optional) A container for asking multiple questions. Use <question> tags inside.
- follow_up: (optional) A list of suggested answers, each in its own <suggest> tag.
Usage:
<ask_followup_question>
@ -15,6 +16,14 @@ Usage:
</follow_up>
</ask_followup_question>
Usage with multiple questions:
<ask_followup_question>
<questions>
<question>Question 1?</question>
<question>Question 2?</question>
</questions>
</ask_followup_question>
Example:
<ask_followup_question>
<question>What is the path to the frontend-config.json file?</question>

View file

@ -12,6 +12,7 @@ interface Suggestion {
interface AskFollowupQuestionParams {
question: string
questions?: string[]
follow_up: Suggestion[]
}
@ -20,9 +21,33 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> {
parseLegacy(params: Partial<Record<string, string>>): AskFollowupQuestionParams {
const question = params.question || ""
const questions_xml = params.questions
const follow_up_xml = params.follow_up
const suggestions: Suggestion[] = []
const questions: string[] = []
if (questions_xml) {
try {
const parsedQuestions = parseXml(questions_xml, ["question"]) as {
question: string[] | string
}
const rawQuestions = Array.isArray(parsedQuestions?.question)
? parsedQuestions.question
: [parsedQuestions?.question].filter((q): q is string => q !== undefined)
for (const q of rawQuestions) {
if (typeof q === "string") {
questions.push(q)
}
}
} catch (error) {
throw new Error(
`Failed to parse questions XML: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
if (follow_up_xml) {
// Define the actual structure returned by the XML parser
@ -60,16 +85,17 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> {
return {
question,
questions,
follow_up: suggestions,
}
}
async execute(params: AskFollowupQuestionParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { question, follow_up } = params
const { question, questions, follow_up } = params
const { handleError, pushToolResult, toolProtocol } = callbacks
try {
if (!question) {
if (!question && (!questions || questions.length === 0)) {
task.consecutiveMistakeCount++
task.recordToolError("ask_followup_question")
task.didToolFailInCurrentTurn = true
@ -80,6 +106,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,
questions,
suggest: follow_up.map((s) => ({ answer: s.text, mode: s.mode })),
}
@ -95,6 +122,8 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> {
override async handlePartial(task: Task, block: ToolUse<"ask_followup_question">): Promise<void> {
// Get question from params (for XML protocol) or nativeArgs (for native protocol)
const question: string | undefined = block.params.question ?? block.nativeArgs?.question
// For now we don't stream multiple questions, only the main one if present
// We could improve this to stream multiple questions but it requires UI changes to handle partial arrays
// During partial streaming, only show the question to avoid displaying raw JSON
// The full JSON with suggestions will be sent when the tool call is complete (!block.partial)

View file

@ -103,6 +103,32 @@ describe("askFollowupQuestionTool", () => {
)
})
it("should parse multiple questions from XML", async () => {
const block: ToolUse = {
type: "tool_use",
name: "ask_followup_question",
params: {
questions: "<question>Question 1</question><question>Question 2</question>",
follow_up: "",
},
partial: false,
}
await askFollowupQuestionTool.handle(mockCline, block as ToolUse<"ask_followup_question">, {
askApproval: vi.fn(),
handleError: vi.fn(),
pushToolResult: mockPushToolResult,
removeClosingTag: vi.fn((tag, content) => content),
toolProtocol: "xml",
})
expect(mockCline.ask).toHaveBeenCalledWith(
"followup",
expect.stringContaining('"questions":["Question 1","Question 2"]'),
false,
)
})
describe("handlePartial with native protocol", () => {
it("should only send question during partial streaming to avoid raw JSON display", async () => {
const block: ToolUse<"ask_followup_question"> = {

View file

@ -76,6 +76,7 @@ export const toolParamNames = [
"old_string", // search_replace and edit_file parameter
"new_string", // search_replace and edit_file parameter
"expected_replacements", // edit_file parameter for multiple occurrences
"questions", // ask_followup_question parameter for multiple questions
] as const
export type ToolParamName = (typeof toolParamNames)[number]

View file

@ -38,6 +38,7 @@ import McpResourceRow from "../mcp/McpResourceRow"
import { Mention } from "./Mention"
import { CheckpointSaved } from "./checkpoints/CheckpointSaved"
import { FollowUpSuggest } from "./FollowUpSuggest"
import { MultiQuestionHandler } from "./MultiQuestionHandler"
import { BatchFilePermission } from "./BatchFilePermission"
import { BatchDiffApproval } from "./BatchDiffApproval"
import { ProgressIndicator } from "./ProgressIndicator"
@ -1632,17 +1633,28 @@ export const ChatRowContent = ({
</div>
)}
<div className="flex flex-col gap-2 ml-6">
<Markdown
markdown={message.partial === true ? message?.text : followUpData?.question}
/>
<FollowUpSuggest
suggestions={followUpData?.suggest}
onSuggestionClick={onSuggestionClick}
ts={message?.ts}
onCancelAutoApproval={onFollowUpUnmount}
isAnswered={isFollowUpAnswered}
isFollowUpAutoApprovalPaused={isFollowUpAutoApprovalPaused}
/>
{followUpData?.questions && followUpData.questions.length > 0 ? (
<MultiQuestionHandler
questions={followUpData.questions}
onSendResponse={(response) => {
onSuggestionClick?.({ answer: response })
}}
/>
) : (
<>
<Markdown
markdown={message.partial === true ? message?.text : followUpData?.question}
/>
<FollowUpSuggest
suggestions={followUpData?.suggest}
onSuggestionClick={onSuggestionClick}
ts={message?.ts}
onCancelAutoApproval={onFollowUpUnmount}
isAnswered={isFollowUpAnswered}
isFollowUpAutoApprovalPaused={isFollowUpAutoApprovalPaused}
/>
</>
)}
</div>
</>
)

View file

@ -0,0 +1,76 @@
import React, { useState, useEffect } from "react"
import { Button, Textarea } from "@/components/ui"
import { useAppTranslation } from "@src/i18n/TranslationContext"
interface MultiQuestionHandlerProps {
questions: string[]
onSendResponse: (response: string) => void
}
export const MultiQuestionHandler = ({ questions, onSendResponse }: MultiQuestionHandlerProps) => {
const { t } = useAppTranslation()
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
const [answers, setAnswers] = useState<string[]>(new Array(questions.length).fill(""))
const [inputValue, setInputValue] = useState("")
useEffect(() => {
setInputValue(answers[currentQuestionIndex] || "")
}, [currentQuestionIndex, answers])
const handleNext = () => {
const newAnswers = [...answers]
newAnswers[currentQuestionIndex] = inputValue
setAnswers(newAnswers)
if (currentQuestionIndex < questions.length - 1) {
setCurrentQuestionIndex(currentQuestionIndex + 1)
} else {
// Finish
const combined = questions
.map((q, i) => `Question: ${q}\nAnswer: ${newAnswers[i] || "(skipped)"}`)
.join("\n\n")
onSendResponse(combined)
}
}
const handlePrevious = () => {
const newAnswers = [...answers]
newAnswers[currentQuestionIndex] = inputValue
setAnswers(newAnswers)
if (currentQuestionIndex > 0) {
setCurrentQuestionIndex(currentQuestionIndex - 1)
}
}
return (
<div className="flex flex-col gap-3">
<div className="font-bold">
{t("chat:questions.questionNumberOfTotal", {
current: currentQuestionIndex + 1,
total: questions.length,
})}
</div>
<div>{questions[currentQuestionIndex]}</div>
<Textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
rows={3}
placeholder={t("chat:questions.typeAnswer")}
className="w-full"
/>
<div className="flex gap-2">
{currentQuestionIndex > 0 && (
<Button variant="secondary" onClick={handlePrevious}>
{t("chat:questions.previous")}
</Button>
)}
<Button variant="primary" onClick={handleNext}>
{currentQuestionIndex < questions.length - 1
? t("chat:questions.next")
: t("chat:questions.finish")}
</Button>
</div>
</div>
)
}

View file

@ -287,7 +287,12 @@
"completionInstructions": "Subtask completed! You can review the results and suggest any corrections or next steps. If everything looks good, confirm to return the result to the parent task."
},
"questions": {
"hasQuestion": "Roo has a question"
"hasQuestion": "Roo has a question",
"questionNumberOfTotal": "Question {{current}} of {{total}}",
"typeAnswer": "Type your answer here...",
"previous": "Previous",
"next": "Next",
"finish": "Finish"
},
"taskCompleted": "Task Completed",
"error": "Error",