fix: keep multi-select answers when a custom answer is typed (#1031)

## Summary

`applyAskElicitationResponse` drops a multi-select question's checked
options as soon as
that question's custom-answer field is non-empty: the typed text
replaces the selection
instead of joining it, so the checked options never reach the model.

Versions: reproduced on v0.69.0, code unchanged on v0.70.0 and current
`main` (996d488);
`@agentclientprotocol/sdk` 1.3.0; Node 22. Noticed through Zed 1.16.1,
which does return
both fields, but the repro below is adapter-local and needs no client.

## Where the two fields diverge

`askUserQuestionsToCreateRequest` publishes, per question, two
**independent optional**
fields: `question_<n>` and `question_<n>_custom`. Neither is `required`,
and nothing in
the schema makes them exclusive. The
`_meta._askUserQuestionCustomAnswer` marker from #929
identifies the companion field's role (`{ questionId, isCustomAnswer }`)
— it doesn't ask a
client to clear the selection when the box is filled. So a client that
returns both is
doing nothing wrong, and the fold has no ground to keep only one of
them.

Before #774 the two coexisted: selections in `answers`, the custom text
in a separate
top-level `response` field. Scoping the custom answer per question
(fixing #770) turned
that coexistence into an overwrite:

```js
const custom = content[questionCustomFieldKey(index)];
if (typeof custom === "string" && custom.trim() !== "") {
  answers[question.question] = custom.trim();
  return;                     // <- selections discarded
}
```

For a **single-select** question replacing is right — the user typed an
answer instead of
picking one — and that stays. For a **multi-select** question the
options were never
mutually exclusive, so the typed answer should join them.

Restoring `response` isn't the alternative: it is a single form-level
field, so using it
for per-question custom answers would reintroduce exactly the collapse
#770 reported.

## Reproduction (no client, no model, no network)

```js
import { applyAskElicitationResponse } from "@agentclientprotocol/claude-agent-acp/dist/elicitation.js";

const q = [{ question: "Which features?", header: "F", multiSelect: true,
             options: [{ label: "Alpha" }, { label: "Bravo" }] }];
const fold = (content) =>
  applyAskElicitationResponse({ action: "accept", content }, { questions: q }, q)
    .updatedInput.answers;

fold({ question_0: ["Alpha", "Bravo"] });                          // { "Which features?": "Alpha, Bravo" }  (control)
fold({ question_0: ["Alpha", "Bravo"], question_0_custom: "Charlie" });
// actual:   { "Which features?": "Charlie" }
// expected: { "Which features?": "Alpha, Bravo, Charlie" }
```

## Change

Multi-select questions join the custom answer onto their selections;
single-select keeps
replacing. The selection normalization is deliberately untouched, so a
multi-select
question with a non-empty custom answer is the only input that folds
differently than
before. Comma-joining follows the documented shape of `answers`
("multi-select answers are
comma-separated"), which also means a label containing a comma stays
exactly as ambiguous
as it already was.

The `"Other"` box described itself as *"Type your own answer instead of
choosing an option
above"*; it now describes both roles, since for a multi-select question
it is no longer an
either/or.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sergio-3
2026-09-14 14:59:43 +02:00
committed by GitHub
parent b274923ef8
commit 1a4512c711
2 changed files with 119 additions and 23 deletions

View File

@@ -168,9 +168,10 @@ 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 picking an option, 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.
* 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
* marked required, so the user can also just skip — matching the built-in tool,
* which always offers Skip + a free-text box.
*/
export function askUserQuestionsToCreateRequest(
questions: AskUserQuestion[],
@@ -211,7 +212,9 @@ export function askUserQuestionsToCreateRequest(
properties[questionCustomFieldKey(index)] = {
type: "string",
title: "Other",
description: "Type your own answer instead of choosing an option above (optional).",
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).",
_meta: {
[CUSTOM_ANSWER_META_KEY]: {
questionId: questionFieldKey(index),
@@ -241,17 +244,32 @@ export function askUserQuestionsToCreateRequest(
export type AskUserQuestionOutcome =
{ action: "answered"; updatedInput: Record<string, unknown> } | { action: "cancel" };
/**
* Serialize a multi-select answer the way the CLI's own AskUserQuestion UI
* does: comma-joined, with any item that itself contains the separator (or a
* double quote) JSON-quoted. The tool's `call()` splits the string back on the
* same rule, so a free-text answer like `Redis, not Memcached` stays one item
* instead of reading as two more picks.
*/
function joinMultiSelectAnswer(items: string[]): string {
return items
.map((item) => (item.includes(", ") || item.includes('"') ? JSON.stringify(item) : item))
.join(", ");
}
/**
* Fold an ACP elicitation response into the AskUserQuestion tool's input.
*
* Selected labels are read back from the indexed form fields and written into
* `answers` as a `{ [questionText]: label }` map (comma-joining multi-selects)
* — the key shape the tool's own `call()` reads. A non-empty per-question
* custom-answer field (`question_<n>_custom`) takes precedence over that
* question's selection, since the user typed their own answer instead of
* picking one. 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.
* `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
* custom/future action we don't understand — aborts the tool call.
*/
export function applyAskElicitationResponse(
response: CreateElicitationResponse,
@@ -271,19 +289,28 @@ export function applyAskElicitationResponse(
// stay in sync with what the built-in tool's call() expects to read back.
const answers: AskUserQuestionOutput["answers"] = {};
questions.forEach((question, index) => {
// A typed custom answer wins over the selection: the user chose to write
// their own answer for this question instead of picking an option.
const custom = content[questionCustomFieldKey(index)];
if (typeof custom === "string" && custom.trim() !== "") {
answers[question.question] = custom.trim();
return;
}
const customText = typeof custom === "string" ? custom.trim() : "";
const value = content[questionFieldKey(index)];
if (value === undefined || value === null) {
return;
}
const text = Array.isArray(value) ? value.join(", ") : String(value);
const picks: string[] =
value === undefined || value === null
? []
: Array.isArray(value)
? 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 === "") {
return;
}

View File

@@ -334,7 +334,7 @@ describe("applyAskElicitationResponse", () => {
});
});
it("folds a per-question custom answer into that question's answer", () => {
it("uses a multi-select question's custom answer alone when nothing is selected", () => {
const response = {
action: "accept",
content: { question_0: "A", question_1_custom: "something else entirely" },
@@ -350,7 +350,7 @@ describe("applyAskElicitationResponse", () => {
});
});
it("prefers a question's custom answer over its selection", () => {
it("prefers a single-select question's custom answer over its selection", () => {
const response = {
action: "accept",
content: { question_0: "A", question_0_custom: " my own take " },
@@ -366,6 +366,75 @@ describe("applyAskElicitationResponse", () => {
});
});
it("appends a multi-select question's custom answer to its selections", () => {
const response = {
action: "accept",
content: { question_1: ["X", "Y"], question_1_custom: " Z " },
} as CreateElicitationResponse;
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
action: "answered",
updatedInput: {
questions,
metadata: { source: "test" },
answers: { "Multi?": "X, Y, Z" },
},
});
});
it("quotes a multi-select custom answer that contains the separator, as the CLI does", () => {
const response = {
action: "accept",
content: { question_1: ["X"], question_1_custom: "Use Redis, not Memcached" },
} as CreateElicitationResponse;
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
action: "answered",
updatedInput: {
questions,
metadata: { source: "test" },
answers: { "Multi?": 'X, "Use Redis, not Memcached"' },
},
});
});
it("keeps the selection when the custom answer is blank", () => {
const response = {
action: "accept",
content: {
question_0: "A",
question_0_custom: " ",
question_1: ["X"],
question_1_custom: " ",
},
} as CreateElicitationResponse;
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
action: "answered",
updatedInput: {
questions,
metadata: { source: "test" },
answers: { "Single?": "A", "Multi?": "X" },
},
});
});
it("uses the custom answer alone when the multi-select array is empty", () => {
const response = {
action: "accept",
content: { question_1: [], question_1_custom: "Z" },
} as CreateElicitationResponse;
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
action: "answered",
updatedInput: {
questions,
metadata: { source: "test" },
answers: { "Multi?": "Z" },
},
});
});
it("ignores empty selections and blank custom answers", () => {
const response = {
action: "accept",