@openwop/openwop-conformance 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +241 -0
- package/api/asyncapi.yaml +481 -0
- package/api/openapi.yaml +830 -0
- package/api/redocly.yaml +8 -0
- package/coverage.md +80 -0
- package/dist/cli.js +161 -0
- package/fixtures/conformance-a2a-task-roundtrip.json +27 -0
- package/fixtures/conformance-agent-identity.json +27 -0
- package/fixtures/conformance-agent-low-confidence.json +29 -0
- package/fixtures/conformance-agent-memory-cross-tenant.json +28 -0
- package/fixtures/conformance-agent-memory-redaction.json +32 -0
- package/fixtures/conformance-agent-memory-roundtrip.json +32 -0
- package/fixtures/conformance-agent-memory-ttl.json +31 -0
- package/fixtures/conformance-agent-pack-export.json +26 -0
- package/fixtures/conformance-agent-pack-install.json +26 -0
- package/fixtures/conformance-agent-pack-provenance.json +31 -0
- package/fixtures/conformance-agent-reasoning.json +29 -0
- package/fixtures/conformance-approval.json +27 -0
- package/fixtures/conformance-cancellable.json +33 -0
- package/fixtures/conformance-cap-breach.json +27 -0
- package/fixtures/conformance-capability-missing.json +23 -0
- package/fixtures/conformance-channel-ttl.json +60 -0
- package/fixtures/conformance-clarification.json +30 -0
- package/fixtures/conformance-conversation-capability-negotiation.json +23 -0
- package/fixtures/conformance-conversation-lifecycle.json +32 -0
- package/fixtures/conformance-conversation-replay.json +33 -0
- package/fixtures/conformance-conversation-vs-clarification.json +26 -0
- package/fixtures/conformance-delay.json +33 -0
- package/fixtures/conformance-dispatch-loop.json +38 -0
- package/fixtures/conformance-failure.json +23 -0
- package/fixtures/conformance-idempotent.json +30 -0
- package/fixtures/conformance-identity.json +32 -0
- package/fixtures/conformance-interrupt-auth-required.json +28 -0
- package/fixtures/conformance-interrupt-external-event.json +33 -0
- package/fixtures/conformance-interrupt-parent-child-cancel-child.json +27 -0
- package/fixtures/conformance-interrupt-parent-child-cancel.json +26 -0
- package/fixtures/conformance-interrupt-quorum.json +30 -0
- package/fixtures/conformance-mcp-tool-roundtrip.json +32 -0
- package/fixtures/conformance-message-reducer.json +31 -0
- package/fixtures/conformance-multi-node.json +21 -0
- package/fixtures/conformance-noop.json +23 -0
- package/fixtures/conformance-orchestrator-dispatch.json +47 -0
- package/fixtures/conformance-orchestrator-low-confidence.json +41 -0
- package/fixtures/conformance-orchestrator-terminate.json +44 -0
- package/fixtures/conformance-stream-text.json +26 -0
- package/fixtures/conformance-subworkflow-child.json +21 -0
- package/fixtures/conformance-subworkflow-parent.json +49 -0
- package/fixtures/conformance-version-fold.json +23 -0
- package/fixtures/conformance-wasm-pack-roundtrip.json +25 -0
- package/fixtures/pack-manifests/pack-private-example.json +26 -0
- package/fixtures.md +404 -0
- package/package.json +48 -0
- package/schemas/README.md +75 -0
- package/schemas/agent-manifest.schema.json +107 -0
- package/schemas/agent-ref.schema.json +53 -0
- package/schemas/capabilities.schema.json +287 -0
- package/schemas/channel-written-payload.schema.json +55 -0
- package/schemas/conversation-event.schema.json +120 -0
- package/schemas/conversation-turn.schema.json +72 -0
- package/schemas/debug-bundle.schema.json +196 -0
- package/schemas/dispatch-config.schema.json +46 -0
- package/schemas/error-envelope.schema.json +25 -0
- package/schemas/memory-entry.schema.json +36 -0
- package/schemas/memory-list-options.schema.json +21 -0
- package/schemas/node-pack-manifest.schema.json +235 -0
- package/schemas/orchestrator-decision.schema.json +60 -0
- package/schemas/run-event-payloads.schema.json +663 -0
- package/schemas/run-event.schema.json +116 -0
- package/schemas/run-options.schema.json +81 -0
- package/schemas/run-orchestrator-decided-event.schema.json +20 -0
- package/schemas/run-snapshot.schema.json +121 -0
- package/schemas/suspend-request.schema.json +182 -0
- package/schemas/workflow-definition.schema.json +430 -0
- package/src/cli.ts +187 -0
- package/src/lib/a2a-fake-peer.ts +233 -0
- package/src/lib/canaries.ts +186 -0
- package/src/lib/driver.ts +96 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/fixtures.ts +93 -0
- package/src/lib/mcp-fake-server.ts +185 -0
- package/src/lib/multi-agent-capabilities.ts +155 -0
- package/src/lib/multiProcess.ts +141 -0
- package/src/lib/otel-collector.ts +312 -0
- package/src/lib/paths.ts +198 -0
- package/src/lib/polling.ts +81 -0
- package/src/lib/profiles.ts +258 -0
- package/src/lib/sse.ts +172 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +149 -0
- package/src/scenarios/agentConfidenceEscalation.test.ts +61 -0
- package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +54 -0
- package/src/scenarios/agentMemoryRedactionContract.test.ts +46 -0
- package/src/scenarios/agentMemoryRoundTrip.test.ts +52 -0
- package/src/scenarios/agentMemoryTtlExpiry.test.ts +47 -0
- package/src/scenarios/agentMessageReducer.test.ts +57 -0
- package/src/scenarios/agentMetadata.test.ts +56 -0
- package/src/scenarios/agentPackExport.test.ts +45 -0
- package/src/scenarios/agentPackInstall.test.ts +50 -0
- package/src/scenarios/agentPackProvenance.test.ts +53 -0
- package/src/scenarios/agentReasoningEvents.test.ts +72 -0
- package/src/scenarios/append-ordering.test.ts +91 -0
- package/src/scenarios/approval-payload.test.ts +120 -0
- package/src/scenarios/audit-log-integrity.test.ts +106 -0
- package/src/scenarios/auth.test.ts +55 -0
- package/src/scenarios/byok-roundtrip.test.ts +166 -0
- package/src/scenarios/cancellation.test.ts +68 -0
- package/src/scenarios/cap-breach.test.ts +149 -0
- package/src/scenarios/channel-ttl.test.ts +70 -0
- package/src/scenarios/configurable-schema.test.ts +76 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +39 -0
- package/src/scenarios/conversationLifecycle.test.ts +64 -0
- package/src/scenarios/conversationReplayDeterminism.test.ts +52 -0
- package/src/scenarios/conversationVsLegacySuspend.test.ts +46 -0
- package/src/scenarios/cost-attribution.test.ts +207 -0
- package/src/scenarios/debugBundle.test.ts +222 -0
- package/src/scenarios/discovery.test.ts +147 -0
- package/src/scenarios/dispatchLoop.test.ts +52 -0
- package/src/scenarios/errors.test.ts +144 -0
- package/src/scenarios/eventOrdering.test.ts +144 -0
- package/src/scenarios/failure-path.test.ts +46 -0
- package/src/scenarios/fixtures-gating.test.ts +137 -0
- package/src/scenarios/fixtures-valid.test.ts +140 -0
- package/src/scenarios/highConcurrency.test.ts +263 -0
- package/src/scenarios/idempotency.test.ts +83 -0
- package/src/scenarios/idempotencyRetry.test.ts +130 -0
- package/src/scenarios/identity-passthrough.test.ts +54 -0
- package/src/scenarios/interrupt-approval.test.ts +97 -0
- package/src/scenarios/interrupt-auth-required-resume.test.ts +88 -0
- package/src/scenarios/interrupt-clarification.test.ts +45 -0
- package/src/scenarios/interrupt-external-event-correlation.test.ts +113 -0
- package/src/scenarios/interrupt-parent-child-cascade.test.ts +102 -0
- package/src/scenarios/interrupt-quorum-resolution.test.ts +97 -0
- package/src/scenarios/interruptRace.test.ts +176 -0
- package/src/scenarios/maliciousManifest.test.ts +154 -0
- package/src/scenarios/mcp-discoverability.test.ts +129 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +149 -0
- package/src/scenarios/multi-node-ordering.test.ts +60 -0
- package/src/scenarios/multi-region-idempotency.test.ts +52 -0
- package/src/scenarios/orchestratorConservativePath.test.ts +63 -0
- package/src/scenarios/orchestratorDispatch.test.ts +66 -0
- package/src/scenarios/orchestratorTermination.test.ts +54 -0
- package/src/scenarios/otel-emission.test.ts +113 -0
- package/src/scenarios/otel-trace-propagation.test.ts +90 -0
- package/src/scenarios/pack-registry-publish.test.ts +93 -0
- package/src/scenarios/pack-registry.test.ts +328 -0
- package/src/scenarios/pause-resume.test.ts +109 -0
- package/src/scenarios/policies.test.ts +162 -0
- package/src/scenarios/profileDerivation.test.ts +335 -0
- package/src/scenarios/providerPolicyEnforcement.test.ts +132 -0
- package/src/scenarios/rate-limit-envelope.test.ts +97 -0
- package/src/scenarios/redaction.test.ts +254 -0
- package/src/scenarios/redactionAdversarial.test.ts +162 -0
- package/src/scenarios/replay-fork-arbitrary.test.ts +347 -0
- package/src/scenarios/replay-fork.test.ts +216 -0
- package/src/scenarios/replayDeterminism.test.ts +171 -0
- package/src/scenarios/route-coverage.test.ts +129 -0
- package/src/scenarios/runs-lifecycle.test.ts +65 -0
- package/src/scenarios/runtime-capabilities.test.ts +118 -0
- package/src/scenarios/spec-corpus-validity.test.ts +1257 -0
- package/src/scenarios/staleClaim.test.ts +223 -0
- package/src/scenarios/stream-modes-buffer.test.ts +148 -0
- package/src/scenarios/stream-modes-mixed.test.ts +149 -0
- package/src/scenarios/stream-modes.test.ts +139 -0
- package/src/scenarios/streamReconnect.test.ts +162 -0
- package/src/scenarios/subworkflow.test.ts +126 -0
- package/src/scenarios/version-negotiation.test.ts +157 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +47 -0
- package/src/scenarios/wasm-pack-invoke-completed.test.ts +69 -0
- package/src/scenarios/wasm-pack-invoke-suspended.test.ts +74 -0
- package/src/scenarios/wasm-pack-load.test.ts +75 -0
- package/src/scenarios/wasm-pack-memory-cap.test.ts +43 -0
- package/src/scenarios/wasm-pack-replay-determinism.test.ts +61 -0
- package/src/scenarios/webhook-sig-algorithm.test.ts +61 -0
- package/src/setup.ts +173 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track 13: operator-driven pause/resume (rest-endpoints.md v1.1).
|
|
3
|
+
*
|
|
4
|
+
* Exercises the `POST /v1/runs/{runId}:pause` and `:resume` endpoints
|
|
5
|
+
* against a long-running fixture (`conformance-delay` or `conformance-cancellable`).
|
|
6
|
+
*
|
|
7
|
+
* Verifies:
|
|
8
|
+
* 1. :pause on a running run transitions to `paused` and emits `run.paused`.
|
|
9
|
+
* 2. :resume on a paused run transitions to `running` and emits `run.resumed`.
|
|
10
|
+
* 3. :pause on a terminal run returns 409 with details.runStatus.
|
|
11
|
+
* 4. :resume on a non-paused run returns 409 with details.runStatus.
|
|
12
|
+
*
|
|
13
|
+
* Capability gating: skips when the host doesn't advertise
|
|
14
|
+
* `capabilities.runs.pauseResume.supported: true` (when present) AND
|
|
15
|
+
* skips when no long-running fixture is advertised.
|
|
16
|
+
*
|
|
17
|
+
* @see spec/v1/rest-endpoints.md §pause/resume
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from 'vitest';
|
|
21
|
+
import { driver } from '../lib/driver.js';
|
|
22
|
+
import { pollUntilStatus, pollUntilTerminal } from '../lib/polling.js';
|
|
23
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
24
|
+
|
|
25
|
+
const FIXTURE =
|
|
26
|
+
(isFixtureAdvertised('conformance-cancellable') && 'conformance-cancellable') ||
|
|
27
|
+
(isFixtureAdvertised('conformance-delay') && 'conformance-delay') ||
|
|
28
|
+
null;
|
|
29
|
+
|
|
30
|
+
const SKIP = !FIXTURE;
|
|
31
|
+
|
|
32
|
+
describe.skipIf(SKIP)('pause/resume: running → paused → running → terminal', () => {
|
|
33
|
+
it('pause transitions to paused; resume returns the run to running', async () => {
|
|
34
|
+
const create = await driver.post('/v1/runs', {
|
|
35
|
+
workflowId: FIXTURE!,
|
|
36
|
+
inputs: { delaySeconds: 30 },
|
|
37
|
+
});
|
|
38
|
+
expect(create.status).toBe(201);
|
|
39
|
+
const runId = (create.json as { runId: string }).runId;
|
|
40
|
+
|
|
41
|
+
await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
|
|
42
|
+
|
|
43
|
+
const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {
|
|
44
|
+
reason: 'conformance-test',
|
|
45
|
+
drainPolicy: 'drain-current-node',
|
|
46
|
+
});
|
|
47
|
+
if (pause.status === 404) {
|
|
48
|
+
// Pause endpoint not yet implemented by the host — surface the skip honestly.
|
|
49
|
+
// eslint-disable-next-line no-console
|
|
50
|
+
console.warn(
|
|
51
|
+
'[pause-resume] host returned 404 for :pause — endpoint not implemented; skipping rest',
|
|
52
|
+
);
|
|
53
|
+
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
54
|
+
reason: 'conformance-cleanup',
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
expect(pause.status, driver.describe(
|
|
59
|
+
'rest-endpoints.md POST /v1/runs/{runId}:pause',
|
|
60
|
+
':pause MUST return 202 on a pausable run',
|
|
61
|
+
)).toBe(202);
|
|
62
|
+
|
|
63
|
+
await pollUntilStatus(runId, 'paused', { timeoutMs: 10_000 });
|
|
64
|
+
|
|
65
|
+
const resume = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:resume`, {
|
|
66
|
+
reason: 'conformance-test',
|
|
67
|
+
});
|
|
68
|
+
expect(resume.status, driver.describe(
|
|
69
|
+
'rest-endpoints.md POST /v1/runs/{runId}:resume',
|
|
70
|
+
':resume MUST return 202 on a paused run',
|
|
71
|
+
)).toBe(202);
|
|
72
|
+
|
|
73
|
+
const terminal = await pollUntilTerminal(runId, { timeoutMs: 60_000 });
|
|
74
|
+
expect(['completed', 'cancelled']).toContain(terminal.status);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe.skipIf(SKIP)('pause/resume: :resume on a non-paused run returns 409', () => {
|
|
79
|
+
it('resuming a running (not paused) run returns 409 with details.runStatus', async () => {
|
|
80
|
+
const create = await driver.post('/v1/runs', {
|
|
81
|
+
workflowId: FIXTURE!,
|
|
82
|
+
inputs: { delaySeconds: 30 },
|
|
83
|
+
});
|
|
84
|
+
expect(create.status).toBe(201);
|
|
85
|
+
const runId = (create.json as { runId: string }).runId;
|
|
86
|
+
|
|
87
|
+
await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
|
|
88
|
+
|
|
89
|
+
const resume = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:resume`, {});
|
|
90
|
+
if (resume.status === 404) {
|
|
91
|
+
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
92
|
+
reason: 'conformance-cleanup',
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
expect(resume.status, driver.describe(
|
|
97
|
+
'rest-endpoints.md POST /v1/runs/{runId}:resume',
|
|
98
|
+
':resume on a non-paused run MUST return 409',
|
|
99
|
+
)).toBe(409);
|
|
100
|
+
|
|
101
|
+
const body = resume.json as { error?: string; details?: { runStatus?: string } };
|
|
102
|
+
expect(body.error).toBe('conflict');
|
|
103
|
+
expect(typeof body.details?.runStatus).toBe('string');
|
|
104
|
+
|
|
105
|
+
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
106
|
+
reason: 'conformance-cleanup',
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-policy scenarios — `capabilities.md` §"`aiProviders.policies`".
|
|
3
|
+
*
|
|
4
|
+
* Vendor-neutral discovery-shape contracts for the four-mode provider-
|
|
5
|
+
* policy taxonomy (`disabled` / `optional` / `required` / `restricted`)
|
|
6
|
+
* that hosts MAY advertise on `/.well-known/openwop`.
|
|
7
|
+
*
|
|
8
|
+
* Why these scenarios are discovery-shape only:
|
|
9
|
+
*
|
|
10
|
+
* The four modes describe HOST-SIDE enforcement decisions. A round-
|
|
11
|
+
* trip enforcement scenario (e.g., "configure a `disabled` policy for
|
|
12
|
+
* anthropic, then assert that an anthropic run is rejected with
|
|
13
|
+
* `provider_policy_denied`") requires a configured policy document
|
|
14
|
+
* AND a real provider call AND admin write access — far outside the
|
|
15
|
+
* black-box contract surface this suite asserts. Hosts MUST run their
|
|
16
|
+
* own integration tests for enforcement; the in-tree reference impl
|
|
17
|
+
* carries 31 such tests in `openwop-provider-policy-modes`.
|
|
18
|
+
*
|
|
19
|
+
* What IS testable cross-implementation: the wire shape of the
|
|
20
|
+
* capability advertisement and the documented denial-error contract.
|
|
21
|
+
*
|
|
22
|
+
* Scenario gating:
|
|
23
|
+
*
|
|
24
|
+
* - **Discovery shape contract** runs against every host. It verifies
|
|
25
|
+
* `aiProviders.policies` is well-formed when present and absent-
|
|
26
|
+
* friendly when omitted (hosts MAY skip the field entirely).
|
|
27
|
+
*
|
|
28
|
+
* - **Mode-enum contract** runs against hosts that advertise
|
|
29
|
+
* `policies.modes`. Verifies every advertised mode is one of the
|
|
30
|
+
* four canonical values per the spec section.
|
|
31
|
+
*
|
|
32
|
+
* @see capabilities.md §"`aiProviders.policies`"
|
|
33
|
+
* @see schemas/capabilities.schema.json — additive `policies` subtree
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { describe, it, expect } from 'vitest';
|
|
37
|
+
import { driver } from '../lib/driver.js';
|
|
38
|
+
|
|
39
|
+
const CANONICAL_MODES = ['disabled', 'optional', 'required', 'restricted'] as const;
|
|
40
|
+
|
|
41
|
+
interface PoliciesShape {
|
|
42
|
+
modes?: unknown;
|
|
43
|
+
scopes?: unknown;
|
|
44
|
+
errorCode?: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface AiProvidersShape {
|
|
48
|
+
supported?: unknown;
|
|
49
|
+
byok?: unknown;
|
|
50
|
+
policies?: PoliciesShape;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function fetchAiProviders(): Promise<AiProvidersShape | undefined> {
|
|
54
|
+
const res = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
55
|
+
expect(res.status).toBe(200);
|
|
56
|
+
const body = res.json as { aiProviders?: AiProvidersShape } | undefined;
|
|
57
|
+
return body?.aiProviders;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('policies: /.well-known/openwop aiProviders.policies shape contract', () => {
|
|
61
|
+
it('aiProviders.policies is well-formed when present (or absent — both spec-allowed)', async () => {
|
|
62
|
+
const ap = await fetchAiProviders();
|
|
63
|
+
if (ap === undefined) return; // Optional v1 field — hosts MAY omit aiProviders entirely.
|
|
64
|
+
|
|
65
|
+
const policies = ap.policies;
|
|
66
|
+
if (policies === undefined) {
|
|
67
|
+
// Spec-allowed: omitting `policies` means the host implements no
|
|
68
|
+
// enforcement. Clients see only `optional` semantics. Nothing
|
|
69
|
+
// further to assert.
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
expect(typeof policies, driver.describe(
|
|
74
|
+
'capabilities.md §"`aiProviders.policies`"',
|
|
75
|
+
'aiProviders.policies MUST be an object when present',
|
|
76
|
+
)).toBe('object');
|
|
77
|
+
expect(policies, driver.describe(
|
|
78
|
+
'capabilities.md §"`aiProviders.policies`"',
|
|
79
|
+
'aiProviders.policies MUST NOT be null when present',
|
|
80
|
+
)).not.toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('policies.modes — every advertised mode is one of the four canonical values', async () => {
|
|
84
|
+
const ap = await fetchAiProviders();
|
|
85
|
+
const modes = ap?.policies?.modes;
|
|
86
|
+
if (modes === undefined) return;
|
|
87
|
+
|
|
88
|
+
expect(Array.isArray(modes), driver.describe(
|
|
89
|
+
'capabilities.md §"`aiProviders.policies`"',
|
|
90
|
+
'policies.modes MUST be a string[] when present',
|
|
91
|
+
)).toBe(true);
|
|
92
|
+
|
|
93
|
+
const arr = modes as unknown[];
|
|
94
|
+
for (const mode of arr) {
|
|
95
|
+
expect(typeof mode, driver.describe(
|
|
96
|
+
'capabilities.md §"`aiProviders.policies`"',
|
|
97
|
+
'policies.modes entries MUST be strings',
|
|
98
|
+
)).toBe('string');
|
|
99
|
+
expect(CANONICAL_MODES, driver.describe(
|
|
100
|
+
'capabilities.md §"`aiProviders.policies`"',
|
|
101
|
+
`mode "${mode}" MUST be one of ${CANONICAL_MODES.join(', ')}`,
|
|
102
|
+
)).toContain(mode);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('policies.modes — no duplicate entries (uniqueItems contract)', async () => {
|
|
107
|
+
const ap = await fetchAiProviders();
|
|
108
|
+
const modes = ap?.policies?.modes;
|
|
109
|
+
if (!Array.isArray(modes)) return;
|
|
110
|
+
|
|
111
|
+
const arr = modes as string[];
|
|
112
|
+
const set = new Set(arr);
|
|
113
|
+
expect(set.size, driver.describe(
|
|
114
|
+
'capabilities.schema.json — policies.modes uniqueItems: true',
|
|
115
|
+
'policies.modes MUST NOT contain duplicate entries',
|
|
116
|
+
)).toBe(arr.length);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('policies.scopes — string[] of non-empty entries when present', async () => {
|
|
120
|
+
const ap = await fetchAiProviders();
|
|
121
|
+
const scopes = ap?.policies?.scopes;
|
|
122
|
+
if (scopes === undefined) return;
|
|
123
|
+
|
|
124
|
+
expect(Array.isArray(scopes), driver.describe(
|
|
125
|
+
'capabilities.md §"`aiProviders.policies`"',
|
|
126
|
+
'policies.scopes MUST be a string[] when present',
|
|
127
|
+
)).toBe(true);
|
|
128
|
+
|
|
129
|
+
const arr = scopes as unknown[];
|
|
130
|
+
for (const scope of arr) {
|
|
131
|
+
expect(typeof scope, driver.describe(
|
|
132
|
+
'capabilities.md §"`aiProviders.policies`"',
|
|
133
|
+
'policies.scopes entries MUST be strings',
|
|
134
|
+
)).toBe('string');
|
|
135
|
+
expect((scope as string).length, driver.describe(
|
|
136
|
+
'capabilities.md §"`aiProviders.policies`"',
|
|
137
|
+
'policies.scopes entries MUST be non-empty',
|
|
138
|
+
)).toBeGreaterThan(0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const set = new Set(arr as string[]);
|
|
142
|
+
expect(set.size, driver.describe(
|
|
143
|
+
'capabilities.schema.json — policies.scopes uniqueItems: true',
|
|
144
|
+
'policies.scopes MUST NOT contain duplicate entries',
|
|
145
|
+
)).toBe(arr.length);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('policies.errorCode — non-empty string when present (defaults to provider_policy_denied)', async () => {
|
|
149
|
+
const ap = await fetchAiProviders();
|
|
150
|
+
const errorCode = ap?.policies?.errorCode;
|
|
151
|
+
if (errorCode === undefined) return;
|
|
152
|
+
|
|
153
|
+
expect(typeof errorCode, driver.describe(
|
|
154
|
+
'capabilities.md §"`aiProviders.policies`"',
|
|
155
|
+
'policies.errorCode MUST be a string when present',
|
|
156
|
+
)).toBe('string');
|
|
157
|
+
expect((errorCode as string).length, driver.describe(
|
|
158
|
+
'capabilities.md §"`aiProviders.policies`"',
|
|
159
|
+
'policies.errorCode MUST be non-empty when present',
|
|
160
|
+
)).toBeGreaterThan(0);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile-derivation scenarios — verify that `deriveProfiles()`
|
|
3
|
+
* produces the correct profile set for representative discovery
|
|
4
|
+
* payloads.
|
|
5
|
+
*
|
|
6
|
+
* Server-free. Runs against fixture payloads, not a live host. The
|
|
7
|
+
* derivation MUST be deterministic and pure (per spec/v1/profiles.md
|
|
8
|
+
* §"Derivation"); these scenarios are the proof of that property.
|
|
9
|
+
*
|
|
10
|
+
* A separate runtime check would derive the profile set from the live
|
|
11
|
+
* `/.well-known/openwop` response; that's covered piecemeal by
|
|
12
|
+
* `discovery.test.ts` and the per-profile runtime suites
|
|
13
|
+
* (`stream-modes*.test.ts`, `pack-registry*.test.ts`, etc.).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest';
|
|
17
|
+
import {
|
|
18
|
+
deriveProfiles,
|
|
19
|
+
hasProfile,
|
|
20
|
+
isCore,
|
|
21
|
+
isInterrupts,
|
|
22
|
+
isSecrets,
|
|
23
|
+
isProviderPolicy,
|
|
24
|
+
isFixtures,
|
|
25
|
+
type DiscoveryPayload,
|
|
26
|
+
type ProfileName,
|
|
27
|
+
} from '../lib/profiles.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Minimum payload that satisfies `openwop-core`. Other fixtures extend this.
|
|
31
|
+
*/
|
|
32
|
+
const CORE_PAYLOAD: DiscoveryPayload = {
|
|
33
|
+
protocolVersion: '1.0',
|
|
34
|
+
supportedEnvelopes: ['prd.create'],
|
|
35
|
+
schemaVersions: { 'prd.create': 1 },
|
|
36
|
+
limits: {
|
|
37
|
+
clarificationRounds: 3,
|
|
38
|
+
schemaRounds: 2,
|
|
39
|
+
envelopesPerTurn: 5,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe('profiles: openwop-core predicate per spec/v1/profiles.md §`openwop-core`', () => {
|
|
44
|
+
it('accepts the minimum-conforming payload', () => {
|
|
45
|
+
expect(isCore(CORE_PAYLOAD)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects payload without protocolVersion', () => {
|
|
49
|
+
const { protocolVersion: _omit, ...rest } = CORE_PAYLOAD;
|
|
50
|
+
expect(isCore(rest as DiscoveryPayload)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects v2.x protocolVersion', () => {
|
|
54
|
+
expect(isCore({ ...CORE_PAYLOAD, protocolVersion: '2.0.0' })).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rejects negative limits (RFC 2119 MUST: non-negative integers)', () => {
|
|
58
|
+
expect(
|
|
59
|
+
isCore({
|
|
60
|
+
...CORE_PAYLOAD,
|
|
61
|
+
limits: { ...CORE_PAYLOAD.limits!, clarificationRounds: -1 },
|
|
62
|
+
}),
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('rejects fractional limits', () => {
|
|
67
|
+
expect(
|
|
68
|
+
isCore({
|
|
69
|
+
...CORE_PAYLOAD,
|
|
70
|
+
limits: { ...CORE_PAYLOAD.limits!, schemaRounds: 1.5 },
|
|
71
|
+
}),
|
|
72
|
+
).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('accepts empty supportedEnvelopes array (engine-only host)', () => {
|
|
76
|
+
expect(isCore({ ...CORE_PAYLOAD, supportedEnvelopes: [] })).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects non-array supportedEnvelopes', () => {
|
|
80
|
+
expect(
|
|
81
|
+
isCore({ ...CORE_PAYLOAD, supportedEnvelopes: 'prd.create' as unknown as string[] }),
|
|
82
|
+
).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('profiles: openwop-interrupts predicate per spec/v1/profiles.md §`openwop-interrupts`', () => {
|
|
87
|
+
it('passes when clarification.request is in supportedEnvelopes', () => {
|
|
88
|
+
expect(
|
|
89
|
+
isInterrupts({
|
|
90
|
+
...CORE_PAYLOAD,
|
|
91
|
+
supportedEnvelopes: ['prd.create', 'clarification.request'],
|
|
92
|
+
}),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('fails when clarification.request is absent (fire-and-forget host)', () => {
|
|
97
|
+
expect(isInterrupts(CORE_PAYLOAD)).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('implies openwop-core', () => {
|
|
101
|
+
const broken = {
|
|
102
|
+
...CORE_PAYLOAD,
|
|
103
|
+
protocolVersion: '2.0.0',
|
|
104
|
+
supportedEnvelopes: ['clarification.request'],
|
|
105
|
+
};
|
|
106
|
+
expect(isInterrupts(broken)).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('profiles: openwop-secrets predicate per spec/v1/profiles.md §`openwop-secrets`', () => {
|
|
111
|
+
it('passes when secrets.supported=true and scopes includes user', () => {
|
|
112
|
+
expect(
|
|
113
|
+
isSecrets({
|
|
114
|
+
...CORE_PAYLOAD,
|
|
115
|
+
secrets: { supported: true, scopes: ['user'] },
|
|
116
|
+
}),
|
|
117
|
+
).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('passes with multiple scopes', () => {
|
|
121
|
+
expect(
|
|
122
|
+
isSecrets({
|
|
123
|
+
...CORE_PAYLOAD,
|
|
124
|
+
secrets: { supported: true, scopes: ['user', 'tenant'] },
|
|
125
|
+
}),
|
|
126
|
+
).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('fails when scopes omits user', () => {
|
|
130
|
+
expect(
|
|
131
|
+
isSecrets({
|
|
132
|
+
...CORE_PAYLOAD,
|
|
133
|
+
secrets: { supported: true, scopes: ['tenant'] },
|
|
134
|
+
}),
|
|
135
|
+
).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('fails when secrets.supported=false', () => {
|
|
139
|
+
expect(
|
|
140
|
+
isSecrets({
|
|
141
|
+
...CORE_PAYLOAD,
|
|
142
|
+
secrets: { supported: false, scopes: ['user'] },
|
|
143
|
+
}),
|
|
144
|
+
).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('fails when secrets field is absent', () => {
|
|
148
|
+
expect(isSecrets(CORE_PAYLOAD)).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('profiles: openwop-provider-policy predicate per spec/v1/profiles.md §`openwop-provider-policy`', () => {
|
|
153
|
+
it('passes when policies.modes contains optional', () => {
|
|
154
|
+
expect(
|
|
155
|
+
isProviderPolicy({
|
|
156
|
+
...CORE_PAYLOAD,
|
|
157
|
+
aiProviders: {
|
|
158
|
+
supported: ['anthropic'],
|
|
159
|
+
policies: { modes: ['optional', 'required'] },
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('fails when policies.modes is empty (per spec: empty {} not a valid third state)', () => {
|
|
166
|
+
expect(
|
|
167
|
+
isProviderPolicy({
|
|
168
|
+
...CORE_PAYLOAD,
|
|
169
|
+
aiProviders: {
|
|
170
|
+
supported: ['anthropic'],
|
|
171
|
+
policies: { modes: [] },
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('fails when policies.modes omits optional (cannot satisfy default-no-restriction case)', () => {
|
|
178
|
+
expect(
|
|
179
|
+
isProviderPolicy({
|
|
180
|
+
...CORE_PAYLOAD,
|
|
181
|
+
aiProviders: {
|
|
182
|
+
supported: ['anthropic'],
|
|
183
|
+
policies: { modes: ['required'] },
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('fails when policies field is absent', () => {
|
|
190
|
+
expect(
|
|
191
|
+
isProviderPolicy({
|
|
192
|
+
...CORE_PAYLOAD,
|
|
193
|
+
aiProviders: { supported: ['anthropic'] },
|
|
194
|
+
}),
|
|
195
|
+
).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('profiles: openwop-fixtures predicate per spec/v1/profiles.md §`openwop-fixtures` (RFC 0003)', () => {
|
|
200
|
+
it('rejects payloads missing `fixtures`', () => {
|
|
201
|
+
expect(isFixtures(CORE_PAYLOAD)).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('rejects empty `fixtures` array', () => {
|
|
205
|
+
expect(isFixtures({ ...CORE_PAYLOAD, fixtures: [] })).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('accepts non-empty string array', () => {
|
|
209
|
+
expect(
|
|
210
|
+
isFixtures({ ...CORE_PAYLOAD, fixtures: ['conformance-noop'] }),
|
|
211
|
+
).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('accepts vendor-prefixed fixture ids', () => {
|
|
215
|
+
expect(
|
|
216
|
+
isFixtures({
|
|
217
|
+
...CORE_PAYLOAD,
|
|
218
|
+
fixtures: ['conformance-noop', 'openwop.smoke.byok'],
|
|
219
|
+
}),
|
|
220
|
+
).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('rejects array with empty-string entries', () => {
|
|
224
|
+
expect(isFixtures({ ...CORE_PAYLOAD, fixtures: ['', 'conformance-noop'] })).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('rejects non-array `fixtures`', () => {
|
|
228
|
+
expect(isFixtures({ ...CORE_PAYLOAD, fixtures: 'conformance-noop' })).toBe(false);
|
|
229
|
+
expect(
|
|
230
|
+
isFixtures({ ...CORE_PAYLOAD, fixtures: { 'conformance-noop': true } }),
|
|
231
|
+
).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('rejects array with non-string entries', () => {
|
|
235
|
+
expect(
|
|
236
|
+
isFixtures({ ...CORE_PAYLOAD, fixtures: ['conformance-noop', 42] as unknown[] }),
|
|
237
|
+
).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('rejects when openwop-core fails (predicate is layered)', () => {
|
|
241
|
+
expect(isFixtures({ fixtures: ['conformance-noop'] })).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('profiles: deriveProfiles produces the full set', () => {
|
|
246
|
+
it('returns openwop-core + stream-* + node-packs for the minimum payload', () => {
|
|
247
|
+
// The minimum payload satisfies the structural profiles automatically:
|
|
248
|
+
// openwop-core (predicate trivially), openwop-stream-sse + openwop-stream-poll
|
|
249
|
+
// (no supportedTransports set => permitted), openwop-node-packs (discovery-
|
|
250
|
+
// only predicate is openwop-core).
|
|
251
|
+
const result = deriveProfiles(CORE_PAYLOAD);
|
|
252
|
+
expect(result).toContain('openwop-core');
|
|
253
|
+
expect(result).toContain('openwop-stream-sse');
|
|
254
|
+
expect(result).toContain('openwop-stream-poll');
|
|
255
|
+
expect(result).toContain('openwop-node-packs');
|
|
256
|
+
expect(result).not.toContain('openwop-interrupts');
|
|
257
|
+
expect(result).not.toContain('openwop-secrets');
|
|
258
|
+
expect(result).not.toContain('openwop-provider-policy');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('returns the full set for a richly-advertised host', () => {
|
|
262
|
+
const rich: DiscoveryPayload = {
|
|
263
|
+
...CORE_PAYLOAD,
|
|
264
|
+
supportedEnvelopes: ['prd.create', 'clarification.request'],
|
|
265
|
+
supportedTransports: ['rest', 'mcp'],
|
|
266
|
+
secrets: { supported: true, scopes: ['user', 'tenant'] },
|
|
267
|
+
aiProviders: {
|
|
268
|
+
supported: ['anthropic', 'openai'],
|
|
269
|
+
policies: { modes: ['optional', 'required', 'restricted'] },
|
|
270
|
+
},
|
|
271
|
+
fixtures: ['conformance-noop'],
|
|
272
|
+
};
|
|
273
|
+
const result = deriveProfiles(rich);
|
|
274
|
+
const expected: ProfileName[] = [
|
|
275
|
+
'openwop-core',
|
|
276
|
+
'openwop-interrupts',
|
|
277
|
+
'openwop-stream-sse',
|
|
278
|
+
'openwop-stream-poll',
|
|
279
|
+
'openwop-secrets',
|
|
280
|
+
'openwop-provider-policy',
|
|
281
|
+
'openwop-node-packs',
|
|
282
|
+
'openwop-fixtures',
|
|
283
|
+
];
|
|
284
|
+
expect(result).toEqual(expected);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('returns stable order matching PROFILE_NAMES', () => {
|
|
288
|
+
const rich: DiscoveryPayload = {
|
|
289
|
+
...CORE_PAYLOAD,
|
|
290
|
+
supportedEnvelopes: ['clarification.request'],
|
|
291
|
+
secrets: { supported: true, scopes: ['user'] },
|
|
292
|
+
};
|
|
293
|
+
const first = deriveProfiles(rich);
|
|
294
|
+
const second = deriveProfiles(rich);
|
|
295
|
+
expect(first).toEqual(second);
|
|
296
|
+
// Specifically: openwop-interrupts before openwop-secrets even though openwop-secrets
|
|
297
|
+
// was added to the payload "second."
|
|
298
|
+
expect(first.indexOf('openwop-interrupts')).toBeLessThan(first.indexOf('openwop-secrets'));
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('returns empty for a non-conforming payload', () => {
|
|
302
|
+
const broken: DiscoveryPayload = { protocolVersion: '0.9.0' };
|
|
303
|
+
expect(deriveProfiles(broken)).toEqual([]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('is deterministic across calls (same input → same output)', () => {
|
|
307
|
+
const calls = Array.from({ length: 10 }, () => deriveProfiles(CORE_PAYLOAD));
|
|
308
|
+
for (let i = 1; i < calls.length; i++) {
|
|
309
|
+
expect(calls[i]).toEqual(calls[0]);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe('profiles: hasProfile is consistent with deriveProfiles', () => {
|
|
315
|
+
it('membership matches the derived set for every profile', () => {
|
|
316
|
+
const rich: DiscoveryPayload = {
|
|
317
|
+
...CORE_PAYLOAD,
|
|
318
|
+
supportedEnvelopes: ['clarification.request'],
|
|
319
|
+
secrets: { supported: true, scopes: ['user'] },
|
|
320
|
+
};
|
|
321
|
+
const derived = new Set(deriveProfiles(rich));
|
|
322
|
+
for (const p of [
|
|
323
|
+
'openwop-core',
|
|
324
|
+
'openwop-interrupts',
|
|
325
|
+
'openwop-stream-sse',
|
|
326
|
+
'openwop-stream-poll',
|
|
327
|
+
'openwop-secrets',
|
|
328
|
+
'openwop-provider-policy',
|
|
329
|
+
'openwop-node-packs',
|
|
330
|
+
'openwop-fixtures',
|
|
331
|
+
] as const) {
|
|
332
|
+
expect(hasProfile(rich, p)).toBe(derived.has(p));
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
});
|