Files
PaperClipAI/packages/paperclip-runner/test/protocol-contract.test.mjs
T
Nicky LeachandPaperclip 80a2b9284b Define durable runtime activity events (#18129)
## Thinking Path

> - Paperclip manages agents and tasks.
> - Runner reports durable run events through PRP.
> - Runtime clients need a typed record of reported activity.
> - The event must keep the normal run authority and read boundaries.
> - This pull request adds the generic runtime activity event contract.
> - Later PRs can publish and consume this contract without private
plugin coupling.

## Linked Issues or Issue Description

First feature prerequisite extracted from #18050. Runner publishing is
#18050; the dependent consumer is #18131. Related independent test
stability work: #18128. The parent search and review history is
preserved in [the verification
archive](https://github.com/paperclipai/paperclip/pull/18050#issuecomment-6489369676).

**Subsystem affected**

Runner event protocol, server run-event persistence and redaction, and
UI event taxonomy.

**Problem or motivation**

Core needs a durable, typed activity record tied to the authenticated
run. A text marker is not an event contract.

**Proposed solution**

Add `runtime.activity.recorded` as a P1 event with a closed `{
namespace, activityKey }` payload. Runner knows no plugin or panel
identity. Run/session identity, sequence and emission time stay in the
outer envelope; Core resolves company, task and agent through the
authorized run binding. Use the existing authenticated persistence,
acknowledgement, replay and redaction paths.

## What Changed

- Define and validate the event in PRP v1, v2 and v3. Update the
generated protocol artifacts and event catalog.
- Preserve saved and live event redaction. Test persistence, replay
deduplication and the existing run-read boundary.
- Register the event in the UI taxonomy without changing panel display
behavior.
- Advance the source-bound Copilot identity to 78. Retain all published
historical identity fixtures without changing their bytes.

## Verification

- Root `pnpm typecheck` and `pnpm build`: passed for the revised
activity contract.
- Node protocol checks: 14 passed. Provider identity and registry tests:
185 passed. Server tests: 103 passed. UI event registry: five passed.
- Maintained Grok and Copilot identity generator checks and `git diff
--check`: passed.
- The first activity-contract review scored 4/5 because the dependent
producer and readers still used the earlier contract. Both dependent PRs
are now updated and restacked; the finding is resolved and review is
running again.
- Initial extraction retained a consumer-only import in one server
suite. That suite failed to load. The consumer case and import were
moved out; the final three server files passed all 103 cases.
- Earlier panel-specific head `6137c50bc` had green CI and standard/Apex
5/5. Those scores do not qualify this revised activity contract. Fresh
CI and reviews are pending. Earlier combined results in the archive
apply only to their recorded source revisions.

## Risks

This change is additive. It adds no route, publisher, filesystem
transport, provider sandbox grant, or panel display behavior. Activation
hints grant no permission to read a plugin view. Provider identities
bind the changed protocol source; historical identities remain
decode-only. No schema migration or dependency change is required.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools and code
execution. The exact serving snapshot is not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-29 00:32:12 -07:00

670 lines
26 KiB
JavaScript

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { buildProtocolManifest } from "../scripts/generate-protocol-manifest.mjs";
import {
assertAcpxQuestionFixture,
assertCodexQuestionFixture,
assertConformanceFixturePair,
assertQuestionAdapterFixture,
assertReplayFixtureCompatibility,
assertSchemaInstance,
compileProtocolValidators,
loadSchemaCatalog,
readJson,
} from "../scripts/protocol-contract.mjs";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const protocolRoot = resolve(packageRoot, "protocol");
async function fixture(relativePath) {
return (await readJson(resolve(protocolRoot, "fixtures", relativePath))).value;
}
test("all schema IDs are unique and all external references resolve", async () => {
const schemas = await loadSchemaCatalog(resolve(protocolRoot, "schemas"));
assert.equal(schemas.length, 29);
assert.doesNotThrow(() => compileProtocolValidators(schemas));
});
test("the generated manifest matches all checked-in schemas and fixtures", async () => {
const expected = `${JSON.stringify(await buildProtocolManifest(), null, 2)}\n`;
const actual = await readFile(resolve(protocolRoot, "manifest.json"), "utf8");
assert.equal(actual, expected);
});
test("canonical replay fixtures use supported required versions", async () => {
for (const name of [
"duplicate-event.json",
"failed-run.json",
"happy-path.json",
"interrupted-run.json",
"source-gap.json",
]) {
assert.equal(assertReplayFixtureCompatibility(await fixture(`replay/${name}`)).protocolVersion, 1);
}
});
test("runtime activity is a closed Runner notification in every supported event version", async () => {
const validators = compileProtocolValidators(await loadSchemaCatalog(resolve(protocolRoot, "schemas")));
const happy = await fixture("replay/happy-path.json");
const base = happy.events[0];
for (const [version, validate] of [[1, event => validators.fixture({ ...happy, events: [event] })], [2, validators.eventV2], [3, validators.eventV3]]) {
const event = { ...base, schema: `paperclip.prp.event.v${version}`, schemaVersion: version,
eventType: "runtime.activity.recorded", priority: 1, sourceKind: "runner", payload: { namespace: "example.runtime", activityKey: "desktop.used" } };
assert.equal(validate(event), true);
assert.equal(validate({ ...event, sourceKind: "control_plane" }), false);
for (const payload of [{ ...event.payload, runId: "caller-run" }, { ...event.payload, activityKey: "" },
{ ...event.payload, activityKey: "x".repeat(161) }, { ...event.payload, namespace: "invalid\nkey" }]) {
assert.equal(validate({ ...event, payload }), false);
}
}
});
test("unknown additive fields remain compatible with PRP v1", async () => {
const value = await fixture("replay/unknown-optional-fields.json");
assert.equal(value.futureFixtureHint.producerVersion, "1.1-preview");
assert.doesNotThrow(() => assertReplayFixtureCompatibility(value));
});
test("unknown required versions and schemas fail closed", async () => {
const unsupported = await fixture("replay/unsupported-required-version.json");
assert.throws(
() => assertReplayFixtureCompatibility(unsupported),
/unsupported_required_version: protocolVersion=4; supported=1-3/,
);
const eventVersion = structuredClone(await fixture("replay/happy-path.json"));
eventVersion.events[0].schemaVersion = 3;
assert.throws(() => assertReplayFixtureCompatibility(eventVersion), /unsupported_required_version/);
const commandSchema = structuredClone(await fixture("replay/happy-path.json"));
commandSchema.commands[0].schema = "paperclip.prp.command.v4";
assert.throws(() => assertReplayFixtureCompatibility(commandSchema), /unsupported_required_schema/);
});
test("accepted fixtures satisfy the complete JSON Schemas", async () => {
const schemas = await loadSchemaCatalog(resolve(protocolRoot, "schemas"));
const validators = compileProtocolValidators(schemas);
const happyPath = await fixture("replay/happy-path.json");
assert.doesNotThrow(() => assertSchemaInstance(validators.fixture, happyPath, "happy-path"));
const missingRequiredField = structuredClone(happyPath);
delete missingRequiredField.commands[0].commandId;
assert.throws(
() => assertSchemaInstance(validators.fixture, missingRequiredField, "missing-command-id"),
/schema_validation_failed: missing-command-id must be accepted: \/commands\/0 must have required property 'commandId'/,
);
const unsupported = await fixture("replay/unsupported-required-version.json");
assert.doesNotThrow(() => assertSchemaInstance(validators.fixture, unsupported, "required-v2", false));
});
test("PRP v3 external operations are closed and external providers have no native model or thread", async () => {
const schemas = await loadSchemaCatalog(resolve(protocolRoot, "schemas"));
const validators = compileProtocolValidators(schemas);
const happy = await fixture("replay/happy-path.json");
const binding = { companyId: "company", agentId: "agent", bindingId: "binding", bindingGeneration: 1,
runId: happy.identity.runId, normalizedSessionId: happy.identity.normalizedSessionId, turnId: "turn", assignmentRevision: 1 };
const { companyId, agentId, ...commandBinding } = binding;
const command = { ...happy.commands[0], schema: "paperclip.prp.command.v3", type: "external_provider.operation",
payload: { ...commandBinding, requestId: "request", digest: "sha256:" + "a".repeat(64), action: "tool", input: { name: "write_document", arguments: {} } } };
assert.equal(validators.commandV3(command), true);
assert.equal(validators.commandV3({ ...command, payload: { ...command.payload, apiKey: "hidden" } }), false);
assert.equal(validators.commandV3({ ...command, payload: { ...command.payload, input: { ...command.payload.input, unexpected: true } } }), false);
const event = { ...happy.events[0], schema: "paperclip.prp.event.v3", schemaVersion: 3,
eventType: "external_provider.dispatch_requested", payload: { binding, kind: "authority_revoked", externalStopConfirmed: false } };
assert.equal(validators.eventV3(event), true);
assert.equal(validators.eventV3({ ...event, payload: { ...event.payload, externalStopConfirmed: true } }), false);
const replay = { ...happy, protocolVersion: 3, commands: [...happy.commands, command], events: [event, ...happy.events.slice(1)] };
assert.doesNotThrow(() => assertReplayFixtureCompatibility(replay));
assert.equal(validators.fixture(replay), true);
const descriptor = { provider: "openai_dot", driver: "openai_dot_mcp", executionKind: "remote_service", service: "openai_dot", model: null, providerSessionId: null, providerVersion: "unknown" };
assert.equal(validators.providerDescriptor(descriptor), true);
assert.equal(validators.providerDescriptor({ ...descriptor, model: "invented" }), false);
assert.equal(validators.providerDescriptor({ ...descriptor, providerSessionId: "invented-thread" }), false);
const museDescriptor = { ...descriptor, provider: "muse", driver: "muse_external", service: "muse" };
assert.equal(validators.providerDescriptor(museDescriptor), true);
assert.equal(validators.providerDescriptor({ ...museDescriptor, model: "invented" }), false);
assert.equal(validators.providerDescriptor({ ...museDescriptor, providerSessionId: "invented-thread" }), false);
assert.equal(validators.providerDescriptor({ ...museDescriptor, driver: "openai_dot_mcp" }), false);
});
test("provider descriptors require coherent provider, driver, and execution combinations", async () => {
const schemas = await loadSchemaCatalog(resolve(protocolRoot, "schemas"));
const validators = compileProtocolValidators(schemas);
const codex = {
provider: "codex",
driver: "codex_app_server",
model: "gpt-5.3-codex",
executionKind: "local_process",
providerVersion: "1",
};
assert.doesNotThrow(() => assertSchemaInstance(validators.providerDescriptor, codex, "codex-provider"));
assert.throws(
() => assertSchemaInstance(
validators.providerDescriptor,
{ ...codex, driver: "opencode_server" },
"mismatched-provider",
),
/schema_validation_failed: mismatched-provider must be accepted/,
);
});
test("the Codex question fixture uses stable provider-neutral IDs", async () => {
const value = await fixture("questions/codex.json");
assert.doesNotThrow(() => assertCodexQuestionFixture(value));
assert.deepEqual(Object.keys(value.canonicalResponse.answers), ["environment"]);
});
test("the ACPX question fixture enforces its provider-native contract", async () => {
const canonical = await fixture("questions/acpx.json");
assert.doesNotThrow(() => assertAcpxQuestionFixture(canonical));
const implicitObjectSchema = structuredClone(canonical);
delete implicitObjectSchema.nativeRequest.params.requestedSchema.type;
assert.doesNotThrow(() => assertAcpxQuestionFixture(implicitObjectSchema));
const unionObjectSchema = structuredClone(canonical);
unionObjectSchema.nativeRequest.params.requestedSchema.type = ["object", "null"];
assert.doesNotThrow(() => assertAcpxQuestionFixture(unionObjectSchema));
const malformedRequest = structuredClone(canonical);
malformedRequest.nativeRequest.params.requestedSchema.properties.environment.type = "object";
assert.throws(
() => assertAcpxQuestionFixture(malformedRequest),
/unsupported native property environment/,
);
const malformedResponse = structuredClone(canonical);
malformedResponse.nativeResponse.content.environment = "unknown";
assert.throws(
() => assertAcpxQuestionFixture(malformedResponse),
/native response option for environment/,
);
const patternBearingQuestion = structuredClone(canonical);
const patternBearingProperty = patternBearingQuestion.nativeRequest.params
.requestedSchema.properties.environment;
delete patternBearingProperty.oneOf;
patternBearingProperty.pattern = "^staging$";
patternBearingQuestion.canonicalQuestionSet.questions[0] = {
id: "field-1-environment-ba5285161ba6",
header: "Environment",
prompt: "Where should we deploy?",
required: true,
answerMode: "text",
textValidation: { inputType: "text", pattern: "^staging$" },
};
patternBearingQuestion.canonicalResponse.answers = {
"field-1-environment-ba5285161ba6": { text: "staging" },
};
assert.throws(
() => assertAcpxQuestionFixture(patternBearingQuestion),
/native property environment uses an unsupported pattern/,
);
const optionPattern = structuredClone(canonical);
optionPattern.nativeRequest.params.requestedSchema.properties.environment
.pattern = "^staging$";
assert.doesNotThrow(() => assertAcpxQuestionFixture(optionPattern));
const emptyOptionalAnswer = structuredClone(canonical);
emptyOptionalAnswer.nativeRequest.params.requestedSchema.required = [];
emptyOptionalAnswer.nativeResponse.content = {};
emptyOptionalAnswer.canonicalQuestionSet.questions[0] = {
...emptyOptionalAnswer.canonicalQuestionSet.questions[0],
required: false,
};
emptyOptionalAnswer.canonicalResponse.answers = {
[emptyOptionalAnswer.canonicalQuestionSet.questions[0].id]: {},
};
assert.doesNotThrow(() => assertAcpxQuestionFixture(emptyOptionalAnswer));
const emptyOption = structuredClone(canonical);
emptyOption.nativeRequest.params.requestedSchema.properties.environment
.oneOf[0].const = "";
assert.throws(
() => assertAcpxQuestionFixture(emptyOption),
/native options must be non-empty bounded strings/,
);
const missingResponse = structuredClone(canonical);
missingResponse.nativeResponse.content = {};
assert.throws(
() => assertAcpxQuestionFixture(missingResponse),
/native response omits required property environment/,
);
const divergentQuestion = structuredClone(canonical);
divergentQuestion.nativeRequest.params.requestedSchema.required = [];
assert.throws(
() => assertAcpxQuestionFixture(divergentQuestion),
/native and canonical question sets differ/,
);
const normalizedRequired = structuredClone(canonical);
normalizedRequired.nativeRequest.params.requestedSchema.required = [
"environment",
"environment",
42,
"not-a-property",
];
assert.doesNotThrow(() => assertAcpxQuestionFixture(normalizedRequired));
const manyUnknownRequired = structuredClone(canonical);
manyUnknownRequired.nativeRequest.params.requestedSchema.required = [
...Array.from({ length: 80 }, (_, index) => `unknown-${index}`),
"environment",
];
assert.doesNotThrow(() => assertAcpxQuestionFixture(manyUnknownRequired));
const divergentResponse = structuredClone(canonical);
divergentResponse.nativeResponse.content.environment = "production";
assert.throws(
() => assertAcpxQuestionFixture(divergentResponse),
/canonical and native responses differ/,
);
const arrayWithBothOptionForms = structuredClone(canonical);
const environment = arrayWithBothOptionForms.nativeRequest.params
.requestedSchema.properties.environment;
environment.type = "array";
environment.items = {
anyOf: environment.oneOf,
oneOf: [{ const: "wrong", title: "Wrong precedence" }],
};
delete environment.oneOf;
arrayWithBothOptionForms.canonicalQuestionSet.questions[0].answerMode = "multi_select";
arrayWithBothOptionForms.nativeResponse.content.environment = ["staging"];
assert.doesNotThrow(() => assertAcpxQuestionFixture(arrayWithBothOptionForms));
const malformedPreferredUnion = structuredClone(arrayWithBothOptionForms);
malformedPreferredUnion.nativeRequest.params.requestedSchema.properties
.environment.items.anyOf = { const: "staging" };
assert.throws(
() => assertAcpxQuestionFixture(malformedPreferredUnion),
/native array property environment requires options/,
);
const enumFallback = structuredClone(malformedPreferredUnion);
enumFallback.nativeRequest.params.requestedSchema.properties.environment.items = {
anyOf: { const: "ignored-malformed-preferred-union" },
oneOf: [{ const: "wrong", title: "Wrong alternate union" }],
enum: ["staging", "production"],
};
enumFallback.canonicalQuestionSet.questions[0].options = [
{ id: "option-1", label: "staging" },
{ id: "option-2", label: "production" },
];
assert.doesNotThrow(() => assertAcpxQuestionFixture(enumFallback));
const specialPropertyName = "__proto__";
const specialProperty = structuredClone(
canonical.nativeRequest.params.requestedSchema.properties.environment,
);
const specialPropertyFixture = structuredClone(canonical);
const specialProperties = specialPropertyFixture.nativeRequest.params
.requestedSchema.properties;
delete specialProperties.environment;
Object.defineProperty(specialProperties, specialPropertyName, {
value: specialProperty,
enumerable: true,
configurable: true,
writable: true,
});
specialPropertyFixture.nativeRequest.params.requestedSchema.required = [
specialPropertyName,
];
const specialQuestionId = `field-1-__proto__-${createHash("sha256")
.update(specialPropertyName)
.digest("hex")
.slice(0, 12)}`;
specialPropertyFixture.canonicalQuestionSet.questions[0].id = specialQuestionId;
specialPropertyFixture.canonicalResponse.answers = {
[specialQuestionId]: { selectedOptionIds: ["option-1"] },
};
specialPropertyFixture.nativeResponse.content = {};
Object.defineProperty(
specialPropertyFixture.nativeResponse.content,
specialPropertyName,
{
value: "staging",
enumerable: true,
configurable: true,
writable: true,
},
);
assert.doesNotThrow(() => assertAcpxQuestionFixture(specialPropertyFixture));
});
test("the ACPX question fixture ignores scalar enums the adapter does not project", async () => {
const base = await fixture("questions/acpx.json");
const id = "field-1-environment-ba5285161ba6";
const cases = [
{
type: "boolean",
property: { type: "boolean", title: "Confirm", enum: [true] },
question: {
id,
header: "Confirm",
prompt: "Confirm",
required: true,
answerMode: "single_select",
options: [
{ id: "option-1", label: "Yes" },
{ id: "option-2", label: "No" },
],
},
canonicalAnswer: { selectedOptionIds: ["option-2"] },
nativeAnswer: false,
},
{
type: "number",
property: { type: "number", title: "Threshold", enum: [1.5] },
question: {
id,
header: "Threshold",
prompt: "Threshold",
required: true,
answerMode: "text",
textValidation: { inputType: "number" },
},
canonicalAnswer: { text: "2.5" },
nativeAnswer: 2.5,
},
{
type: "integer",
property: { type: "integer", title: "Replicas", enum: [1] },
question: {
id,
header: "Replicas",
prompt: "Replicas",
required: true,
answerMode: "text",
textValidation: { inputType: "integer" },
},
canonicalAnswer: { text: "2" },
nativeAnswer: 2,
},
];
for (const testCase of cases) {
const value = structuredClone(base);
value.nativeRequest.params.requestedSchema.properties.environment = testCase.property;
value.canonicalQuestionSet.questions[0] = testCase.question;
value.canonicalResponse.answers[id] = testCase.canonicalAnswer;
value.nativeResponse.content.environment = testCase.nativeAnswer;
assert.doesNotThrow(
() => assertAcpxQuestionFixture(value),
`${testCase.type} enum must not constrain the adapter's projected scalar answer`,
);
}
const invalidNativeType = structuredClone(base);
invalidNativeType.nativeRequest.params.requestedSchema.properties.environment =
cases[0].property;
invalidNativeType.canonicalQuestionSet.questions[0] = cases[0].question;
invalidNativeType.canonicalResponse.answers[id] = cases[0].canonicalAnswer;
invalidNativeType.nativeResponse.content.environment = "false";
assert.throws(
() => assertAcpxQuestionFixture(invalidNativeType),
/native response type for environment/,
);
});
test("every question adapter fixture satisfies its declared schema", async () => {
const schemas = await loadSchemaCatalog(resolve(protocolRoot, "schemas"));
const validators = compileProtocolValidators(schemas);
for (const adapter of ["codex", "acpx"]) {
const value = await fixture(`questions/${adapter}.json`);
assert.doesNotThrow(() =>
assertSchemaInstance(
validators.questionAdapterFixture,
value,
`${adapter}-question-fixture`,
),
);
assert.doesNotThrow(() => assertQuestionAdapterFixture(value));
}
const malformed = structuredClone(await fixture("questions/acpx.json"));
delete malformed.canonicalQuestionSet.schema;
assert.throws(
() =>
assertSchemaInstance(
validators.questionAdapterFixture,
malformed,
"malformed-acpx-question-fixture",
),
/schema_validation_failed/,
);
const unknownQuestion = structuredClone(await fixture("questions/acpx.json"));
unknownQuestion.canonicalResponse.answers = {
"unknown-question": { selectedOptionIds: ["option-1"] },
};
assert.throws(
() => assertQuestionAdapterFixture(unknownQuestion),
/answer has unknown question ID unknown-question/,
);
const unknownOption = structuredClone(await fixture("questions/acpx.json"));
const [questionId] = Object.keys(unknownOption.canonicalResponse.answers);
unknownOption.canonicalResponse.answers[questionId].selectedOptionIds = [
"unknown-option",
];
assert.throws(
() => assertQuestionAdapterFixture(unknownOption),
/answer has unknown option ID unknown-option/,
);
const canonical = await fixture("questions/acpx.json");
const [requiredQuestion] = canonical.canonicalQuestionSet.questions;
const requiredQuestionId = requiredQuestion.id;
const malformedAnswers = [
{
label: "missing required answer",
answer: undefined,
pattern: /required question .* is missing/,
},
{
label: "empty required answer",
answer: {},
pattern: /required question .* is empty/,
},
{
label: "multiple single-select values",
answer: { selectedOptionIds: requiredQuestion.options.map((option) => option.id) },
pattern: /single-select answer .* chooses more than one option/,
},
{
label: "text on a select question",
answer: { text: "staging" },
pattern: /select answer .* carries text/,
},
{
label: "disabled custom answer",
answer: { customText: "canary" },
pattern: /custom answer .* is not enabled/,
},
];
for (const malformedAnswer of malformedAnswers) {
const malformedResponse = structuredClone(canonical);
malformedResponse.canonicalResponse.answers = malformedAnswer.answer === undefined
? {}
: { [requiredQuestionId]: malformedAnswer.answer };
assert.throws(
() => assertQuestionAdapterFixture(malformedResponse),
malformedAnswer.pattern,
malformedAnswer.label,
);
}
const emptyOptionalAnswer = structuredClone(canonical);
emptyOptionalAnswer.canonicalQuestionSet.questions[0] = {
...requiredQuestion,
required: false,
};
emptyOptionalAnswer.canonicalResponse.answers = {
[requiredQuestionId]: {},
};
assert.doesNotThrow(() => assertQuestionAdapterFixture(emptyOptionalAnswer));
const textModeMismatch = structuredClone(canonical);
textModeMismatch.canonicalQuestionSet.questions[0] = {
...requiredQuestion,
answerMode: "text",
options: [],
};
textModeMismatch.canonicalResponse.answers[requiredQuestionId] = {
customText: "select-only custom value",
};
assert.throws(
() => assertQuestionAdapterFixture(textModeMismatch),
/text answer .* carries select-only fields/,
);
const invalidTextAnswers = [
{
label: "minimum text length",
validation: { minLength: 4 },
text: "abc",
pattern: /must contain at least 4 characters/,
},
{
label: "maximum text length",
validation: { maxLength: 2 },
text: "abc",
pattern: /must contain at most 2 characters/,
},
{
label: "text pattern",
validation: { pattern: "^z+$" },
text: "abc",
pattern: /does not match the required format/,
},
{
label: "numeric input",
validation: { inputType: "number" },
text: "not-a-number",
pattern: /must be a valid number/,
},
{
label: "integer input",
validation: { inputType: "integer" },
text: "1.5",
pattern: /must be a valid integer/,
},
{
label: "numeric minimum",
validation: { inputType: "number", minimum: 2 },
text: "1",
pattern: /must be at least 2/,
},
{
label: "numeric maximum",
validation: { inputType: "number", maximum: 2 },
text: "3",
pattern: /must be at most 2/,
},
];
for (const invalid of invalidTextAnswers) {
const malformedResponse = structuredClone(canonical);
malformedResponse.canonicalQuestionSet.questions[0] = {
...requiredQuestion,
answerMode: "text",
options: [],
textValidation: invalid.validation,
};
malformedResponse.canonicalResponse.answers[requiredQuestionId] = {
text: invalid.text,
};
assert.throws(
() => assertQuestionAdapterFixture(malformedResponse),
invalid.pattern,
invalid.label,
);
}
const invalidCustomText = structuredClone(canonical);
invalidCustomText.canonicalQuestionSet.questions[0] = {
...requiredQuestion,
customAnswer: { enabled: true },
textValidation: { minLength: 2, inputType: "text" },
};
invalidCustomText.canonicalResponse.answers[requiredQuestionId] = {
customText: "x",
};
assert.throws(
() => assertQuestionAdapterFixture(invalidCustomText),
/must contain at least 2 characters/,
"select custom text validation",
);
for (const invalid of [
{
label: "contradictory optional text lengths",
validation: { minLength: 3, maxLength: 2 },
pattern: /minLength greater than maxLength/,
},
{
label: "contradictory optional numeric bounds",
validation: { inputType: "number", minimum: 3, maximum: 2 },
pattern: /minimum greater than maximum/,
},
]) {
const malformedQuestion = structuredClone(canonical);
malformedQuestion.canonicalQuestionSet.questions[0] = {
...requiredQuestion,
required: false,
answerMode: "text",
options: [],
textValidation: invalid.validation,
};
malformedQuestion.canonicalResponse.answers = {};
assert.throws(
() => assertQuestionAdapterFixture(malformedQuestion),
invalid.pattern,
invalid.label,
);
}
const pathologicalPattern = structuredClone(canonical);
pathologicalPattern.canonicalQuestionSet.questions[0] = {
...requiredQuestion,
answerMode: "text",
options: [],
textValidation: { pattern: "(a+)+$" },
};
pathologicalPattern.canonicalResponse.answers[requiredQuestionId] = {
text: `${"a".repeat(100_000)}!`,
};
assert.throws(
() => assertQuestionAdapterFixture(pathologicalPattern),
/could not be evaluated safely/,
);
});
test("the cross-language conformance input and output have one stable identity", async () => {
const input = await fixture("conformance-minimal-run.json");
const output = await fixture("conformance-expected-output.json");
const schemas = await loadSchemaCatalog(resolve(protocolRoot, "schemas"));
const validators = compileProtocolValidators(schemas);
assert.doesNotThrow(() => assertSchemaInstance(validators.conformanceFixture, input, "conformance-input"));
assert.doesNotThrow(() => assertSchemaInstance(validators.conformanceOutput, output, "conformance-output"));
assert.doesNotThrow(() => assertConformanceFixturePair(input, output));
assert.equal(input.result.summary, output.result.summary);
const missingSessionId = structuredClone(output);
delete missingSessionId.runIdentity.sessionId;
assert.throws(
() => assertSchemaInstance(validators.conformanceOutput, missingSessionId, "missing-session-id"),
/schema_validation_failed: missing-session-id must be accepted: \/runIdentity must have required property 'sessionId'/,
);
});