@openwop/openwop-conformance 1.29.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 +4 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +53 -0
- package/coverage.md +13 -0
- package/package.json +1 -1
- package/schemas/capabilities.schema.json +58 -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/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/voice-event-payloads-shape.test.ts +127 -0
|
@@ -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,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice run-event payload shapes (RFC 0106 §B/§C/§D) — server-free.
|
|
3
|
+
*
|
|
4
|
+
* Always-on schema-shape probe for the `voice.*` run-event taxonomy — the single
|
|
5
|
+
* canonical record of a live voice turn (the `ctx.callTranscriber` Promise resolves
|
|
6
|
+
* at `turn_commit`; the events ARE the streaming representation). Verifies that:
|
|
7
|
+
* - all seven `voice.*` types are members of `run-event.schema.json#$defs.RunEventType`;
|
|
8
|
+
* - each has a payload `$def` in `run-event-payloads.schema.json` reachable via the
|
|
9
|
+
* `typeIndex` lookup map;
|
|
10
|
+
* - `voice.transcript` REQUIRES `contentTrust: "untrusted"` — the schema-enforced wire
|
|
11
|
+
* half of SECURITY invariant `voice-transcript-untrusted` (live transcript is
|
|
12
|
+
* untrusted ingress and MUST NOT be promoted to system/developer authority);
|
|
13
|
+
* - `voice.synthesis_chunk` is metadata-shaped (seq + mimeType required; bytes by
|
|
14
|
+
* `url`/`streamRef`, inline `base64` optional-only) — backing the G8 metadata-only
|
|
15
|
+
* event-log rule (`voice-streamref-tenant-bound` keeps the log bounded);
|
|
16
|
+
* - the content-free events (`speech_start`/`endpoint_candidate`/`turn_commit`/
|
|
17
|
+
* `barge_in`/`cancelled`) reject an unknown property (`additionalProperties:false`).
|
|
18
|
+
*
|
|
19
|
+
* The behavioral legs (interim-not-durable, barge-in no-partial-leak, streamRef
|
|
20
|
+
* tenant-binding, the live `callTranscriber` Promise + emission) are the gated
|
|
21
|
+
* reference-host scenarios that land at `Active → Accepted` — those invariants are
|
|
22
|
+
* reference-impl tier until a host proves them non-vacuously.
|
|
23
|
+
*
|
|
24
|
+
* Spec references:
|
|
25
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0106-realtime-voice-session-profile.md (§B, §C, §D, §F)
|
|
26
|
+
* - https://github.com/openwop/openwop/blob/main/schemas/run-event-payloads.schema.json
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect } from 'vitest';
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
33
|
+
import addFormats from 'ajv-formats';
|
|
34
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
35
|
+
|
|
36
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
37
|
+
|
|
38
|
+
function loadSchema(name: string): Record<string, any> {
|
|
39
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, any>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const VOICE_TYPES = [
|
|
43
|
+
'voice.speech_start',
|
|
44
|
+
'voice.transcript',
|
|
45
|
+
'voice.endpoint_candidate',
|
|
46
|
+
'voice.turn_commit',
|
|
47
|
+
'voice.synthesis_chunk',
|
|
48
|
+
'voice.barge_in',
|
|
49
|
+
'voice.cancelled',
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
const DEF_KEY: Record<string, string> = {
|
|
53
|
+
'voice.speech_start': 'voiceSpeechStart',
|
|
54
|
+
'voice.transcript': 'voiceTranscript',
|
|
55
|
+
'voice.endpoint_candidate': 'voiceEndpointCandidate',
|
|
56
|
+
'voice.turn_commit': 'voiceTurnCommit',
|
|
57
|
+
'voice.synthesis_chunk': 'voiceSynthesisChunk',
|
|
58
|
+
'voice.barge_in': 'voiceBargeIn',
|
|
59
|
+
'voice.cancelled': 'voiceCancelled',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe('voice-event-payloads-shape: the voice.* run-event taxonomy (RFC 0106 §B/§C/§D, server-free)', () => {
|
|
63
|
+
it('all seven voice.* types are in the RunEventType enum', () => {
|
|
64
|
+
const enumVals: string[] = loadSchema('run-event.schema.json').$defs.RunEventType.enum;
|
|
65
|
+
for (const t of VOICE_TYPES) {
|
|
66
|
+
expect(enumVals.includes(t), why('run-event.schema.json §RunEventType', `${t} MUST be a RunEventType`)).toBe(true);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('each voice.* type has a payload $def reachable via typeIndex', () => {
|
|
71
|
+
const payloads = loadSchema('run-event-payloads.schema.json');
|
|
72
|
+
const typeIndex = payloads.$defs._typeIndex.properties as Record<string, { $ref: string }>;
|
|
73
|
+
for (const t of VOICE_TYPES) {
|
|
74
|
+
expect(typeIndex[t], why('run-event-payloads.schema.json §typeIndex', `${t} MUST map to a $def`)).toBeDefined();
|
|
75
|
+
expect(payloads.$defs[DEF_KEY[t]], why('run-event-payloads.schema.json', `$defs.${DEF_KEY[t]} MUST exist`)).toBeDefined();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('voice.transcript REQUIRES contentTrust:"untrusted" (SECURITY: voice-transcript-untrusted)', () => {
|
|
80
|
+
const def = loadSchema('run-event-payloads.schema.json').$defs.voiceTranscript;
|
|
81
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
82
|
+
addFormats(ajv);
|
|
83
|
+
const validate = ajv.compile(def);
|
|
84
|
+
|
|
85
|
+
expect(
|
|
86
|
+
validate({ text: 'book a table', isFinal: true, atMs: 1200, contentTrust: 'untrusted' }),
|
|
87
|
+
why('RFC 0106 §F INV-2', 'a transcript marked untrusted MUST validate'),
|
|
88
|
+
).toBe(true);
|
|
89
|
+
expect(
|
|
90
|
+
validate({ text: 'book a table', isFinal: true, atMs: 1200 }),
|
|
91
|
+
why('RFC 0106 §F INV-2 (voice-transcript-untrusted)', 'a transcript WITHOUT contentTrust MUST be rejected'),
|
|
92
|
+
).toBe(false);
|
|
93
|
+
expect(
|
|
94
|
+
validate({ text: 'book a table', isFinal: true, atMs: 1200, contentTrust: 'trusted' }),
|
|
95
|
+
why('RFC 0106 §F INV-2 (voice-transcript-untrusted)', 'contentTrust MUST be the const "untrusted" — never promoted'),
|
|
96
|
+
).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('voice.synthesis_chunk is metadata-shaped (seq+mimeType required; bytes by reference, inline base64 optional)', () => {
|
|
100
|
+
const def = loadSchema('run-event-payloads.schema.json').$defs.voiceSynthesisChunk;
|
|
101
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
102
|
+
addFormats(ajv);
|
|
103
|
+
const validate = ajv.compile(def);
|
|
104
|
+
|
|
105
|
+
expect(
|
|
106
|
+
validate({ seq: 0, mimeType: 'audio/mpeg', durationMs: 240, url: 'https://host/seg/0', final: false }),
|
|
107
|
+
why('RFC 0106 §C', 'a metadata chunk referencing bytes by url MUST validate'),
|
|
108
|
+
).toBe(true);
|
|
109
|
+
expect(
|
|
110
|
+
validate({ mimeType: 'audio/mpeg' }),
|
|
111
|
+
why('RFC 0106 §C', 'a chunk without seq MUST be rejected'),
|
|
112
|
+
).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('content-free voice events reject unknown properties (additionalProperties:false)', () => {
|
|
116
|
+
const payloads = loadSchema('run-event-payloads.schema.json');
|
|
117
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
118
|
+
addFormats(ajv);
|
|
119
|
+
for (const key of ['voiceSpeechStart', 'voiceTurnCommit', 'voiceBargeIn', 'voiceCancelled', 'voiceEndpointCandidate']) {
|
|
120
|
+
const validate = ajv.compile(payloads.$defs[key]);
|
|
121
|
+
expect(
|
|
122
|
+
validate({ atMs: 100, transcriptBody: 'should not be here' }),
|
|
123
|
+
why('RFC 0106 §D', `${key} MUST reject an unknown (content-bearing) property`),
|
|
124
|
+
).toBe(false);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|