mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 20:42:10 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user