diff --git a/src/elicitation.ts b/src/elicitation.ts index 57a14930..ded03a2f 100644 --- a/src/elicitation.ts +++ b/src/elicitation.ts @@ -168,9 +168,10 @@ const CUSTOM_ANSWER_META_KEY = "_askUserQuestionCustomAnswer"; * * Each question is followed by its own optional free-text "custom answer" field * (`question__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 } | { 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__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__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; } diff --git a/src/tests/elicitation.test.ts b/src/tests/elicitation.test.ts index f8e2b9ca..827d98d3 100644 --- a/src/tests/elicitation.test.ts +++ b/src/tests/elicitation.test.ts @@ -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",