fix(questions): reject conflicting canonical answer semantics

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-19 08:20:18 -05:00
1 parent 1f9e61288f
commit d6741b7ba6
2 files changed
+39

No files matched your search

@@ -313,6 +313,22 @@ describe("issue thread interaction schemas", () => {
expect(createIssueThreadInteractionSchema.parse({ kind: "ask_user_questions", payload: complete }).payload).toMatchObject({ questionSet: { questions: expect.any(Array) } });
});
it.each([
["required", { required: false }],
["answer mode", { answerMode: "multi_select" }],
["option IDs", { options: [{ id: "different", label: "Blue" }] }],
["option labels", { options: [{ id: "blue", label: "Red" }] }],
["prompt", { prompt: "A different question?" }],
])("rejects conflicting canonical %s at creation", (_name, changes) => {
const payload = {
version: 1,
questions: [{ id: "color", prompt: "Color?", required: true, selectionMode: "single", options: [{ id: "blue", label: "Blue" }] }],
questionSet: { schema: "paperclip.question_set.v1", questions: [{ id: "color", prompt: "Color?", required: true, answerMode: "single_select", options: [{ id: "blue", label: "Blue" }], ...changes }] },
};
expect(() => createIssueThreadInteractionSchema.parse({ kind: "ask_user_questions", payload })).toThrow("must match");
expect(askUserQuestionsPayloadSchema.parse(payload).questions).toHaveLength(1);
});
it("rejects unsafe request_confirmation target hrefs", () => {
const base = {
kind: "request_confirmation",
+23
View File
@@ -1879,6 +1879,29 @@ const createAskUserQuestionsPayloadSchema = askUserQuestionsPayloadSchema.superR
message: "questionSet must present every questions entry with the same question IDs. Include choice questions as well as text questions; a partial form hides required answers.",
});
}
const storedById = new Map(value.questions.map((question) => [question.id, question]));
for (const [index, question] of value.questionSet.questions.entries()) {
const storage = storedById.get(question.id);
if (!storage) continue;
const mismatch = (field: string) => ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["questionSet", "questions", index, field],
message: `questionSet ${field} must match the corresponding questions entry.`,
});
if (question.prompt !== storage.prompt) mismatch("prompt");
// An omitted storage flag adds no constraint; an explicit flag must agree.
if (storage.required !== undefined && question.required !== storage.required) mismatch("required");
const mode = question.answerMode === "multi_select" ? "multi" : "single";
if (storage.selectionMode !== mode) mismatch("answerMode");
if (question.answerMode === "text") {
if (storage.options.length !== 1 || !storage.options[0].freeText) mismatch("answerMode");
} else {
// Text/custom-answer sentinels are storage compatibility, not visible choices.
const choices = storage.options.filter((option) => !option.freeText);
const canonical = new Map((question.options ?? []).map((option) => [option.id, option.label]));
if (choices.length !== canonical.size || choices.some((option) => canonical.get(option.id) !== option.label)) mismatch("options");
}
}
});
export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [