From b7ea247f45cd6ed31843f6958ad6e659e8a7a73a Mon Sep 17 00:00:00 2001 From: ScDor <18174994+ScDor@users.noreply.github.com> Date: Fri, 16 Jan 2026 18:30:15 +0200 Subject: [PATCH 1/5] feat: enhance ask_followup_question to support multiple questions --- packages/types/src/followup.ts | 3 + .../prompts/tools/ask-followup-question.ts | 15 +++- src/core/tools/AskFollowupQuestionTool.ts | 33 +++++++- .../__tests__/askFollowupQuestionTool.spec.ts | 26 +++++++ src/shared/tools.ts | 1 + webview-ui/src/components/chat/ChatRow.tsx | 34 ++++++--- .../components/chat/MultiQuestionHandler.tsx | 76 +++++++++++++++++++ webview-ui/src/i18n/locales/en/chat.json | 7 +- 8 files changed, 178 insertions(+), 17 deletions(-) create mode 100644 webview-ui/src/components/chat/MultiQuestionHandler.tsx diff --git a/packages/types/src/followup.ts b/packages/types/src/followup.ts index 1a5424cd11e..dc21fc26dd6 100644 --- a/packages/types/src/followup.ts +++ b/packages/types/src/followup.ts @@ -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 } @@ -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(), }) diff --git a/src/core/prompts/tools/ask-followup-question.ts b/src/core/prompts/tools/ask-followup-question.ts index c40684b8bc7..c00fd707f85 100644 --- a/src/core/prompts/tools/ask-followup-question.ts +++ b/src/core/prompts/tools/ask-followup-question.ts @@ -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 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 tags inside. +- follow_up: (optional) A list of suggested answers, each in its own tag. Usage: @@ -15,6 +16,14 @@ Usage: +Usage with multiple questions: + + +Question 1? +Question 2? + + + Example: What is the path to the frontend-config.json file? diff --git a/src/core/tools/AskFollowupQuestionTool.ts b/src/core/tools/AskFollowupQuestionTool.ts index b75ca3b618e..69fe6c38d69 100644 --- a/src/core/tools/AskFollowupQuestionTool.ts +++ b/src/core/tools/AskFollowupQuestionTool.ts @@ -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>): 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 { - 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 { // 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) diff --git a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts index 074617130c9..0c3ab5d0dc3 100644 --- a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts +++ b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts @@ -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 1Question 2", + 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"> = { diff --git a/src/shared/tools.ts b/src/shared/tools.ts index f893a3d332e..602ebd785e6 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -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] diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index a609d2dc7ec..8c0ef5e61ea 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -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 = ({ )}
- - + {followUpData?.questions && followUpData.questions.length > 0 ? ( + { + onSuggestionClick?.({ answer: response }) + }} + /> + ) : ( + <> + + + + )}
) diff --git a/webview-ui/src/components/chat/MultiQuestionHandler.tsx b/webview-ui/src/components/chat/MultiQuestionHandler.tsx new file mode 100644 index 00000000000..34bf5e3ca75 --- /dev/null +++ b/webview-ui/src/components/chat/MultiQuestionHandler.tsx @@ -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(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 ( +
+
+ {t("chat:questions.questionNumberOfTotal", { + current: currentQuestionIndex + 1, + total: questions.length, + })} +
+
{questions[currentQuestionIndex]}
+