Files
PaperClipAI/packages/paperclip-runner/scripts/generate-protocol-manifest.mjs
Dotta b93ad538b6 test(runner): add question adapter conformance (#12409)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The runner maps provider questions to one versioned Paperclip
contract.
> - Codex and ACPX now perform that mapping through separate adapters.
> - Separate adapter tests do not prove that both paths preserve the
same user-visible form.
> - Fixture validation must also match production behavior for optional
answers and unsupported patterns.
> - This pull request adds shared fixtures, validation, and
cross-adapter conformance checks.
> - The benefit is a reusable question contract for later providers
without enabling a new runtime.

## Linked Issues or Issue Description

Refs #12408

This pull request builds on the Codex ACPX question bridge merged in
#12408. It adds fixture and generator checks for the existing Codex and
ACPX question adapters. It does not add another provider or production
execution path.

## What Changed

- Add a canonical ACPX form fixture and native response. Mark the
equivalent Codex fixture field as required.
- Add shared validation for question IDs, option IDs, answer modes,
required answers, text bounds, numeric bounds, and response shapes.
- Evaluate fixture-only regular expressions in a bounded child process.
Reject patterns that cannot finish safely.
- Validate ACPX fixtures against a manifest-side mirror of the
production form projection. Reject free-text ACPX patterns, but ignore
patterns on enumerated option fields.
- Accept explicit empty optional answers and omit them from the
projected ACPX response, which matches the production parser.
- Validate every question fixture during manifest generation and
regenerate the checked-in manifest.
- Add cross-adapter tests that compare user-visible presentation while
preserving provider-owned IDs and provider-specific response conversion.
- Add negative regressions for malformed forms, invalid responses,
unsafe patterns, special property names, and projection drift.

## Verification

- Replay base: `4fe3189f0256873a359d2d53c209076919fd1c3b` (`master`
after #12408 merged).
- Exact replay head: `0532e7dfbb5a246033ffeef55a0c0013fdab07f1`.
- Stable patch ID for the intended seven-file delta:
`28154d86b2c37e0e8d442419e26703584852f67e`.
- The intended pull request delta contains exactly these seven files:
  - `packages/paperclip-runner/protocol/fixtures/questions/acpx.json`
  - `packages/paperclip-runner/protocol/fixtures/questions/codex.json`
  - `packages/paperclip-runner/protocol/manifest.json`
  - `packages/paperclip-runner/scripts/generate-protocol-manifest.mjs`
  - `packages/paperclip-runner/scripts/protocol-contract.mjs`
-
`packages/paperclip-runner/src/contracts/question-adapter-conformance.test.ts`
  - `packages/paperclip-runner/test/protocol-contract.test.mjs`
- The intended combined delta is 1,211 additions and 20 deletions.
- This change does not add a dependency, lockfile update, migration,
workflow, server route, UI change, documentation file, or production
runtime change.
- GitHub Actions run `33352004952` passed the complete matrix on retry
at the unchanged exact head, including protocol/package verification,
build, typecheck/release-registry, general and serialized server suites,
canary, and all e2e shards.
- Superagent, Socket, Snyk, contributor-trust, policy, and PR-review
checks pass on the exact replay head.
- Greptile reviewed the exact replay head at 5/5 with no blocking
finding and zero unresolved review threads.
- No local test result is claimed. GitHub Actions is the authoritative
verification environment for the replayed revision.

## Risks

This change has low runtime risk because it changes fixtures, generator
validation, generated metadata, and tests only. Fixture pattern checks
run in a child process with a one-second timeout and a bounded output
buffer. The ACPX gate intentionally rejects free-text patterns because
the production adapter has no bounded expression engine. It
intentionally permits an explicit empty optional answer because
production omits that answer from the native response. A validation
mismatch can block manifest generation, but it cannot change server
selection, direct adapters, or task-page behavior.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex with GPT-5.6, extended reasoning, repository tool use, and
code execution.

## 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
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] 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
2026-08-30 22:09:03 -05:00

139 lines
4.8 KiB
JavaScript

import { readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
SUPPORTED_FIXTURE_VERSION,
SUPPORTED_PROTOCOL_VERSION,
assertAcpxQuestionFixture,
assertCodexQuestionFixture,
assertConformanceFixturePair,
assertQuestionAdapterFixture,
assertReplayFixtureCompatibility,
assertSchemaInstance,
compileProtocolValidators,
listJsonFiles,
loadSchemaCatalog,
portableRelative,
readJson,
sha256,
} from "./protocol-contract.mjs";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const protocolRoot = resolve(packageRoot, "protocol");
const schemaDirectory = resolve(protocolRoot, "schemas");
const fixtureDirectory = resolve(protocolRoot, "fixtures");
const outputPath = resolve(protocolRoot, "manifest.json");
const expectedRejectedFixtures = new Set([
"fixtures/replay/unsupported-required-version.json",
"fixtures/replay/semantic-tool-unsupported-required-version.json",
]);
export async function buildProtocolManifest() {
const schemas = await loadSchemaCatalog(schemaDirectory);
const validators = compileProtocolValidators(schemas);
const fixtureFiles = await listJsonFiles(fixtureDirectory);
const fixtures = [];
const fixtureValues = new Map();
for (const path of fixtureFiles) {
const relativePath = portableRelative(protocolRoot, path);
const { source, value } = await readJson(path);
fixtureValues.set(relativePath, value);
let expectation = "accept";
let compatibilityCase = "canonical";
if (relativePath.startsWith("fixtures/replay/golden/")) {
compatibilityCase = "deterministic-replay-oracle";
} else if (relativePath.startsWith("fixtures/replay/")) {
if (expectedRejectedFixtures.has(relativePath)) {
expectation = "reject";
compatibilityCase = "unknown-required-version";
try {
assertReplayFixtureCompatibility(value);
throw new Error(`${relativePath} did not fail closed`);
} catch (error) {
if (
!String(error.message).startsWith("unsupported_required_version:")
)
throw error;
}
assertSchemaInstance(validators.fixture, value, relativePath, false);
} else {
assertReplayFixtureCompatibility(value);
assertSchemaInstance(validators.fixture, value, relativePath);
if (relativePath.endsWith("unknown-optional-fields.json")) {
compatibilityCase = "additive-optional-fields";
}
}
} else if (relativePath.startsWith("fixtures/questions/")) {
assertSchemaInstance(
validators.questionAdapterFixture,
value,
relativePath,
);
assertQuestionAdapterFixture(value);
if (relativePath === "fixtures/questions/codex.json") {
assertCodexQuestionFixture(value);
} else if (relativePath === "fixtures/questions/acpx.json") {
assertAcpxQuestionFixture(value);
}
compatibilityCase = `${value.adapter}-structured-input`;
} else if (relativePath === "fixtures/conformance-minimal-run.json") {
assertSchemaInstance(validators.conformanceFixture, value, relativePath);
compatibilityCase = "cross-language-input";
} else if (relativePath === "fixtures/conformance-expected-output.json") {
assertSchemaInstance(validators.conformanceOutput, value, relativePath);
compatibilityCase = "cross-language-output";
}
fixtures.push({
path: relativePath,
sha256: sha256(source),
expectation,
compatibilityCase,
});
}
assertConformanceFixturePair(
fixtureValues.get("fixtures/conformance-minimal-run.json"),
fixtureValues.get("fixtures/conformance-expected-output.json"),
);
return {
schema: "paperclip.prp.contract_manifest.v1",
protocolVersion: SUPPORTED_PROTOCOL_VERSION,
fixtureVersion: SUPPORTED_FIXTURE_VERSION,
generatedFrom: ["protocol/schemas", "protocol/fixtures"],
schemas: schemas.map((record) => ({
path: portableRelative(protocolRoot, record.path),
id: record.value.$id,
sha256: sha256(record.source),
})),
fixtures,
};
}
async function main() {
const encoded = `${JSON.stringify(await buildProtocolManifest(), null, 2)}\n`;
if (process.argv.includes("--check")) {
const current = await readFile(outputPath, "utf8").catch(() => "");
if (current !== encoded) {
process.stderr.write(
"The generated PRP contract manifest is stale. Run pnpm generate:protocol-manifest.\n",
);
process.exitCode = 1;
} else {
process.stdout.write(
"The generated PRP contract manifest matches its sources.\n",
);
}
} else {
await writeFile(outputPath, encoded);
process.stdout.write(`Wrote ${outputPath}\n`);
}
}
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url))
await main();