mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native Runner starts providers and records their run outcomes. > - Provider failures can contain useful details below a generic protocol error. > - Those details were lost at the sidecar, command receipt, and run projection boundaries. > - A blocked task therefore showed a generic failure even when the provider gave a clear reason. > - This pull request carries a bounded, sanitized provider failure through those boundaries. > - Operators can read the actual cause on the task and run pages. Existing recovery policy still controls retries. ## Linked Issues or Issue Description Refs #17543 for the related closed JSON-RPC classification work. Refs #17272 for provider recovery in the legacy OpenCode adapter. This change adds display evidence for native Runner providers and keeps recovery policy separate. **What happened?** A native Claude session returned an HTTP 429 message below an ACP `Internal error`. The task and run showed only a generic startup failure. Operators had to inspect the provider response to learn that the account was rate limited. **Expected behavior** The task and run show the sanitized provider message, provider name, startup or turn phase, and available HTTP status. Unknown categories keep the message. Display evidence does not grant a retry or replace a recovery decision. **Steps to reproduce** 1. Start a native Claude run whose account is rate limited during model selection, or return a structured provider failure from a Codex or OpenCode turn. 2. Let the run fail and open its task page. 3. Before this change, the page shows a generic failure. With this change, the page shows the provider explanation. The run page shows the same explanation after reload. **Paperclip version or commit** Source checkout based on `f8a888a072`. ## What Changed - Add one JSON Schema for `ProviderFailure` and generate matching Runner and shared TypeScript readers. Keep the Runner package independent of the app workspace at runtime. - Normalize known ACPX, Codex, and OpenCode error fields. Preserve unknown messages. Redact known credential values and credential patterns before applying the 4,000-character limit. Do not copy response bodies, headers, stacks, or prompts. - Carry startup failures through the sidecar, Rust Runner, durable command receipts, and private transport error details. Keep provider text out of generic transport error strings and Sentry failure messages. - Keep turn failures in authenticated run events. Recover the same evidence from persisted events during server finalization. Store the cause in the company-scoped run result. - Show one shared notice on failed tasks and run details. Keep existing retry permissions, scheduled retry state, and resolved-task behavior. - Reapply registered run-secret and current-user redaction to task display data. Batch secret lookups for only the visible provider causes. - Document the contract and update the capability maps. Add regressions for nested Claude 429 errors, OpenAI quota and rate limits, OpenCode errors, redaction, durable receipt replay, restart finalization, and UI rendering. ## Verification - `pnpm -r typecheck` passed. `pnpm build` passed. All GitHub Actions build, test, package, and validation jobs passed on `d49c79ab4a`. The security checks passed. Greptile gave this head 5/5 with no open review threads. Paperclip Review remains pending. Its review worker exhausted six workspace recovery attempts with export_interrupted. A fresh review request was accepted and is waiting for that saved workspace. The ordinary Retry action rejected reuse of the stopped request. This is an external review-worker blocker; no check was overridden. The duplicate local `pnpm test:run` was stopped after the CI test shards passed. - Focused server settlement, provider evidence, and restart tests: 93 passed. Runner admission and normalization tests: 103 passed. Focused Runner wire and terminal tests: 29 passed. The real Rust/sidecar command receipt replay test passed. - The new projection test failed before implementation because the task had no provider cause. The startup producer regression also failed before extraction moved ahead of the generic error-code replacement. Both pass with this change. - Live check: a rebuilt local sidecar received an actual Claude HTTP 429 during startup. The new cause kept the full provider message. A synthetic task in an isolated local app then reached the same provider error through the native Runner path. The dedicated run result, task API, task banner, and run detail all showed the same message after reload. The later reporting fix keeps run.error generic; a regression proves the display cause still survives restart and stays out of Sentry capture. - The first isolated app attempt failed earlier in `admission_credential`. An ordinary manual Retry reached the provider and produced the 429 above. This PR does not claim to fix that separate admission failure. An initial parallel local database test setup also failed; the serial restart suite passed. - Security review found that copying display text into run.error could forward it to Sentry. A regression failed with that behavior. The fix keeps the cause in its display field. All 38 settlement/restart tests and the server typecheck pass. - All 29 protocol contract tests pass, including bounds and extra-field rejection. An earlier full Rust core run passed. The later full run hit one 5-second receipt-limit timing assertion; its isolated rerun passed. The full Linux and Windows Rust CI jobs passed on this head. - Review regressions pass for explicit Codex driver identity, native Rust startup and resume receipt propagation, oversized OpenCode/ACPX errors, task read-time registered-secret redaction, and the standalone source closure. The focused Runner run passed 103 tests; projection/restart tests passed 16. - Read-time redaction edge cases passed: a missing decryption key omits only the unsafe optional explanation; expanded redacted messages remain visible within the Unicode character limit. All 14 task projection tests and server typecheck pass. - The package Build check found an old Pi assertion against Error.message. The probe now checks the exact closed classification in the authenticated receipt and requires the new provider cause. Both polling transport cases pass. A local credential-free Pi probe stopped at admission_installation because this checkout lacks the Pi distribution; the installed Linux package probe passed in CI on this head. - UI coverage includes startup and turn text, unknown categories, literal rendering of provider text, read-only retry behavior, and resolved-task suppression. Token gates and map checks passed. - The live check covers Claude startup on macOS. Codex, OpenCode, terminal failures, and restart paths have deterministic regression coverage. The change is not deployed. ## Risks - Provider messages are untrusted text. The boundary selects specific fields, redacts credential patterns and known environment secret values, bounds the message, and renders plain text. New provider-specific secret formats may need added redaction patterns. - Optional fields preserve compatibility with older producers and stored runs. Old runs without the field keep their current fallback text. No database migration is required. - The cause stays in company-scoped run data. This change does not send provider text to first-party Telemetry or add it to OpenTelemetry span attributes. - Normalized categories and retryability are display evidence. Existing recovery classifiers, retry limits, ownership checks, and approval gates keep their authority. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, repository tools, code execution, and browser verification. The host does not expose the exact serving variant or context window size. ## 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 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 - [ ] 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>
670 lines
26 KiB
JavaScript
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, 30);
|
|
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'/,
|
|
);
|
|
});
|