@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.
- package/CHANGELOG.md +8 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +53 -0
- package/coverage.md +21 -0
- package/package.json +1 -1
- package/schemas/capabilities.schema.json +62 -1
- package/schemas/conversation-event.schema.json +50 -2
- package/schemas/conversation-turn.schema.json +35 -0
- package/schemas/registry-version-manifest.schema.json +49 -2
- package/schemas/run-event-payloads.schema.json +87 -2
- package/schemas/run-event.schema.json +8 -1
- package/src/lib/multi-agent-capabilities.ts +23 -4
- package/src/lib/multiPartyConversation.ts +121 -0
- package/src/scenarios/aiproviders-realtimevoice-shape.test.ts +120 -0
- package/src/scenarios/aiproviders-speechsynth-shape.test.ts +92 -0
- package/src/scenarios/multi-party-conversation-behavioral.test.ts +137 -0
- package/src/scenarios/multi-party-conversation-shape.test.ts +206 -0
- package/src/scenarios/registry-declarative-kinds.test.ts +111 -0
- package/src/scenarios/speech-synthesis-roundtrip.test.ts +78 -0
- package/src/scenarios/speech-synthesis-unadvertised.test.ts +65 -0
- package/src/scenarios/voice-event-payloads-shape.test.ts +127 -0
|
@@ -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
|
+
});
|