mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 20:42:10 +00:00
fix: keep the picked option when AskUserQuestion custom text is also supplied (#1131)
## Summary For a single-select `AskUserQuestion`, a non-empty custom-answer field (`question_<n>_custom`) replaced the selected option. A client that renders that box as a notes step (as in #1050, seen via Devin/Windsurf) therefore returned only the typed text and the selection never reached the model. - The pick now stays in `answers`, and the typed text travels beside it as the tool's own per-question `annotations[question].notes`. That is the slot the CLI itself uses for free text attached to a selection and renders to the model as `"Q"="A" notes: ...`, so no new answer format is invented. - Custom text with no pick is still the answer on its own (the CLI's "Other"). - Multi-select folding from #1031 is unchanged: the text still joins the checked options with CLI-style quoting. - `annotations` is only added to `updatedInput` when non-empty. - The single-select field description now says the box can also add a note to the chosen option. Supersedes #1054, which conflicted with #1031 and glued label and text into one string. The "empty notes field won't submit" part of #1050 is client-side: nothing in the requested schema is marked required. Closes #1050 ## Test plan - `npm run check` - `npm run build` - `npm run test:run` (1325 passed, 27 skipped) - New test: pick + custom text yields `answers: { Q: "A" }` and `annotations: { Q: { notes } }` - New test: custom text alone on a single-select stays the answer 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -168,8 +168,9 @@ const CUSTOM_ANSWER_META_KEY = "_askUserQuestionCustomAnswer";
|
||||
*
|
||||
* Each question is followed by its own optional free-text "custom answer" field
|
||||
* (`question_<n>_custom`), mirroring the CLI's per-question "Other" box: the
|
||||
* user can type their own answer instead of (or, for a multi-select question,
|
||||
* as well as) picking an option, scoped to that specific question. Nothing is
|
||||
* user can type their own answer instead of picking an option, add it to a
|
||||
* multi-select's picks, or attach it as a note to a single-select's pick (see
|
||||
* `applyAskElicitationResponse`), scoped to that specific question. Nothing is
|
||||
* marked required, so the user can also just skip — matching the built-in tool,
|
||||
* which always offers Skip + a free-text box.
|
||||
*/
|
||||
@@ -214,7 +215,7 @@ export function askUserQuestionsToCreateRequest(
|
||||
title: "Other",
|
||||
description: question.multiSelect
|
||||
? "Type your own answer to add to your selection above (optional)."
|
||||
: "Type your own answer instead of choosing an option above (optional).",
|
||||
: "Type your own answer, or add a note to the option you chose above (optional).",
|
||||
_meta: {
|
||||
[CUSTOM_ANSWER_META_KEY]: {
|
||||
questionId: questionFieldKey(index),
|
||||
@@ -264,11 +265,15 @@ function joinMultiSelectAnswer(items: string[]): string {
|
||||
* `answers` as a `{ [questionText]: label }` map — the key shape the tool's own
|
||||
* `call()` reads — with multi-selects comma-joined in the CLI's own quoted form
|
||||
* (see `joinMultiSelectAnswer`). A non-empty per-question custom-answer field
|
||||
* (`question_<n>_custom`) replaces the selection of a single-select question,
|
||||
* since the user typed their own answer instead of picking one, and joins the
|
||||
* selection of a multi-select question, where the two fields are independent
|
||||
* and filling both means both. Decline yields empty answers (the model is told
|
||||
* the user skipped rather than the turn aborting); cancel — and any
|
||||
* (`question_<n>_custom`) joins the selection of a multi-select question, where
|
||||
* the two fields are independent and filling both means both. For a
|
||||
* single-select question it is the answer when nothing was picked (the user
|
||||
* typed their own instead), and otherwise travels beside the pick as the
|
||||
* tool's own per-question `annotations[question].notes` — the slot the CLI
|
||||
* uses for free text attached to a selection and renders to the model as
|
||||
* `"Q"="A" notes: ...` — so a client that presents the box as a notes field
|
||||
* cannot make the selection disappear. Decline yields empty answers (the model
|
||||
* is told the user skipped rather than the turn aborting); cancel — and any
|
||||
* custom/future action we don't understand — aborts the tool call.
|
||||
*/
|
||||
export function applyAskElicitationResponse(
|
||||
@@ -288,6 +293,7 @@ export function applyAskElicitationResponse(
|
||||
// Typed against the tool's own output schema so the answer/response shapes
|
||||
// stay in sync with what the built-in tool's call() expects to read back.
|
||||
const answers: AskUserQuestionOutput["answers"] = {};
|
||||
const annotations: NonNullable<AskUserQuestionInput["annotations"]> = {};
|
||||
questions.forEach((question, index) => {
|
||||
const custom = content[questionCustomFieldKey(index)];
|
||||
const customText = typeof custom === "string" ? custom.trim() : "";
|
||||
@@ -300,24 +306,44 @@ export function applyAskElicitationResponse(
|
||||
? value.filter((item) => item !== undefined && item !== null && item !== "").map(String)
|
||||
: [String(value)];
|
||||
|
||||
// A single-select question is answered by exactly one thing, so a typed
|
||||
// custom answer replaces the pick: the user wrote their own answer instead
|
||||
// of choosing an option. A multi-select is additive, and the form offers
|
||||
// the selection and the custom box as independent fields — a user who
|
||||
// fills both means both, so the typed answer joins the checked options.
|
||||
const items =
|
||||
customText === "" ? picks : question.multiSelect ? [...picks, customText] : [customText];
|
||||
|
||||
// A single-select normally holds one item; the plain join only matters if a
|
||||
// client hands back an array for it, and then mirrors the old behavior.
|
||||
const text = question.multiSelect ? joinMultiSelectAnswer(items) : items.join(", ");
|
||||
if (text === "") {
|
||||
// A multi-select is additive, and the form offers the selection and the
|
||||
// custom box as independent fields — a user who fills both means both, so
|
||||
// the typed answer joins the checked options.
|
||||
if (question.multiSelect) {
|
||||
const text = joinMultiSelectAnswer(customText === "" ? picks : [...picks, customText]);
|
||||
if (text !== "") {
|
||||
answers[question.question] = text;
|
||||
}
|
||||
return;
|
||||
}
|
||||
answers[question.question] = text;
|
||||
|
||||
// A single-select question is answered by exactly one thing. With no option
|
||||
// picked, the typed text is that answer (the CLI's "Other"). With an option
|
||||
// picked as well, the pick stays the answer and the text rides along as the
|
||||
// tool's per-question `notes` annotation, so neither is lost. A single-select
|
||||
// normally holds one item; the plain join only matters if a client hands
|
||||
// back an array for it, and then mirrors the old behavior.
|
||||
const picked = picks.join(", ");
|
||||
if (picked === "") {
|
||||
if (customText !== "") {
|
||||
answers[question.question] = customText;
|
||||
}
|
||||
return;
|
||||
}
|
||||
answers[question.question] = picked;
|
||||
if (customText !== "") {
|
||||
annotations[question.question] = { notes: customText };
|
||||
}
|
||||
});
|
||||
|
||||
return { action: "answered", updatedInput: { ...toolInput, answers } };
|
||||
return {
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
...toolInput,
|
||||
answers,
|
||||
...(Object.keys(annotations).length > 0 ? { annotations } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -350,7 +350,23 @@ describe("applyAskElicitationResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a single-select question's custom answer over its selection", () => {
|
||||
it("uses a single-select question's custom answer alone when nothing is selected", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0_custom: " my own take " },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Single?": "my own take" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a single-select pick and carries its custom text as the tool's notes annotation", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "A", question_0_custom: " my own take " },
|
||||
@@ -361,7 +377,8 @@ describe("applyAskElicitationResponse", () => {
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Single?": "my own take" },
|
||||
answers: { "Single?": "A" },
|
||||
annotations: { "Single?": { notes: "my own take" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user