@openwop/openwop-conformance 1.29.0 → 1.34.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-bargein-no-partial-leak.test.ts +61 -0
- package/src/scenarios/voice-event-payloads-shape.test.ts +127 -0
- package/src/scenarios/voice-interim-not-durable.test.ts +59 -0
- package/src/scenarios/voice-streamref-tenant-bound.test.ts +59 -0
- package/src/scenarios/voice-synthesis-streaming.test.ts +77 -0
- package/src/scenarios/voice-transcription-streaming.test.ts +64 -0
- package/src/scenarios/voice-transcription-unadvertised.test.ts +46 -0
|
@@ -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", "
|
|
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\
|
|
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` `
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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,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
|
+
});
|