From d58004a34880e0a76833697319eb2a9efa6a43c7 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 16 Jun 2026 10:55:52 +0200 Subject: [PATCH] fix: Scope custom answers per question (#774) Closes #770 Maps one custom answer per top-level question, not a single global one. --- src/elicitation.ts | 57 ++++++++++++++++++++--------------- src/tests/elicitation.test.ts | 43 +++++++++++++++++++++----- 2 files changed, 68 insertions(+), 32 deletions(-) diff --git a/src/elicitation.ts b/src/elicitation.ts index 9fc27f37..1c76533e 100644 --- a/src/elicitation.ts +++ b/src/elicitation.ts @@ -116,8 +116,14 @@ function questionFieldKey(index: number): string { return `question_${index}`; } -/** Form-field key for the optional free-text "custom answer" field. */ -const CUSTOM_ANSWER_FIELD = "customAnswer"; +/** + * Form-field key for the per-question free-text "custom answer" field that sits + * alongside `question_`. Mirrors the first-party clients, where every + * question carries its own "Other" box rather than one form-level field. + */ +function questionCustomFieldKey(index: number): string { + return `question_${index}_custom`; +} /** * Render the AskUserQuestion tool's questions as an ACP form elicitation. @@ -128,10 +134,11 @@ const CUSTOM_ANSWER_FIELD = "customAnswer"; * an array with a titled `anyOf` item enum. The enum `const` is always the * option label, since that is what the tool records as the answer. * - * A trailing optional free-text field mirrors the CLI's custom-answer box: the - * user can type their own answer instead of (or as well as) picking an option. - * Nothing is marked required, so the user can also just skip — matching the - * built-in tool, which always offers Skip + a free-text box. + * 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. */ export function askUserQuestionsToCreateRequest( questions: AskUserQuestion[], @@ -156,13 +163,13 @@ export function askUserQuestionsToCreateRequest( properties[questionFieldKey(index)] = question.multiSelect ? { type: "array", title, description, items: { anyOf: options } } : { type: "string", title, description, oneOf: options }; - }); - properties[CUSTOM_ANSWER_FIELD] = { - type: "string", - title: "Other", - description: "Type your own answer instead of choosing an option above (optional).", - }; + properties[questionCustomFieldKey(index)] = { + type: "string", + title: "Other", + description: "Type your own answer instead of choosing an option above (optional).", + }; + }); const requestedSchema: ElicitationSchema = { type: "object", @@ -190,10 +197,11 @@ export type AskUserQuestionOutcome = * * 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. Free text from the custom- - * answer field becomes the tool's top-level `response`. Decline yields empty - * answers (the model is told the user skipped rather than the turn aborting); - * cancel aborts the tool call. + * — 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 aborts the tool call. */ export function applyAskElicitationResponse( response: CreateElicitationResponse, @@ -213,6 +221,14 @@ 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 value = content[questionFieldKey(index)]; if (value === undefined || value === null) { return; @@ -224,14 +240,7 @@ export function applyAskElicitationResponse( answers[question.question] = text; }); - const updatedInput: Record = { ...toolInput, answers }; - const custom = content[CUSTOM_ANSWER_FIELD]; - if (typeof custom === "string" && custom.trim() !== "") { - const response: AskUserQuestionOutput["response"] = custom; - updatedInput.response = response; - } - - return { action: "answered", updatedInput }; + return { action: "answered", updatedInput: { ...toolInput, answers } }; } /** diff --git a/src/tests/elicitation.test.ts b/src/tests/elicitation.test.ts index 4111c3ab..30701537 100644 --- a/src/tests/elicitation.test.ts +++ b/src/tests/elicitation.test.ts @@ -187,8 +187,11 @@ describe("askUserQuestionsToCreateRequest", () => { expect(schema.properties?.["Which library?"]).toBeUndefined(); }); - it("includes an optional free-text custom-answer field", () => { - const questions = [mkQuestion("Which?", [{ label: "A" }, { label: "B" }])]; + it("includes a per-question optional free-text custom-answer field", () => { + const questions = [ + mkQuestion("Which?", [{ label: "A" }, { label: "B" }]), + mkQuestion("What else?", [{ label: "C" }, { label: "D" }]), + ]; const schema = ( askUserQuestionsToCreateRequest(questions, SESSION_ID, undefined) as Extract< CreateElicitationRequest, @@ -196,7 +199,16 @@ describe("askUserQuestionsToCreateRequest", () => { > ).requestedSchema; - expect(schema.properties?.["customAnswer"]).toMatchObject({ type: "string", title: "Other" }); + // Each question gets its own "Other" box, not one shared form-level field. + expect(schema.properties?.["question_0_custom"]).toMatchObject({ + type: "string", + title: "Other", + }); + expect(schema.properties?.["question_1_custom"]).toMatchObject({ + type: "string", + title: "Other", + }); + expect(schema.properties?.["customAnswer"]).toBeUndefined(); }); it("builds an array property for multi-select questions and includes per-field question text", () => { @@ -250,10 +262,10 @@ describe("applyAskElicitationResponse", () => { }); }); - it("maps the free-text custom-answer field to the tool's response", () => { + it("folds a per-question custom answer into that question's answer", () => { const response = { action: "accept", - content: { question_0: "A", customAnswer: "something else entirely" }, + content: { question_0: "A", question_1_custom: "something else entirely" }, } as CreateElicitationResponse; expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({ @@ -261,8 +273,23 @@ describe("applyAskElicitationResponse", () => { updatedInput: { questions, metadata: { source: "test" }, - answers: { "Single?": "A" }, - response: "something else entirely", + answers: { "Single?": "A", "Multi?": "something else entirely" }, + }, + }); + }); + + it("prefers a question's custom answer over its selection", () => { + const response = { + action: "accept", + content: { question_0: "A", 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" }, }, }); }); @@ -270,7 +297,7 @@ describe("applyAskElicitationResponse", () => { it("ignores empty selections and blank custom answers", () => { const response = { action: "accept", - content: { question_1: [], customAnswer: " " }, + content: { question_1: [], question_0_custom: " " }, } as CreateElicitationResponse; expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({