Skip to content

Commit 25cef89

Browse files
Fix Claude AskUserQuestion handling for multiple prompts (#6)
* Initial plan * fix: support multiple Claude ask question answers Co-authored-by: OpenSource03 <29690431+OpenSource03@users.noreply.github.com> * test: harden Claude ask question regression coverage Co-authored-by: OpenSource03 <29690431+OpenSource03@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: OpenSource03 <29690431+OpenSource03@users.noreply.github.com>
1 parent 0a2b013 commit 25cef89

6 files changed

Lines changed: 260 additions & 45 deletions

File tree

src/components/PermissionPrompt.tsx

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState } from "react";
22
import { ShieldAlert, Check, X, Send, Play } from "lucide-react";
33
import { Button } from "@/components/ui/button";
4+
import { buildAskUserQuestionResult, getAskUserQuestionKey } from "@/lib/ask-user-question";
45
import type { PermissionRequest, RespondPermissionFn } from "@/types";
56

67
const TOOL_LABELS: Record<string, string> = {
@@ -24,7 +25,7 @@ interface QuestionOption {
2425
}
2526

2627
interface Question {
27-
id: string;
28+
id?: string;
2829
question: string;
2930
header: string;
3031
isOther?: boolean;
@@ -128,16 +129,16 @@ function AskUserQuestionPrompt({ request, onRespond }: PermissionPromptProps) {
128129
const questions = (request.toolInput.questions ?? []) as Question[];
129130
const [selections, setSelections] = useState<Record<string, Set<string>>>(() => {
130131
const init: Record<string, Set<string>> = {};
131-
for (const q of questions) {
132-
init[q.id] = new Set();
132+
for (const [index, q] of questions.entries()) {
133+
init[getAskUserQuestionKey(q, index)] = new Set();
133134
}
134135
return init;
135136
});
136137
const [freeText, setFreeText] = useState<Record<string, string>>({});
137138

138-
const toggleOption = (questionId: string, label: string, multiSelect: boolean) => {
139+
const toggleOption = (questionKey: string, label: string, multiSelect: boolean) => {
139140
setSelections((prev) => {
140-
const current = prev[questionId] ?? new Set();
141+
const current = prev[questionKey] ?? new Set();
141142
const next = new Set(current);
142143
if (multiSelect) {
143144
if (next.has(label)) next.delete(label);
@@ -149,35 +150,24 @@ function AskUserQuestionPrompt({ request, onRespond }: PermissionPromptProps) {
149150
next.add(label);
150151
}
151152
}
152-
return { ...prev, [questionId]: next };
153+
return { ...prev, [questionKey]: next };
153154
});
154-
setFreeText((prev) => ({ ...prev, [questionId]: "" }));
155+
setFreeText((prev) => ({ ...prev, [questionKey]: "" }));
155156
};
156157

157158
const handleSubmit = () => {
158-
const answers: Record<string, string> = {};
159-
const answersByQuestionId: Record<string, string[]> = {};
160-
for (const q of questions) {
161-
const custom = freeText[q.id]?.trim();
162-
if (custom) {
163-
answers[q.question] = custom;
164-
answersByQuestionId[q.id] = [custom];
165-
} else {
166-
const selected = [...(selections[q.id] ?? [])];
167-
answers[q.question] = selected.join(", ");
168-
answersByQuestionId[q.id] = selected;
169-
}
170-
}
159+
const { answers, answersByQuestionId } = buildAskUserQuestionResult(questions, selections, freeText);
171160
onRespond("allow", {
172161
questions: request.toolInput.questions,
173162
answers,
174163
answersByQuestionId,
175164
});
176165
};
177166

178-
const hasAllAnswers = questions.every((q) => {
179-
const custom = freeText[q.id]?.trim();
180-
const selected = selections[q.id];
167+
const hasAllAnswers = questions.every((q, index) => {
168+
const questionKey = getAskUserQuestionKey(q, index);
169+
const custom = freeText[questionKey]?.trim();
170+
const selected = selections[questionKey];
181171
return custom || (selected && selected.size > 0);
182172
});
183173

@@ -186,19 +176,20 @@ function AskUserQuestionPrompt({ request, onRespond }: PermissionPromptProps) {
186176
<div className="pointer-events-auto rounded-2xl border border-border/60 bg-background/55 shadow-lg backdrop-blur-lg">
187177
{questions.map((q, qi) => (
188178
<div
189-
key={q.id}
179+
key={q.id ?? `${qi}-${q.question}`}
190180
className={`flex flex-col gap-3 px-4 py-3.5 ${qi > 0 ? "border-t border-border/40" : ""}`}
191181
>
192182
<p className="text-[13px] text-foreground">{q.question}</p>
193183

194184
<div className="grid grid-cols-2 gap-1.5">
195185
{(q.options ?? []).map((opt) => {
196-
const isSelected = selections[q.id]?.has(opt.label);
186+
const questionKey = getAskUserQuestionKey(q, qi);
187+
const isSelected = selections[questionKey]?.has(opt.label);
197188
return (
198189
<button
199190
key={opt.label}
200191
type="button"
201-
onClick={() => toggleOption(q.id, opt.label, q.multiSelect)}
192+
onClick={() => toggleOption(questionKey, opt.label, q.multiSelect)}
202193
className={`flex flex-col items-start rounded-lg border px-3 py-2 text-start transition-colors ${
203194
isSelected
204195
? "border-border bg-accent text-foreground"
@@ -215,12 +206,13 @@ function AskUserQuestionPrompt({ request, onRespond }: PermissionPromptProps) {
215206
<input
216207
type={q.isSecret ? "password" : "text"}
217208
placeholder="Or type your own answer..."
218-
value={freeText[q.id] ?? ""}
209+
value={freeText[getAskUserQuestionKey(q, qi)] ?? ""}
219210
onChange={(e) => {
220211
const value = e.target.value;
221-
setFreeText((prev) => ({ ...prev, [q.id]: value }));
212+
const questionKey = getAskUserQuestionKey(q, qi);
213+
setFreeText((prev) => ({ ...prev, [questionKey]: value }));
222214
if (value.trim()) {
223-
setSelections((prev) => ({ ...prev, [q.id]: new Set() }));
215+
setSelections((prev) => ({ ...prev, [questionKey]: new Set() }));
224216
}
225217
}}
226218
onKeyDown={(e) => {

src/components/tool-renderers/AskUserQuestion.tsx

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Loader2 } from "lucide-react";
2+
import { getAskUserQuestionAnswer, getAskUserQuestionKey } from "@/lib/ask-user-question";
23
import type { UIMessage } from "@/types";
34

45
interface AskQuestionOption {
@@ -7,6 +8,7 @@ interface AskQuestionOption {
78
}
89

910
interface AskQuestionItem {
11+
id?: string;
1012
question: string;
1113
header: string;
1214
options: AskQuestionOption[];
@@ -16,18 +18,12 @@ interface AskQuestionItem {
1618
export function AskUserQuestionContent({ message }: { message: UIMessage }) {
1719
const questions = (message.toolInput?.questions ?? []) as AskQuestionItem[];
1820
const hasResult = !!message.toolResult;
19-
const answers = (() => {
20-
const raw = message.toolResult?.answers;
21-
if (!raw || typeof raw !== "object") return null;
22-
return raw as Record<string, unknown>;
23-
})();
24-
const orderedAnswers = answers ? Object.values(answers) : [];
2521

2622
return (
2723
<div className="space-y-2 text-xs">
2824
{questions.map((q, qi) => (
2925
<div
30-
key={q.question}
26+
key={getAskUserQuestionKey(q, qi)}
3127
className={qi > 0 ? "border-t border-border/40 pt-2" : ""}
3228
>
3329
<span className="text-[13px] text-foreground/80 leading-snug">
@@ -47,14 +43,7 @@ export function AskUserQuestionContent({ message }: { message: UIMessage }) {
4743
<div className="mt-1.5">
4844
<span className="text-[11px] text-foreground/40">Answer: </span>
4945
<span className="text-[12px] text-foreground/80">
50-
{(() => {
51-
const direct = answers?.[q.question];
52-
if (typeof direct === "string" && direct.trim()) return direct;
53-
// Fallback for edge cases where question text keys differ
54-
const indexed = orderedAnswers[qi];
55-
if (typeof indexed === "string" && indexed.trim()) return indexed;
56-
return "No answer captured";
57-
})()}
46+
{getAskUserQuestionAnswer(q, qi, message.toolResult)}
5847
</span>
5948
</div>
6049
)}

src/lib/ask-user-question.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildAskUserQuestionResult, getAskUserQuestionAnswer, getAskUserQuestionKey } from "./ask-user-question";
3+
4+
describe("getAskUserQuestionKey", () => {
5+
it("uses a fallback key when Claude questions do not provide ids", () => {
6+
expect(getAskUserQuestionKey({ question: "First question?" }, 0)).toBe("question-0");
7+
expect(getAskUserQuestionKey({ question: "Second question?" }, 1)).toBe("question-1");
8+
});
9+
10+
it("prefers the provided question id when available", () => {
11+
expect(getAskUserQuestionKey({ id: "q-1", question: "First question?" }, 0)).toBe("q-1");
12+
});
13+
});
14+
15+
describe("getAskUserQuestionAnswer", () => {
16+
it("reads answers keyed by fallback question ids when Claude questions have no ids", () => {
17+
const answer = getAskUserQuestionAnswer(
18+
{ question: "Second question?" },
19+
1,
20+
{
21+
answersByQuestionId: {
22+
"question-0": ["Alpha"],
23+
"question-1": ["Beta"],
24+
},
25+
},
26+
);
27+
28+
expect(answer).toBe("Beta");
29+
});
30+
31+
it("reads answers keyed by question id", () => {
32+
const answer = getAskUserQuestionAnswer(
33+
{ id: "q-1", question: "Pick one" },
34+
0,
35+
{
36+
answersByQuestionId: {
37+
"q-1": ["Option A"],
38+
},
39+
},
40+
);
41+
42+
expect(answer).toBe("Option A");
43+
});
44+
45+
it("falls back to answers keyed by question text for Claude", () => {
46+
const answer = getAskUserQuestionAnswer(
47+
{ question: "Second question?" },
48+
1,
49+
{
50+
answers: {
51+
"First question?": "Alpha",
52+
"Second question?": "Beta",
53+
},
54+
},
55+
);
56+
57+
expect(answer).toBe("Beta");
58+
});
59+
60+
it("falls back to indexed answers when keys do not match", () => {
61+
const answer = getAskUserQuestionAnswer(
62+
{ question: "Second question?" },
63+
1,
64+
{
65+
answers: {
66+
first: "Alpha",
67+
second: "Beta",
68+
},
69+
},
70+
);
71+
72+
expect(answer).toBe("Beta");
73+
});
74+
75+
it("supports Codex-style structured answers", () => {
76+
const answer = getAskUserQuestionAnswer(
77+
{ id: "q-2", question: "Choose many" },
78+
0,
79+
{
80+
answersByQuestionId: {
81+
"q-2": { answers: ["One", "Two"] },
82+
},
83+
},
84+
);
85+
86+
expect(answer).toBe("One, Two");
87+
});
88+
});
89+
90+
describe("buildAskUserQuestionResult", () => {
91+
it("keeps answers separate for multiple Claude questions without ids", () => {
92+
const result = buildAskUserQuestionResult(
93+
[
94+
{ question: "First question?" },
95+
{ question: "Second question?" },
96+
],
97+
{
98+
"question-0": new Set(["Alpha"]),
99+
"question-1": new Set(["Beta"]),
100+
},
101+
{},
102+
);
103+
104+
expect(result).toEqual({
105+
answers: {
106+
"First question?": "Alpha",
107+
"Second question?": "Beta",
108+
},
109+
answersByQuestionId: {
110+
"question-0": ["Alpha"],
111+
"question-1": ["Beta"],
112+
},
113+
});
114+
});
115+
116+
it("prefers free text over selected options for the matching question only", () => {
117+
const result = buildAskUserQuestionResult(
118+
[
119+
{ question: "First question?" },
120+
{ question: "Second question?" },
121+
],
122+
{
123+
"question-0": new Set(["Alpha"]),
124+
"question-1": new Set(["Beta"]),
125+
},
126+
{
127+
"question-1": "Custom answer",
128+
},
129+
);
130+
131+
expect(result).toEqual({
132+
answers: {
133+
"First question?": "Alpha",
134+
"Second question?": "Custom answer",
135+
},
136+
answersByQuestionId: {
137+
"question-0": ["Alpha"],
138+
"question-1": ["Custom answer"],
139+
},
140+
});
141+
});
142+
});

0 commit comments

Comments
 (0)