@openwop/openwop-conformance 2.45.14 → 2.45.16
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/CHANGELOG.md +45 -0
- package/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +2668 -4
- package/scenario-majors.json +164 -2
- package/schemas/CORPUS-STAMP.json +13 -13
- package/src/lib/artifact-schema-url-witness.ts +83 -0
- package/src/lib/backpressure-witness.ts +2 -2
- package/src/lib/budget-witness.ts +3 -3
- package/src/lib/dispatch-witness.ts +212 -0
- package/src/lib/envelope-schema-static.ts +166 -0
- package/src/lib/exhaustion-facet-witness.ts +1 -1
- package/src/lib/family-advert-witness.ts +411 -0
- package/src/lib/fixture-run-observer.ts +97 -0
- package/src/lib/i18n-double.ts +99 -0
- package/src/lib/i18n-negotiation-witness.ts +173 -0
- package/src/lib/localized-content-witness.ts +49 -0
- package/src/lib/model-capability-insufficient-witness.ts +121 -0
- package/src/lib/prompt-double.ts +217 -0
- package/src/lib/prompt-events-witness.ts +211 -0
- package/src/lib/prompt-library-witness.ts +406 -0
- package/src/lib/provider-usage-witness.ts +63 -0
- package/src/lib/run-bounds-witness.ts +111 -0
- package/src/lib/run-graph-double.ts +144 -0
- package/src/lib/scratch-host.ts +4 -1
- package/src/lib/subworkflow-witness.ts +192 -0
- package/src/lib/table-schema-witness.ts +3 -1
- package/src/scenarios/v2-agent-runtime-advertisement.test.ts +51 -0
- package/src/scenarios/v2-ai-envelope-advertisement.test.ts +43 -0
- package/src/scenarios/v2-ai-envelope-shape-static.test.ts +93 -0
- package/src/scenarios/v2-ai-providers-advertisement.test.ts +94 -0
- package/src/scenarios/v2-artifact-created-static.test.ts +92 -0
- package/src/scenarios/v2-artifact-type-schema-url.test.ts +44 -0
- package/src/scenarios/v2-artifact-types-advertisement.test.ts +64 -0
- package/src/scenarios/v2-authorization-advertisement.test.ts +66 -0
- package/src/scenarios/v2-channel-presence-advertisement.test.ts +41 -0
- package/src/scenarios/v2-conversation-turn-model-provenance-advertisement.test.ts +41 -0
- package/src/scenarios/v2-conversation-turn-model-provenance.test.ts +110 -0
- package/src/scenarios/v2-credentials-advertisement.test.ts +66 -0
- package/src/scenarios/v2-data-residency-admission.test.ts +98 -0
- package/src/scenarios/v2-data-residency-advertisement.test.ts +52 -0
- package/src/scenarios/v2-dead-letter-advertisement.test.ts +54 -0
- package/src/scenarios/v2-dispatch-input-mapping.test.ts +70 -0
- package/src/scenarios/v2-dispatch-loop.test.ts +80 -0
- package/src/scenarios/v2-envelope-contracts-advertisement.test.ts +40 -0
- package/src/scenarios/v2-envelope-reasoning-shape.test.ts +81 -0
- package/src/scenarios/v2-envelope-tier-one-subset-static.test.ts +77 -0
- package/src/scenarios/v2-envelope-variant-discriminator-static.test.ts +57 -0
- package/src/scenarios/v2-envelopes-advertisement.test.ts +85 -0
- package/src/scenarios/v2-export-bundle-static.test.ts +108 -0
- package/src/scenarios/v2-i18n-negotiation.test.ts +91 -0
- package/src/scenarios/v2-limits-envelope-caps-advertisement.test.ts +61 -0
- package/src/scenarios/v2-localized-content-delivery.test.ts +136 -0
- package/src/scenarios/v2-media-inline-cap-static.test.ts +59 -0
- package/src/scenarios/v2-model-capabilities-advertisement.test.ts +61 -0
- package/src/scenarios/v2-model-capability-insufficient.test.ts +63 -0
- package/src/scenarios/v2-multi-party-conversation-advertisement.test.ts +52 -0
- package/src/scenarios/v2-node-pack-runtimes-advertisement.test.ts +52 -0
- package/src/scenarios/v2-nondeterminism-policy-advertisement.test.ts +41 -0
- package/src/scenarios/v2-portability-advertisement.test.ts +63 -0
- package/src/scenarios/v2-prompt-all-four-kinds-events.test.ts +69 -0
- package/src/scenarios/v2-prompt-end-to-end-events.test.ts +71 -0
- package/src/scenarios/v2-prompt-library-advertisement.test.ts +40 -0
- package/src/scenarios/v2-prompt-list-and-fetch.test.ts +103 -0
- package/src/scenarios/v2-prompt-mutable-lifecycle.test.ts +130 -0
- package/src/scenarios/v2-prompt-pack-install.test.ts +78 -0
- package/src/scenarios/v2-prompt-render-deterministic.test.ts +87 -0
- package/src/scenarios/v2-prompt-resolution-chain-event.test.ts +46 -0
- package/src/scenarios/v2-prompt-template-shape.test.ts +112 -0
- package/src/scenarios/v2-provider-usage-advertisement.test.ts +61 -0
- package/src/scenarios/v2-provider-usage-emission.test.ts +50 -0
- package/src/scenarios/v2-provider-usage-payload-static.test.ts +92 -0
- package/src/scenarios/v2-purpose-propagation-advertisement.test.ts +52 -0
- package/src/scenarios/v2-run-execution-bounds.test.ts +67 -0
- package/src/scenarios/v2-scheduling-advertisement.test.ts +55 -0
- package/src/scenarios/v2-self-hosted-runner-advertisement.test.ts +52 -0
- package/src/scenarios/v2-self-hosted-runner-frames-static.test.ts +121 -0
- package/src/scenarios/v2-sub-workflow-advertisement.test.ts +53 -0
- package/src/scenarios/v2-subworkflow-input-mapping.test.ts +74 -0
- package/src/scenarios/v2-subworkflow-linkage.test.ts +76 -0
- package/src/scenarios/v2-ui-plugins-advertisement.test.ts +85 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the AI envelope wire shape, statically (`spec/v2/core/events.md`
|
|
3
|
+
* §"AI envelopes: E1–E5"; `schemas/v2/ai-envelope.schema.json`). The v1 twin is
|
|
4
|
+
* the static half of `ai-envelope-shape`; its live halves (the
|
|
5
|
+
* `envelope/accept` seam legs) are seam-only and not ported.
|
|
6
|
+
*
|
|
7
|
+
* compile `ai-envelope.schema.json` and each universal-kind payload
|
|
8
|
+
* schema compile under Ajv 2020 with the v2 tree registered;
|
|
9
|
+
* positive a well-formed envelope validates — with `correlationId` and
|
|
10
|
+
* `meta.source`, which v2 makes REQUIRED (migration C4.16);
|
|
11
|
+
* meta an envelope without `meta` is rejected;
|
|
12
|
+
* closed an unknown top-level property is rejected;
|
|
13
|
+
* correlation NEW at v2: an envelope without `correlationId` is rejected
|
|
14
|
+
* ("`correlationId` and `meta.source` are REQUIRED on every
|
|
15
|
+
* envelope … nothing is synthesized");
|
|
16
|
+
* source NEW at v2: an envelope whose `meta` has no `source` is rejected.
|
|
17
|
+
*
|
|
18
|
+
* The `<org>.` namespacing rule for kinds is not witnessed here: the schema's
|
|
19
|
+
* `type` is an open string, so the rule binds the engine's acceptance, not the
|
|
20
|
+
* wire shape.
|
|
21
|
+
*
|
|
22
|
+
* Host-free: every leg runs with no OPENWOP_BASE_URL.
|
|
23
|
+
*
|
|
24
|
+
* @see spec/v2/core/events.md §"AI envelopes: E1–E5"
|
|
25
|
+
* @see spec/v1/migrations.json openwop.migration.C4.16
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect } from 'vitest';
|
|
29
|
+
import { v2Validator } from '../lib/v2.js';
|
|
30
|
+
import { req } from '../lib/requirement-ids.js';
|
|
31
|
+
import { UNIVERSAL_KINDS } from '../lib/envelope-schema-static.js';
|
|
32
|
+
|
|
33
|
+
const DOC = 'events.md §AI envelopes: E1–E5';
|
|
34
|
+
const ID_COMPILE = 'openwop.requirement.ai-envelope.schemas-compile';
|
|
35
|
+
const ID_POSITIVE = 'openwop.requirement.ai-envelope.well-formed-accepted';
|
|
36
|
+
const ID_META = 'openwop.requirement.ai-envelope.meta-required';
|
|
37
|
+
const ID_CLOSED = 'openwop.requirement.ai-envelope.top-level-closed';
|
|
38
|
+
const ID_CORRELATION = 'openwop.requirement.ai-envelope.correlation-id-required';
|
|
39
|
+
const ID_SOURCE = 'openwop.requirement.ai-envelope.meta-source-required';
|
|
40
|
+
|
|
41
|
+
const WELL_FORMED = {
|
|
42
|
+
type: 'clarification.request',
|
|
43
|
+
schemaVersion: 1,
|
|
44
|
+
envelopeId: 'env-v2-positive-1',
|
|
45
|
+
correlationId: 'run-1:node-2:turn-0:abc123',
|
|
46
|
+
payload: { questions: [{ id: 'q1', question: 'Which provider?' }] },
|
|
47
|
+
meta: { source: 'ai-generation', ts: '2026-10-04T10:00:00Z', contentTrust: 'trusted' },
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
/** The well-formed envelope with one top-level field removed. */
|
|
51
|
+
function without(field: string): Record<string, unknown> {
|
|
52
|
+
const e: Record<string, unknown> = { ...WELL_FORMED };
|
|
53
|
+
delete e[field];
|
|
54
|
+
return e;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('v2 AI envelope shape: schemas compile', () => {
|
|
58
|
+
it('ai-envelope.schema.json compiles under Ajv 2020', () => {
|
|
59
|
+
expect(() => v2Validator('ai-envelope'), req(ID_COMPILE, DOC, 'schemas/v2/ai-envelope.schema.json MUST compile')).not.toThrow();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
for (const kind of UNIVERSAL_KINDS) {
|
|
63
|
+
it(`envelopes/${kind}.schema.json compiles under Ajv 2020`, () => {
|
|
64
|
+
expect(() => v2Validator(`envelopes/${kind}`), req(ID_COMPILE, DOC, `schemas/v2/envelopes/${kind}.schema.json MUST compile`)).not.toThrow();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('v2 AI envelope shape: round-trip', () => {
|
|
70
|
+
const validate = v2Validator('ai-envelope');
|
|
71
|
+
|
|
72
|
+
it('accepts a well-formed envelope carrying correlationId and meta.source', () => {
|
|
73
|
+
const r = validate(WELL_FORMED);
|
|
74
|
+
expect(r.ok, req(ID_POSITIVE, DOC, `a well-formed envelope MUST validate (${r.errors})`)).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('rejects an envelope missing meta', () => {
|
|
78
|
+
expect(validate(without('meta')).ok, req(ID_META, DOC, 'an envelope without meta MUST be rejected')).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('rejects an envelope with an unknown top-level property', () => {
|
|
82
|
+
expect(validate({ ...WELL_FORMED, unknownTopLevel: 'x' }).ok, req(ID_CLOSED, 'schemas/v2/ai-envelope.schema.json', 'the envelope is closed: an unknown top-level property MUST be rejected')).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects an envelope missing correlationId', () => {
|
|
86
|
+
expect(validate(without('correlationId')).ok, req(ID_CORRELATION, DOC, 'correlationId is REQUIRED on every envelope at v2 — an envelope without it MUST be rejected, never synthesized')).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rejects an envelope whose meta carries no source', () => {
|
|
90
|
+
const { source: _source, ...meta } = WELL_FORMED.meta;
|
|
91
|
+
expect(validate({ ...WELL_FORMED, meta }).ok, req(ID_SOURCE, DOC, 'meta.source is REQUIRED on every envelope at v2 — an envelope without it MUST be rejected')).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the `aiProviders` advertisement facets that survive at major 2
|
|
3
|
+
* (`spec/v2/core/host-services.md` §aiProviders; migration C2.11 narrowed the
|
|
4
|
+
* v1 facets). The v1 twins are the advertisement halves of
|
|
5
|
+
* `aiproviders-selfhosted-shape`, `byok-auth-modes` and `media-url-inline-cap`;
|
|
6
|
+
* the legs live in `lib/family-advert-witness.ts`.
|
|
7
|
+
*
|
|
8
|
+
* record the record validates against its seat in
|
|
9
|
+
* `schemas/v2/capabilities.schema.json`;
|
|
10
|
+
* selfHosted one boolean (the v1 `selfHosted[]` id list retired);
|
|
11
|
+
* authModes shape one flat list (the v1 per-provider map retired);
|
|
12
|
+
* (not a leg) authModes vocabulary: the prose names `apiKey`, `oauth-pkce`,
|
|
13
|
+
* `oauth-device`, `none`, `subscription`, but its only
|
|
14
|
+
* MUSTs bind clients ("a client MUST ignore an unknown
|
|
15
|
+
* mode"), and the v2 schema seat is `string[]` with no
|
|
16
|
+
* enum. No host-side MUST exists, so a misspelt mode is an
|
|
17
|
+
* interop defect the host should fix, not a failing row.
|
|
18
|
+
* Restoring the v1 enum would need an RFC.
|
|
19
|
+
* maxInlineMediaBytes an integer ≥ 1;
|
|
20
|
+
* promptPrefixCache one boolean (the v1 `{ supported, providers[] }` retired).
|
|
21
|
+
*
|
|
22
|
+
* The v1 rules C2.11 retired (`selfHosted[]` ⊆ `supported[]`, the per-provider
|
|
23
|
+
* `authModes` keys/byok/`["none"]` rules) are not ported: they have no v2 seat.
|
|
24
|
+
*
|
|
25
|
+
* Dispositions: discovery unreadable ⇒ `blocked`; `aiProviders` absent, or the
|
|
26
|
+
* facet a leg reads absent ⇒ `inapplicable`.
|
|
27
|
+
*
|
|
28
|
+
* @see spec/v2/core/host-services.md §aiProviders
|
|
29
|
+
* @see spec/v1/migrations.json openwop.migration.C2.11
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect } from 'vitest';
|
|
33
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
34
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
35
|
+
import { req } from '../lib/requirement-ids.js';
|
|
36
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
37
|
+
import {
|
|
38
|
+
aiProvidersAuthModesShapeLeg, aiProvidersInlineMediaLeg, aiProvidersPromptPrefixCacheLeg, aiProvidersSelfHostedLeg,
|
|
39
|
+
recordSchemaLeg,
|
|
40
|
+
} from '../lib/family-advert-witness.js';
|
|
41
|
+
|
|
42
|
+
const PROFILE = majorProfile(2);
|
|
43
|
+
const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
|
|
44
|
+
const ID_RECORD = 'openwop.requirement.ai-providers.advert-record-schema';
|
|
45
|
+
const ID_SELF_HOSTED = 'openwop.requirement.ai-providers.self-hosted-boolean';
|
|
46
|
+
const ID_AUTH_SHAPE = 'openwop.requirement.ai-providers.auth-modes-flat-list';
|
|
47
|
+
const ID_INLINE_MEDIA = 'openwop.requirement.ai-providers.max-inline-media-bytes';
|
|
48
|
+
const ID_PREFIX_CACHE = 'openwop.requirement.ai-providers.prompt-prefix-cache-boolean';
|
|
49
|
+
|
|
50
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
51
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('v2 aiProviders advertisement (host-services.md §aiProviders)', () => {
|
|
55
|
+
it('the aiProviders record validates against its capabilities-schema seat', async () => {
|
|
56
|
+
const doc = await discovery();
|
|
57
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
58
|
+
const out = recordSchemaLeg(PROFILE, doc, 'aiProviders', 'host-services.md §aiProviders');
|
|
59
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
60
|
+
for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('aiProviders.selfHosted is one boolean', async () => {
|
|
64
|
+
const doc = await discovery();
|
|
65
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
66
|
+
const out = aiProvidersSelfHostedLeg(PROFILE, doc);
|
|
67
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
68
|
+
for (const x of out.findings) expect(x.ok, req(ID_SELF_HOSTED, x.doc, x.message)).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('aiProviders.authModes is one flat list of modes', async () => {
|
|
72
|
+
const doc = await discovery();
|
|
73
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
74
|
+
const out = aiProvidersAuthModesShapeLeg(PROFILE, doc);
|
|
75
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
76
|
+
for (const x of out.findings) expect(x.ok, req(ID_AUTH_SHAPE, x.doc, x.message)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('aiProviders.maxInlineMediaBytes is an integer of at least 1', async () => {
|
|
80
|
+
const doc = await discovery();
|
|
81
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
82
|
+
const out = aiProvidersInlineMediaLeg(PROFILE, doc);
|
|
83
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
84
|
+
for (const x of out.findings) expect(x.ok, req(ID_INLINE_MEDIA, x.doc, x.message)).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('aiProviders.promptPrefixCache is one boolean', async () => {
|
|
88
|
+
const doc = await discovery();
|
|
89
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
90
|
+
const out = aiProvidersPromptPrefixCacheLeg(PROFILE, doc);
|
|
91
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
92
|
+
for (const x of out.findings) expect(x.ok, req(ID_PREFIX_CACHE, x.doc, x.message)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the `artifact.created` payload, statically
|
|
3
|
+
* (`spec/v2/core/artifact-type-packs.md` §Registration;
|
|
4
|
+
* `schemas/v2/run-event-payloads.schema.json#/$defs/artifactCreated`; RFCs
|
|
5
|
+
* 0071, 0075, 0138, 0145, 0185). The v1 twins are the schema halves of
|
|
6
|
+
* `artifact-type-store-emission` and `artifact-type-registration-source`;
|
|
7
|
+
* their fixture-run legs are not ported here.
|
|
8
|
+
*
|
|
9
|
+
* type `artifact.created` is a registered v2 event type;
|
|
10
|
+
* required `artifactId` and `artifactType` are REQUIRED (the RFC 0138
|
|
11
|
+
* replay correction reads the type from the event);
|
|
12
|
+
* provenance `registrationSource` is `pack` or `host` ("pack-registered …
|
|
13
|
+
* `registrationSource: "pack"`, host-registered … `"host"`"),
|
|
14
|
+
* and the discovery per-type seat carries the same vocabulary,
|
|
15
|
+
* so the two surfaces cannot disagree on a value;
|
|
16
|
+
* registered `registered` is a boolean;
|
|
17
|
+
* closed an unknown key is rejected; a vendor key under the RFC 0185
|
|
18
|
+
* hatch (`x-`, `vendor.`, `openwop-`) is carried.
|
|
19
|
+
*
|
|
20
|
+
* Host-free: every leg runs with no OPENWOP_BASE_URL.
|
|
21
|
+
*
|
|
22
|
+
* @see spec/v2/core/artifact-type-packs.md §Registration
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
29
|
+
import { v2RefValidator } from '../lib/v2.js';
|
|
30
|
+
import { req } from '../lib/requirement-ids.js';
|
|
31
|
+
|
|
32
|
+
const DOC = 'artifact-type-packs.md §Registration';
|
|
33
|
+
const ID_TYPE = 'openwop.requirement.artifact-types.created-event-type-registered';
|
|
34
|
+
const ID_REQUIRED = 'openwop.requirement.artifact-types.created-requires-artifact-type';
|
|
35
|
+
const ID_PROVENANCE = 'openwop.requirement.artifact-types.created-registration-source-enum';
|
|
36
|
+
const ID_REGISTERED = 'openwop.requirement.artifact-types.created-registered-boolean';
|
|
37
|
+
const ID_CLOSED = 'openwop.requirement.artifact-types.created-payload-closed';
|
|
38
|
+
|
|
39
|
+
const GOOD = { artifactId: 'art_1', artifactType: 'vendor.acme.prd', nodeId: 'n1', registered: true, registrationSource: 'host' } as const;
|
|
40
|
+
|
|
41
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
42
|
+
function enumAt(root: unknown, path: readonly string[]): unknown {
|
|
43
|
+
let cur: unknown = root;
|
|
44
|
+
for (const k of path) cur = isRecord(cur) ? cur[k] : undefined;
|
|
45
|
+
return isRecord(cur) ? cur['enum'] : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('v2 artifact.created payload (artifact-type-packs.md §Registration)', () => {
|
|
49
|
+
const validate = v2RefValidator('run-event-payloads.schema.json#/$defs/artifactCreated');
|
|
50
|
+
|
|
51
|
+
it('artifact.created is a registered v2 run-event type', () => {
|
|
52
|
+
const r = v2RefValidator('run-event.schema.json#/properties/type')('artifact.created');
|
|
53
|
+
expect(r.ok, req(ID_TYPE, DOC, `artifact.created MUST be accepted by the v2 run-event type union (${r.errors})`)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('accepts a well-formed payload, with either provenance or none', () => {
|
|
57
|
+
const { registrationSource: _src, ...noSource } = GOOD;
|
|
58
|
+
for (const p of [GOOD, { ...GOOD, registrationSource: 'pack' }, noSource]) {
|
|
59
|
+
const r = validate(p);
|
|
60
|
+
expect(r.ok, req(ID_PROVENANCE, DOC, `artifact.created ${JSON.stringify(p)} MUST validate (${r.errors})`)).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it.each(['artifactId', 'artifactType'])('rejects a payload without %s', (field) => {
|
|
65
|
+
const p: Record<string, unknown> = { ...GOOD };
|
|
66
|
+
delete p[field];
|
|
67
|
+
expect(validate(p).ok, req(ID_REQUIRED, DOC, `artifact.created without ${field} MUST be rejected`)).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects a registrationSource outside pack | host', () => {
|
|
71
|
+
expect(validate({ ...GOOD, registrationSource: 'vendor' }).ok, req(ID_PROVENANCE, DOC, 'registrationSource MUST be pack or host')).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('the discovery per-type registrationSource carries the same vocabulary as the event', () => {
|
|
75
|
+
const payloads: unknown = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'run-event-payloads.schema.json'), 'utf8'));
|
|
76
|
+
const caps: unknown = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'capabilities.schema.json'), 'utf8'));
|
|
77
|
+
const event = enumAt(payloads, ['$defs', 'artifactCreated', 'properties', 'registrationSource']);
|
|
78
|
+
const advert = enumAt(caps, ['properties', 'artifactTypes', 'properties', 'types', 'additionalProperties', 'properties', 'registrationSource']);
|
|
79
|
+
expect(event, req(ID_PROVENANCE, DOC, 'artifact.created.registrationSource MUST be the pack | host enum')).toEqual(['pack', 'host']);
|
|
80
|
+
expect(advert, req(ID_PROVENANCE, 'capabilities.schema.json §artifactTypes.types', 'the advertised per-type registrationSource MUST use the vocabulary artifact.created carries')).toEqual(event);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects a non-boolean registered', () => {
|
|
84
|
+
expect(validate({ ...GOOD, registered: 'true' }).ok, req(ID_REGISTERED, DOC, 'registered MUST be a boolean')).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('rejects an unknown key, and carries a vendor key under the RFC 0185 hatch', () => {
|
|
88
|
+
expect(validate({ ...GOOD, payload: { secret: 1 } }).ok, req(ID_CLOSED, DOC, 'an unknown key on artifact.created MUST be rejected')).toBe(false);
|
|
89
|
+
const r = validate({ ...GOOD, 'x-acme-trace': 'abc', 'vendor.acme.note': 'n' });
|
|
90
|
+
expect(r.ok, req(ID_CLOSED, 'run-event-payloads.schema.json §artifactCreated (RFC 0185)', `a vendor-prefixed key MUST be carried (${r.errors})`)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the canonical schema URL of a host-registered artifact type
|
|
3
|
+
* (`spec/v2/core/artifact-type-packs.md` §Schema distribution; RFCs 0071, 0075,
|
|
4
|
+
* 0145). NEW at v2, unaided: no seam and no fixture.
|
|
5
|
+
*
|
|
6
|
+
* "A host advertising `artifactTypes` SHOULD serve each installed type's schema
|
|
7
|
+
* there, and MUST for a host-registered type whose `schemaVersion` it
|
|
8
|
+
* advertises." The MUST set is read from discovery: each
|
|
9
|
+
* `artifactTypes.types[id]` with `registrationSource: "host"` and a
|
|
10
|
+
* `schemaVersion`. For each, `GET /schemas/artifacts/{id}.schema.json` answers
|
|
11
|
+
* `200`, `Content-Type: application/schema+json`, and a schema whose `$id` is
|
|
12
|
+
* that canonical URL. Pack-backed types are a SHOULD and are not asserted.
|
|
13
|
+
*
|
|
14
|
+
* Dispositions: `artifactTypes` not advertised, or no host-registered type with
|
|
15
|
+
* a `schemaVersion` ⇒ `inapplicable`; discovery unreadable ⇒ `blocked`.
|
|
16
|
+
*
|
|
17
|
+
* Proven against a double in `lib/artifact-schema-url-witness.test.ts`.
|
|
18
|
+
*
|
|
19
|
+
* @see spec/v2/core/artifact-type-packs.md §Schema distribution
|
|
20
|
+
* @see schemas/v2/capabilities.schema.json §artifactTypes.types.registrationSource
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import { familyAdvertised, v2Discovery } from '../lib/v2.js';
|
|
25
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
26
|
+
import { req } from '../lib/requirement-ids.js';
|
|
27
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
28
|
+
import { driveSchemaUrls, judgeSchemaUrl } from '../lib/artifact-schema-url-witness.js';
|
|
29
|
+
|
|
30
|
+
const PROFILE = majorProfile(2);
|
|
31
|
+
const ID_SERVED = 'openwop.requirement.artifact-types.host-registered-schema-served';
|
|
32
|
+
|
|
33
|
+
describe('v2 artifact types: the canonical schema URL (artifact-type-packs.md §Schema distribution)', () => {
|
|
34
|
+
it('every host-registered type with an advertised schemaVersion has its schema served at {HostBase}/schemas/artifacts/{id}.schema.json', async () => {
|
|
35
|
+
if (!process.env['OPENWOP_BASE_URL']) return softSkip('inapplicable', 'no target: OPENWOP_BASE_URL is unset');
|
|
36
|
+
let doc: Record<string, unknown> | null;
|
|
37
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
38
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0');
|
|
39
|
+
if (!(await familyAdvertised('artifactTypes'))) return softSkip('inapplicable', 'the host does not advertise artifactTypes');
|
|
40
|
+
const o = await driveSchemaUrls(PROFILE, doc);
|
|
41
|
+
if (!Array.isArray(o)) return softSkip(o.disposition, o.reason);
|
|
42
|
+
for (const x of o.flatMap(judgeSchemaUrl)) expect(x.ok, req(ID_SERVED, x.doc, x.message)).toBe(true);
|
|
43
|
+
}, 120_000);
|
|
44
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the `artifactTypes` advertisement
|
|
3
|
+
* (`spec/v2/core/artifact-type-packs.md`; RFCs 0071, 0075, 0145). The v1 twin
|
|
4
|
+
* is the advertisement legs of `artifact-type-registration-source` and
|
|
5
|
+
* `artifact-type-store-emission`; the legs live in
|
|
6
|
+
* `lib/family-advert-witness.ts`.
|
|
7
|
+
*
|
|
8
|
+
* record the record validates against its capabilities-schema seat;
|
|
9
|
+
* global `store` and `render` are booleans, `export` a list of format ids;
|
|
10
|
+
* per-type each `types` entry is closed, with `validation` open | closed,
|
|
11
|
+
* `schemaVersion` ≥ 0 and `registrationSource` pack | host.
|
|
12
|
+
*
|
|
13
|
+
* Not here: store-implies-emit and the emitted-vs-advertised `registrationSource`
|
|
14
|
+
* match need a fixture run; the event shape is `v2-artifact-created-static`.
|
|
15
|
+
*
|
|
16
|
+
* Dispositions: discovery unreadable ⇒ `blocked`; `artifactTypes` absent, or the
|
|
17
|
+
* facet a leg reads absent ⇒ `inapplicable`.
|
|
18
|
+
*
|
|
19
|
+
* @see spec/v2/core/artifact-type-packs.md §The capability
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest';
|
|
23
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
24
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
25
|
+
import { req } from '../lib/requirement-ids.js';
|
|
26
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
27
|
+
import { artifactTypesGlobalFacetsLeg, artifactTypesPerTypeLeg, recordSchemaLeg } from '../lib/family-advert-witness.js';
|
|
28
|
+
|
|
29
|
+
const PROFILE = majorProfile(2);
|
|
30
|
+
const DOC = 'artifact-type-packs.md §The capability';
|
|
31
|
+
const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
|
|
32
|
+
const ID_RECORD = 'openwop.requirement.artifact-types.advert-record-schema';
|
|
33
|
+
const ID_GLOBAL_FACETS_SHAPE = 'openwop.requirement.artifact-types.global-facets-shape';
|
|
34
|
+
const ID_PER_TYPE_ENTRY_SHAPE = 'openwop.requirement.artifact-types.per-type-entry-shape';
|
|
35
|
+
|
|
36
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
37
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('v2 artifactTypes advertisement (artifact-type-packs.md §The capability)', () => {
|
|
41
|
+
it('the artifactTypes record validates against its capabilities-schema seat', async () => {
|
|
42
|
+
const doc = await discovery();
|
|
43
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
44
|
+
const out = recordSchemaLeg(PROFILE, doc, 'artifactTypes', DOC);
|
|
45
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
46
|
+
for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('artifactTypes.store, render and export are well-formed', async () => {
|
|
50
|
+
const doc = await discovery();
|
|
51
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
52
|
+
const out = artifactTypesGlobalFacetsLeg(PROFILE, doc);
|
|
53
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
54
|
+
for (const x of out.findings) expect(x.ok, req(ID_GLOBAL_FACETS_SHAPE, x.doc, x.message)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('every artifactTypes.types entry validates against its seat', async () => {
|
|
58
|
+
const doc = await discovery();
|
|
59
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
60
|
+
const out = artifactTypesPerTypeLeg(PROFILE, doc);
|
|
61
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
62
|
+
for (const x of out.findings) expect(x.ok, req(ID_PER_TYPE_ENTRY_SHAPE, x.doc, x.message)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the `authorization` advertisement (`spec/v2/core/identity.md` §2.1; RFC
|
|
3
|
+
* 0049; invariant `authorization-fail-closed`). The v1 twin is the
|
|
4
|
+
* advertisement legs of `authorization-roles-shape` and
|
|
5
|
+
* `authorization-fail-closed`; the legs live in `lib/family-advert-witness.ts`.
|
|
6
|
+
*
|
|
7
|
+
* record the record validates against its capabilities-schema seat;
|
|
8
|
+
* failClosed `failClosed` MUST be `true` when present;
|
|
9
|
+
* roles each `roles[]` entry names a non-empty `role` and a unique
|
|
10
|
+
* `scopes[]` of non-empty strings, and nothing else.
|
|
11
|
+
*
|
|
12
|
+
* Not here: the unseeded-role decision leg of `authorization-fail-closed` needs
|
|
13
|
+
* a decision seam with no v2 counterpart.
|
|
14
|
+
*
|
|
15
|
+
* Dispositions: discovery unreadable ⇒ `blocked`; `authorization` absent, or the
|
|
16
|
+
* facet a leg reads absent ⇒ `inapplicable`.
|
|
17
|
+
*
|
|
18
|
+
* @see spec/v2/core/identity.md §2.1
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from 'vitest';
|
|
22
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
23
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
24
|
+
import { req } from '../lib/requirement-ids.js';
|
|
25
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
26
|
+
import { authorizationFailClosedLeg, authorizationRolesLeg, recordSchemaLeg } from '../lib/family-advert-witness.js';
|
|
27
|
+
|
|
28
|
+
const PROFILE = majorProfile(2);
|
|
29
|
+
const DOC = 'identity.md §2.1';
|
|
30
|
+
const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
|
|
31
|
+
const ID_RECORD = 'openwop.requirement.authorization.advert-record-schema';
|
|
32
|
+
const ID_FAIL_CLOSED_TRUE = 'openwop.requirement.authorization.fail-closed-true';
|
|
33
|
+
const ID_ROLES_SHAPE = 'openwop.requirement.authorization.roles-shape';
|
|
34
|
+
|
|
35
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
36
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('v2 authorization advertisement (identity.md §2.1)', () => {
|
|
40
|
+
it('the authorization record validates against its capabilities-schema seat', async () => {
|
|
41
|
+
const doc = await discovery();
|
|
42
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
43
|
+
if (!(await familyAdvertised('authorization'))) return softSkip('inapplicable', 'the host does not advertise authorization');
|
|
44
|
+
const out = recordSchemaLeg(PROFILE, doc, 'authorization', DOC);
|
|
45
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
46
|
+
for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('authorization.failClosed is true when present', async () => {
|
|
50
|
+
const doc = await discovery();
|
|
51
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
52
|
+
if (!(await familyAdvertised('authorization'))) return softSkip('inapplicable', 'the host does not advertise authorization');
|
|
53
|
+
const out = authorizationFailClosedLeg(PROFILE, doc);
|
|
54
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
55
|
+
for (const x of out.findings) expect(x.ok, req(ID_FAIL_CLOSED_TRUE, x.doc, x.message)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('every authorization.roles entry has a non-empty role and a scopes list', async () => {
|
|
59
|
+
const doc = await discovery();
|
|
60
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
61
|
+
if (!(await familyAdvertised('authorization'))) return softSkip('inapplicable', 'the host does not advertise authorization');
|
|
62
|
+
const out = authorizationRolesLeg(PROFILE, doc);
|
|
63
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
64
|
+
for (const x of out.findings) expect(x.ok, req(ID_ROLES_SHAPE, x.doc, x.message)).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the `channelPresence` advertisement (`spec/v2/core/conversation.md` §`channelPresence`): the channel presence claim. The v1 twin is
|
|
3
|
+
* `channel-presence-shape`;
|
|
4
|
+
* the legs live in `lib/family-advert-witness.ts` (witness wave 3).
|
|
5
|
+
*
|
|
6
|
+
* record the record validates against its capabilities-schema seat;
|
|
7
|
+
*
|
|
8
|
+
* Not here: presence delivery needs a channel conversation the suite can create, and the non-persistence rule lives only in a schema description (TODO §8).
|
|
9
|
+
*
|
|
10
|
+
* Dispositions: discovery unreadable ⇒ `blocked`; `channelPresence` absent, or the
|
|
11
|
+
* facet a leg reads absent ⇒ `inapplicable`.
|
|
12
|
+
*
|
|
13
|
+
* @see spec/v2/core/conversation.md §`channelPresence`
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest';
|
|
17
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
18
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
19
|
+
import { req } from '../lib/requirement-ids.js';
|
|
20
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
21
|
+
import { recordSchemaLeg } from '../lib/family-advert-witness.js';
|
|
22
|
+
|
|
23
|
+
const PROFILE = majorProfile(2);
|
|
24
|
+
const DOC = 'conversation.md §channelPresence';
|
|
25
|
+
const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
|
|
26
|
+
const ID_RECORD = 'openwop.requirement.channel-presence.advert-record-schema';
|
|
27
|
+
|
|
28
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
29
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('v2 channelPresence advertisement (conversation.md §channelPresence)', () => {
|
|
33
|
+
it('the channelPresence record validates against its capabilities-schema seat', async () => {
|
|
34
|
+
const doc = await discovery();
|
|
35
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
36
|
+
if (!(await familyAdvertised('channelPresence'))) return softSkip('inapplicable', 'the host does not advertise channelPresence');
|
|
37
|
+
const out = recordSchemaLeg(PROFILE, doc, 'channelPresence', DOC);
|
|
38
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
39
|
+
for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the `conversationTurnModelProvenance` advertisement (`spec/v2/core/conversation.md` §`conversationTurnModelProvenance`): the claim that a host stamps `agent.model` on agent turns. The v1 twin is
|
|
3
|
+
* `conversation-turn-model-provenance-shape`;
|
|
4
|
+
* the legs live in `lib/family-advert-witness.ts` (witness wave 3).
|
|
5
|
+
*
|
|
6
|
+
* record the record validates against its capabilities-schema seat;
|
|
7
|
+
*
|
|
8
|
+
* Not here: the stamp itself (only `provider` and `model`, read verbatim on fork) needs a conversation run; the record carries no facet a v2 rule gives meaning to.
|
|
9
|
+
*
|
|
10
|
+
* Dispositions: discovery unreadable ⇒ `blocked`; `conversationTurnModelProvenance` absent, or the
|
|
11
|
+
* facet a leg reads absent ⇒ `inapplicable`.
|
|
12
|
+
*
|
|
13
|
+
* @see spec/v2/core/conversation.md §`conversationTurnModelProvenance`
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest';
|
|
17
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
18
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
19
|
+
import { req } from '../lib/requirement-ids.js';
|
|
20
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
21
|
+
import { recordSchemaLeg } from '../lib/family-advert-witness.js';
|
|
22
|
+
|
|
23
|
+
const PROFILE = majorProfile(2);
|
|
24
|
+
const DOC = 'conversation.md §conversationTurnModelProvenance';
|
|
25
|
+
const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
|
|
26
|
+
const ID_RECORD = 'openwop.requirement.conversation-turn-model-provenance.advert-record-schema';
|
|
27
|
+
|
|
28
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
29
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('v2 conversationTurnModelProvenance advertisement (conversation.md §conversationTurnModelProvenance)', () => {
|
|
33
|
+
it('the conversationTurnModelProvenance record validates against its capabilities-schema seat', async () => {
|
|
34
|
+
const doc = await discovery();
|
|
35
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
36
|
+
if (!(await familyAdvertised('conversationTurnModelProvenance'))) return softSkip('inapplicable', 'the host does not advertise conversationTurnModelProvenance');
|
|
37
|
+
const out = recordSchemaLeg(PROFILE, doc, 'conversationTurnModelProvenance', DOC);
|
|
38
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
39
|
+
for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `conversationTurnModelProvenance`, the emission legs
|
|
3
|
+
* (`spec/v2/core/conversation.md` §`conversationTurnModelProvenance`). The v1
|
|
4
|
+
* twin is `conversation-turn-model-provenance-shape`; the advertisement record
|
|
5
|
+
* leg is `v2-conversation-turn-model-provenance-advertisement`.
|
|
6
|
+
*
|
|
7
|
+
* stamp-requires-advert a host that stamps `agent.model` on an agent turn
|
|
8
|
+
* MUST advertise `conversationTurnModelProvenance`;
|
|
9
|
+
* stamp-closed the stamp carries provider and model identifiers
|
|
10
|
+
* only, so no prompt or completion content can ride
|
|
11
|
+
* in it (closed at `conversation-turn.schema.json`).
|
|
12
|
+
*
|
|
13
|
+
* Both legs read the turns of one `conformance-conversation-lifecycle` run,
|
|
14
|
+
* the fixture `v2-conversation-turn-parts` drives. A host whose turns carry no
|
|
15
|
+
* stamp records both legs `inapplicable`: the rule binds a host that stamps,
|
|
16
|
+
* and the suite cannot make it stamp.
|
|
17
|
+
*
|
|
18
|
+
* Gates, decided before any assertion: discovery unreadable ⇒ `blocked`;
|
|
19
|
+
* `conversationPrimitive` or the fixture not advertised ⇒ `inapplicable`; the
|
|
20
|
+
* fixture run not completing, or completing with no turn ⇒ `blocked` (the
|
|
21
|
+
* fixture's own contract is one exchange).
|
|
22
|
+
*
|
|
23
|
+
* @see spec/v2/core/conversation.md §`conversationTurnModelProvenance`
|
|
24
|
+
* @see schemas/v2/conversation-turn.schema.json
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest';
|
|
28
|
+
import { driver } from '../lib/driver.js';
|
|
29
|
+
import { familyAdvertised, v2Discovery, v2RefValidator } from '../lib/v2.js';
|
|
30
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
31
|
+
import { req } from '../lib/requirement-ids.js';
|
|
32
|
+
|
|
33
|
+
type Json = Record<string, unknown>;
|
|
34
|
+
const FIXTURE = 'conformance-conversation-lifecycle';
|
|
35
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
36
|
+
const DOC = 'conversation.md §conversationTurnModelProvenance';
|
|
37
|
+
const ID_ADVERT = 'openwop.requirement.conversation-turn-model-provenance.stamp-requires-advert';
|
|
38
|
+
const ID_CLOSED = 'openwop.requirement.conversation-turn-model-provenance.stamp-closed';
|
|
39
|
+
|
|
40
|
+
type Turns = { kind: 'turns'; turns: Json[] } | { kind: 'skip'; disposition: 'blocked' | 'inapplicable'; reason: string };
|
|
41
|
+
let cached: Promise<Turns> | undefined;
|
|
42
|
+
|
|
43
|
+
async function waitTerminal(runId: string, timeoutMs: number): Promise<string | null> {
|
|
44
|
+
const deadline = Date.now() + timeoutMs;
|
|
45
|
+
for (;;) {
|
|
46
|
+
const res = await driver.get(`/runs/${encodeURIComponent(runId)}`);
|
|
47
|
+
const status = res.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : null;
|
|
48
|
+
if (status !== null && TERMINAL.has(status)) return status;
|
|
49
|
+
if (Date.now() > deadline) return status;
|
|
50
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One fixture run per file: both legs read the same turns. */
|
|
55
|
+
function fixtureTurns(): Promise<Turns> {
|
|
56
|
+
cached ??= (async (): Promise<Turns> => {
|
|
57
|
+
let doc: Json | null;
|
|
58
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
59
|
+
if (!doc) return { kind: 'skip', disposition: 'blocked', reason: 'v2 discovery unreachable' };
|
|
60
|
+
if (!(await familyAdvertised('conversationPrimitive'))) return { kind: 'skip', disposition: 'inapplicable', reason: 'host does not advertise conversationPrimitive' };
|
|
61
|
+
const fixtures = Array.isArray(doc['fixtures']) ? (doc['fixtures'] as unknown[]) : [];
|
|
62
|
+
if (!fixtures.includes(FIXTURE)) return { kind: 'skip', disposition: 'inapplicable', reason: `host does not advertise the ${FIXTURE} fixture` };
|
|
63
|
+
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
64
|
+
if (create.status !== 201) return { kind: 'skip', disposition: 'blocked', reason: `POST /runs {workflowId: ${FIXTURE}} answered ${create.status}` };
|
|
65
|
+
const runId = String((create.json as { runId?: unknown } | null)?.runId ?? '');
|
|
66
|
+
const status = await waitTerminal(runId, 20_000);
|
|
67
|
+
if (status !== 'completed') return { kind: 'skip', disposition: 'blocked', reason: `${FIXTURE} did not complete (status ${status})` };
|
|
68
|
+
const poll = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
|
|
69
|
+
const events = ((poll.json as { events?: unknown } | null)?.events ?? []) as Json[];
|
|
70
|
+
const turns = (Array.isArray(events) ? events : [])
|
|
71
|
+
.filter((e) => e['type'] === 'conversation.exchanged')
|
|
72
|
+
.map((e) => ((e['payload'] ?? {}) as Json)['turn'])
|
|
73
|
+
.filter((t): t is Json => t !== null && typeof t === 'object' && !Array.isArray(t));
|
|
74
|
+
if (turns.length === 0) return { kind: 'skip', disposition: 'blocked', reason: `${FIXTURE} completed without a conversation.exchanged event carrying a turn` };
|
|
75
|
+
return { kind: 'turns', turns };
|
|
76
|
+
})();
|
|
77
|
+
return cached;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The `agent.model` stamps the run's turns carry. */
|
|
81
|
+
function stamps(turns: Json[]): { messageId: string; model: unknown }[] {
|
|
82
|
+
return turns.flatMap((t) => {
|
|
83
|
+
const agent = t['agent'];
|
|
84
|
+
if (agent === null || typeof agent !== 'object' || Array.isArray(agent) || !('model' in agent)) return [];
|
|
85
|
+
return [{ messageId: String(t['messageId']), model: (agent as Json)['model'] }];
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('v2 conversationTurnModelProvenance emission (conversation.md §conversationTurnModelProvenance)', () => {
|
|
90
|
+
it('a host that stamps agent.model on a turn advertises conversationTurnModelProvenance', async () => {
|
|
91
|
+
const out = await fixtureTurns();
|
|
92
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
93
|
+
const stamped = stamps(out.turns);
|
|
94
|
+
if (stamped.length === 0) return softSkip('inapplicable', 'no emitted turn carries an agent.model stamp');
|
|
95
|
+
const advertised = (await familyAdvertised('conversationTurnModelProvenance')) !== null;
|
|
96
|
+
expect(advertised, req(ID_ADVERT, DOC, `${stamped.length} turn(s) carry agent.model (first: ${stamped[0]?.messageId}); a host that stamps MUST advertise conversationTurnModelProvenance`)).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('every agent.model stamp carries provider and model identifiers only', async () => {
|
|
100
|
+
const out = await fixtureTurns();
|
|
101
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
102
|
+
const stamped = stamps(out.turns);
|
|
103
|
+
if (stamped.length === 0) return softSkip('inapplicable', 'no emitted turn carries an agent.model stamp');
|
|
104
|
+
const validate = v2RefValidator('conversation-turn.schema.json#/properties/agent/properties/model');
|
|
105
|
+
for (const s of stamped) {
|
|
106
|
+
const r = validate(s.model);
|
|
107
|
+
expect(r.ok, req(ID_CLOSED, DOC, `turn ${s.messageId}: agent.model MUST validate against schemas/v2/conversation-turn.schema.json (provider and model only): ${r.errors}`)).toBe(true);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|