@openwop/openwop-conformance 1.28.0 → 1.33.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.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Multi-party group conversation — behavioral leg (RFC 0101 §Conformance).
3
+ *
4
+ * Gated on `capabilities.multiPartyConversation.supported` (root-first per RFC
5
+ * 0073, via `isMultiPartyConversationSupported()`). Soft-skips when unadvertised
6
+ * (default) / hard-fails under `OPENWOP_REQUIRE_BEHAVIOR=true` via `behaviorGate`.
7
+ * The companion always-on wire-shape coverage lives in
8
+ * `multi-party-conversation-shape.test.ts`; THIS scenario asserts host BEHAVIOR
9
+ * — the cross-field / runtime MUSTs JSON Schema cannot express.
10
+ *
11
+ * RFC 0101 standardizes the multi-party *shape* but mints NO normative client
12
+ * wire-route to OPEN a conversation (opening / turn order / rounds are
13
+ * non-normative product policy). The driver therefore initiates a council + submits
14
+ * turns via the conformance-only seam `POST /v1/host/sample/conversation/multi-party/
15
+ * {open,exchange}` (`host-sample-test-seams.md`), which routes through the SAME
16
+ * roster-membership + attribution enforcement the host applies in production. The
17
+ * seam is OPTIONAL — the scenario soft-skips on `404`/`405` (a capability-advertising
18
+ * host whose enforcement is bound to a product flow witnesses instead via its own
19
+ * host-side test + an `INTEROP-MATRIX.md` row, the RFC 0086 dual-staging).
20
+ *
21
+ * Behavioral MUSTs asserted (RFC 0101 §Spec):
22
+ * 1. POSITIVE — a 3-agent council opens and a roster-valid, attributed agent
23
+ * turn (role:'agent' + in-roster speakerId) is accepted.
24
+ * 2. ATTRIBUTION — a role:'agent' turn missing `speakerId` is rejected with
25
+ * `validation_error` (§Spec item 2).
26
+ * 3. MEMBERSHIP — a turn whose `speakerId` is NOT in the declared roster is
27
+ * rejected with `validation_error` (§Spec item 3 / RFC 0005 §E).
28
+ * 4. MAXPARTICIPANTS — when the host advertises `maxParticipants`, an `open`
29
+ * whose roster exceeds it is rejected with `validation_error` (§Spec item 4).
30
+ *
31
+ * RFC 0005 §E pins the rejection *code* (`validation_error`), not the HTTP status —
32
+ * the leg asserts on `error.code` and tolerates `400`/`422`.
33
+ *
34
+ * Spec references:
35
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0101-multi-party-group-conversation.md
36
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0005-conversation.md (§E turn-validation)
37
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/host-sample-test-seams.md (multi-party seam)
38
+ */
39
+
40
+ import { describe, it, expect } from 'vitest';
41
+ import { driver } from '../lib/driver.js';
42
+ import { behaviorGate } from '../lib/behavior-gate.js';
43
+ import { isMultiPartyConversationSupported } from '../lib/multi-agent-capabilities.js';
44
+ import {
45
+ readMultiPartyCap,
46
+ openMultiPartyConversation,
47
+ exchangeMultiPartyTurn,
48
+ isValidationErrorRejection,
49
+ type SeamAgentRef,
50
+ type SeamTurn,
51
+ } from '../lib/multiPartyConversation.js';
52
+
53
+ const PROFILE = 'openwop-multi-party-conversation';
54
+
55
+ const ROSTER: SeamAgentRef[] = [
56
+ { agentId: 'host:advisor-cfo', name: 'CFO' },
57
+ { agentId: 'host:advisor-cmo', name: 'CMO' },
58
+ { agentId: 'host:advisor-cto', name: 'CTO' },
59
+ ];
60
+
61
+ /** A roster-valid, attributed agent turn from a named member. */
62
+ function agentTurn(speakerId: string, turnIndex: number): SeamTurn {
63
+ return {
64
+ messageId: `council-q1:${turnIndex}:agent`,
65
+ from: speakerId,
66
+ content: 'A roster-valid attributed turn.',
67
+ ts: 1718900000000 + turnIndex,
68
+ role: 'agent',
69
+ turnIndex,
70
+ speakerId,
71
+ };
72
+ }
73
+
74
+ describe('multi-party-conversation-behavioral (RFC 0101 §Conformance)', () => {
75
+ it('opens a council, accepts an attributed turn, and rejects missing/non-participant/over-cap', async () => {
76
+ const cap = await readMultiPartyCap();
77
+ const advertised = isMultiPartyConversationSupported() || cap?.supported === true;
78
+ if (!behaviorGate(PROFILE, advertised)) return;
79
+
80
+ // ---- Open a 3-agent council via the conformance seam ------------------
81
+ const convId = 'conf:multi-party:council-q1';
82
+ const opened = await openMultiPartyConversation({ conversationId: convId, participants: ROSTER });
83
+ if (opened.unwired) return; // seam not wired on this host — soft-skip the behavioral leg
84
+ expect(
85
+ opened.status === 200,
86
+ driver.describe('RFC 0101 §Spec', 'a conforming 3-agent council MUST open (≤ maxParticipants)'),
87
+ ).toBe(true);
88
+
89
+ // ---- MUST 1: POSITIVE — a roster-valid attributed turn is accepted ----
90
+ const ok = await exchangeMultiPartyTurn({ conversationId: convId, turn: agentTurn('host:advisor-cfo', 1) });
91
+ if (ok.unwired) return;
92
+ expect(
93
+ ok.status === 200,
94
+ driver.describe('RFC 0101 §Spec', "a role:'agent' turn with an in-roster speakerId MUST be accepted"),
95
+ ).toBe(true);
96
+
97
+ // ---- MUST 2: ATTRIBUTION — agent turn missing speakerId is rejected ---
98
+ const missing: SeamTurn = { ...agentTurn('host:advisor-cmo', 2) };
99
+ delete missing.speakerId;
100
+ const missingRes = await exchangeMultiPartyTurn({ conversationId: convId, turn: missing });
101
+ if (!missingRes.unwired) {
102
+ expect(
103
+ isValidationErrorRejection(missingRes),
104
+ driver.describe('RFC 0101 §Spec (attribution MUST)', "a role:'agent' turn missing speakerId MUST be rejected with validation_error"),
105
+ ).toBe(true);
106
+ }
107
+
108
+ // ---- MUST 3: MEMBERSHIP — non-participant speakerId is rejected --------
109
+ const intruder = agentTurn('host:advisor-intruder', 3);
110
+ const intruderRes = await exchangeMultiPartyTurn({ conversationId: convId, turn: intruder });
111
+ if (!intruderRes.unwired) {
112
+ expect(
113
+ isValidationErrorRejection(intruderRes),
114
+ driver.describe('RFC 0101 §Spec (membership MUST) / RFC 0005 §E', 'a turn whose speakerId is not in the roster MUST be rejected with validation_error'),
115
+ ).toBe(true);
116
+ }
117
+
118
+ // ---- MUST 4: MAXPARTICIPANTS — over-cap open is rejected ---------------
119
+ // Only assertable when the host advertises a maxParticipants ceiling.
120
+ const maxP = typeof cap?.maxParticipants === 'number' ? cap.maxParticipants : undefined;
121
+ if (typeof maxP === 'number' && maxP >= 2) {
122
+ const overflow: SeamAgentRef[] = Array.from({ length: maxP + 1 }, (_v, i) => ({
123
+ agentId: `host:advisor-${i}`,
124
+ }));
125
+ const overRes = await openMultiPartyConversation({
126
+ conversationId: 'conf:multi-party:overflow',
127
+ participants: overflow,
128
+ });
129
+ if (!overRes.unwired) {
130
+ expect(
131
+ isValidationErrorRejection(overRes),
132
+ driver.describe('RFC 0101 §Spec (maxParticipants MUST)', 'an open whose roster exceeds the advertised maxParticipants MUST be rejected with validation_error'),
133
+ ).toBe(true);
134
+ }
135
+ }
136
+ });
137
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Multi-party group conversation — shared transcript + speaker attribution (RFC 0101).
3
+ *
4
+ * Always-on, server-free schema-shape probe. Verifies the three additive,
5
+ * normative RFC 0101 wire facts on the published schemas:
6
+ *
7
+ * 1. `conversation-event.schema.json` `ConversationOpenedPayload` carries an
8
+ * OPTIONAL `participants: AgentRef[]` roster — a conforming 3-agent council
9
+ * transcript validates, and a malformed participant item is rejected.
10
+ * 2. `conversation-turn.schema.json` REQUIRES `speakerId` when `role: 'agent'`
11
+ * (the `allOf`/`if` conditional) — an agent turn WITH a roster-instance
12
+ * `speakerId` validates; an agent turn that OMITS `speakerId` MUST FAIL
13
+ * schema validation (the RFC 0101 attribution MUST); a `user`/`system` turn
14
+ * without `speakerId` is unaffected (additive — pre-RFC-0101 producers).
15
+ * 3. `capabilities.schema.json` declares the `multiPartyConversation` block with
16
+ * its `supported` flag (+ optional `maxParticipants`), and it is closed
17
+ * (`additionalProperties: false`).
18
+ *
19
+ * The non-participant-rejection MUST (a turn whose `speakerId` is NOT in the
20
+ * declared `participants` roster MUST be rejected) is a HOST-enforced runtime
21
+ * contract — it is not expressible by JSON Schema alone (roster membership is
22
+ * cross-field state). This probe asserts the wire SHAPE that makes the check
23
+ * possible (roster present + every agent turn attributed) and verifies the
24
+ * membership predicate directly; the behavioral leg (a live host rejecting a
25
+ * non-participant turn with `validation_error`) is gated on
26
+ * `multiPartyConversation.supported` and lands at the reference-host
27
+ * implementation (RFC 0101 §Conformance — same staging as RFC 0086's roster
28
+ * `roster.run.initiated` behavioral leg). This scenario asserts the wire
29
+ * contract, not host behavior.
30
+ *
31
+ * Normative references:
32
+ * - RFCS/0101-multi-party-group-conversation.md (§Spec / §Schema / §Conformance)
33
+ * - RFCS/0005-conversation.md (the single-agent conversation primitive this extends)
34
+ * - schemas/conversation-event.schema.json (ConversationOpenedPayload.participants)
35
+ * - schemas/conversation-turn.schema.json (speakerId + the role==='agent' conditional)
36
+ * - schemas/capabilities.schema.json (multiPartyConversation)
37
+ *
38
+ * @see RFCS/0101-multi-party-group-conversation.md
39
+ */
40
+
41
+ import { describe, it, expect } from 'vitest';
42
+ import { readFileSync } from 'node:fs';
43
+ import { join } from 'node:path';
44
+ import Ajv2020 from 'ajv/dist/2020.js';
45
+ import addFormats from 'ajv-formats';
46
+ import { SCHEMAS_DIR } from '../lib/paths.js';
47
+
48
+ /** Server-free assertion-message helper. */
49
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
50
+
51
+ function loadSchema(name: string): Record<string, unknown> {
52
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
53
+ }
54
+
55
+ describe('multi-party-conversation-shape: participant roster on conversation.opened (RFC 0101 §Schema, server-free)', () => {
56
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
57
+ addFormats(ajv);
58
+ ajv.addSchema(loadSchema('conversation-event.schema.json'), 'conversation-event');
59
+ const opened = ajv.getSchema('conversation-event#/$defs/ConversationOpenedPayload');
60
+
61
+ it('the ConversationOpenedPayload $def exists and declares participants: AgentRef[]', () => {
62
+ expect(opened, 'the ConversationOpenedPayload $def MUST exist').toBeTruthy();
63
+ });
64
+
65
+ it('a conforming 3-agent council transcript (participant roster + user opening turn) validates', () => {
66
+ const good = {
67
+ conversationId: 'run-abc:n1:0',
68
+ agentId: 'host:advisor-cfo',
69
+ participants: [
70
+ { agentId: 'host:advisor-cfo', name: 'CFO' },
71
+ { agentId: 'host:advisor-cmo', name: 'CMO' },
72
+ { agentId: 'host:advisor-cto', name: 'CTO' },
73
+ ],
74
+ initialTurn: {
75
+ messageId: 'run-abc:n1:0:user',
76
+ from: 'user',
77
+ content: 'Should we launch in Q3 or Q4?',
78
+ ts: 1718900000000,
79
+ role: 'user',
80
+ turnIndex: 0,
81
+ },
82
+ };
83
+ expect(opened!(good), why('RFC 0101 §Schema', 'a conversation.opened with a participant roster MUST validate')).toBe(true);
84
+ });
85
+
86
+ it('a participant entry that is not a valid AgentRef (no agentId) is rejected', () => {
87
+ const bad = {
88
+ conversationId: 'run-abc:n1:0',
89
+ participants: [{ name: 'CFO' }],
90
+ initialTurn: {
91
+ messageId: 'run-abc:n1:0:user',
92
+ from: 'user',
93
+ content: 'x',
94
+ ts: 1,
95
+ role: 'user',
96
+ turnIndex: 0,
97
+ },
98
+ };
99
+ expect(opened!(bad), why('RFC 0101 §Schema', 'a participants[] item without agentId MUST be rejected')).toBe(false);
100
+ });
101
+
102
+ it('participants is OPTIONAL — a single-agent conversation.opened (no roster) still validates (back-compat)', () => {
103
+ const noRoster = {
104
+ conversationId: 'run-abc:n1:0',
105
+ agentId: 'host:advisor-cfo',
106
+ initialTurn: {
107
+ messageId: 'run-abc:n1:0:user',
108
+ from: 'user',
109
+ content: 'x',
110
+ ts: 1,
111
+ role: 'user',
112
+ turnIndex: 0,
113
+ },
114
+ };
115
+ expect(opened!(noRoster), why('RFC 0101 §Compatibility', 'participants is additive — a roster-less conversation MUST still validate')).toBe(true);
116
+ });
117
+ });
118
+
119
+ describe('multi-party-conversation-shape: per-turn speaker attribution (RFC 0101 §Schema, server-free)', () => {
120
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
121
+ addFormats(ajv);
122
+ const turn = ajv.compile(loadSchema('conversation-turn.schema.json'));
123
+
124
+ const agentBase = {
125
+ messageId: 'council-q1:1:agent',
126
+ from: 'host:advisor-cfo',
127
+ content: "From a cash-runway view I'd push the launch one quarter.",
128
+ ts: 1718900000000,
129
+ role: 'agent' as const,
130
+ turnIndex: 1,
131
+ };
132
+
133
+ it('an agent turn WITH a roster-instance speakerId validates', () => {
134
+ expect(
135
+ turn({ ...agentBase, speakerId: 'host:advisor-cfo' }),
136
+ why('RFC 0101 §Schema', "a role:'agent' turn carrying speakerId MUST validate"),
137
+ ).toBe(true);
138
+ });
139
+
140
+ it("a role:'agent' turn MISSING speakerId MUST fail schema validation (the attribution MUST)", () => {
141
+ expect(
142
+ turn(agentBase),
143
+ why('RFC 0101 §Schema', "a role:'agent' turn that omits speakerId MUST be rejected (the conditional required)"),
144
+ ).toBe(false);
145
+ });
146
+
147
+ it("speakerId is OPTIONAL for role:'user' / role:'system' turns (additive — pre-RFC-0101 producers)", () => {
148
+ expect(
149
+ turn({ messageId: 'council-q1:0:user', from: 'user', content: 'Q3 or Q4?', ts: 1, role: 'user', turnIndex: 0 }),
150
+ why('RFC 0101 §Compatibility', "a role:'user' turn without speakerId MUST still validate"),
151
+ ).toBe(true);
152
+ expect(
153
+ turn({ messageId: 'council-q1:9:system', from: 'system', content: 'conversation timed out', ts: 1, role: 'system', turnIndex: 9 }),
154
+ why('RFC 0101 §Compatibility', "a role:'system' turn without speakerId MUST still validate"),
155
+ ).toBe(true);
156
+ });
157
+ });
158
+
159
+ describe('multi-party-conversation-shape: non-participant turn detection (RFC 0101 §Spec, server-free)', () => {
160
+ // The roster-membership MUST ("a turn whose speakerId is not a participant MUST
161
+ // be rejected") is host-enforced cross-field state, not JSON-Schema-expressible.
162
+ // This probe asserts the membership predicate the host applies, over the same
163
+ // shapes the schema validates — so the wire contract that makes the check
164
+ // possible is verified server-free; the live-host rejection is the gated
165
+ // behavioral leg (below).
166
+ const roster = new Set(['host:advisor-cfo', 'host:advisor-cmo', 'host:advisor-cto']);
167
+
168
+ it('a turn whose speakerId IS a participant is admissible', () => {
169
+ expect(roster.has('host:advisor-cmo'), why('RFC 0101 §Spec', 'an agent in the roster MAY speak')).toBe(true);
170
+ });
171
+
172
+ it('a turn whose speakerId is NOT a participant MUST be rejected by the host (roster membership)', () => {
173
+ expect(
174
+ roster.has('host:advisor-intruder'),
175
+ why('RFC 0101 §Spec', 'a turn from a non-participant agent MUST be rejected'),
176
+ ).toBe(false);
177
+ });
178
+ });
179
+
180
+ describe('multi-party-conversation-shape: capability advertisement (RFC 0101 §Schema, server-free)', () => {
181
+ it('capabilities.schema.json declares multiPartyConversation with supported + optional maxParticipants, closed', () => {
182
+ const caps = loadSchema('capabilities.schema.json');
183
+ const props = caps.properties as Record<string, Record<string, unknown>>;
184
+ const mpc = props.multiPartyConversation as
185
+ | { properties?: Record<string, unknown>; required?: string[]; additionalProperties?: boolean }
186
+ | undefined;
187
+ expect(mpc, why('RFC 0101 §Schema', 'capabilities.multiPartyConversation MUST be declared')).toBeDefined();
188
+ expect(mpc?.properties?.supported, why('RFC 0101 §Schema', 'multiPartyConversation.supported MUST be declared')).toBeDefined();
189
+ expect(mpc?.properties?.maxParticipants, why('RFC 0101 §Schema', 'multiPartyConversation.maxParticipants MUST be declared (optional)')).toBeDefined();
190
+ expect(mpc?.required, why('RFC 0101 §Schema', 'supported MUST be required on the block')).toContain('supported');
191
+ expect(mpc?.additionalProperties, why('RFC 0101 §Schema', 'the multiPartyConversation block MUST be closed')).toBe(false);
192
+ });
193
+
194
+ it('the multiPartyConversation block validates a conforming advertisement and rejects extras', () => {
195
+ const caps = loadSchema('capabilities.schema.json');
196
+ const mpc = (caps.properties as Record<string, Record<string, unknown>>).multiPartyConversation;
197
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
198
+ addFormats(ajv);
199
+ const validate = ajv.compile(mpc);
200
+ expect(validate({ supported: true, maxParticipants: 8 }), why('RFC 0101 §Schema', 'a conforming advertisement MUST validate')).toBe(true);
201
+ expect(validate({ supported: true }), why('RFC 0101 §Schema', 'maxParticipants is optional')).toBe(true);
202
+ expect(validate({ maxParticipants: 8 }), why('RFC 0101 §Schema', 'supported is required')).toBe(false);
203
+ expect(validate({ supported: true, unexpected: 1 }), why('RFC 0101 §Schema', 'an extra key MUST be rejected (closed block)')).toBe(false);
204
+ expect(validate({ supported: true, maxParticipants: 1 }), why('RFC 0101 §Schema', 'maxParticipants minimum is 2 (a council has ≥2)')).toBe(false);
205
+ });
206
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Publishable declarative pack kinds (RFC 0107, `Active`).
3
+ *
4
+ * OpenWOP defines declarative pack kinds at the SOURCE level — artifact-type
5
+ * (RFC 0075, `kind: "artifact-type"`) and connection (RFC 0095,
6
+ * `kind: "connection"`). RFC 0107 extends the PUBLISHED contract,
7
+ * `registry-version-manifest.schema.json`, so those kinds can be published to a
8
+ * registry: it adds a `kind` discriminator (absent ≡ `node`), the per-kind
9
+ * declarative payload (`artifactTypes[]` / `provider` / `chains[]` / …), and
10
+ * makes `runtime` required only for executable kinds.
11
+ *
12
+ * Always-on + server-free. Two parts (mirrors the project's other
13
+ * schema-contract scenarios):
14
+ *
15
+ * PART 1 — contract present. `registry-operations.md` §"Validation flow"
16
+ * carries the kind-aware manifest-schema selection (#3) and the
17
+ * skip-runtime-check-for-declarative-kinds rule (#7); the version-manifest
18
+ * schema carries the `kind` enum + declarative payload + conditional runtime.
19
+ * Guards against the requirement being silently dropped.
20
+ *
21
+ * PART 2 — schema admits declarative kinds and still rejects malformed ones.
22
+ * A published artifact-type and a published connection version manifest
23
+ * validate; a node manifest is unchanged; a declarative manifest carrying
24
+ * `runtime` (forbidden) and a node manifest missing `runtime` are rejected.
25
+ *
26
+ * @see spec/v1/registry-operations.md §"Validation flow"
27
+ * @see schemas/registry-version-manifest.schema.json
28
+ * @see RFCS/0107-publishable-declarative-pack-kinds.md
29
+ * @see RFCS/0075-artifact-type-packs-realworld-amendment.md, RFCS/0095-connection-packs-portable-provider-definitions.md
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest';
33
+ import { readFileSync } from 'node:fs';
34
+ import { join } from 'node:path';
35
+ import Ajv2020 from 'ajv/dist/2020.js';
36
+ import addFormats from 'ajv-formats';
37
+ import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
38
+
39
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
40
+
41
+ describe('registry-declarative-kinds: contract present in the corpus (RFC 0107, server-free)', () => {
42
+ const registryDoc = V1_DIR ? readFileSync(join(V1_DIR, 'registry-operations.md'), 'utf8') : '';
43
+ const versionManifestSchema = JSON.parse(
44
+ readFileSync(join(SCHEMAS_DIR, 'registry-version-manifest.schema.json'), 'utf8'),
45
+ );
46
+
47
+ it.skipIf(V1_DIR === null)('registry-operations.md §Validation flow selects the manifest schema by `kind`', () => {
48
+ expect(
49
+ /kind[\s\S]{0,120}artifact-type-pack-manifest\.schema\.json/.test(registryDoc),
50
+ why('registry-operations.md §Validation flow #3', 'pack.json validates against the per-`kind` source schema (RFC 0107)'),
51
+ ).toBe(true);
52
+ });
53
+
54
+ it.skipIf(V1_DIR === null)('registry-operations.md §Validation flow skips the runtime check for declarative kinds', () => {
55
+ expect(
56
+ /declarative[\s\S]{0,160}(skipped|MUST NOT)/i.test(registryDoc) && /runtime/i.test(registryDoc),
57
+ why('registry-operations.md §Validation flow #7', 'runtime-support check is skipped for declarative kinds (RFC 0107)'),
58
+ ).toBe(true);
59
+ });
60
+
61
+ it('registry-version-manifest.schema.json carries the `kind` discriminator + declarative payload + conditional runtime', () => {
62
+ const props = versionManifestSchema.properties ?? {};
63
+ expect(props.kind?.enum, why('registry-version-manifest.schema.json', '`kind` enum present')).toEqual(
64
+ expect.arrayContaining(['node', 'artifact-type', 'connection']),
65
+ );
66
+ expect(!!props.artifactTypes && !!props.provider, why('registry-version-manifest.schema.json', 'declarative payload props present')).toBe(true);
67
+ expect(
68
+ (versionManifestSchema.required ?? []).includes('runtime'),
69
+ why('registry-version-manifest.schema.json', '`runtime` is NOT top-level required (conditional on kind)'),
70
+ ).toBe(false);
71
+ expect(
72
+ JSON.stringify(versionManifestSchema.allOf ?? []).includes('runtime'),
73
+ why('registry-version-manifest.schema.json', '`allOf` if/then/else makes runtime conditional on kind'),
74
+ ).toBe(true);
75
+ });
76
+ });
77
+
78
+ describe('registry-declarative-kinds: version-manifest schema admits declarative kinds (RFC 0107, server-free)', () => {
79
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
80
+ addFormats(ajv);
81
+ const validate = ajv.compile(
82
+ JSON.parse(readFileSync(join(SCHEMAS_DIR, 'registry-version-manifest.schema.json'), 'utf8')),
83
+ );
84
+
85
+ const base = { name: 'core.openwop.x', version: '1.0.0', engines: { openwop: '>=1.0.0' }, integrity: 'sha256-abc=' };
86
+
87
+ it('a published artifact-type version manifest validates (kind + artifactTypes, no runtime)', () => {
88
+ const ok = validate({ ...base, kind: 'artifact-type', artifactTypes: [{ artifactTypeId: 'doc.one-pager', title: 'One-Pager', schema: { type: 'object' } }] });
89
+ expect(ok, why('registry-operations.md §Validation flow', 'artifact-type manifest publishes (RFC 0107)')).toBe(true);
90
+ });
91
+
92
+ it('a published connection version manifest validates (kind + provider, no runtime)', () => {
93
+ const ok = validate({ ...base, kind: 'connection', provider: { id: 'github', category: 'dev' } });
94
+ expect(ok, why('registry-operations.md §Validation flow', 'connection manifest publishes (RFC 0107)')).toBe(true);
95
+ });
96
+
97
+ it('an unchanged node version manifest still validates (no kind, runtime + nodes)', () => {
98
+ const ok = validate({ ...base, runtime: { language: 'javascript' }, nodes: [{ typeId: 'core.openwop.x.n', version: '1.0.0', category: 'data', role: 'pure' }] });
99
+ expect(ok, why('COMPATIBILITY.md §2.1', 'RFC 0107 is additive — node manifests validate unchanged')).toBe(true);
100
+ });
101
+
102
+ it('a declarative manifest carrying `runtime` is REJECTED (declarative kinds carry no runtime)', () => {
103
+ const ok = validate({ ...base, kind: 'artifact-type', artifactTypes: [{ artifactTypeId: 'doc.x' }], runtime: { language: 'javascript' } });
104
+ expect(ok, why('registry-version-manifest.schema.json', 'declarative kind MUST NOT carry runtime')).toBe(false);
105
+ });
106
+
107
+ it('a node manifest MISSING `runtime` is still REJECTED (executable kinds require runtime)', () => {
108
+ const ok = validate({ ...base, nodes: [{ typeId: 'core.openwop.x.n', version: '1.0.0', category: 'data', role: 'pure' }] });
109
+ expect(ok, why('registry-version-manifest.schema.json', 'executable kind (node/absent) still requires runtime')).toBe(false);
110
+ });
111
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Speech-synthesis round-trip (RFC 0105 §A) — behavioral.
3
+ *
4
+ * Gated on `capabilities.aiProviders.speechSynthesis === 'supported'`
5
+ * (root-first per RFC 0073). Soft-skips when unadvertised (default) /
6
+ * hard-fails under `OPENWOP_REQUIRE_BEHAVIOR=true`. The always-on wire-shape
7
+ * coverage lives in `aiproviders-speechsynth-shape.test.ts`; this asserts host
8
+ * BEHAVIOR via the documented host-sample seam
9
+ * `POST /v1/host/sample/ai/call-speech-synthesizer` (soft-skips on 404 until a
10
+ * host wires it):
11
+ *
12
+ * - the synthesizer returns 200 with an `audio` object;
13
+ * - EXACTLY ONE of `audio.url` / `audio.base64` is present (a host-served URL
14
+ * reference OR an inline base64 asset — never both, never neither);
15
+ * - `audio.mimeType` is a non-empty string;
16
+ * - `audio.voiceId` echoes the input `voiceId` (the opaque host-resolved id).
17
+ *
18
+ * Spec references:
19
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0105-speech-synthesis-adapter.md (§A)
20
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/host-capabilities.md (§host.aiProviders)
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest';
24
+ import { driver } from '../lib/driver.js';
25
+ import { behaviorGate } from '../lib/behavior-gate.js';
26
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
27
+
28
+ const SEAM = '/v1/host/sample/ai/call-speech-synthesizer';
29
+ const VOICE_ID = 'host:narrator-test';
30
+
31
+ /** Pull the `audio` object out of a seam response body (tolerant). */
32
+ function audioOf(json: unknown): Record<string, unknown> | undefined {
33
+ const a = (json as { audio?: unknown })?.audio;
34
+ return a && typeof a === 'object' ? (a as Record<string, unknown>) : undefined;
35
+ }
36
+
37
+ describe('speech-synthesis-roundtrip (RFC 0105 §A)', () => {
38
+ it('synthesizes an audio asset with exactly one of url/base64 and echoes the voiceId', async () => {
39
+ const ai = await readCapabilityFamily<Record<string, unknown>>('aiProviders');
40
+ const advertised = ai?.speechSynthesis === 'supported';
41
+ if (!behaviorGate('openwop-speech-synthesis', advertised)) return;
42
+
43
+ const res = await driver.post(SEAM, {
44
+ text: 'Welcome to the weekly digest.',
45
+ voiceId: VOICE_ID,
46
+ });
47
+ if (res.status === 404) return; // seam unwired — soft-skip the behavioral suite
48
+
49
+ expect(
50
+ res.status === 200,
51
+ driver.describe('RFC 0105 §A', 'an advertised host MUST synthesize and return 200'),
52
+ ).toBe(true);
53
+
54
+ const audio = audioOf(res.json);
55
+ expect(
56
+ audio !== undefined,
57
+ driver.describe('RFC 0105 §A', 'the response MUST carry an `audio` object'),
58
+ ).toBe(true);
59
+ if (!audio) return;
60
+
61
+ const hasUrl = typeof audio.url === 'string' && (audio.url as string).length > 0;
62
+ const hasBase64 = typeof audio.base64 === 'string' && (audio.base64 as string).length > 0;
63
+ expect(
64
+ hasUrl !== hasBase64,
65
+ driver.describe('RFC 0105 §A', 'audio MUST carry EXACTLY ONE of `url` / `base64`'),
66
+ ).toBe(true);
67
+
68
+ expect(
69
+ typeof audio.mimeType === 'string' && (audio.mimeType as string).length > 0,
70
+ driver.describe('RFC 0105 §A', 'audio.mimeType MUST be a non-empty string'),
71
+ ).toBe(true);
72
+
73
+ expect(
74
+ audio.voiceId === VOICE_ID,
75
+ driver.describe('RFC 0105 §A', 'audio.voiceId MUST echo the input voiceId'),
76
+ ).toBe(true);
77
+ });
78
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Speech-synthesis on an unadvertised host (RFC 0105 §C) — behavioral.
3
+ *
4
+ * The mirror of `speech-synthesis-roundtrip.test.ts`: a host that does NOT
5
+ * advertise `capabilities.aiProviders.speechSynthesis === 'supported'` MUST
6
+ * REJECT a `ctx.callSpeechSynthesizer` call with the canonical
7
+ * `speech_synthesis_unsupported` error — never a 200 success, never a silent
8
+ * no-op (parallel to RFC 0091's `unsupported_modality`).
9
+ *
10
+ * Gating is BY ABSENCE: the leg is active precisely when TTS is NOT advertised
11
+ * (`behaviorGate('openwop-speech-synthesis-unadvertised', !advertised)`), so
12
+ * it soft-skips on a host that DOES advertise (where the round-trip leg runs
13
+ * instead) / hard-fails under `OPENWOP_REQUIRE_BEHAVIOR=true` when the seam is
14
+ * wired but the host fails to reject. Exercised via the documented host-sample
15
+ * seam `POST /v1/host/sample/ai/call-speech-synthesizer` (soft-skips on 404
16
+ * until a host wires it).
17
+ *
18
+ * Spec references:
19
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0105-speech-synthesis-adapter.md (§C)
20
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/host-capabilities.md (§host.aiProviders)
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest';
24
+ import { driver } from '../lib/driver.js';
25
+ import { behaviorGate } from '../lib/behavior-gate.js';
26
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
27
+
28
+ const SEAM = '/v1/host/sample/ai/call-speech-synthesizer';
29
+
30
+ /** Read the canonical error code from a seam response body (tolerant of
31
+ * `{error}` / `{code}` / `{error:{code}}` shapes) — mirrors
32
+ * `callai-multimodal.test.ts`'s `errCode`. */
33
+ function errCode(json: unknown): string | undefined {
34
+ const j = json as { error?: unknown; code?: unknown };
35
+ if (typeof j?.code === 'string') return j.code;
36
+ if (typeof j?.error === 'string') return j.error;
37
+ const e = j?.error as { code?: unknown } | undefined;
38
+ if (e && typeof e.code === 'string') return e.code;
39
+ return undefined;
40
+ }
41
+
42
+ describe('speech-synthesis-unadvertised (RFC 0105 §C)', () => {
43
+ it('a host NOT advertising speechSynthesis MUST reject the call with speech_synthesis_unsupported', async () => {
44
+ const ai = await readCapabilityFamily<Record<string, unknown>>('aiProviders');
45
+ const advertised = ai?.speechSynthesis === 'supported';
46
+ // Active precisely when TTS is ABSENT but the seam exists.
47
+ if (!behaviorGate('openwop-speech-synthesis-unadvertised', !advertised)) return;
48
+
49
+ const res = await driver.post(SEAM, {
50
+ text: 'Welcome to the weekly digest.',
51
+ voiceId: 'host:narrator-test',
52
+ });
53
+ if (res.status === 404) return; // seam unwired — soft-skip
54
+
55
+ // MUST reject — never a 200 success, never a silent no-op.
56
+ expect(
57
+ res.status !== 200,
58
+ driver.describe('RFC 0105 §C', 'an unadvertising host MUST NOT return a 200 success (never a no-op)'),
59
+ ).toBe(true);
60
+ expect(
61
+ errCode(res.json) === 'speech_synthesis_unsupported',
62
+ driver.describe('RFC 0105 §C', 'the call MUST be rejected with `speech_synthesis_unsupported`'),
63
+ ).toBe(true);
64
+ });
65
+ });