Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/interaction-allow-custom.md
Original file line number Diff line number Diff line change
@@ -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.
114 changes: 114 additions & 0 deletions packages/agent-interface/src/interaction.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
19 changes: 18 additions & 1 deletion packages/agent-interface/src/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
};

/**
Expand All @@ -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 })),
};
}
Expand Down Expand Up @@ -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;
}
Expand Down
Loading