Files
PaperClipAI/packages/paperclip-runner/scripts/protocol-contract.mjs
DottaandPaperclip fc6304dfe5 feat(runner): add experimental OpenAI Dot provider over MCP Events (#15402)
## Thinking Path

> - Paperclip manages AI agents, tasks, permissions, and execution
budgets.
> - Paperclip Runner gives each provider the same admitted task and tool
authority.
> - OpenAI Dot runs outside the local process tree and needs
asynchronous work delivery.
> - The merged MCP gateway supplies OAuth consent and signed event
delivery.
> - A personal assistant grant cannot safely stand in for an assigned
agent.
> - This pull request adds a separate Dot agent connection and a durable
Rust Runner bridge.
> - The operator can assign work to Dot and inspect its accepted work,
tool receipts, and result.

## Linked Issues or Issue Description

**Agent or provider**

OpenAI Dot, as an experimental provider of the existing Paperclip Runner
adapter.

**Why this adapter is useful**

An operator can assign normal Paperclip tasks to an existing Dot. Dot
can read its mailbox, request work on an assigned task, use admitted
task tools, and submit a result. Paperclip keeps company scope,
checkout, approvals, known budget limits, and activity attribution.

**How the agent is invoked**

A dedicated `/mcp/runner` OAuth resource pairs one Dot grant with one
agent. A signed MCP mailbox event wakes Dot. Dot explicitly accepts the
assignment. The Rust Runner owns the durable turn and operation
receipts. The first release supports self-hosted instances with a local
Runner controller.

**Additional context**

This extends the merged public MCP gateway from #14846 and the assistant
invitation and device-consent work from #14933. This also integrates the
merged assistant tool and configuration expansion in #15380. Dot retains
its dedicated agent resource and cannot receive personal configuration
permission. The public assistant connection remains a personal
connection.

## What Changed

- Add a durable Rust Dot provider and its TypeScript Runner driver.
- Add closed PRP v3 external-provider operations and native execution
input v6.
- Add company-scoped pairing, mailbox, assignment, and operation
records.
- Reuse merged browser/device consent, client metadata verification,
webhook admissions, refresh, secret rotation, and warm-standby gates.
- Keep Dot scopes, issuer, grants, event workers, and tool access
separate from personal assistant access.
- Add Dot configuration, pairing, readiness, and consent UI. Keep agent
grants out of the personal Connections entry.
- Regenerate the Dot-only migration after master. Preserve published
gateway migrations. Make the new migration safe to reapply.
- Document setup, recovery, accounting limits, evidence, and remaining
account qualification.
- Reverify reconnect callbacks and wake outstanding work with a fresh
mailbox reference; preserve the existing assignment and operation
receipts.
- Clean up Dot bindings and waiting runs on OAuth revoke and
refresh-token replay. Old grants cannot revoke replacement bindings.
- Restore the pairing reference when an unsaved agent form is reopened;
document board-only pairing routes in OpenAPI.
- Accept a clean Rust exit after the acknowledged shutdown receipt.
Unexpected exits still require recovery.
- Clear the cached binding after a successful revoke so a failed
connection refresh cannot restore it.
- Add production-component Storybook states and screenshots for pairing
and connection review. All preview account data is synthetic.
- Persist normalized completion, serialize Dot turns and durable work
admission, and poll subscription readiness.
- Serialize mailbox writes and cursor reads; retain paused fence
acknowledgement without task authority.
- Authorize admitted review runs without changing the worker assignee.
Include the fenced assignment ID in production stop notices.

## Verification

- This PR integrates master `4a8178e9c`. Dot migration
`0317_messy_famine.sql` follows the published history and is safe to
reapply. The merge preserves the reserved migration connection,
batch-commit handling, private task checks, task monitors, and native
accounting.
- Local workspace typecheck, full build, and UI token gates pass. The
server typecheck passes after the review fixes. Database and native
executor regressions pass.
- All twelve real Rust/PostgreSQL Dot integration tests and twelve Dot
driver tests pass. The tests cover native document writing and
finalization, durable replay, queue admission, mailbox ordering,
admitted reviews, stale authority, production stop references, and
paused acknowledgements.
- Current head `d0e7e0626` passes all 57 checks: 53 pass and four are
intentionally skipped. This includes full typecheck, build, tests, Rust
Runner verification, browser E2E, release verification, and Canary Dry
Run. Greptile rates this exact head 5/5. All review threads are
resolved.
- The full local root test run is slower than the sharded CI run and has
not completed. The full CI test gates pass on the current commit.
Focused local regressions pass.
- Real-account pairing and event delivery on this base commit remain
unqualified. Live account and setup proof are recorded in the follow-up
#15414.

The following screenshots use synthetic preview data. They show the
production pairing component and do not qualify a real account or the
full agent setup journey.

![Synthetic pairing
preview](https://github.com/paperclipai/paperclip/blob/codex/dot-events-prototype/doc/screenshots/openai-dot-runner/pairing.jpg?raw=true)

![Synthetic connected
preview](https://github.com/paperclipai/paperclip/blob/codex/dot-events-prototype/doc/screenshots/openai-dot-runner/connected.jpg?raw=true)

## Risks

- This base adapter uses `PAPERCLIP_ENABLE_OPENAI_DOT=1` plus Public MCP
and Paperclip Runner. The separate experimental-settings follow-up in
#15414 replaces this environment flag with saved operator settings.
- Dot does not expose provider token usage or cost. The operator must
acknowledge external billing. Known Paperclip budget gates still apply.
- Cancellation fences Paperclip authority. It does not confirm that Dot
stopped all external activity.
- Assigned skill files and third-party MCP bindings are unsupported and
reject admission. There is no mounted workspace, model selector, or
provider thread identifier.
- Hosted agent-broker and remote controller deployments are not
qualified.
- The new migration follows the merged master history. Existing
prototype databases still need the normal master migration history
before this Dot-only migration.

## Model Used

OpenAI Codex, based on GPT-6. The exact deployment ID and context window
size are not exposed in this session. Capabilities used: reasoning,
repository editing, code execution, and test inspection.

## 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 #123` 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 references)
- [x] My branch name describes the change 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-07 19:11:50 -05:00

748 lines
30 KiB
JavaScript

import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import { relative, resolve, sep } from "node:path";
import Ajv2020 from "ajv/dist/2020.js";
export const JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
export const PRP_SCHEMA_ID_PREFIX = "https://paperclip.dev/schemas/prp/";
export const PRP_V1_SCHEMA_ID_PREFIX = `${PRP_SCHEMA_ID_PREFIX}v1/`;
export const SUPPORTED_FIXTURE_VERSION = 1;
export const MIN_SUPPORTED_PROTOCOL_VERSION = 1;
export const SUPPORTED_PROTOCOL_VERSION = 3;
export const SUPPORTED_EVENT_SCHEMA_VERSION = 1;
function contractError(code, detail) {
return new Error(`${code}: ${detail}`);
}
export async function readJson(path) {
const source = await readFile(path, "utf8");
try {
return { source, value: JSON.parse(source) };
} catch (error) {
throw contractError("invalid_json", `${path}: ${error.message}`);
}
}
export async function listJsonFiles(root) {
const entries = await readdir(root, { recursive: true, withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map((entry) => resolve(entry.parentPath, entry.name))
.sort();
}
export function portableRelative(root, path) {
return relative(root, path).split(sep).join("/");
}
export function sha256(source) {
return createHash("sha256").update(source).digest("hex");
}
function collectReferences(value, output = []) {
if (Array.isArray(value)) {
for (const item of value) collectReferences(item, output);
return output;
}
if (value === null || typeof value !== "object") return output;
for (const [key, child] of Object.entries(value)) {
if (key === "$ref" && typeof child === "string") output.push(child);
else collectReferences(child, output);
}
return output;
}
function resolveJsonPointer(value, fragment) {
if (fragment === "") return value;
if (!fragment.startsWith("/")) return undefined;
return fragment
.slice(1)
.split("/")
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce((current, part) => {
if (current === null || typeof current !== "object") return undefined;
return Object.hasOwn(current, part) ? current[part] : undefined;
}, value);
}
export async function loadSchemaCatalog(schemaDirectory) {
const files = await listJsonFiles(schemaDirectory);
const records = await Promise.all(files.map(async (path) => ({ path, ...(await readJson(path)) })));
const ids = new Map();
for (const record of records) {
const schema = record.value;
if (schema.$schema !== JSON_SCHEMA_DIALECT) {
throw contractError("unsupported_schema_dialect", portableRelative(schemaDirectory, record.path));
}
if (typeof schema.$id !== "string" || !schema.$id.startsWith(PRP_SCHEMA_ID_PREFIX)) {
throw contractError("invalid_schema_id", portableRelative(schemaDirectory, record.path));
}
if (ids.has(schema.$id)) throw contractError("duplicate_schema_id", schema.$id);
if (typeof schema.title !== "string" || schema.title.length === 0) {
throw contractError("missing_schema_title", schema.$id);
}
ids.set(schema.$id, record);
}
for (const record of records) {
for (const reference of collectReferences(record.value)) {
const [targetId, fragment = ""] = reference.split("#", 2);
const target = targetId === "" ? record : ids.get(targetId);
if (target === undefined) {
throw contractError("unresolved_schema_reference", `${record.value.$id} -> ${reference}`);
}
if (resolveJsonPointer(target.value, fragment) === undefined) {
throw contractError("unresolved_schema_fragment", `${record.value.$id} -> ${reference}`);
}
}
}
return records;
}
export function compileProtocolValidators(schemaRecords) {
const ajv = new Ajv2020({
allErrors: true,
strict: true,
strictRequired: false,
formats: {
"date-time": /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/,
},
});
for (const record of schemaRecords) ajv.addSchema(record.value);
const get = (name) => {
const id = `${PRP_V1_SCHEMA_ID_PREFIX}${name}.schema.json`;
const validator = ajv.getSchema(id);
if (validator === undefined) throw contractError("missing_schema_validator", id);
return validator;
};
const getVersioned = (version, name) => {
const id = `${PRP_SCHEMA_ID_PREFIX}v${version}/${name}.schema.json`;
const validator = ajv.getSchema(id);
if (validator === undefined) throw contractError("missing_schema_validator", id);
return validator;
};
return {
conformanceFixture: get("conformance-fixture"),
conformanceOutput: get("conformance-output"),
fixture: get("fixture"),
providerDescriptor: get("provider-descriptor"),
questionAdapterFixture: get("question-adapter-fixture"),
capabilitiesV2: getVersioned(2, "capabilities"),
commandV2: getVersioned(2, "command"),
eventV2: getVersioned(2, "event"),
capabilitiesV3: getVersioned(3, "capabilities"),
commandV3: getVersioned(3, "command"),
eventV3: getVersioned(3, "event"),
};
}
export function assertSchemaInstance(validator, value, location, expectedValid = true) {
const valid = validator(value);
if (valid !== expectedValid) {
const detail = (validator.errors ?? [])
.slice(0, 8)
.map((error) => `${error.instancePath || "/"} ${error.message}`)
.join("; ");
const expectation = expectedValid ? "accepted" : "rejected";
throw contractError("schema_validation_failed", `${location} must be ${expectation}: ${detail || "no AJV error"}`);
}
return value;
}
function requireSchema(value, expected, location) {
if (value?.schema !== expected) {
throw contractError("unsupported_required_schema", `${location} requires ${String(value?.schema)}`);
}
}
function requireVersion(value, expected, name) {
if (value !== expected) {
throw contractError("unsupported_required_version", `${name}=${String(value)}; supported=${expected}`);
}
}
function requireSupportedProtocolVersion(value) {
if (!Number.isInteger(value) || value < MIN_SUPPORTED_PROTOCOL_VERSION || value > SUPPORTED_PROTOCOL_VERSION) {
throw contractError(
"unsupported_required_version",
`protocolVersion=${String(value)}; supported=${MIN_SUPPORTED_PROTOCOL_VERSION}-${SUPPORTED_PROTOCOL_VERSION}`,
);
}
}
export function assertReplayFixtureCompatibility(fixture) {
requireSchema(fixture, "paperclip.prp.fixture.v1", "fixture");
requireVersion(fixture.fixtureVersion, SUPPORTED_FIXTURE_VERSION, "fixtureVersion");
requireSupportedProtocolVersion(fixture.protocolVersion);
requireSchema(fixture.identity, "paperclip.prp.identity.v1", "identity");
if (!/^paperclip\.prp\.capabilities\.v[1-3]$/.test(fixture.capabilities?.schema)) throw contractError("unsupported_required_schema", "capabilities");
if (!Array.isArray(fixture.commands)) throw contractError("invalid_fixture", "commands must be an array");
for (const [index, command] of fixture.commands.entries()) {
if (command?.schema !== "paperclip.prp.command.v1" && command?.schema !== "paperclip.prp.command.v2" && command?.schema !== "paperclip.prp.command.v3") {
throw contractError("unsupported_required_schema", `commands[${index}] requires ${String(command?.schema)}`);
}
}
if (!Array.isArray(fixture.events) || fixture.events.length === 0) {
throw contractError("invalid_fixture", "events must be a non-empty array");
}
for (const [index, event] of fixture.events.entries()) {
if (![1, 2, 3].includes(event.schemaVersion) || event.schemaVersion > fixture.protocolVersion) throw contractError("unsupported_required_version", `events[${index}].schemaVersion`);
requireSchema(event, `paperclip.prp.event.v${event.schemaVersion}`, `events[${index}]`);
const semanticToolVersion = event.payload?.semantic_tool?.schemaVersion;
if (semanticToolVersion !== undefined) {
requireVersion(semanticToolVersion, 1, `events[${index}].payload.semantic_tool.schemaVersion`);
}
}
requireSchema(fixture.result, "paperclip.run_result.v1", "result");
return fixture;
}
export function assertQuestionAdapterFixture(fixture) {
requireSchema(fixture, "paperclip.question_adapter_fixture.v1", "question fixture");
requireSchema(fixture.canonicalQuestionSet, "paperclip.question_set.v1", "canonicalQuestionSet");
requireSchema(fixture.canonicalResponse, "paperclip.question_response.v1", "canonicalResponse");
const questions = fixture.canonicalQuestionSet.questions;
if (!Array.isArray(questions) || questions.length === 0) {
throw contractError("invalid_question_adapter_fixture", "questions must be non-empty");
}
const questionsById = new Map();
const optionIdsByQuestion = new Map();
for (const question of questions) {
if (typeof question.id !== "string" || question.id.length === 0 || questionsById.has(question.id)) {
throw contractError("invalid_question_adapter_fixture", "question IDs must be unique");
}
questionsById.set(question.id, question);
const optionIds = new Set();
for (const option of question.options ?? []) {
if (typeof option.id !== "string" || option.id.length === 0 || optionIds.has(option.id)) {
throw contractError("invalid_question_adapter_fixture", `option IDs for ${question.id} must be unique`);
}
optionIds.add(option.id);
}
optionIdsByQuestion.set(question.id, optionIds);
const validation = question.textValidation;
if (
validation?.minLength !== undefined
&& validation.maxLength !== undefined
&& validation.minLength > validation.maxLength
) {
throw contractError("invalid_question_adapter_fixture", `text validation for ${question.id} has minLength greater than maxLength`);
}
if (
validation?.minimum !== undefined
&& validation.maximum !== undefined
&& validation.minimum > validation.maximum
) {
throw contractError("invalid_question_adapter_fixture", `text validation for ${question.id} has minimum greater than maximum`);
}
if (validation?.pattern !== undefined) {
try {
new RegExp(validation.pattern);
} catch {
throw contractError("invalid_question_adapter_fixture", `text validation pattern for ${question.id} is invalid`);
}
}
}
const answers = fixture.canonicalResponse.answers ?? {};
for (const [answerId, answer] of Object.entries(answers)) {
if (!questionsById.has(answerId)) {
throw contractError("invalid_question_adapter_fixture", `answer has unknown question ID ${answerId}`);
}
if (answer === null || typeof answer !== "object" || Array.isArray(answer)) {
throw contractError("invalid_question_adapter_fixture", `answer for ${answerId} must be an object`);
}
const selectedOptionIds = answer.selectedOptionIds ?? [];
if (!Array.isArray(selectedOptionIds) || selectedOptionIds.some((id) => typeof id !== "string")) {
throw contractError("invalid_question_adapter_fixture", `answer for ${answerId} has invalid option IDs`);
}
if (new Set(selectedOptionIds).size !== selectedOptionIds.length) {
throw contractError("invalid_question_adapter_fixture", `answer for ${answerId} repeats an option ID`);
}
for (const optionId of selectedOptionIds) {
if (!optionIdsByQuestion.get(answerId)?.has(optionId)) {
throw contractError("invalid_question_adapter_fixture", `answer has unknown option ID ${optionId}`);
}
}
}
for (const question of questions) {
const answer = answers[question.id];
if (answer === undefined) {
if (question.required) {
throw contractError("invalid_question_adapter_fixture", `answer for required question ${question.id} is missing`);
}
continue;
}
const selectedOptionIds = answer.selectedOptionIds ?? [];
const text = answer.text;
const customText = answer.customText;
if (question.answerMode === "text") {
if (selectedOptionIds.length > 0 || customText !== undefined) {
throw contractError("invalid_question_adapter_fixture", `text answer for ${question.id} carries select-only fields`);
}
} else {
if (text !== undefined) {
throw contractError("invalid_question_adapter_fixture", `select answer for ${question.id} carries text`);
}
if (question.answerMode === "single_select" && selectedOptionIds.length > 1) {
throw contractError("invalid_question_adapter_fixture", `single-select answer for ${question.id} chooses more than one option`);
}
if (customText !== undefined && question.customAnswer?.enabled !== true) {
throw contractError("invalid_question_adapter_fixture", `custom answer for ${question.id} is not enabled`);
}
if (
question.answerMode === "single_select"
&& typeof customText === "string"
&& customText.trim().length > 0
&& selectedOptionIds.length > 0
) {
throw contractError("invalid_question_adapter_fixture", `single-select answer for ${question.id} mixes option and custom values`);
}
}
const hasValue = fixtureAnswerHasValue(answer);
if (question.required && !hasValue) {
throw contractError("invalid_question_adapter_fixture", `answer for required question ${question.id} is empty`);
}
const boundedText = question.answerMode === "text" ? text : customText;
if (boundedText !== undefined) {
const validation = question.textValidation;
if (validation?.minLength !== undefined && boundedText.length < validation.minLength) {
throw contractError("invalid_question_adapter_fixture", `answer for ${question.id} must contain at least ${validation.minLength} characters`);
}
if (validation?.maxLength !== undefined && boundedText.length > validation.maxLength) {
throw contractError("invalid_question_adapter_fixture", `answer for ${question.id} must contain at most ${validation.maxLength} characters`);
}
if (validation?.pattern !== undefined) {
let pattern;
try {
pattern = new RegExp(validation.pattern);
} catch {
throw contractError("invalid_question_adapter_fixture", `text validation pattern for ${question.id} is invalid`);
}
if (!testFixturePatternWithDeadline(pattern.source, boundedText, question.id)) {
throw contractError("invalid_question_adapter_fixture", `answer for ${question.id} does not match the required format`);
}
}
if (validation?.inputType === "number" || validation?.inputType === "integer") {
const numeric = Number(boundedText);
if (!Number.isFinite(numeric) || (validation.inputType === "integer" && !Number.isInteger(numeric))) {
throw contractError("invalid_question_adapter_fixture", `answer for ${question.id} must be a valid ${validation.inputType}`);
}
if (validation.minimum !== undefined && numeric < validation.minimum) {
throw contractError("invalid_question_adapter_fixture", `answer for ${question.id} must be at least ${validation.minimum}`);
}
if (validation.maximum !== undefined && numeric > validation.maximum) {
throw contractError("invalid_question_adapter_fixture", `answer for ${question.id} must be at most ${validation.maximum}`);
}
}
}
}
return fixture;
}
export function assertCodexQuestionFixture(fixture) {
assertQuestionAdapterFixture(fixture);
if (fixture.adapter !== "codex") throw contractError("unsupported_provider", String(fixture.adapter));
if (fixture.nativeRequest?.method !== "item/tool/requestUserInput") {
throw contractError("invalid_codex_question_fixture", "native request method");
}
return fixture;
}
export function assertAcpxQuestionFixture(fixture) {
assertQuestionAdapterFixture(fixture);
if (fixture.adapter !== "acpx") {
throw contractError("unsupported_provider", String(fixture.adapter));
}
const request = fixture.nativeRequest;
if (!isPlainRecord(request) || request.method !== "elicitation/create") {
throw contractError("invalid_acpx_question_fixture", "native request method");
}
const params = request.params;
const requestedSchema = isPlainRecord(params) ? params.requestedSchema : null;
if (
!isPlainRecord(params)
|| params.mode !== "form"
|| !isPlainRecord(requestedSchema)
|| !isPlainRecord(requestedSchema.properties)
) {
throw contractError("invalid_acpx_question_fixture", "native form request");
}
const properties = Object.entries(requestedSchema.properties);
if (properties.length === 0 || properties.length > 64) {
throw contractError("invalid_acpx_question_fixture", "native form property count");
}
const required = normalizedAcpxRequired(requestedSchema);
for (const [name, property] of properties) {
assertAcpxProperty(name, property);
if (
isPlainRecord(property)
&& property.type === "string"
&& acpxEnumValues(property, "oneOf").length === 0
&& optionalFixtureText(property.pattern) !== undefined
) {
throw contractError(
"invalid_acpx_question_fixture",
`native property ${name} uses an unsupported pattern`,
);
}
}
const response = fixture.nativeResponse;
if (
!isPlainRecord(response)
|| response.action !== "accept"
|| !isPlainRecord(response.content)
) {
throw contractError("invalid_acpx_question_fixture", "native accept response");
}
for (const [name] of properties) {
if (required.has(name) && !Object.hasOwn(response.content, name)) {
throw contractError("invalid_acpx_question_fixture", `native response omits required property ${name}`);
}
}
for (const [name, value] of Object.entries(response.content)) {
const property = requestedSchema.properties[name];
if (!isPlainRecord(property)) {
throw contractError("invalid_acpx_question_fixture", `native response has unknown property ${name}`);
}
assertAcpxPropertyValue(name, property, value);
}
const projected = projectAcpxFixture(params, fixture.canonicalResponse);
if (canonicalFixtureJson(projected.questionSet) !== canonicalFixtureJson(fixture.canonicalQuestionSet)) {
throw contractError("invalid_acpx_question_fixture", "native and canonical question sets differ");
}
if (canonicalFixtureJson(projected.nativeResponse) !== canonicalFixtureJson(fixture.nativeResponse)) {
throw contractError("invalid_acpx_question_fixture", "canonical and native responses differ");
}
return fixture;
}
function projectAcpxFixture(params, canonicalResponse) {
const schema = params.requestedSchema;
const required = normalizedAcpxRequired(schema);
const bindings = Object.entries(schema.properties).map(([name, property], index) =>
projectAcpxProperty(name, property, index, required.has(name))
);
const title = optionalFixtureText(schema.title) ?? "Additional information needed";
const descriptions = [
optionalFixtureText(params.message),
optionalFixtureText(schema.description),
].filter((value, index, all) => value !== undefined && value !== title && all.indexOf(value) === index);
const questionSet = {
schema: "paperclip.question_set.v1",
title,
...(descriptions.length > 0 ? { description: descriptions.join("\n\n") } : {}),
submitLabel: "Submit answers",
questions: bindings.map((binding) => binding.question),
};
const content = {};
for (const binding of bindings) {
const answer = canonicalResponse.answers?.[binding.question.id];
// The production parser accepts explicit empty optional answers and omits
// them from its normalized response. Mirror that before projecting ACP
// content so the fixture gate certifies the same wire behavior.
if (!answer || !fixtureAnswerHasValue(answer)) continue;
if (binding.type === "string" && binding.question.answerMode === "text") {
if (answer.text !== undefined) defineAcpxResponseProperty(content, binding.name, answer.text);
} else if (binding.type === "number" || binding.type === "integer") {
if (answer.text !== undefined) {
defineAcpxResponseProperty(content, binding.name, Number(answer.text));
}
} else if (binding.type === "boolean") {
const selected = answer.selectedOptionIds?.[0];
if (selected !== undefined) {
defineAcpxResponseProperty(
content,
binding.name,
binding.optionValues.get(selected) === "true",
);
}
} else {
const selected = (answer.selectedOptionIds ?? []).map((id) => binding.optionValues.get(id));
if (binding.type === "array") {
defineAcpxResponseProperty(content, binding.name, selected);
} else if (selected[0] !== undefined) {
defineAcpxResponseProperty(content, binding.name, selected[0]);
}
}
}
return { questionSet, nativeResponse: { action: "accept", content } };
}
function fixtureAnswerHasValue(answer) {
return (
(typeof answer.text === "string" && answer.text.trim().length > 0)
|| (typeof answer.customText === "string" && answer.customText.trim().length > 0)
|| (Array.isArray(answer.selectedOptionIds) && answer.selectedOptionIds.length > 0)
);
}
function defineAcpxResponseProperty(content, name, value) {
Object.defineProperty(content, name, {
value,
enumerable: true,
configurable: true,
writable: true,
});
}
function normalizedAcpxRequired(schema) {
const propertyNames = new Set(
isPlainRecord(schema.properties) ? Object.keys(schema.properties) : [],
);
return new Set(
Array.isArray(schema.required)
? schema.required
.filter(
(value) => typeof value === "string" && propertyNames.has(value),
)
: []
);
}
function projectAcpxProperty(name, property, index, required) {
const id = stableAcpxFieldId(name, index);
const header = optionalFixtureText(property.title) ?? name;
const base = {
id,
header,
prompt: optionalFixtureText(property.description) ?? header,
required,
};
if (property.type === "string") {
const options = acpxNativeOptions(property, "oneOf");
if (options.length > 0) return projectedAcpxOptions(name, property.type, base, options, "single_select");
const pattern = optionalFixtureText(property.pattern);
return {
name,
type: property.type,
optionValues: new Map(),
question: {
...base,
answerMode: "text",
textValidation: {
inputType: "text",
...(finiteNonNegativeFixtureInteger(property.minLength) !== undefined
? { minLength: property.minLength }
: {}),
...(finiteNonNegativeFixtureInteger(property.maxLength) !== undefined
? { maxLength: property.maxLength }
: {}),
...(pattern !== undefined ? { pattern } : {}),
},
},
};
}
if (property.type === "number" || property.type === "integer") {
return {
name,
type: property.type,
optionValues: new Map(),
question: {
...base,
answerMode: "text",
textValidation: {
inputType: property.type,
...(Number.isFinite(property.minimum) ? { minimum: property.minimum } : {}),
...(Number.isFinite(property.maximum) ? { maximum: property.maximum } : {}),
},
},
};
}
if (property.type === "boolean") {
return projectedAcpxOptions(name, property.type, base, [
{ value: "true", label: "Yes" },
{ value: "false", label: "No" },
], "single_select");
}
return projectedAcpxOptions(
name,
property.type,
base,
acpxNativeOptions(property.items, "anyOf"),
"multi_select",
);
}
function testFixturePatternWithDeadline(pattern, value, questionId) {
const outcome = spawnSync(
process.execPath,
[
"--input-type=module",
"--eval",
"const chunks=[];for await(const chunk of process.stdin)chunks.push(chunk);const {pattern,value}=JSON.parse(Buffer.concat(chunks).toString());process.stdout.write(new RegExp(pattern).test(value)?'1':'0');",
],
{
input: JSON.stringify({ pattern, value }),
encoding: "utf8",
timeout: 1_000,
maxBuffer: 1_024,
windowsHide: true,
},
);
if (outcome.error || outcome.signal || outcome.status !== 0) {
throw contractError(
"invalid_question_adapter_fixture",
`text validation pattern for ${questionId} could not be evaluated safely`,
);
}
return outcome.stdout === "1";
}
function projectedAcpxOptions(name, type, base, nativeOptions, answerMode) {
const optionValues = new Map();
const options = nativeOptions.map((option, index) => {
const id = `option-${index + 1}`;
optionValues.set(id, option.value);
return {
id,
label: option.label,
...(option.description !== undefined ? { description: option.description } : {}),
};
});
return { name, type, optionValues, question: { ...base, answerMode, options } };
}
function acpxNativeOptions(property, preferredTitledKey) {
const alternateTitledKey = preferredTitledKey === "anyOf" ? "oneOf" : "anyOf";
const titled = property[preferredTitledKey]
?? property[alternateTitledKey];
if (Array.isArray(titled)) {
return titled.map((entry) => ({
value: entry.const,
label: optionalFixtureText(entry.title) ?? entry.const,
...(optionalFixtureText(entry.description) !== undefined
? { description: entry.description }
: {}),
}));
}
return (Array.isArray(property.enum) ? property.enum : [])
.map((value) => ({ value, label: value }));
}
function stableAcpxFieldId(value, index) {
const readable = value
.trim()
.replace(/[^A-Za-z0-9._:-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 96);
return `field-${index + 1}-${readable || "value"}-${sha256(value).slice(0, 12)}`;
}
function optionalFixtureText(value) {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function finiteNonNegativeFixtureInteger(value) {
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
}
function canonicalFixtureJson(value) {
if (Array.isArray(value)) return `[${value.map(canonicalFixtureJson).join(",")}]`;
if (isPlainRecord(value)) {
return `{${Object.keys(value).sort().map((key) =>
`${JSON.stringify(key)}:${canonicalFixtureJson(value[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}
function assertAcpxProperty(name, value) {
if (!isPlainRecord(value)) {
throw contractError("invalid_acpx_question_fixture", `native property ${name}`);
}
if (!["string", "number", "integer", "boolean", "array"].includes(value.type)) {
throw contractError("invalid_acpx_question_fixture", `unsupported native property ${name}`);
}
if (value.type === "array") {
if (!isPlainRecord(value.items) || acpxEnumValues(value.items, "anyOf").length === 0) {
throw contractError("invalid_acpx_question_fixture", `native array property ${name} requires options`);
}
} else if (value.type === "string") {
acpxEnumValues(value, "oneOf");
}
}
function assertAcpxPropertyValue(name, property, value) {
const typeMatches = property.type === "string"
? typeof value === "string"
: property.type === "number"
? typeof value === "number" && Number.isFinite(value)
: property.type === "integer"
? Number.isInteger(value)
: property.type === "boolean"
? typeof value === "boolean"
: Array.isArray(value) && value.every((item) => typeof item === "string");
if (!typeMatches) {
throw contractError("invalid_acpx_question_fixture", `native response type for ${name}`);
}
const enumValues = property.type === "array"
? acpxEnumValues(property.items, "anyOf")
: property.type === "string"
? acpxEnumValues(property, "oneOf")
: [];
const responseValues = Array.isArray(value) ? value : [value];
if (enumValues.length > 0 && responseValues.some((item) => !enumValues.includes(item))) {
throw contractError("invalid_acpx_question_fixture", `native response option for ${name}`);
}
}
function acpxEnumValues(property, preferredTitledKey) {
if (!isPlainRecord(property)) return [];
const alternateTitledKey = preferredTitledKey === "anyOf" ? "oneOf" : "anyOf";
const titled = property[preferredTitledKey]
?? property[alternateTitledKey];
const values = Array.isArray(titled)
? titled.map((entry) => isPlainRecord(entry) ? entry.const : undefined)
: Array.isArray(property.enum)
? property.enum
: [];
if (
!Array.isArray(values)
|| values.length > 128
|| values.some((value) => typeof value !== "string" || value.length === 0)
) {
throw contractError("invalid_acpx_question_fixture", "native options must be non-empty bounded strings");
}
return values;
}
function isPlainRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function assertConformanceFixturePair(fixture, output) {
if (fixture?.schemaVersion !== "paperclip.runner.conformance.fixture.v1") {
throw contractError("unsupported_required_schema", `conformance fixture requires ${String(fixture?.schemaVersion)}`);
}
if (output?.schemaVersion !== "paperclip.runner.conformance.output.v1") {
throw contractError("unsupported_required_schema", `conformance output requires ${String(output?.schemaVersion)}`);
}
if (fixture.run?.runId !== output.runIdentity?.runId || fixture.run?.sessionId !== output.runIdentity?.sessionId) {
throw contractError("conformance_identity_mismatch", "run or session identity differs");
}
for (const [index, event] of (fixture.events ?? []).entries()) {
if (event.runId !== fixture.run.runId || event.sequence !== index + 1) {
throw contractError("invalid_conformance_event", `events[${index}] does not match the run sequence`);
}
}
if (
fixture.result?.status !== output.result?.status
|| fixture.result?.summary !== output.result?.summary
|| fixture.result?.runId !== fixture.run.runId
) {
throw contractError("conformance_result_mismatch", "expected output does not match the fixture result");
}
return { fixture, output };
}