mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 06:02:18 +02:00
Add the external Runner foundation with distinct Dot/Grok V6, canonical home-mode V7 and Muse V8 contracts. Keep generated schemas and profiles synchronized. Bound default question-answer command IDs in a disjoint namespace while preserving existing IDs and durable replay. Preserve canonical migration history and add normally generated, replay-safe Muse schema milestones with literal preview-history fixtures. Refs #16444. Co-Authored-By: Paperclip <noreply@paperclip.ing>
124 lines
5.9 KiB
TypeScript
124 lines
5.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { museCommandSchema, museQuerySchema } from "./muse-protocol.js";
|
|
import { paperclipQuestionSetPayloadSchema } from "./validators/issue.js";
|
|
|
|
const assignmentId = "00000000-0000-4000-8000-000000000001";
|
|
const requestId = "00000000-0000-4000-8000-000000000002";
|
|
const operation = { version: 1, assignmentId, requestId };
|
|
const questionSet = {
|
|
schema: "paperclip.question_set.v1",
|
|
questions: [{ id: "answer", prompt: "What should happen next?", required: true, answerMode: "text" }],
|
|
};
|
|
const nativeRequestEnvelopes = [
|
|
{
|
|
name: "request_user_input",
|
|
validate: (nativeRequestId: string) => museCommandSchema.safeParse({
|
|
...operation, command: "request_user_input", nativeRequestId, questionSet,
|
|
}),
|
|
},
|
|
{
|
|
name: "consume_input",
|
|
validate: (nativeRequestId: string) => museCommandSchema.safeParse({
|
|
...operation, command: "consume_input", nativeRequestId,
|
|
inputDigest: `sha256:${"a".repeat(64)}`,
|
|
continuationReceiptId: "00000000-0000-4000-8000-000000000003",
|
|
continuationPersisted: true,
|
|
}),
|
|
},
|
|
{
|
|
name: "input.pending",
|
|
validate: (nativeRequestId: string) => museQuerySchema.safeParse({
|
|
version: 1, query: "input.pending", assignmentId, nativeRequestId,
|
|
}),
|
|
},
|
|
];
|
|
|
|
describe("Muse native request identity bounds", () => {
|
|
it.each(nativeRequestEnvelopes)("accepts the 160-byte ASCII boundary for $name", ({ validate }) => {
|
|
expect(validate("Ab09._:-".repeat(20)).success).toBe(true);
|
|
});
|
|
|
|
it.each(nativeRequestEnvelopes)("rejects identities Runner cannot accept for $name", ({ validate }) => {
|
|
for (const value of ["", "a".repeat(161), "request id", "request/1", "é", "回答", "request\n", "request\u0000", ".question", "_question", ":question", "-question"]) {
|
|
expect(validate(value).success, JSON.stringify(value)).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Muse progress UTF-8 bounds", () => {
|
|
const validate = (text: string) => museCommandSchema.safeParse({ ...operation, command: "progress", text });
|
|
|
|
it("accepts exactly 12000 ASCII bytes and rejects the next byte", () => {
|
|
expect(validate("a".repeat(12000)).success).toBe(true);
|
|
expect(validate("a".repeat(12001)).success).toBe(false);
|
|
});
|
|
|
|
it.each(["é", "答", "😀"])("bounds %s by UTF-8 bytes rather than JavaScript length", (character) => {
|
|
const bytes = new TextEncoder().encode(character).byteLength;
|
|
const boundary = character.repeat(12000 / bytes);
|
|
expect(validate(boundary).success).toBe(true);
|
|
expect(validate(`${boundary}a`).success).toBe(false);
|
|
});
|
|
|
|
it("retains trimmed text semantics at the byte boundary and rejects blank text", () => {
|
|
const text = "é".repeat(6000);
|
|
const parsed = validate(` \n${text}\t `);
|
|
expect(parsed.success).toBe(true);
|
|
if (parsed.success && parsed.data.command === "progress") expect(parsed.data.text).toBe(text);
|
|
expect(validate(" \n\t ").success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Muse native question-set durable byte bound", () => {
|
|
const encoder = new TextEncoder();
|
|
const validate = (questionSet: unknown) => museCommandSchema.safeParse({
|
|
...operation, command: "request_user_input", nativeRequestId: "scope-01", questionSet,
|
|
});
|
|
const body = (initialText: string) => ({ ...questionSet, title: "t".repeat(1000),
|
|
description: "d".repeat(100000), questions: [{ ...questionSet.questions[0], initialText }] });
|
|
|
|
it("accepts the exact serialized 196 KiB boundary and rejects one more byte", () => {
|
|
const length = 196 * 1024 - encoder.encode(JSON.stringify(body(""))).byteLength;
|
|
const boundary = body("a".repeat(length));
|
|
expect(encoder.encode(JSON.stringify(boundary))).toHaveLength(196 * 1024);
|
|
expect(validate(boundary).success).toBe(true);
|
|
expect(validate(body("a".repeat(length + 1))).success).toBe(false);
|
|
});
|
|
|
|
it.each(["é", "答", "😀", "\n", '"'])("counts UTF-8 and JSON escaping for %s", character => {
|
|
const budget = 196 * 1024 - encoder.encode(JSON.stringify(body(""))).byteLength;
|
|
const unit = encoder.encode(JSON.stringify(character)).byteLength - 2;
|
|
const count = Math.floor(budget / unit), padding = "a".repeat(budget % unit);
|
|
expect(validate(body(character.repeat(count) + padding)).success).toBe(true);
|
|
expect(validate(body(character.repeat(count) + padding + "a")).success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Muse bridge-supported question admission", () => {
|
|
const validate = (questionSet: unknown) => museCommandSchema.safeParse({
|
|
...operation, command: "request_user_input", nativeRequestId: "1question._:-", questionSet,
|
|
});
|
|
|
|
it.each(["^[0-9]+$", "", "(a+)+$"])("rejects provider pattern %j while the generic canonical form remains valid", pattern => {
|
|
const payload = { ...questionSet, questions: [{ ...questionSet.questions[0], textValidation: { pattern } }] };
|
|
expect(paperclipQuestionSetPayloadSchema.safeParse(payload).success).toBe(true);
|
|
expect(validate(payload).success).toBe(false);
|
|
});
|
|
|
|
it.each([{ minLength: 2, maxLength: 1 }, { minimum: 2, maximum: 1 }])("rejects conflicting canonical bounds %j", textValidation => {
|
|
expect(validate({ ...questionSet, questions: [{ ...questionSet.questions[0], textValidation }] }).success).toBe(false);
|
|
});
|
|
|
|
it("preserves independent question and option identities plus supported text and choice forms", () => {
|
|
const payload = { ...questionSet, questions: [
|
|
{ id: ".question", prompt: "Amount?", required: true, answerMode: "text", initialText: "2",
|
|
textValidation: { inputType: "integer", minLength: 0, maxLength: 3, minimum: 1, maximum: 10 } },
|
|
{ id: "é choice", prompt: "Choose", required: false, answerMode: "single_select",
|
|
options: [{ id: "_option", label: "Test" }, { id: "option space", label: "Production" }], customAnswer: { enabled: true } },
|
|
] };
|
|
const result = validate(payload);
|
|
expect(result.success).toBe(true);
|
|
if (result.success && result.data.command === "request_user_input") expect(result.data.questionSet).toEqual(payload);
|
|
});
|
|
});
|