mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 21:03:51 +02:00
## 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.   ## 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>
649 lines
24 KiB
JavaScript
649 lines
24 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, 28);
|
|
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("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 Dot has 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);
|
|
});
|
|
|
|
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'/,
|
|
);
|
|
});
|