diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts index a4406e924fd..9610acc750d 100644 --- a/packages/shared/src/issue-thread-interactions.test.ts +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -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", diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 3c49460a118..5501c1258f5 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -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", [