@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.
@@ -66,7 +66,42 @@
66
66
  "type": "integer",
67
67
  "minimum": 0,
68
68
  "description": "0-based index within the conversation. `conversation.opened.initialTurn` carries `turnIndex: 0`; subsequent `conversation.exchanged` turns increment monotonically; `conversation.closed.finalTurn` carries the highest index."
69
+ },
70
+ "speakerId": {
71
+ "type": "string",
72
+ "minLength": 1,
73
+ "maxLength": 256,
74
+ "description": "RFC 0101. Stable speaker identity of this turn — the roster INSTANCE id of the agent that produced it (the `host:<id>` AgentRef agentId per `agent-roster-entry.schema.json` §A / RFC 0086), NOT the manifest/class `AgentRef.agentId`. REQUIRED when `role: 'agent'` (the conditional below) so that 'turn N was spoken by agent X' is an observable, replay-stable, cross-host-projectable fact in a multi-party transcript. When a `participants` roster is declared on `conversation.opened` (RFC 0101), `speakerId` MUST be a member of that roster; a turn whose `speakerId` is not a participant MUST be rejected (host-enforced, capability-gated on `multiPartyConversation.supported`). For `role: 'user'` / `role: 'system'` turns the field is OPTIONAL and carries no normative meaning. Additive-safe: pre-RFC-0101 `role: 'agent'` turns that omit it are accepted by hosts that do NOT advertise `multiPartyConversation`."
69
75
  }
70
76
  },
77
+ "allOf": [
78
+ {
79
+ "$comment": "RFC 0101 — agent turns MUST carry speaker attribution. Conditional (not unconditional `required`) so the field is only mandatory for `role: 'agent'`; `user`/`system` turns and pre-RFC-0101 producers on non-multi-party hosts are unaffected. Capability-gated: hosts enforce this only when advertising `multiPartyConversation.supported` (capabilities.schema.json).",
80
+ "if": { "properties": { "role": { "const": "agent" } }, "required": ["role"] },
81
+ "then": { "required": ["speakerId"] }
82
+ }
83
+ ],
84
+ "examples": [
85
+ {
86
+ "$comment": "RFC 0101 POSITIVE — an agent turn in a multi-party council carrying speaker attribution (the roster instance id).",
87
+ "messageId": "council-q1:1:agent",
88
+ "from": "host:advisor-cfo",
89
+ "content": "From a cash-runway view I'd push the launch one quarter.",
90
+ "ts": 1718900000000,
91
+ "role": "agent",
92
+ "turnIndex": 1,
93
+ "speakerId": "host:advisor-cfo"
94
+ },
95
+ {
96
+ "$comment": "RFC 0101 POSITIVE — a user turn; speakerId is OPTIONAL for non-agent roles and omitted here.",
97
+ "messageId": "council-q1:0:user",
98
+ "from": "user",
99
+ "content": "Should we launch in Q3 or Q4?",
100
+ "ts": 1718900000000,
101
+ "role": "user",
102
+ "turnIndex": 0
103
+ }
104
+ ],
105
+ "$comment": "RFC 0101 NEGATIVE (covered by the `allOf`/`if` conditional + conversation scenarios, NOT validatable as an `examples[]` entry which must be VALID): a `{ role: 'agent', ... }` turn that OMITS `speakerId` MUST FAIL validation; see conformance/src/scenarios/multi-party-conversation-shape.test.ts.",
71
106
  "additionalProperties": true
72
107
  }
@@ -4,10 +4,26 @@
4
4
  "title": "RegistryVersionManifest",
5
5
  "description": "Registry-augmented version manifest served at `/v1/packs/{name}/-/{version}.json`. Extends the bare pack manifest (`node-pack-manifest.schema.json`) with registry-side metadata (integrity hash, publishedAt timestamp, signed-blob URLs, lifecycle flags). This is the shape clients receive when they GET a specific pack version; the bare manifest is what publishers commit inside the tarball.\n\n**Why two schemas?** The bare manifest is the authoring contract — what a vendor writes in their pack source tree. The registry-augmented version is the SERVING contract — what `packs.openwop.dev` produces after `build-index.mjs` computes integrity hashes + adds URL templates. Validating both surfaces independently catches drift between authored content and served metadata.",
6
6
  "type": "object",
7
- "required": ["name", "version", "engines", "runtime", "integrity"],
7
+ "required": ["name", "version", "engines", "integrity"],
8
8
  "anyOf": [
9
9
  { "properties": { "nodes": { "type": "array", "minItems": 1 } }, "required": ["nodes"] },
10
- { "properties": { "agents": { "type": "array", "minItems": 1 } }, "required": ["agents"] }
10
+ { "properties": { "agents": { "type": "array", "minItems": 1 } }, "required": ["agents"] },
11
+ { "properties": { "artifactTypes": { "type": "array", "minItems": 1 } }, "required": ["artifactTypes"] },
12
+ { "required": ["provider"] },
13
+ { "properties": { "chains": { "type": "array", "minItems": 1 } }, "required": ["chains"] },
14
+ { "properties": { "prompts": { "type": "array", "minItems": 1 } }, "required": ["prompts"] },
15
+ { "properties": { "cards": { "type": "array", "minItems": 1 } }, "required": ["cards"] }
16
+ ],
17
+ "allOf": [
18
+ {
19
+ "$comment": "RFC 0107 — runtime is required for EXECUTABLE kinds (node, or kind absent) and MUST be absent for DECLARATIVE kinds (artifact-type, connection, workflow-chain, prompt, chat-card).",
20
+ "if": {
21
+ "properties": { "kind": { "enum": ["artifact-type", "connection", "workflow-chain", "prompt", "chat-card"] } },
22
+ "required": ["kind"]
23
+ },
24
+ "then": { "not": { "required": ["runtime"] } },
25
+ "else": { "required": ["runtime"] }
26
+ }
11
27
  ],
12
28
  "properties": {
13
29
  "name": {
@@ -40,6 +56,37 @@
40
56
  },
41
57
  "additionalProperties": { "type": "string" }
42
58
  },
59
+ "kind": {
60
+ "type": "string",
61
+ "enum": ["node", "artifact-type", "connection", "workflow-chain", "prompt", "chat-card"],
62
+ "default": "node",
63
+ "description": "Pack-kind discriminator (RFC 0107). ABSENT ≡ `node` (the original, executable kind — backward compatible). Executable kinds (`node`) carry `runtime` + `nodes[]`/`agents[]`. DECLARATIVE kinds carry their own payload and NO `runtime`: `artifact-type` → `artifactTypes[]` (RFC 0075); `connection` → `provider` (RFC 0095); `workflow-chain` → `chains[]` (RFC 0013); `prompt` → `prompts[]`; `chat-card` → `cards[]`. The registry's runtime-support check (registry-operations.md §Validation flow #7) is skipped for declarative kinds."
64
+ },
65
+ "artifactTypes": {
66
+ "type": "array",
67
+ "description": "Present iff `kind == \"artifact-type\"` (RFC 0075). Mirrors `artifact-type-pack-manifest.schema.json` `artifactTypes[]` (each: `artifactTypeId`, `title`, `schema`, `export[]`). Carried loosely here (the source schema is authoritative); the registry denormalizes `artifactTypes[].artifactTypeId` into the per-pack index for discovery.",
68
+ "items": { "type": "object", "additionalProperties": true }
69
+ },
70
+ "provider": {
71
+ "type": "object",
72
+ "description": "Present iff `kind == \"connection\"` (RFC 0095). Mirrors `connection-pack-manifest.schema.json` `provider` (id, category, auth, reach, …). MUST NOT carry credential material (RFC 0095 §B.2 — enforced at source; a registry SHOULD re-scan). The registry denormalizes `provider.id` into the per-pack index.",
73
+ "additionalProperties": true
74
+ },
75
+ "chains": {
76
+ "type": "array",
77
+ "description": "Present iff `kind == \"workflow-chain\"` (RFC 0013). Carried loosely (the source schema is authoritative); the registry denormalizes `chains[].chainId` into the per-pack index.",
78
+ "items": { "type": "object", "additionalProperties": true }
79
+ },
80
+ "prompts": {
81
+ "type": "array",
82
+ "description": "Present iff `kind == \"prompt\"`. Carried loosely; source schema authoritative.",
83
+ "items": { "type": "object", "additionalProperties": true }
84
+ },
85
+ "cards": {
86
+ "type": "array",
87
+ "description": "Present iff `kind == \"chat-card\"`. Carried loosely; source schema authoritative.",
88
+ "items": { "type": "object", "additionalProperties": true }
89
+ },
43
90
  "runtime": {
44
91
  "type": "object",
45
92
  "required": ["language"],
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://openwop.dev/spec/v1/run-event-payloads.schema.json",
4
4
  "title": "RunEventPayloads",
5
- "description": "Per-RunEventType payload schemas. The base RunEventDoc shape (run-event.schema.json) leaves `payload` permissive for forward-compat. This schema defines the canonical payload contract for each known RunEventType. Consumers MAY pin strict payload validation via `$defs.<typeId>` and `ajv.validate(schema.$defs[event.type], event.payload)`. Unknown event types MUST be tolerated (no $defs match → fold best-effort).\n\n100 variants from `run-event.schema.json#$defs.RunEventType` are covered, grouped into ~20 shape families with shared $defs. Naming convention: camelCase keys mirror dotted RunEventType names (e.g., `run.started` → `runStarted`).",
5
+ "description": "Per-RunEventType payload schemas. The base RunEventDoc shape (run-event.schema.json) leaves `payload` permissive for forward-compat. This schema defines the canonical payload contract for each known RunEventType. Consumers MAY pin strict payload validation via `$defs.<typeId>` and `ajv.validate(schema.$defs[event.type], event.payload)`. Unknown event types MUST be tolerated (no $defs match → fold best-effort).\n\n107 variants from `run-event.schema.json#$defs.RunEventType` are covered, grouped into ~20 shape families with shared $defs. Naming convention: camelCase keys mirror dotted RunEventType names (e.g., `run.started` → `runStarted`).",
6
6
  "type": "object",
7
7
  "$defs": {
8
8
  "_typeIndex": {
@@ -109,10 +109,95 @@
109
109
  "proposal.activated": { "$ref": "#/$defs/proposalActivated" },
110
110
  "goal.evaluated": { "$ref": "#/$defs/goalEvaluated" },
111
111
  "goal.closed": { "$ref": "#/$defs/goalClosed" },
112
- "import.applied": { "$ref": "#/$defs/importApplied" }
112
+ "import.applied": { "$ref": "#/$defs/importApplied" },
113
+ "voice.speech_start": { "$ref": "#/$defs/voiceSpeechStart" },
114
+ "voice.transcript": { "$ref": "#/$defs/voiceTranscript" },
115
+ "voice.endpoint_candidate": { "$ref": "#/$defs/voiceEndpointCandidate" },
116
+ "voice.turn_commit": { "$ref": "#/$defs/voiceTurnCommit" },
117
+ "voice.synthesis_chunk": { "$ref": "#/$defs/voiceSynthesisChunk" },
118
+ "voice.barge_in": { "$ref": "#/$defs/voiceBargeIn" },
119
+ "voice.cancelled": { "$ref": "#/$defs/voiceCancelled" }
113
120
  }
114
121
  },
115
122
 
123
+ "voiceSpeechStart": {
124
+ "description": "RFC 0106 §D. Emitted when inbound user speech onset is detected in a live voice session. Content-free (a timestamp only). MUST NOT be emitted unless `aiProviders.realtimeVoice.transcription: \"streaming\"` is advertised.",
125
+ "type": "object",
126
+ "additionalProperties": false,
127
+ "required": ["atMs"],
128
+ "properties": {
129
+ "atMs": { "type": "integer", "minimum": 0, "description": "Session-relative onset timestamp (ms)." }
130
+ }
131
+ },
132
+ "voiceTranscript": {
133
+ "description": "RFC 0106 §B/§D. A settled or provisional transcript part for the current turn, emitted on the durable event log as the single canonical record (the `ctx.callTranscriber` Promise resolves separately at `turn_commit`). `text` is the recognized speech — UNTRUSTED input (anyone in acoustic range is an unauthenticated writer): it MUST carry `contentTrust: \"untrusted\"` and MUST NOT be promoted to system/developer authority (SECURITY invariant `voice-transcript-untrusted`). A part with `isFinal: false` is provisional and MUST NOT be persisted to durable memory / the replay log / RAG or drive a side-effecting tool call before it finalizes (`voice-interim-not-durable`).",
134
+ "type": "object",
135
+ "additionalProperties": false,
136
+ "required": ["text", "isFinal", "atMs", "contentTrust"],
137
+ "properties": {
138
+ "text": { "type": "string", "description": "The current hypothesis for this segment (editable tail when `isFinal:false`)." },
139
+ "isFinal": { "type": "boolean", "description": "true ⇒ this segment's text is settled and will not change." },
140
+ "committedPrefix": { "type": "string", "description": "The leading portion the host guarantees will not change (Deepgram/Web-Speech model)." },
141
+ "stability": { "type": "number", "minimum": 0, "maximum": 1, "description": "Graded tail confidence (Google `stability`); omit/1.0 for immutable ASR." },
142
+ "formatted": { "type": "boolean", "description": "Punctuation/casing/ITN applied; absent ⇒ raw." },
143
+ "atMs": { "type": "integer", "minimum": 0 },
144
+ "contentTrust": { "const": "untrusted", "description": "MUST be `\"untrusted\"` — live transcript is untrusted ingress (`voice-transcript-untrusted`); never promote to higher authority." }
145
+ }
146
+ },
147
+ "voiceEndpointCandidate": {
148
+ "description": "RFC 0106 §B/§D. A silence boundary / likely end-of-turn appeared (only when `turnDetection: \"semantic\"`), DISTINCT from `voice.turn_commit` (Deepgram `UtteranceEnd` vs `speech_final`). Content-free.",
149
+ "type": "object",
150
+ "additionalProperties": false,
151
+ "required": ["atMs"],
152
+ "properties": {
153
+ "atMs": { "type": "integer", "minimum": 0 },
154
+ "confidence": { "type": "number", "minimum": 0, "maximum": 1, "description": "Optional end-of-turn confidence." }
155
+ }
156
+ },
157
+ "voiceTurnCommit": {
158
+ "description": "RFC 0106 §B/§D. The user yielded the floor (turn boundary). This is where the `ctx.callTranscriber` Promise resolves. `finalText` is the settled transcript and is UNTRUSTED (it inherits the `voice.transcript` boundary).",
159
+ "type": "object",
160
+ "additionalProperties": false,
161
+ "required": ["atMs", "finalText"],
162
+ "properties": {
163
+ "atMs": { "type": "integer", "minimum": 0 },
164
+ "finalText": { "type": "string", "description": "The settled transcript for the committed turn (untrusted input)." }
165
+ }
166
+ },
167
+ "voiceSynthesisChunk": {
168
+ "description": "RFC 0106 §C. Announces a clause-boundary streaming-synthesis chunk. METADATA ONLY — the audio bytes are referenced by a session-scoped `streamRef`/`url`, NOT inlined on the event log past the host's inline cap (RFC 0055 256 KiB precedent; spill to a tenant-scoped `url`), so replay/`:fork` stays bounded over long sessions. MUST NOT be emitted unless `aiProviders.realtimeVoice.synthesis: \"streaming\"` is advertised.",
169
+ "type": "object",
170
+ "additionalProperties": false,
171
+ "required": ["seq", "mimeType"],
172
+ "properties": {
173
+ "seq": { "type": "integer", "minimum": 0, "description": "Monotonic chunk sequence." },
174
+ "mimeType": { "type": "string", "minLength": 1, "description": "Audio chunk type (e.g. `audio/mpeg`)." },
175
+ "durationMs": { "type": "integer", "minimum": 0, "description": "Optional chunk duration." },
176
+ "url": { "type": "string", "description": "Tenant-scoped, SSRF-guarded (RFC 0076) reference to the chunk bytes." },
177
+ "streamRef": { "type": "string", "description": "Session-scoped live-stream handle for the chunk bytes." },
178
+ "base64": { "type": "string", "description": "OPTIONAL inline bytes, permitted ONLY while under the host's inline cap; past the cap the host MUST use `url`/`streamRef`." },
179
+ "final": { "type": "boolean", "description": "true on the terminal chunk." }
180
+ }
181
+ },
182
+ "voiceBargeIn": {
183
+ "description": "RFC 0106 §D. User speech overlapped active assistant playback (probable cut-in). A host advertising `realtimeVoice.bargeIn: \"supported\"` MUST emit this on detected overlap. Content-free. Distinct from `voice.cancelled` (a backchannel \"uh-huh\" MAY produce `barge_in` with no `cancelled`).",
184
+ "type": "object",
185
+ "additionalProperties": false,
186
+ "required": ["atMs"],
187
+ "properties": {
188
+ "atMs": { "type": "integer", "minimum": 0 }
189
+ }
190
+ },
191
+ "voiceCancelled": {
192
+ "description": "RFC 0106 §D. Downstream LLM/TTS work was cancelled (barge-in or explicit). A cancellation MUST NOT emit partial tool outputs or partial un-guardrailed model output and MUST roll back or fully complete an in-flight side effect (SECURITY invariant `voice-bargein-no-partial-leak`). Content-free.",
193
+ "type": "object",
194
+ "additionalProperties": false,
195
+ "required": ["atMs"],
196
+ "properties": {
197
+ "atMs": { "type": "integer", "minimum": 0 },
198
+ "reason": { "type": "string", "enum": ["barge-in", "explicit"], "description": "Why downstream work was cancelled." }
199
+ }
200
+ },
116
201
  "authorizationDecided": {
117
202
  "description": "RFC 0049 — emitted on a role-based authorization decision (allow or deny). Redaction-safe: `principal` is an opaque RFC 0048 id; `reason` carries no credential material. Every deny SHOULD be emitted and SHOULD feed the audit log (RFC 0009/0010). MUST NOT be emitted unless `capabilities.authorization.supported: true`.",
118
203
  "type": "object",
@@ -171,7 +171,14 @@
171
171
  "proposal.activated",
172
172
  "goal.evaluated",
173
173
  "goal.closed",
174
- "import.applied"
174
+ "import.applied",
175
+ "voice.speech_start",
176
+ "voice.transcript",
177
+ "voice.endpoint_candidate",
178
+ "voice.turn_commit",
179
+ "voice.synthesis_chunk",
180
+ "voice.barge_in",
181
+ "voice.cancelled"
175
182
  ]
176
183
  }
177
184
  }
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * Multi-Agent Shift capability-gating helper.
3
3
  *
4
- * Reads the host's `/.well-known/openwop` `capabilities.agents` block
5
- * + `capabilities.conversationPrimitive` flag at suite init and caches
6
- * them as discrete predicates. Sibling to `lib/fixtures.ts` — same
7
- * pattern, different surface.
4
+ * Reads the host's `/.well-known/openwop` `agents` block + the
5
+ * `conversationPrimitive` flag — both at the document ROOT (RFC 0073
6
+ * root-first; `setMultiAgentCapabilities` reads `body.agents` /
7
+ * `body.conversationPrimitive`, NOT a nested `.capabilities` object) —
8
+ * at suite init and caches them as discrete predicates. A host that
9
+ * advertises these only under a `.capabilities` wrapper resolves to
10
+ * `false` and its gated scenarios skip vacuously. Sibling to
11
+ * `lib/fixtures.ts` — same pattern, different surface.
8
12
  *
9
13
  * Why: Multi-Agent Shift scenarios (Phases 1-6) gate on per-phase
10
14
  * capability flags. Mirrors the fixture-gating pattern from RFC 0003
@@ -46,6 +50,7 @@ interface AgentCaps {
46
50
 
47
51
  let _agentCaps: AgentCaps | null = null;
48
52
  let _conversationPrimitive = false;
53
+ let _multiPartyConversation = false;
49
54
 
50
55
  function asBoolean(value: unknown): boolean {
51
56
  return value === true;
@@ -68,6 +73,7 @@ export function setMultiAgentCapabilities(c: DiscoveryPayload | null | undefined
68
73
  if (!c || typeof c !== 'object') {
69
74
  _agentCaps = null;
70
75
  _conversationPrimitive = false;
76
+ _multiPartyConversation = false;
71
77
  return;
72
78
  }
73
79
 
@@ -105,6 +111,12 @@ export function setMultiAgentCapabilities(c: DiscoveryPayload | null | undefined
105
111
  }
106
112
 
107
113
  _conversationPrimitive = asBoolean((c as { conversationPrimitive?: unknown }).conversationPrimitive);
114
+
115
+ // RFC 0101 — root-first read of the `multiPartyConversation` block (sibling
116
+ // of `conversationPrimitive`), matching the root-first convention above.
117
+ const mpcRaw = (c as { multiPartyConversation?: unknown }).multiPartyConversation;
118
+ _multiPartyConversation =
119
+ !!mpcRaw && typeof mpcRaw === 'object' && asBoolean((mpcRaw as Record<string, unknown>).supported);
108
120
  }
109
121
 
110
122
  /** Phase 1 master switch. */
@@ -143,6 +155,12 @@ export function isConversationPrimitiveSupported(): boolean {
143
155
  return _conversationPrimitive;
144
156
  }
145
157
 
158
+ /** RFC 0101 — host enforces the multi-party group-conversation roster +
159
+ * speaker-attribution contract (`multiPartyConversation.supported: true`). */
160
+ export function isMultiPartyConversationSupported(): boolean {
161
+ return _multiPartyConversation;
162
+ }
163
+
146
164
  /** Phase 5 — host implements `core.orchestrator.supervisor` + CP-1. */
147
165
  export function isOrchestratorSupported(): boolean {
148
166
  return _agentCaps?.orchestrator === true;
@@ -162,4 +180,5 @@ export function getCachedAgentCaps(): AgentCaps | null {
162
180
  export function __resetForTests(): void {
163
181
  _agentCaps = null;
164
182
  _conversationPrimitive = false;
183
+ _multiPartyConversation = false;
165
184
  }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Shared helpers for the RFC 0101 multi-party group-conversation behavioral
3
+ * scenario. Lives in lib/ (not a `*.test.ts`) so scenarios import it via
4
+ * `../lib/multiPartyConversation.js`.
5
+ *
6
+ * RFC 0101 standardizes the multi-party *shape* (a `participants` roster on
7
+ * `conversation.opened` + a REQUIRED-for-agent-turns `speakerId` + the
8
+ * `multiPartyConversation` capability) but mints NO normative client wire-route
9
+ * to *open* a conversation — opening, turn order, and round protocol are
10
+ * non-normative host product policy (RFC 0101 §"Non-normative product policy").
11
+ * A conformance driver therefore initiates a council and submits turns via the
12
+ * conformance-only **multi-party conversation test seam**
13
+ * (`POST /v1/host/sample/conversation/multi-party/{open,exchange}`,
14
+ * `host-sample-test-seams.md` §"Open seams") — the same `/v1/host/sample/*`
15
+ * convention every other capability-gated behavioral leg uses. The seam routes
16
+ * through the SAME roster-membership + attribution enforcement the host applies
17
+ * on its production conversation path; it is OPTIONAL and self-contained
18
+ * (it does NOT require the host to implement the full RFC 0005 conversation
19
+ * gate). Scenarios soft-skip on `404`/`405`.
20
+ *
21
+ * @see RFCS/0101-multi-party-group-conversation.md (§Spec / §Conformance)
22
+ * @see spec/v1/host-sample-test-seams.md (multi-party conversation seam)
23
+ * @see RFCS/0005-conversation.md §E (turn-validation rejection — validation_error)
24
+ */
25
+ import { driver } from './driver.js';
26
+ import { readCapabilityFamily } from './discovery-capabilities.js';
27
+
28
+ /** A slim AgentRef the seam accepts in a participant roster (mirror of
29
+ * `agent-ref.schema.json`; only `agentId` is load-bearing for membership). */
30
+ export interface SeamAgentRef {
31
+ agentId: string;
32
+ name?: string;
33
+ }
34
+
35
+ /** A conversation turn as the seam accepts it (subset of
36
+ * `conversation-turn.schema.json` + the RFC 0101 `speakerId`). */
37
+ export interface SeamTurn {
38
+ messageId: string;
39
+ from: string;
40
+ content: string;
41
+ ts: number;
42
+ role: 'user' | 'agent' | 'system';
43
+ turnIndex: number;
44
+ speakerId?: string;
45
+ [k: string]: unknown;
46
+ }
47
+
48
+ export interface MultiPartyConversationCap {
49
+ supported?: boolean;
50
+ maxParticipants?: number;
51
+ [k: string]: unknown;
52
+ }
53
+
54
+ /** Reads the root-first `multiPartyConversation` capability block from
55
+ * discovery (RFC 0073 root-first); null when unadvertised. */
56
+ export async function readMultiPartyCap(): Promise<MultiPartyConversationCap | null> {
57
+ const mpc = await readCapabilityFamily<MultiPartyConversationCap>('multiPartyConversation');
58
+ return mpc && typeof mpc === 'object' ? mpc : null;
59
+ }
60
+
61
+ export interface SeamResult {
62
+ /** HTTP status. `0` when the seam is unwired (404/405 → soft-skip sentinel). */
63
+ status: number;
64
+ /** Decoded JSON body, when present. */
65
+ body: Record<string, unknown> | undefined;
66
+ /** True when the seam is absent (404/405) — caller soft-skips. */
67
+ unwired: boolean;
68
+ }
69
+
70
+ function toResult(res: { status: number; json: unknown }): SeamResult {
71
+ const unwired = res.status === 404 || res.status === 405;
72
+ return {
73
+ status: res.status,
74
+ body: res.json && typeof res.json === 'object' ? (res.json as Record<string, unknown>) : undefined,
75
+ unwired,
76
+ };
77
+ }
78
+
79
+ /** Open a multi-party council via the conformance seam. Returns `unwired:true`
80
+ * on 404/405 so the scenario soft-skips. NOTE: a `validation_error` rejection
81
+ * (e.g. roster exceeds maxParticipants) is a REAL 400/422 result, NOT unwired —
82
+ * only 404/405 mean the seam is absent. */
83
+ export async function openMultiPartyConversation(body: {
84
+ conversationId: string;
85
+ participants: SeamAgentRef[];
86
+ maxParticipants?: number;
87
+ }): Promise<SeamResult> {
88
+ const res = await driver.post('/v1/host/sample/conversation/multi-party/open', body);
89
+ return toResult(res);
90
+ }
91
+
92
+ /** Submit one turn to an open council via the conformance seam. */
93
+ export async function exchangeMultiPartyTurn(body: {
94
+ conversationId: string;
95
+ turn: SeamTurn;
96
+ }): Promise<SeamResult> {
97
+ const res = await driver.post('/v1/host/sample/conversation/multi-party/exchange', body);
98
+ return toResult(res);
99
+ }
100
+
101
+ /** The canonical `error.code` an RFC 0101 rejection carries (RFC 0005 §E).
102
+ * Tolerant of both `{ error: { code } }` and `{ error: '<code>' }` shapes. */
103
+ export function errorCodeOf(body: Record<string, unknown> | undefined): string | undefined {
104
+ if (!body) return undefined;
105
+ const err = body.error;
106
+ if (typeof err === 'string') return err;
107
+ if (err && typeof err === 'object') {
108
+ const code = (err as Record<string, unknown>).code;
109
+ if (typeof code === 'string') return code;
110
+ }
111
+ const code = body.code;
112
+ return typeof code === 'string' ? code : undefined;
113
+ }
114
+
115
+ /** A rejection is RFC-0101-conformant when the status is a client error
116
+ * (`400`/`422` — RFC 0005 §E pins the code, not the status) AND the body
117
+ * carries `error.code === 'validation_error'`. */
118
+ export function isValidationErrorRejection(r: SeamResult): boolean {
119
+ const clientError = r.status === 400 || r.status === 422;
120
+ return clientError && errorCodeOf(r.body) === 'validation_error';
121
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Real-time voice capability advertisement shape (RFC 0106 §A) — server-free.
3
+ *
4
+ * Always-on schema-shape probe for the RFC 0106 real-time voice profile. Verifies that:
5
+ * - `capabilities.aiProviders.realtimeVoice` is an OBJECT sibling of `speechSynthesis`/
6
+ * `input` in the `aiProviders` block, with the sub-flags `transcription`
7
+ * (const `"streaming"`), `synthesis` (const `"streaming"`), `turnDetection`
8
+ * (enum `vad|semantic`), and `bargeIn` (const `"supported"`).
9
+ * - `realtimeVoice` is NOT in `aiProviders.required`: absence is the default (a host
10
+ * WITHOUT live voice is a valid discovery doc).
11
+ * - the §A closures hold via Ajv2020: `turnDetection`/`bargeIn` imply `transcription`
12
+ * (`dependentRequired`), and `realtimeVoice.synthesis` implies
13
+ * `aiProviders.speechSynthesis` (the if/then on the `aiProviders` block).
14
+ *
15
+ * Behavioral assertions (the live `ctx.callTranscriber` Promise-resolves-at-turn_commit
16
+ * + `voice.*` emission; the unadvertised-host reject; the streaming synthesis arm) are
17
+ * gated on `aiProviders.realtimeVoice.{transcription,synthesis}` and live in the gated
18
+ * `voice-transcription-streaming.test.ts` / `voice-transcription-unadvertised.test.ts` /
19
+ * `voice-synthesis-streaming.test.ts` scenarios. This scenario asserts the wire contract,
20
+ * not host behavior.
21
+ *
22
+ * Spec references:
23
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/host-capabilities.md (§host.aiProviders)
24
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0106-realtime-voice-session-profile.md (§A, §B)
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest';
28
+ import { readFileSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+ import Ajv2020 from 'ajv/dist/2020.js';
31
+ import addFormats from 'ajv-formats';
32
+ import { SCHEMAS_DIR } from '../lib/paths.js';
33
+
34
+ /** Server-free assertion-message helper. */
35
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
36
+
37
+ function loadSchema(name: string): Record<string, unknown> {
38
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
39
+ }
40
+
41
+ function aiProvidersSchema(): Record<string, unknown> {
42
+ const caps = loadSchema('capabilities.schema.json');
43
+ return (caps.properties as Record<string, Record<string, unknown>>).aiProviders;
44
+ }
45
+
46
+ describe('aiproviders-realtimevoice-shape: capability advertisement (RFC 0106 §A, server-free)', () => {
47
+ it('aiProviders.realtimeVoice is an object with the four sub-flags', () => {
48
+ const realtimeVoice = (aiProvidersSchema().properties as Record<string, { type?: unknown; properties?: Record<string, unknown> }>)
49
+ .realtimeVoice;
50
+ expect(
51
+ realtimeVoice,
52
+ why('host-capabilities.md §host.aiProviders', 'aiProviders.realtimeVoice MUST be declared'),
53
+ ).toBeDefined();
54
+ expect(realtimeVoice?.type, why('RFC 0106 §A', 'realtimeVoice MUST be an object')).toBe('object');
55
+ const props = realtimeVoice?.properties ?? {};
56
+ for (const flag of ['transcription', 'synthesis', 'turnDetection', 'bargeIn']) {
57
+ expect(
58
+ Object.prototype.hasOwnProperty.call(props, flag),
59
+ why('RFC 0106 §A', `realtimeVoice.${flag} MUST be declared`),
60
+ ).toBe(true);
61
+ }
62
+ });
63
+
64
+ it('realtimeVoice is NOT in aiProviders.required — absence (no live voice) is a valid default', () => {
65
+ const aiProviders = aiProvidersSchema() as { required?: unknown };
66
+ const required = Array.isArray(aiProviders.required) ? (aiProviders.required as string[]) : [];
67
+ expect(
68
+ required.includes('realtimeVoice'),
69
+ why('RFC 0106 §A', 'aiProviders.realtimeVoice MUST be optional (a host without live voice is valid)'),
70
+ ).toBe(false);
71
+ });
72
+
73
+ it('Ajv: the realtimeVoice subschema accepts the floor and enforces the turnDetection/bargeIn ⇒ transcription closure', () => {
74
+ const realtimeVoice = (aiProvidersSchema().properties as Record<string, Record<string, unknown>>).realtimeVoice;
75
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
76
+ addFormats(ajv);
77
+ const validate = ajv.compile(realtimeVoice);
78
+
79
+ expect(
80
+ validate({ transcription: 'streaming', turnDetection: 'semantic', bargeIn: 'supported' }),
81
+ why('RFC 0106 §A', 'a full transcription advertisement MUST validate'),
82
+ ).toBe(true);
83
+ expect(validate({}), why('RFC 0106 §A', 'an empty realtimeVoice object MUST validate')).toBe(true);
84
+ expect(
85
+ validate({ turnDetection: 'semantic' }),
86
+ why('RFC 0106 §A', 'turnDetection without transcription MUST be rejected (dependentRequired)'),
87
+ ).toBe(false);
88
+ expect(
89
+ validate({ bargeIn: 'supported' }),
90
+ why('RFC 0106 §A', 'bargeIn without transcription MUST be rejected (dependentRequired)'),
91
+ ).toBe(false);
92
+ expect(
93
+ validate({ transcription: 'streaming', turnDetection: 'aggressive' }),
94
+ why('RFC 0106 §A', 'an out-of-enum turnDetection MUST be rejected'),
95
+ ).toBe(false);
96
+ expect(
97
+ validate({ transcription: true }),
98
+ why('RFC 0106 §A', 'transcription MUST be the string const "streaming", not a boolean'),
99
+ ).toBe(false);
100
+ });
101
+
102
+ it('Ajv: the aiProviders subschema enforces realtimeVoice.synthesis ⇒ speechSynthesis', () => {
103
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
104
+ addFormats(ajv);
105
+ const validate = ajv.compile(aiProvidersSchema());
106
+
107
+ expect(
108
+ validate({ realtimeVoice: { transcription: 'streaming', synthesis: 'streaming' } }),
109
+ why('RFC 0106 §A', 'streaming synthesis without speechSynthesis: "supported" MUST be rejected'),
110
+ ).toBe(false);
111
+ expect(
112
+ validate({ speechSynthesis: 'supported', realtimeVoice: { transcription: 'streaming', synthesis: 'streaming' } }),
113
+ why('RFC 0106 §A', 'streaming synthesis WITH speechSynthesis: "supported" MUST validate'),
114
+ ).toBe(true);
115
+ expect(
116
+ validate({ realtimeVoice: { transcription: 'streaming' } }),
117
+ why('RFC 0106 §A', 'transcription-only (no synthesis) MUST validate without speechSynthesis'),
118
+ ).toBe(true);
119
+ });
120
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Speech-synthesis capability advertisement shape (RFC 0105 §B) — server-free.
3
+ *
4
+ * Always-on schema-shape probe for the RFC 0105 TTS adapter. Verifies that:
5
+ * - `capabilities.aiProviders.speechSynthesis` is declared as the STRING
6
+ * const `"supported"` (a sibling of `byok` / `input` in the `aiProviders`
7
+ * block) — NOT an object. The const is what rejects an object-shaped
8
+ * advertisement.
9
+ * - `speechSynthesis` is NOT in `aiProviders.required`: absence is the
10
+ * default (a host WITHOUT TTS is a valid discovery doc).
11
+ * - via Ajv2020, a capabilities doc advertising
12
+ * `aiProviders: { supported: ['minimax'], speechSynthesis: 'supported' }`
13
+ * validates, while the object form
14
+ * `aiProviders: { speechSynthesis: { supported: true } }` FAILS (the const
15
+ * rejects the object).
16
+ *
17
+ * Behavioral assertions (the live `ctx.callSpeechSynthesizer` round-trip; the
18
+ * unadvertised-host reject) are gated on
19
+ * `aiProviders.speechSynthesis === 'supported'` and live in
20
+ * `speech-synthesis-roundtrip.test.ts` / `speech-synthesis-unadvertised.test.ts`.
21
+ * This scenario asserts the wire contract, not host behavior.
22
+ *
23
+ * Spec references:
24
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/host-capabilities.md (§host.aiProviders)
25
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0105-speech-synthesis-adapter.md (§B)
26
+ */
27
+
28
+ import { describe, it, expect } from 'vitest';
29
+ import { readFileSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import Ajv2020 from 'ajv/dist/2020.js';
32
+ import addFormats from 'ajv-formats';
33
+ import { SCHEMAS_DIR } from '../lib/paths.js';
34
+
35
+ /** Server-free assertion-message helper. */
36
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
37
+
38
+ function loadSchema(name: string): Record<string, unknown> {
39
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
40
+ }
41
+
42
+ describe('aiproviders-speechsynth-shape: capability advertisement (RFC 0105 §B, server-free)', () => {
43
+ it('aiProviders.speechSynthesis is declared as the string const "supported"', () => {
44
+ const caps = loadSchema('capabilities.schema.json');
45
+ const aiProviders = (caps.properties as Record<string, { properties?: Record<string, unknown> }>)
46
+ .aiProviders;
47
+ const speechSynthesis = aiProviders?.properties?.speechSynthesis as
48
+ | { const?: unknown }
49
+ | undefined;
50
+ expect(
51
+ speechSynthesis,
52
+ why('host-capabilities.md §host.aiProviders', 'aiProviders.speechSynthesis MUST be declared'),
53
+ ).toBeDefined();
54
+ expect(
55
+ speechSynthesis?.const,
56
+ why('RFC 0105 §B', 'aiProviders.speechSynthesis MUST be the string const "supported" (not an object)'),
57
+ ).toBe('supported');
58
+ });
59
+
60
+ it('speechSynthesis is NOT in aiProviders.required — absence (no TTS) is a valid default', () => {
61
+ const caps = loadSchema('capabilities.schema.json');
62
+ const aiProviders = (caps.properties as Record<string, { required?: unknown }>).aiProviders;
63
+ const required = Array.isArray(aiProviders?.required) ? (aiProviders!.required as string[]) : [];
64
+ expect(
65
+ required.includes('speechSynthesis'),
66
+ why('RFC 0105 §B', 'aiProviders.speechSynthesis MUST be optional (a host without TTS is valid)'),
67
+ ).toBe(false);
68
+ });
69
+
70
+ it('Ajv accepts the string const form and rejects the object form', () => {
71
+ // Compile ONLY the speechSynthesis subschema (`{ const: "supported" }`) — the
72
+ // full capabilities.schema.json carries cross-file $refs that don't resolve
73
+ // standalone; the const flag has none, so it validates in isolation.
74
+ const caps = loadSchema('capabilities.schema.json');
75
+ const aiProviders = (caps.properties as Record<string, { properties?: Record<string, unknown> }>).aiProviders;
76
+ const speechSynthesisSchema = aiProviders?.properties?.speechSynthesis as Record<string, unknown>;
77
+
78
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
79
+ addFormats(ajv);
80
+ const validate = ajv.compile(speechSynthesisSchema);
81
+
82
+ expect(
83
+ validate('supported'),
84
+ why('RFC 0105 §B', 'aiProviders.speechSynthesis === "supported" MUST validate'),
85
+ ).toBe(true);
86
+
87
+ expect(
88
+ validate({ supported: true }),
89
+ why('RFC 0105 §B', 'the object form { supported: true } MUST be rejected by the const'),
90
+ ).toBe(false);
91
+ });
92
+ });