diff --git a/.changeset/interaction-allow-custom.md b/.changeset/interaction-allow-custom.md new file mode 100644 index 0000000..c760198 --- /dev/null +++ b/.changeset/interaction-allow-custom.md @@ -0,0 +1,5 @@ +--- +"@tangle-network/agent-interface": minor +--- + +Add `allowCustom` to select interaction fields: when set, `validateInteractionAnswer` accepts non-blank write-in values beyond the declared options, and `questionAnswerSpec` propagates the flag from `LegacyQuestion.allowCustom`. Enables "Other…" style questions where the user supplies their own text as the answer value. diff --git a/packages/agent-interface/src/interaction.test.ts b/packages/agent-interface/src/interaction.test.ts new file mode 100644 index 0000000..2926b14 --- /dev/null +++ b/packages/agent-interface/src/interaction.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import { + InteractionFieldSchema, + questionAnswerSpec, + validateInteractionAnswer, + type InteractionAnswerSpec, +} from "./interaction.js"; + +const selectSpec = (overrides?: { + allowCustom?: boolean; + multi?: boolean; +}): InteractionAnswerSpec => ({ + fields: [ + { + type: "select", + name: "choice", + label: "Pick one", + required: true, + multi: overrides?.multi, + allowCustom: overrides?.allowCustom, + options: [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + ], + }, + ], +}); + +describe("select field schema", () => { + it("accepts allowCustom on select fields", () => { + const parsed = InteractionFieldSchema.parse({ + type: "select", + name: "choice", + label: "Pick one", + allowCustom: true, + options: [{ value: "a", label: "A" }], + }); + expect(parsed).toMatchObject({ type: "select", allowCustom: true }); + }); +}); + +describe("validateInteractionAnswer select", () => { + it("rejects out-of-options values when allowCustom is unset", () => { + const result = validateInteractionAnswer(selectSpec(), { choice: ["write-in"] }); + expect(result).toEqual({ + ok: false, + errors: ['field "choice" has invalid option "write-in"'], + }); + }); + + it("accepts declared options regardless of allowCustom", () => { + expect(validateInteractionAnswer(selectSpec(), { choice: ["a"] })).toEqual({ ok: true }); + expect( + validateInteractionAnswer(selectSpec({ allowCustom: true }), { choice: ["b"] }), + ).toEqual({ ok: true }); + }); + + it("accepts a write-in value when allowCustom is true", () => { + const result = validateInteractionAnswer(selectSpec({ allowCustom: true }), { + choice: ["my own answer"], + }); + expect(result).toEqual({ ok: true }); + }); + + it("accepts a mix of declared and write-in values on multi selects", () => { + const result = validateInteractionAnswer( + selectSpec({ allowCustom: true, multi: true }), + { choice: ["a", "something else"] }, + ); + expect(result).toEqual({ ok: true }); + }); + + it("rejects blank write-ins even when allowCustom is true", () => { + const result = validateInteractionAnswer(selectSpec({ allowCustom: true }), { + choice: [" "], + }); + expect(result).toEqual({ + ok: false, + errors: ['field "choice" has blank write-in value'], + }); + }); + + it("still enforces single-value and required rules with allowCustom", () => { + expect( + validateInteractionAnswer(selectSpec({ allowCustom: true }), { + choice: ["x", "y"], + }), + ).toEqual({ ok: false, errors: ['field "choice" accepts a single value'] }); + expect( + validateInteractionAnswer(selectSpec({ allowCustom: true }), { choice: [] }), + ).toEqual({ ok: false, errors: ['field "choice" requires a selection'] }); + }); +}); + +describe("questionAnswerSpec", () => { + it("propagates allowCustom onto the select field", () => { + const spec = questionAnswerSpec([ + { + question: "Which color?", + options: [{ label: "red" }, { label: "blue" }], + allowCustom: true, + }, + ]); + expect(spec.fields[0]).toMatchObject({ type: "select", allowCustom: true }); + }); + + it("omits allowCustom when the question does not opt in", () => { + const spec = questionAnswerSpec([ + { question: "Which color?", options: [{ label: "red" }] }, + ]); + expect(spec.fields[0]).not.toHaveProperty("allowCustom"); + }); +}); diff --git a/packages/agent-interface/src/interaction.ts b/packages/agent-interface/src/interaction.ts index a3e565f..c9b1a41 100644 --- a/packages/agent-interface/src/interaction.ts +++ b/packages/agent-interface/src/interaction.ts @@ -68,6 +68,12 @@ export const InteractionFieldSchema = z.discriminatedUnion("type", [ .min(1), /** When true the user may pick more than one option. */ multi: z.boolean().optional(), + /** + * When true the answer may contain write-in values outside `options` + * (e.g. a rendered "Other…" choice carrying the user's own text). + * Write-ins must still be non-empty strings. + */ + allowCustom: z.boolean().optional(), default: z.array(z.string()).optional(), }), /** Like `text` but the value is sensitive (token/key) and must be masked. */ @@ -211,6 +217,8 @@ export type LegacyQuestion = { question: string; options?: Array<{ label: string; description?: string }>; multiSelect?: boolean; + /** When true the select field accepts write-in answers beyond `options`. */ + allowCustom?: boolean; }; /** @@ -228,6 +236,7 @@ export function questionAnswerSpec(questions: LegacyQuestion[]): InteractionAnsw label: q.question, required: true, multi: q.multiSelect === true, + ...(q.allowCustom === true ? { allowCustom: true } : {}), options: q.options.map((o) => ({ value: o.label, label: o.label, description: o.description })), }; } @@ -284,7 +293,15 @@ export function validateInteractionAnswer( if (field.required && v.length === 0) errors.push(`field "${field.name}" requires a selection`); const allowed = new Set(field.options.map((o) => o.value)); for (const choice of v) { - if (!allowed.has(choice)) errors.push(`field "${field.name}" has invalid option "${choice}"`); + if (allowed.has(choice)) continue; + if (field.allowCustom === true) { + // Write-ins are open but stay fail-closed on shape: string, non-blank. + if (typeof choice !== "string" || choice.trim() === "") { + errors.push(`field "${field.name}" has blank write-in value`); + } + continue; + } + errors.push(`field "${field.name}" has invalid option "${choice}"`); } break; }