@ggui-ai/protocol-conformance 0.2.0-alpha.4 → 0.3.0-rc.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.
Files changed (83) hide show
  1. package/README.md +32 -13
  2. package/dist/cli.d.ts +11 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +48 -20
  5. package/dist/conformance-host.d.ts +81 -76
  6. package/dist/conformance-host.d.ts.map +1 -1
  7. package/dist/conformance-host.js +0 -44
  8. package/dist/fixtures/bootstrap-protocol/bootstrap-bundle-fetch-failed.json +3 -3
  9. package/dist/fixtures/bootstrap-protocol/bootstrap-meta-missing.json +3 -3
  10. package/dist/fixtures/bootstrap-protocol/bootstrap-success.json +3 -3
  11. package/dist/fixtures/consume-buffer/action-ack-sequence.json +28 -0
  12. package/dist/fixtures/consume-buffer/action-payload-schema-violation.json +37 -0
  13. package/dist/fixtures/consume-buffer/index.d.ts +6 -0
  14. package/dist/fixtures/consume-buffer/index.d.ts.map +1 -0
  15. package/dist/fixtures/consume-buffer/index.js +41 -0
  16. package/dist/fixtures/consume-buffer/undeclared-action-rejected.json +29 -0
  17. package/dist/fixtures/host-context/host-context-observed-persists.json +34 -0
  18. package/dist/fixtures/host-context/index.d.ts +5 -0
  19. package/dist/fixtures/host-context/index.d.ts.map +1 -0
  20. package/dist/fixtures/host-context/index.js +40 -0
  21. package/dist/fixtures/index.d.ts +28 -9
  22. package/dist/fixtures/index.d.ts.map +1 -1
  23. package/dist/fixtures/index.js +33 -14
  24. package/dist/fixtures/reserved-channel-authority/index.d.ts.map +1 -1
  25. package/dist/fixtures/reserved-channel-authority/index.js +17 -7
  26. package/dist/fixtures/reserved-channel-authority/props-update-roundtrip.json +4 -4
  27. package/dist/fixtures/schema-version-handshake/index.d.ts.map +1 -1
  28. package/dist/fixtures/schema-version-handshake/index.js +12 -4
  29. package/dist/fixtures/schema-version-handshake/version-match.json +7 -5
  30. package/dist/fixtures/schema-version-handshake/version-mismatch.json +9 -10
  31. package/dist/fixtures/subscribe-tenancy/absent-appid-defaults.json +18 -0
  32. package/dist/fixtures/subscribe-tenancy/app-mismatch.json +20 -0
  33. package/dist/fixtures/subscribe-tenancy/index.d.ts +6 -0
  34. package/dist/fixtures/subscribe-tenancy/index.d.ts.map +1 -0
  35. package/dist/fixtures/subscribe-tenancy/index.js +62 -0
  36. package/dist/index.d.ts +4 -4
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +4 -4
  39. package/dist/match-behavior.d.ts +19 -100
  40. package/dist/match-behavior.d.ts.map +1 -1
  41. package/dist/match-behavior.js +289 -246
  42. package/dist/registration-conformance/cases/gate-duplicate-hook.json +26 -0
  43. package/dist/registration-conformance/cases/gate-export-not-registered.json +1 -0
  44. package/dist/registration-conformance/cases/gate-multi-package-distinct-names-accept.json +26 -0
  45. package/dist/registration-conformance/cases/gate-no-gadgets-accept.json +1 -0
  46. package/dist/registration-conformance/cases/gate-package-mismatch.json +1 -0
  47. package/dist/registration-conformance/cases/gate-priority-not-registered-outranks-mismatch.json +1 -0
  48. package/dist/registration-conformance/cases/gate-priority-registry-outranks-public-env.json +22 -0
  49. package/dist/registration-conformance/cases/gate-public-env-missing.json +21 -0
  50. package/dist/registration-conformance/cases/gate-public-env-satisfied-accept.json +21 -0
  51. package/dist/registration-conformance/cases/gate-public-env-undeclared-wrapper-accept.json +26 -0
  52. package/dist/registration-conformance/cases/gate-registered-resolves-accept.json +1 -0
  53. package/dist/registration-conformance/cases/gate-stdlib-hook-accept.json +1 -0
  54. package/dist/registration-conformance/cases/gate-typo-classifies-as-not-registered.json +1 -0
  55. package/dist/registration-conformance/index.d.ts +64 -33
  56. package/dist/registration-conformance/index.d.ts.map +1 -1
  57. package/dist/registration-conformance/index.js +24 -8
  58. package/dist/run-conformance.d.ts +145 -6
  59. package/dist/run-conformance.d.ts.map +1 -1
  60. package/dist/run-conformance.js +531 -93
  61. package/dist/types.d.ts +319 -165
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js +27 -23
  64. package/dist/ws-transport.d.ts.map +1 -1
  65. package/dist/ws-transport.js +3 -2
  66. package/package.json +3 -3
  67. package/dist/fixtures/observability-events/index.d.ts +0 -5
  68. package/dist/fixtures/observability-events/index.d.ts.map +0 -1
  69. package/dist/fixtures/observability-events/index.js +0 -22
  70. package/dist/fixtures/observability-events/observability-contract-error-emitted.json +0 -28
  71. package/dist/fixtures/observability-events/observability-wired-tool-invoked.json +0 -27
  72. package/dist/fixtures/refresh-semantics/index.d.ts +0 -5
  73. package/dist/fixtures/refresh-semantics/index.d.ts.map +0 -1
  74. package/dist/fixtures/refresh-semantics/index.js +0 -19
  75. package/dist/fixtures/refresh-semantics/stream-refresh-success.json +0 -39
  76. package/dist/fixtures/reserved-channel-authority/stream-schema-violation.json +0 -44
  77. package/dist/fixtures/wired-action-dispatch/index.d.ts +0 -5
  78. package/dist/fixtures/wired-action-dispatch/index.d.ts.map +0 -1
  79. package/dist/fixtures/wired-action-dispatch/index.js +0 -29
  80. package/dist/fixtures/wired-action-dispatch/wired-action-success.json +0 -35
  81. package/dist/fixtures/wired-action-dispatch/wired-action-tool-not-found.json +0 -30
  82. package/dist/fixtures/wired-action-dispatch/wired-action-tool-threw.json +0 -31
  83. package/dist/fixtures/wired-action-dispatch/wired-action-tool-timeout.json +0 -31
@@ -6,31 +6,56 @@
6
6
  * 1. Invoke `reporter.onStart(totalFixtures)`.
7
7
  * 2. For each fixture (filtered by `config.only` if provided):
8
8
  * a. If `fixture.skipReason !== null` → skip with the reason.
9
- * b. If `fixture.setup` non-empty AND `config.host` absent →
9
+ * b. Validate every authored setup/teardown directive against
10
+ * the closed `SetupStep` vocabulary (`parseSetupStep`), the
11
+ * authored `inputEnvelope` against the closed input-
12
+ * envelope dispatch vocabulary (`parseInputEnvelope`), and
13
+ * the authored `subscribe` shaping against the closed
14
+ * `SubscribeFrameShaping` vocabulary
15
+ * (`parseSubscribeShaping`). Unknown / malformed directives
16
+ * are fixture-authoring errors — the runner throws, aborting
17
+ * the run loudly (NOT a skip, NOT a fail of the
18
+ * implementation under test).
19
+ * c. If `fixture.setup` non-empty AND `config.host` absent →
10
20
  * skip with "no host provided".
11
- * c. Dispatch every setup step via `host.dispatchSetup()`.
21
+ * d. Dispatch every setup step via `host.dispatchSetup()`.
12
22
  * Throw → skip with the error message (NOT a fail).
13
- * d. Open a WS transport against `config.serverUrl`.
14
- * e. Send the canonical `subscribe` frame (the runner knows the
23
+ * e. Open a WS transport against `config.serverUrl`.
24
+ * f. Send the canonical `subscribe` frame (the runner knows the
15
25
  * wire shape — fixture's `inputEnvelope` is NOT the subscribe
16
- * frame; subscribe is always runner-owned).
17
- * f. If the fixture's `inputEnvelope.type === 'action'`, send
18
- * it as a live-channel action frame AFTER subscribe.
19
- * g. Observe frames for `config.observationTimeoutMs` (default
26
+ * frame; subscribe is always runner-owned), shaped by the
27
+ * fixture's validated `subscribe` knob (`omitAppId`,
28
+ * `supportedVersions` with the `'current'` sentinel resolved
29
+ * to the kit's compiled `PROTOCOL_SCHEMA_VERSION`).
30
+ * g. If the fixture's `inputEnvelope` is a dispatchable C→S
31
+ * frame (`action` live-channel dispatch, or
32
+ * `host_context_observed` — the validated typed arm), send
33
+ * it AFTER subscribe.
34
+ * h. Observe frames for `config.observationTimeoutMs` (default
20
35
  * 2000ms).
21
- * h. Match observed frames against `fixture.expectedBehavior`.
22
- * i. Close the WS.
23
- * j. Dispatch every teardown step via `host.dispatchTeardown()`.
36
+ * i. Match observed frames against `fixture.expectedBehavior` —
37
+ * EXCEPT `session-state`, a stateful obligation with no wire
38
+ * response: the runner grades it after the observation window
39
+ * by reading the GguiSession field back via
40
+ * `host.readSessionField()` (`matchSessionState`). Absent
41
+ * host / absent method / a throwing read → skip-with-reason,
42
+ * never a weakened pass.
43
+ * j. Close the WS.
44
+ * k. Dispatch every teardown step via `host.dispatchTeardown()`.
24
45
  * Throw → reporter warning; does NOT flip pass → fail.
25
- * k. Record the outcome via reporter + accumulate in result.
46
+ * (No teardown vocabulary exists in this kit version, so the
47
+ * loop is empty today.)
48
+ * l. Record the outcome via reporter + accumulate in result.
26
49
  * 3. Invoke `reporter.onComplete(result)` + return.
27
50
  *
28
51
  * Scope v1: pure WS transport. Browser-level fixtures (bootstrap-
29
- * failure, props-update, observability-event) automatically skip via
30
- * the matcher's `unmatchable-on-ws` arm.
52
+ * failure, props-update) automatically skip via the matcher's
53
+ * `unmatchable-on-ws` arm.
31
54
  */
55
+ import { PROTOCOL_SCHEMA_VERSION } from '@ggui-ai/protocol';
32
56
  import { allFixtures, fixturesByContract } from './fixtures/index.js';
33
- import { matchBehavior } from './match-behavior.js';
57
+ import { isRecord } from '@ggui-ai/protocol';
58
+ import { behaviorIs, deepEqual, matchBehavior } from './match-behavior.js';
34
59
  import { openWsTransport } from './ws-transport.js';
35
60
  // =============================================================================
36
61
  // Runner
@@ -82,23 +107,34 @@ async function runOneFixture(fixture, config, _reporter) {
82
107
  if (fixture.skipReason !== null) {
83
108
  return { kind: 'skip', reason: fixture.skipReason };
84
109
  }
85
- if (fixture.setup.length > 0 && config.host === undefined) {
110
+ // Validate the authored directives BEFORE any host or transport
111
+ // work. An unknown / malformed directive is a fixture-authoring
112
+ // error, not a behavior of the implementation under test — the
113
+ // parse throws a descriptive error that aborts the whole run. The
114
+ // input envelope goes through the same gate: a malformed
115
+ // `host_context_observed` frame is a fixture-authoring error, never
116
+ // a verdict on the server.
117
+ const setupSteps = fixture.setup.map((step) => parseSetupStep(fixture.name, step));
118
+ const teardownSteps = (fixture.teardown ?? []).map((step) => parseTeardownStep(fixture.name, step));
119
+ const inputDispatch = parseInputEnvelope(fixture.name, fixture.inputEnvelope);
120
+ const subscribeShaping = parseSubscribeShaping(fixture.name, fixture.subscribe);
121
+ if (setupSteps.length > 0 && config.host === undefined) {
86
122
  return {
87
123
  kind: 'skip',
88
- reason: `fixture has ${fixture.setup.length} setup step(s) and no \`host\` was provided — supply a ConformanceHost to drive setup directives.`,
124
+ reason: `fixture has ${setupSteps.length} setup step(s) and no \`host\` was provided — supply a ConformanceHost to drive setup directives.`,
89
125
  };
90
126
  }
91
127
  // Dispatch setup via host. A throw means "host doesn't implement
92
128
  // this directive" — record as skip, NOT fail.
93
129
  if (config.host !== undefined) {
94
- for (const step of fixture.setup) {
130
+ for (const step of setupSteps) {
95
131
  try {
96
- await config.host.dispatchSetup(narrowSetupStep(step));
132
+ await config.host.dispatchSetup(step);
97
133
  }
98
134
  catch (err) {
99
135
  return {
100
136
  kind: 'skip',
101
- reason: `host refused setup step type='${step.type ?? 'unknown'}': ${String(err.message ?? err)}`,
137
+ reason: `host refused setup step kind='${step.kind}': ${String(err.message ?? err)}`,
102
138
  };
103
139
  }
104
140
  }
@@ -108,33 +144,55 @@ async function runOneFixture(fixture, config, _reporter) {
108
144
  try {
109
145
  const wsUrl = deriveWsUrl(config.serverUrl);
110
146
  transport = await openWsTransport({ kind: 'ws', url: wsUrl, auth: config.auth });
111
- const renderId = extractRenderId(fixture);
147
+ const sessionId = extractSessionId(fixture, setupSteps);
148
+ // `subscribe` (SubscribeFrameShaping, validated above) shapes the
149
+ // runner-owned frame: `omitAppId` drops the conventional
150
+ // `appId: 'conformance'` stamp — the probe for SPEC §12.2's
151
+ // identity-default resolution — and `supportedVersions` declares
152
+ // the client half of §12.2.2's version handshake (the `'current'`
153
+ // sentinel already resolved to the kit's compiled
154
+ // PROTOCOL_SCHEMA_VERSION).
112
155
  transport.send({
113
156
  type: 'subscribe',
114
157
  payload: {
115
- renderId,
116
- appId: 'conformance',
158
+ sessionId,
159
+ ...(subscribeShaping.omitAppId ? {} : { appId: 'conformance' }),
117
160
  role: 'user',
118
- ...(maybeSupportedVersions(fixture.expectedBehavior) ?? {}),
161
+ ...(subscribeShaping.supportedVersions !== undefined
162
+ ? { supportedVersions: subscribeShaping.supportedVersions }
163
+ : {}),
119
164
  },
120
165
  requestId: `conformance-subscribe-${fixture.name}`,
121
166
  });
122
- if (shouldDispatchInputEnvelope(fixture)) {
123
- transport.send(fixture.inputEnvelope);
167
+ if (inputDispatch.kind !== 'none') {
168
+ transport.send(inputDispatch.envelope);
124
169
  }
125
170
  const frames = await transport.observe({
126
171
  timeoutMs: config.observationTimeoutMs ?? 2000,
127
172
  });
128
- const match = matchBehavior(fixture.expectedBehavior, frames);
173
+ // `session-state` is a stateful obligation — the input message
174
+ // produced no wire response, so the grade is a post-observation-
175
+ // window read-back of the GguiSession field via the host, not a
176
+ // frame match. Every other behavior kind grades against observed
177
+ // frames. (`behaviorIs` narrows to the specific arm — the
178
+ // extensibly-closed `UnknownBehavior` in the union has
179
+ // `kind: string & {}` which defeats bare literal narrowing.)
180
+ const expectedBehavior = fixture.expectedBehavior;
181
+ const match = behaviorIs(expectedBehavior, 'session-state')
182
+ ? await matchSessionState(expectedBehavior, sessionId, config.host)
183
+ : matchBehavior(expectedBehavior, frames);
129
184
  // Dispatch teardown regardless of match outcome. Failures in
130
185
  // teardown surface as warnings; they do not flip pass → fail.
131
- if (config.host !== undefined && fixture.teardown !== undefined) {
132
- for (const step of fixture.teardown) {
186
+ // (The teardown vocabulary is empty in this kit version, so
187
+ // `teardownSteps` is always empty today — the loop is the slot
188
+ // future cleanup directives flow through.)
189
+ if (config.host !== undefined) {
190
+ for (const step of teardownSteps) {
133
191
  try {
134
- await config.host.dispatchTeardown(narrowTeardownStep(step));
192
+ await config.host.dispatchTeardown(step);
135
193
  }
136
194
  catch (err) {
137
- _reporter.onTeardownWarning?.(fixture.name, `teardown step type='${step.type ?? 'unknown'}' threw: ${String(err.message ?? err)}`);
195
+ _reporter.onTeardownWarning?.(fixture.name, `teardown step threw: ${String(err.message ?? err)}`);
138
196
  }
139
197
  }
140
198
  }
@@ -182,59 +240,303 @@ function filterFixtures(only) {
182
240
  const allow = new Set(only);
183
241
  return allFixtures.filter((fixture) => allow.has(fixture.name));
184
242
  }
185
- function deriveWsUrl(serverUrl) {
186
- const trimmed = serverUrl.replace(/\/+$/, '');
187
- if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
188
- return `${trimmed}/ws`;
189
- }
190
- if (trimmed.startsWith('https://'))
191
- return `wss://${trimmed.slice('https://'.length)}/ws`;
192
- if (trimmed.startsWith('http://'))
193
- return `ws://${trimmed.slice('http://'.length)}/ws`;
194
- // No scheme provided — assume plain ws.
195
- return `ws://${trimmed}/ws`;
243
+ /**
244
+ * Resolve the WS endpoint from `config.serverUrl`:
245
+ * - `http://` / `https://` schemes become `ws://` / `wss://`; a
246
+ * scheme-less value is assumed `ws://`.
247
+ * - A bare origin (no path) gets the default live-channel path
248
+ * `/ws` appended.
249
+ * - A URL that already carries a path is used exactly as given —
250
+ * the runner never appends to an explicit path, so
251
+ * `ws://host:3000/ws` (or a non-default mount point) works
252
+ * without double-appending.
253
+ *
254
+ * Exported for unit tests; not part of the package's public API.
255
+ */
256
+ export function deriveWsUrl(serverUrl) {
257
+ const withScheme = /^(?:https?|wss?):\/\//.test(serverUrl)
258
+ ? serverUrl
259
+ : `ws://${serverUrl}`;
260
+ const url = new URL(withScheme);
261
+ if (url.protocol === 'http:')
262
+ url.protocol = 'ws:';
263
+ if (url.protocol === 'https:')
264
+ url.protocol = 'wss:';
265
+ if (url.pathname === '' || url.pathname === '/') {
266
+ url.pathname = '/ws';
267
+ }
268
+ return url.toString();
196
269
  }
197
- function extractRenderId(fixture) {
198
- // Prefer a renderId from the first `create-render` setup step —
270
+ function extractSessionId(fixture, setupSteps) {
271
+ // Prefer a sessionId from the first `create-session` setup step —
199
272
  // this is the fixture's declared render identity. Fall back to
200
- // inputEnvelope.renderId, then to the fixture name.
201
- for (const step of fixture.setup) {
202
- if (step.type === 'create-render' &&
203
- typeof step.renderId === 'string') {
204
- return step.renderId;
205
- }
273
+ // inputEnvelope.sessionId, then to the fixture name.
274
+ for (const step of setupSteps) {
275
+ if (step.kind === 'create-session')
276
+ return step.sessionId;
206
277
  }
207
278
  const envelope = fixture.inputEnvelope;
208
- if (envelope !== null &&
209
- typeof envelope === 'object' &&
210
- 'renderId' in envelope &&
211
- typeof envelope.renderId === 'string') {
212
- return envelope.renderId;
279
+ if (isRecord(envelope) && typeof envelope['sessionId'] === 'string') {
280
+ return envelope['sessionId'];
213
281
  }
214
282
  return fixture.name;
215
283
  }
216
284
  /**
217
- * Fixtures with `inputEnvelope.type === 'action'` (or another channel-
218
- * 3 dispatch shape) get sent verbatim after subscribe. Fixtures with
219
- * `type === 'render'` are bootstrap-path — the subscribe itself is
220
- * the dispatch, so we skip the explicit send.
285
+ * Validating classifier for the fixture's `inputEnvelope`. The fixture
286
+ * JSON enters the type system through a compile-time cast, so —
287
+ * exactly like {@link parseSetupStep} — the dispatchable arms are
288
+ * re-validated structurally here, and a malformed
289
+ * `host_context_observed` frame throws a descriptive fixture-authoring
290
+ * error (never a skip, never a fail of the implementation under test).
291
+ *
292
+ * Exported for unit tests; not part of the package's public API.
221
293
  */
222
- function shouldDispatchInputEnvelope(fixture) {
223
- const envelope = fixture.inputEnvelope;
224
- if (envelope === null || typeof envelope !== 'object')
225
- return false;
226
- const type = envelope.type;
227
- return type === 'action';
294
+ export function parseInputEnvelope(fixtureName, envelope) {
295
+ if (!isRecord(envelope) || typeof envelope['type'] !== 'string') {
296
+ return { kind: 'none' };
297
+ }
298
+ if (envelope['type'] === 'action') {
299
+ return { kind: 'action', envelope };
300
+ }
301
+ if (envelope['type'] === 'host_context_observed') {
302
+ return {
303
+ kind: 'host_context_observed',
304
+ envelope: parseHostContextObservedEnvelope(fixtureName, envelope),
305
+ };
306
+ }
307
+ return { kind: 'none' };
308
+ }
309
+ function parseHostContextObservedEnvelope(fixtureName, envelope) {
310
+ const requestId = envelope['requestId'];
311
+ if (requestId !== undefined && typeof requestId !== 'string') {
312
+ throw malformedInputEnvelope(fixtureName, "'requestId' must be a string when present", envelope);
313
+ }
314
+ const payload = envelope['payload'];
315
+ if (!isRecord(payload)) {
316
+ throw malformedInputEnvelope(fixtureName, "'payload' must be an object carrying {sessionId, hostContext}", envelope);
317
+ }
318
+ const sessionId = payload['sessionId'];
319
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
320
+ throw malformedInputEnvelope(fixtureName, "'payload.sessionId' must be a non-empty string", envelope);
321
+ }
322
+ const hostContext = parseHostContextProjection(fixtureName, payload['hostContext'], envelope);
323
+ return {
324
+ type: 'host_context_observed',
325
+ ...(requestId !== undefined ? { requestId } : {}),
326
+ payload: { sessionId, hostContext },
327
+ };
328
+ }
329
+ /**
330
+ * Validating narrower for the authored `hostContext` body against the
331
+ * live `HostContextProjection` (`@ggui-ai/protocol`,
332
+ * `types/host-context`). Every field is optional, so presence is never
333
+ * required — but a present field MUST carry the projection's shape,
334
+ * and unknown keys are REJECTED: a key outside the projection (e.g. a
335
+ * stale `theme` — theme flows through ggui's theming pipeline, not
336
+ * host context) is state no conformant server is obligated to read
337
+ * back, which would make the paired `session-state` grade dishonest.
338
+ */
339
+ function parseHostContextProjection(fixtureName, value, envelope) {
340
+ if (!isRecord(value)) {
341
+ throw malformedInputEnvelope(fixtureName, "'payload.hostContext' must be an object (the HostContextProjection wire shape)", envelope);
342
+ }
343
+ const out = {};
344
+ for (const [key, field] of Object.entries(value)) {
345
+ switch (key) {
346
+ case 'availableDisplayModes': {
347
+ if (!Array.isArray(field)) {
348
+ throw malformedInputEnvelope(fixtureName, "'hostContext.availableDisplayModes' must be an array of display modes ('inline' | 'fullscreen' | 'pip')", envelope);
349
+ }
350
+ const modes = [];
351
+ for (const item of field) {
352
+ if (!isDisplayMode(item)) {
353
+ throw malformedInputEnvelope(fixtureName, `'hostContext.availableDisplayModes' carries ${JSON.stringify(item)} — display modes are 'inline' | 'fullscreen' | 'pip'`, envelope);
354
+ }
355
+ modes.push(item);
356
+ }
357
+ out.availableDisplayModes = modes;
358
+ break;
359
+ }
360
+ case 'currentDisplayMode': {
361
+ if (!isDisplayMode(field)) {
362
+ throw malformedInputEnvelope(fixtureName, "'hostContext.currentDisplayMode' must be 'inline' | 'fullscreen' | 'pip'", envelope);
363
+ }
364
+ out.currentDisplayMode = field;
365
+ break;
366
+ }
367
+ case 'containerDimensions': {
368
+ if (!isRecord(field)) {
369
+ throw malformedInputEnvelope(fixtureName, "'hostContext.containerDimensions' must be an object of numeric width/maxWidth/height/maxHeight", envelope);
370
+ }
371
+ const dims = {};
372
+ for (const [dimKey, dimValue] of Object.entries(field)) {
373
+ if (dimKey !== 'width' &&
374
+ dimKey !== 'maxWidth' &&
375
+ dimKey !== 'height' &&
376
+ dimKey !== 'maxHeight') {
377
+ throw malformedInputEnvelope(fixtureName, `'hostContext.containerDimensions' carries unknown key '${dimKey}' — the projection knows width, maxWidth, height, maxHeight`, envelope);
378
+ }
379
+ if (typeof dimValue !== 'number') {
380
+ throw malformedInputEnvelope(fixtureName, `'hostContext.containerDimensions.${dimKey}' must be a number`, envelope);
381
+ }
382
+ dims[dimKey] = dimValue;
383
+ }
384
+ out.containerDimensions = dims;
385
+ break;
386
+ }
387
+ case 'platform': {
388
+ if (field !== 'web' && field !== 'desktop' && field !== 'mobile') {
389
+ throw malformedInputEnvelope(fixtureName, "'hostContext.platform' must be 'web' | 'desktop' | 'mobile'", envelope);
390
+ }
391
+ out.platform = field;
392
+ break;
393
+ }
394
+ case 'deviceCapabilities': {
395
+ if (!isRecord(field)) {
396
+ throw malformedInputEnvelope(fixtureName, "'hostContext.deviceCapabilities' must be an object of boolean touch/hover", envelope);
397
+ }
398
+ const caps = {};
399
+ for (const [capKey, capValue] of Object.entries(field)) {
400
+ if (capKey !== 'touch' && capKey !== 'hover') {
401
+ throw malformedInputEnvelope(fixtureName, `'hostContext.deviceCapabilities' carries unknown key '${capKey}' — the projection knows touch, hover`, envelope);
402
+ }
403
+ if (typeof capValue !== 'boolean') {
404
+ throw malformedInputEnvelope(fixtureName, `'hostContext.deviceCapabilities.${capKey}' must be a boolean`, envelope);
405
+ }
406
+ caps[capKey] = capValue;
407
+ }
408
+ out.deviceCapabilities = caps;
409
+ break;
410
+ }
411
+ case 'locale': {
412
+ if (typeof field !== 'string' || field.length === 0) {
413
+ throw malformedInputEnvelope(fixtureName, "'hostContext.locale' must be a non-empty string", envelope);
414
+ }
415
+ out.locale = field;
416
+ break;
417
+ }
418
+ case 'timeZone': {
419
+ if (typeof field !== 'string' || field.length === 0) {
420
+ throw malformedInputEnvelope(fixtureName, "'hostContext.timeZone' must be a non-empty string", envelope);
421
+ }
422
+ out.timeZone = field;
423
+ break;
424
+ }
425
+ default:
426
+ throw malformedInputEnvelope(fixtureName, `'hostContext' carries unknown key '${key}' — the HostContextProjection vocabulary is availableDisplayModes, currentDisplayMode, containerDimensions, platform, deviceCapabilities, locale, timeZone`, envelope);
427
+ }
428
+ }
429
+ return out;
430
+ }
431
+ function isDisplayMode(value) {
432
+ return value === 'inline' || value === 'fullscreen' || value === 'pip';
228
433
  }
229
- function maybeSupportedVersions(behavior) {
230
- if (behavior.kind === 'version-mismatch') {
231
- // Cast to the specific arm — the extensibly-closed `UnknownBehavior`
232
- // in the union has `kind: string & {}` which widens narrowing and
233
- // makes TS forget it already checked the discriminant literal.
234
- const narrow = behavior;
235
- return { supportedVersions: narrow.clientAccepts };
236
- }
237
- return undefined;
434
+ function malformedInputEnvelope(fixtureName, problem, envelope) {
435
+ return new Error(`protocol-conformance: fixture '${fixtureName}' authors a malformed 'host_context_observed' input envelope — ${problem}. Received: ${JSON.stringify(envelope)}`);
436
+ }
437
+ // =============================================================================
438
+ // Session-state grading (the kit's third grading mechanism)
439
+ // =============================================================================
440
+ /**
441
+ * Grade a `session-state` expectation — the input message left no wire
442
+ * trace, so the honest verdict is a post-observation-window read-back
443
+ * of the mutated GguiSession field via
444
+ * {@link ConformanceHost.readSessionField}, compared with the SAME
445
+ * exact {@link deepEqual} the frame matchers use.
446
+ *
447
+ * A missing host, a host without `readSessionField`, or a read that
448
+ * throws all yield `unmatchable-on-ws` — the runner records a SKIP
449
+ * with the reason. The kit refuses to grade a stateful obligation it
450
+ * has no introspection seam for (a host that cannot read state cannot
451
+ * grade it); it never converts that gap into a pass or a fail.
452
+ *
453
+ * Exported for unit tests; not part of the package's public API.
454
+ */
455
+ export async function matchSessionState(behavior, sessionId, host) {
456
+ if (host === undefined || host.readSessionField === undefined) {
457
+ return {
458
+ kind: 'unmatchable-on-ws',
459
+ reason: host === undefined
460
+ ? 'session-state expectation needs a ConformanceHost with readSessionField() — no host was provided.'
461
+ : 'session-state expectation needs ConformanceHost.readSessionField() — the provided host does not implement it, so the kit cannot observe the GguiSession-field mutation. SKIP, not a pass.',
462
+ };
463
+ }
464
+ let actual;
465
+ try {
466
+ actual = await host.readSessionField(sessionId, behavior.field);
467
+ }
468
+ catch (err) {
469
+ return {
470
+ kind: 'unmatchable-on-ws',
471
+ reason: `host.readSessionField('${sessionId}', '${behavior.field}') threw — a host that cannot read state cannot grade it: ${String(err.message ?? err)}`,
472
+ };
473
+ }
474
+ if (deepEqual(actual, behavior.expected)) {
475
+ return { kind: 'pass' };
476
+ }
477
+ return {
478
+ kind: 'fail',
479
+ expected: { field: behavior.field, value: behavior.expected },
480
+ received: { field: behavior.field, value: actual },
481
+ message: `session field '${behavior.field}' did not hold the expected value after the input envelope was dispatched — the implementation under test may have dropped the message instead of persisting it.`,
482
+ };
483
+ }
484
+ /**
485
+ * Validating narrower for the fixture-authored `subscribe` knob
486
+ * (`SubscribeFrameShaping`, types.ts). Same trust-boundary posture as
487
+ * {@link parseSetupStep}: the fixture JSON enters the type system
488
+ * through a compile-time cast, so the shape is re-validated
489
+ * structurally here and any malformed or unknown shaping is a
490
+ * fixture-authoring error the runner throws on — never a skip, never
491
+ * a fail of the implementation under test.
492
+ *
493
+ * The vocabulary is CLOSED — exactly the shaping fields the shipped
494
+ * catalog authors (`omitAppId`, `supportedVersions`); an unknown key
495
+ * is rejected so a typo'd knob can never silently no-op into a
496
+ * vacuous grade. `supportedVersions: 'current'` resolves to
497
+ * `[PROTOCOL_SCHEMA_VERSION]` (the kit's compiled canonical), keeping
498
+ * fixtures evergreen across protocol version bumps.
499
+ *
500
+ * Exported for unit tests; not part of the package's public API.
501
+ */
502
+ export function parseSubscribeShaping(fixtureName, shaping) {
503
+ if (shaping === undefined)
504
+ return { omitAppId: false };
505
+ if (!isRecord(shaping)) {
506
+ throw malformedSubscribeShaping(fixtureName, 'expected an object carrying the SubscribeFrameShaping fields', shaping);
507
+ }
508
+ for (const key of Object.keys(shaping)) {
509
+ if (key !== 'omitAppId' && key !== 'supportedVersions') {
510
+ throw malformedSubscribeShaping(fixtureName, `unknown key '${key}' — the subscribe-shaping vocabulary is closed: omitAppId, supportedVersions`, shaping);
511
+ }
512
+ }
513
+ const omitAppId = shaping['omitAppId'];
514
+ if (omitAppId !== undefined && typeof omitAppId !== 'boolean') {
515
+ throw malformedSubscribeShaping(fixtureName, "'omitAppId' must be a boolean when present", shaping);
516
+ }
517
+ const supportedVersions = parseSupportedVersionsDecl(fixtureName, shaping, shaping['supportedVersions']);
518
+ return {
519
+ omitAppId: omitAppId === true,
520
+ ...(supportedVersions !== undefined ? { supportedVersions } : {}),
521
+ };
522
+ }
523
+ function parseSupportedVersionsDecl(fixtureName, shaping, decl) {
524
+ if (decl === undefined)
525
+ return undefined;
526
+ if (decl === 'current') {
527
+ // The evergreen sentinel — the version this kit release was
528
+ // compiled against, never a stale fixture literal.
529
+ return [PROTOCOL_SCHEMA_VERSION];
530
+ }
531
+ if (Array.isArray(decl) &&
532
+ decl.length > 0 &&
533
+ decl.every((version) => typeof version === 'string' && version.length > 0)) {
534
+ return decl;
535
+ }
536
+ throw malformedSubscribeShaping(fixtureName, "'supportedVersions' must be the sentinel 'current' or a non-empty array of non-empty strings", shaping);
537
+ }
538
+ function malformedSubscribeShaping(fixtureName, problem, shaping) {
539
+ return new Error(`protocol-conformance: fixture '${fixtureName}' authors a malformed 'subscribe' frame shaping — ${problem}. Received: ${JSON.stringify(shaping)}`);
238
540
  }
239
541
  /**
240
542
  * Map fixture name → the bar criterion it exercises, for the
@@ -253,34 +555,170 @@ function slugToCriterion(slug) {
253
555
  switch (slug) {
254
556
  case 'bootstrap-protocol':
255
557
  return 'Protocol #5 named failure modes — bootstrap contract';
256
- case 'observability-events':
257
- return 'Phase 2 C12 observability contract';
258
- case 'refresh-semantics':
259
- return 'SPEC §2.3 StreamSpec refresh triggers';
558
+ case 'consume-buffer':
559
+ return 'Single action-routing model — consume-buffer persistence + declared-action contract (name membership + payload schema)';
560
+ case 'host-context':
561
+ return 'Host-context persistence — host_context_observed MUST persist onto GguiSession.hostContext';
260
562
  case 'reserved-channel-authority':
261
563
  return 'SPEC §4.4 reserved-channel authority';
262
564
  case 'schema-version-handshake':
263
565
  return 'Protocol #3 version negotiation';
264
- case 'wired-action-dispatch':
265
- return 'Contract #3 defined failure modes (wiredActionRouter)';
566
+ case 'subscribe-tenancy':
567
+ return 'SPEC §12.2 subscribe tenancy — appId MUST match the bound app (§12.2.3 APP_MISMATCH)';
266
568
  default:
267
569
  return slug;
268
570
  }
269
571
  }
270
572
  /**
271
- * The fixture-JSON `SetupStep` and the host's runtime `SetupStep` use
272
- * slightly different shapes (`type` vs `kind` field, respectively).
273
- * The runner accepts JSON-shape at the fixture boundary and narrows
274
- * here — translating `{type,...}` → `{kind,...}` — before dispatching
275
- * to the host.
573
+ * Validating narrower from the fixture-JSON `SetupStep` (discriminated
574
+ * on `type`) to the host's runtime `SetupStep` (discriminated on
575
+ * `kind`). The fixture JSON enters the type system through a
576
+ * compile-time cast (tsc widens JSON-module string literals), so the
577
+ * static union cannot be trusted at runtime — every directive is
578
+ * re-validated structurally here.
579
+ *
580
+ * The setup vocabulary is CLOSED: an unknown or malformed directive is
581
+ * a fixture-authoring error, thrown with a descriptive message. It is
582
+ * never mapped to a skip or a fail — those verdicts describe the
583
+ * implementation under test, not the fixture catalog.
584
+ *
585
+ * The input is `unknown` by design — this function IS the trust
586
+ * boundary, so it never leans on the static union it produces
587
+ * evidence for.
588
+ *
589
+ * Exported for unit tests; not part of the package's public API.
276
590
  */
277
- function narrowSetupStep(step) {
278
- const raw = step;
279
- const { type, ...rest } = raw;
280
- return { kind: type ?? 'unknown', ...rest };
591
+ export function parseSetupStep(fixtureName, step) {
592
+ if (!isRecord(step) || typeof step['type'] !== 'string') {
593
+ throw new Error(`protocol-conformance: fixture '${fixtureName}' authors a malformed setup directive — expected an object with a string 'type'. Received: ${JSON.stringify(step)}`);
594
+ }
595
+ const type = step['type'];
596
+ if (type === 'create-session') {
597
+ const sessionId = requireStringField(fixtureName, type, step, 'sessionId');
598
+ const appId = step['appId'];
599
+ if (appId !== undefined && typeof appId !== 'string') {
600
+ throw malformedDirective(fixtureName, type, step, "'appId' must be a string when present");
601
+ }
602
+ const actionSpec = step['actionSpec'];
603
+ if (actionSpec !== undefined && !isActionSpecDecl(actionSpec)) {
604
+ throw malformedDirective(fixtureName, type, step, "'actionSpec' must be an object mapping action names to entry objects (each entry's optional 'schema' a shape-valid JSON-Schema node) when present");
605
+ }
606
+ return {
607
+ kind: 'create-session',
608
+ sessionId,
609
+ ...(appId !== undefined ? { appId } : {}),
610
+ ...(actionSpec !== undefined ? { actionSpec } : {}),
611
+ };
612
+ }
613
+ if (type === 'renderer-url-override') {
614
+ return {
615
+ kind: 'renderer-url-override',
616
+ sessionId: requireStringField(fixtureName, type, step, 'sessionId'),
617
+ url: requireStringField(fixtureName, type, step, 'url'),
618
+ };
619
+ }
620
+ if (type === 'server-version-override') {
621
+ return {
622
+ kind: 'server-version-override',
623
+ sessionId: requireStringField(fixtureName, type, step, 'sessionId'),
624
+ advertiseVersion: requireStringField(fixtureName, type, step, 'advertiseVersion'),
625
+ };
626
+ }
627
+ if (type === 'ui-initialize-response-override') {
628
+ const sessionId = requireStringField(fixtureName, type, step, 'sessionId');
629
+ if (!('override' in step)) {
630
+ throw malformedDirective(fixtureName, type, step, "missing the 'override' response body");
631
+ }
632
+ return { kind: 'ui-initialize-response-override', sessionId, override: step['override'] };
633
+ }
634
+ if (type === 'emit-envelope') {
635
+ const channel = requireStringField(fixtureName, type, step, 'channel');
636
+ if (!('payload' in step)) {
637
+ throw malformedDirective(fixtureName, type, step, "missing the 'payload' envelope body");
638
+ }
639
+ return { kind: 'emit-envelope', channel, payload: step['payload'] };
640
+ }
641
+ throw new Error(`protocol-conformance: fixture '${fixtureName}' authors unknown setup directive type='${type}'. The setup vocabulary is closed; known directives: create-session, renderer-url-override, server-version-override, ui-initialize-response-override, emit-envelope.`);
642
+ }
643
+ /**
644
+ * Teardown counterpart of {@link parseSetupStep}. The teardown
645
+ * vocabulary is empty in this kit version (renders decay via TTL), so
646
+ * ANY authored teardown directive is a fixture-authoring error.
647
+ */
648
+ function parseTeardownStep(fixtureName, step) {
649
+ throw new Error(`protocol-conformance: fixture '${fixtureName}' authors a teardown directive, but this kit version defines no teardown vocabulary (renders decay via TTL). Received: ${JSON.stringify(step)}`);
650
+ }
651
+ function requireStringField(fixtureName, directiveType, step, field) {
652
+ const value = step[field];
653
+ if (typeof value !== 'string' || value.length === 0) {
654
+ throw malformedDirective(fixtureName, directiveType, step, `'${field}' must be a non-empty string`);
655
+ }
656
+ return value;
657
+ }
658
+ function malformedDirective(fixtureName, directiveType, step, problem) {
659
+ return new Error(`protocol-conformance: fixture '${fixtureName}' authors a malformed '${directiveType}' setup directive — ${problem}. Received: ${JSON.stringify(step)}`);
660
+ }
661
+ /**
662
+ * Validating narrower for `CreateGguiSessionStep.actionSpec`. Each
663
+ * entry must be an object whose optional `schema` field — when
664
+ * authored — is a shape-valid {@link JsonSchemaDecl} per
665
+ * {@link isJsonSchemaDecl}.
666
+ */
667
+ function isActionSpecDecl(value) {
668
+ return (isRecord(value) &&
669
+ Object.values(value).every((entry) => isRecord(entry) &&
670
+ (entry['schema'] === undefined || isJsonSchemaDecl(entry['schema']))));
281
671
  }
282
- function narrowTeardownStep(step) {
283
- const raw = step;
284
- const { type, ...rest } = raw;
285
- return { kind: type ?? 'unknown', ...rest };
672
+ /** The `type` keyword values the kit's authored JSON-Schema subset names. */
673
+ const JSON_SCHEMA_DECL_TYPES = [
674
+ 'string',
675
+ 'number',
676
+ 'integer',
677
+ 'boolean',
678
+ 'array',
679
+ 'object',
680
+ 'null',
681
+ ];
682
+ /**
683
+ * Validating parse for the typed core of {@link JsonSchemaDecl} —
684
+ * recursively checks that every keyword the kit's authored vocabulary
685
+ * names carries the shape the type declares. Keywords outside the
686
+ * typed core pass through verbatim (the implementation under test
687
+ * owns their interpretation), mirroring the index-signature tail on
688
+ * the declared type. A fixture authoring a malformed schema node is a
689
+ * fixture-authoring error the runner throws on — never a skip or a
690
+ * fail of the implementation under test.
691
+ */
692
+ function isJsonSchemaDecl(value) {
693
+ if (!isRecord(value))
694
+ return false;
695
+ if (value['type'] !== undefined && !JSON_SCHEMA_DECL_TYPES.includes(value['type'])) {
696
+ return false;
697
+ }
698
+ if (value['description'] !== undefined && typeof value['description'] !== 'string') {
699
+ return false;
700
+ }
701
+ if (value['enum'] !== undefined && !Array.isArray(value['enum']))
702
+ return false;
703
+ if (value['items'] !== undefined && !isJsonSchemaDecl(value['items']))
704
+ return false;
705
+ const properties = value['properties'];
706
+ if (properties !== undefined) {
707
+ if (!isRecord(properties))
708
+ return false;
709
+ if (!Object.values(properties).every(isJsonSchemaDecl))
710
+ return false;
711
+ }
712
+ const required = value['required'];
713
+ if (required !== undefined &&
714
+ !(Array.isArray(required) && required.every((name) => typeof name === 'string'))) {
715
+ return false;
716
+ }
717
+ const additionalProperties = value['additionalProperties'];
718
+ if (additionalProperties !== undefined &&
719
+ typeof additionalProperties !== 'boolean' &&
720
+ !isJsonSchemaDecl(additionalProperties)) {
721
+ return false;
722
+ }
723
+ return true;
286
724
  }