mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 16:35:27 +02:00
## 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
139 lines
4.8 KiB
JavaScript
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();
|