mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
fix(questions): reject conflicting canonical answer semantics
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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",
|
||||
|
||||
@@ -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", [
|
||||
|
||||
Reference in new issue
Block a user