@ggui-ai/protocol-conformance 0.2.0-alpha.3 → 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
@@ -1,36 +1,145 @@
1
+ /**
2
+ * Match observed WS frames against a fixture's {@link ExpectedBehavior}.
3
+ *
4
+ * Each matcher is pure — it consumes a read-only array of frames the
5
+ * runner collected from the transport and returns either a pass or a
6
+ * {@link MatchFailure} the runner lifts into a
7
+ * {@link ConformanceFailure}. No side-effects, no hidden state — the
8
+ * runner owns orchestration.
9
+ *
10
+ * ## Path-A vs Path-B partition
11
+ *
12
+ * The kit's behavior vocabulary spans both wire-observable claims (the
13
+ * subscribe ack, the action ack's persistence sequence, error frames,
14
+ * stream updates) AND surface-observable claims (DOM state after a
15
+ * props update, browser-side bootstrap failure modes). This file
16
+ * matches the **Path-A** subset — the wire-observable behaviors a
17
+ * runner can assert from frames alone, with no MCP-Apps-host adapter
18
+ * and no browser page. The Path-B subset (browser-host harness) is
19
+ * inherently NOT WS-reducible: bootstrap-failure modes are caused by
20
+ * the renderer-bundle fetch + `ui/initialize` round-trip the host
21
+ * speaks over postMessage; props-update assertions are assertions on
22
+ * the rendered DOM the renderer paints, not on WS frames the server
23
+ * emits. For these kinds we return `unmatchable-on-ws` with a sharp
24
+ * reason and the runner records SKIP — they are NOT failures of the
25
+ * server, they are claims a different driver must drive.
26
+ *
27
+ * The Path-B driver is not yet packaged — no browser-host harness
28
+ * ships with the kit today, so Path-B fixtures are skipped wherever
29
+ * the kit runs. A future packaged browser-host adapter folds that
30
+ * capability into the kit so third-party adopters don't have to
31
+ * reimplement it. Until then, the partition is honest: Path A handles
32
+ * wire claims, Path B is a declared grading gap, and
33
+ * `unmatchable-on-ws` is the partition seam.
34
+ *
35
+ * v1 Path-A scope:
36
+ *
37
+ * - `bootstrap-success` — subscribe produced an `ack` frame. When
38
+ * the behavior authors `serverVersion: 'current'`, the ack MUST
39
+ * additionally advertise `payload.serverVersion` equal to the
40
+ * kit's compiled `PROTOCOL_SCHEMA_VERSION` — the server half of
41
+ * SPEC §12.2.2's version handshake (the `version-match` fixture's
42
+ * happy-path claim).
43
+ * - `version-mismatch` — subscribe produced an `error` frame with
44
+ * `code: 'UPGRADE_REQUIRED'` (the generic error-frame read
45
+ * narrowed to one code). The provoking client declaration travels
46
+ * on the fixture's `subscribe.supportedVersions` knob, not on
47
+ * this behavior.
48
+ * - `action-ack` — the action's `ack` frame (matched by echoed
49
+ * `requestId`) carries a numeric `payload.sequence` — proof the
50
+ * event persisted to the GguiSession's consume buffer.
51
+ * - `error-frame` — an `error` frame with the expected
52
+ * `payload.code` (+ optional echoed `requestId`).
53
+ * - `stream-update` — canonical channel-3 delivery frame
54
+ * (`{type: 'data', payload: StreamEnvelope}`, SPEC §12.2) whose
55
+ * envelope names the declared channel and carries the declared
56
+ * value as its `payload` body.
57
+ * - `no-op` — no frames observed after input dispatch.
58
+ *
59
+ * One kind is neither Path A nor Path B: `session-state` is a
60
+ * *stateful* obligation — the input message produces no wire response,
61
+ * so frames cannot prove it. This module returns `unmatchable-on-ws`
62
+ * for it; the RUNNER (`run-conformance.ts`) grades it after the
63
+ * observation window via `ConformanceHost.readSessionField`, reusing
64
+ * this module's exported {@link deepEqual}. A caller that routes a
65
+ * `session-state` behavior through the frame matcher gets the honest
66
+ * skip, never a frame-derived verdict.
67
+ *
68
+ * v1 Path-B-only kinds (return `unmatchable-on-ws`):
69
+ *
70
+ * - `bootstrap-failure` — the fault surface lives in the host's
71
+ * bootstrap-fetch + `ui/initialize` round-trip; the fixture's
72
+ * `renderer-url-override` / `ui-initialize-response-override`
73
+ * setup directives are MCP-Apps-host concerns, not WS server
74
+ * concerns. The reference server's host adapter correctly throws
75
+ * "out of scope" on these directives — that's not a bug, it's
76
+ * the partition being respected.
77
+ * - `props-update` — the assertion is on rendered DOM after the
78
+ * server emits a `_ggui:props` frame; matchable on WS only as
79
+ * "frame was emitted", not as "DOM reflects it" — and the fixture
80
+ * vocabulary asserts the latter.
81
+ *
82
+ * ## Declared grading gap — the `ggui_consume` retrieval half
83
+ *
84
+ * `action-ack` proves the append half of the consume-buffer contract.
85
+ * The retrieval half — the agent draining the buffer via
86
+ * `ggui_consume({sessionId})` — is an MCP tool call a WS-only runner
87
+ * cannot drive. Grading it needs an MCP-binding driver; that gap is
88
+ * declared here rather than papered over with a weaker assertion.
89
+ */
90
+ import { PROTOCOL_SCHEMA_VERSION } from '@ggui-ai/protocol';
91
+ import { isRecord } from '@ggui-ai/protocol';
92
+ /**
93
+ * Discriminant narrowing for the extensibly-closed
94
+ * {@link ExpectedBehavior} union. The `UnknownBehavior` arm's
95
+ * `kind: string & {}` deliberately widens the discriminant (future
96
+ * fixture vocabulary must flow through as opaque records), which also
97
+ * stops TS narrowing on direct literal checks — so the kit dispatches
98
+ * through this predicate instead of per-site casts. The runtime check
99
+ * is exactly the literal comparison the predicate claims.
100
+ */
101
+ export function behaviorIs(behavior, kind) {
102
+ return behavior.kind === kind;
103
+ }
1
104
  export function matchBehavior(behavior, frames) {
2
- // Dispatch on the `kind` discriminator with explicit casts because
3
- // the extensibly-closed `UnknownBehavior` (`kind: string & {}`) in
4
- // the union widens the discriminant, preventing TS from narrowing
5
- // on literals. We checked the literal above — the cast is sound.
6
- if (behavior.kind === 'bootstrap-success') {
105
+ // Dispatch on the `kind` discriminator through `behaviorIs` — the
106
+ // extensibly-closed `UnknownBehavior` (`kind: string & {}`) in the
107
+ // union widens the discriminant, preventing TS from narrowing on
108
+ // bare literal checks.
109
+ if (behaviorIs(behavior, 'bootstrap-success')) {
7
110
  return matchBootstrapSuccess(behavior, frames);
8
111
  }
9
- if (behavior.kind === 'version-mismatch') {
112
+ if (behaviorIs(behavior, 'version-mismatch')) {
10
113
  return matchVersionMismatch(behavior, frames);
11
114
  }
12
- if (behavior.kind === 'contract-error') {
13
- return matchContractError(behavior, frames);
115
+ if (behaviorIs(behavior, 'action-ack')) {
116
+ return matchActionAck(behavior, frames);
117
+ }
118
+ if (behaviorIs(behavior, 'error-frame')) {
119
+ return matchErrorFrame(behavior, frames);
14
120
  }
15
- if (behavior.kind === 'stream-update') {
121
+ if (behaviorIs(behavior, 'stream-update')) {
16
122
  return matchStreamUpdate(behavior, frames);
17
123
  }
18
- if (behavior.kind === 'no-op') {
124
+ if (behaviorIs(behavior, 'no-op')) {
19
125
  return matchNoOp(behavior, frames);
20
126
  }
21
127
  if (behavior.kind === 'bootstrap-failure') {
22
128
  return {
23
129
  kind: 'unmatchable-on-ws',
24
- reason: 'bootstrap-failure is a Path-B (browser-host) claim — the fault surface is the host\'s bootstrap-fetch + `ui/initialize` postMessage round-trip, not a WS frame the server emits. The `renderer-url-override` / `ui-initialize-response-override` setup directives are MCP-Apps-host concerns; the reference server\'s host adapter throws "out of scope" on them by design. Drive these fixtures via a Path-B browser-host harness (today: PENDING a new driver; future: packaged Phase-3.2 adapter inside the kit).',
130
+ reason: 'bootstrap-failure is a Path-B (browser-host) claim — the fault surface is the host\'s bootstrap-fetch + `ui/initialize` postMessage round-trip, not a WS frame the server emits. The `renderer-url-override` / `ui-initialize-response-override` setup directives are MCP-Apps-host concerns; the reference server\'s host adapter throws "out of scope" on them by design. The Path-B driver is not yet packaged — these fixtures skip until a browser-host adapter ships with the kit.',
25
131
  };
26
132
  }
27
- if (behavior.kind === 'observability-event') {
28
- return matchObservabilityEvent(behavior, frames);
29
- }
30
133
  if (behavior.kind === 'props-update') {
31
134
  return {
32
135
  kind: 'unmatchable-on-ws',
33
- reason: 'props-update is a Path-B (browser-host) claim — the assertion is on rendered DOM (selector + attribute/text), not on the `_ggui:props` WS frame in isolation. Matchable on WS only as "frame was emitted"; the fixture vocabulary asserts "DOM reflects the update". Drive via a Path-B browser-host harness; future: packaged Phase-3.2 adapter inside the kit.',
136
+ reason: 'props-update is a Path-B (browser-host) claim — the assertion is on rendered DOM (selector + attribute/text), not on the `_ggui:props` WS frame in isolation. Matchable on WS only as "frame was emitted"; the fixture vocabulary asserts "DOM reflects the update". The Path-B driver is not yet packaged — this fixture skips until a browser-host adapter ships with the kit.',
137
+ };
138
+ }
139
+ if (behavior.kind === 'session-state') {
140
+ return {
141
+ kind: 'unmatchable-on-ws',
142
+ reason: 'session-state is a stateful obligation — its grade is a post-dispatch read-back of the GguiSession field via `ConformanceHost.readSessionField`, not a WS frame; frames cannot prove state. The runner (`run-conformance.ts`) grades this kind after the observation window; it never reaches the frame matcher there. Reaching here means a caller invoked `matchBehavior` directly on a session-state behavior.',
34
143
  };
35
144
  }
36
145
  // Extensibly-closed union catch — unknown `kind` = new fixture
@@ -43,7 +152,7 @@ export function matchBehavior(behavior, frames) {
43
152
  // =============================================================================
44
153
  // Per-behavior matchers
45
154
  // =============================================================================
46
- function matchBootstrapSuccess(_behavior, frames) {
155
+ function matchBootstrapSuccess(behavior, frames) {
47
156
  const ack = frames.find((f) => f.kind === 'frame' && f.parsed['type'] === 'ack');
48
157
  if (ack === undefined) {
49
158
  return {
@@ -64,13 +173,32 @@ function matchBootstrapSuccess(_behavior, frames) {
64
173
  message: 'expected bootstrap-success but received an error frame — the subscribe was rejected.',
65
174
  };
66
175
  }
176
+ // Optional ack-field assertion (`serverVersion: 'current'`) — the
177
+ // server half of SPEC §12.2.2: a successful ack MUST advertise
178
+ // `payload.serverVersion` equal to the kit's compiled canonical.
179
+ // An ack that omits the field is legacy-pass-through on the wire,
180
+ // but a fixture authoring this assertion is grading the handshake
181
+ // — silence does not satisfy it.
182
+ if (behavior.serverVersion === 'current') {
183
+ const payload = ack.kind === 'frame' ? ack.parsed['payload'] : undefined;
184
+ const advertised = isRecord(payload) ? payload['serverVersion'] : undefined;
185
+ if (advertised !== PROTOCOL_SCHEMA_VERSION) {
186
+ return {
187
+ kind: 'fail',
188
+ expected: { type: 'ack', payload: { serverVersion: PROTOCOL_SCHEMA_VERSION } },
189
+ received: { ackFrame: ack.kind === 'frame' ? ack.parsed : ack },
190
+ message: advertised === undefined
191
+ ? `ack does not advertise \`payload.serverVersion\` — the fixture asserts the SPEC §12.2.2 server half (advertise '${PROTOCOL_SCHEMA_VERSION}' on every successful ack); a versionless ack proves only the pre-handshake subscribe path.`
192
+ : `ack advertises \`payload.serverVersion\` ${JSON.stringify(advertised)} but the kit's compiled canonical is '${PROTOCOL_SCHEMA_VERSION}'.`,
193
+ };
194
+ }
195
+ }
67
196
  return { kind: 'pass' };
68
197
  }
69
198
  function matchVersionMismatch(behavior, frames) {
70
- const error = frames.find((f) => f.kind === 'frame' &&
71
- f.parsed['type'] === 'error' &&
72
- isRecord(f.parsed['payload']) &&
73
- f.parsed['payload']['code'] === 'UPGRADE_REQUIRED');
199
+ // The generalized error-frame read narrowed to the version
200
+ // handshake's canonical rejection code.
201
+ const error = findErrorFrame(frames, 'UPGRADE_REQUIRED');
74
202
  if (error === undefined) {
75
203
  return {
76
204
  kind: 'fail',
@@ -85,92 +213,111 @@ function matchVersionMismatch(behavior, frames) {
85
213
  }
86
214
  return { kind: 'pass' };
87
215
  }
88
- function matchContractError(behavior, frames) {
89
- const candidates = frames.filter((f) => f.kind === 'frame' &&
90
- f.parsed['type'] === 'stream' &&
91
- isRecord(f.parsed['payload']) &&
92
- f.parsed['payload']['channel'] === '_ggui:contract-error');
93
- if (candidates.length === 0) {
216
+ /**
217
+ * `action-ack` — the ack frame echoing the action's `requestId` MUST
218
+ * carry a numeric `payload.sequence`: the monotonic event sequence the
219
+ * server assigned when it appended the action to the GguiSession's
220
+ * consume buffer. Matching on the echoed `requestId` distinguishes the
221
+ * action's ack from the subscribe ack the runner's own subscribe frame
222
+ * produces.
223
+ */
224
+ function matchActionAck(behavior, frames) {
225
+ const acks = frames.filter((f) => f.kind === 'frame' &&
226
+ f.parsed['type'] === 'ack' &&
227
+ f.parsed['requestId'] === behavior.requestId);
228
+ if (acks.length === 0) {
94
229
  return {
95
230
  kind: 'fail',
96
- expected: {
97
- channel: '_ggui:contract-error',
98
- code: behavior.code,
99
- toolName: behavior.toolName,
100
- },
231
+ expected: { type: 'ack', requestId: behavior.requestId },
101
232
  received: frames.map((f) => (f.kind === 'frame' ? f.parsed : f)),
102
- message: 'expected a `stream` frame on the `_ggui:contract-error` reserved channel; none observed.',
233
+ message: `expected an \`ack\` frame echoing requestId '${behavior.requestId}' after action dispatch; received none.`,
103
234
  };
104
235
  }
105
- // Check at least one candidate carries the expected code + tool.
106
- //
107
- // Canonical shape per SPEC §4.4 `ContractErrorPayload`: `code` is
108
- // NESTED under `value.error.code`, alongside `value.error.message`
109
- // and `value.error.causedBy`. `toolName` / `actionName` /
110
- // `sourceAction` / `timestamp` live at the top of `value`. The
111
- // matcher reads the canonical shape exclusively — the reference
112
- // server (and `@ggui-ai/mcp-server`'s router) emits via
113
- // `makeContractErrorPayload`, which is the authoritative builder.
114
- // Any producer emitting a flat `code` field is a spec violation and
115
- // will correctly fail this match — that's the point of Protocol #5
116
- // (named failure modes) being assertable.
117
- const match = candidates.find((f) => {
236
+ const match = acks.find((f) => {
118
237
  if (f.kind !== 'frame')
119
238
  return false;
120
239
  const payload = f.parsed['payload'];
121
- if (!isRecord(payload))
122
- return false;
123
- const value = payload['value'];
124
- if (!isRecord(value))
125
- return false;
126
- const error = value['error'];
127
- if (!isRecord(error))
128
- return false;
129
- if (error['code'] !== behavior.code)
130
- return false;
131
- if (value['toolName'] !== behavior.toolName)
132
- return false;
133
- if (behavior.actionName !== undefined &&
134
- value['actionName'] !== behavior.actionName) {
135
- return false;
136
- }
137
- if (behavior.sourceAction !== undefined) {
138
- const source = value['sourceAction'];
139
- const sourceType = isRecord(source) ? source['type'] : source;
140
- if (sourceType !== behavior.sourceAction)
141
- return false;
142
- }
143
- return true;
240
+ return isRecord(payload) && typeof payload['sequence'] === 'number';
144
241
  });
145
242
  if (match === undefined) {
146
243
  return {
147
244
  kind: 'fail',
148
245
  expected: {
149
- code: behavior.code,
150
- toolName: behavior.toolName,
151
- actionName: behavior.actionName,
152
- sourceAction: behavior.sourceAction,
246
+ type: 'ack',
247
+ requestId: behavior.requestId,
248
+ payload: { sequence: '<number>' },
153
249
  },
154
- received: candidates.map((f) => (f.kind === 'frame' ? f.parsed : f)),
155
- message: '`_ggui:contract-error` frame observed but payload did not match the expected code/tool/action triple.',
250
+ received: acks.map((f) => (f.kind === 'frame' ? f.parsed : f)),
251
+ message: 'action `ack` frame observed but `payload.sequence` is missing or non-numeric — the ack does not prove the event persisted to the consume buffer.',
156
252
  };
157
253
  }
158
254
  return { kind: 'pass' };
159
255
  }
256
+ /**
257
+ * `error-frame` — an `error` frame with the expected `payload.code`
258
+ * (+ the echoed `requestId` when the fixture declares one).
259
+ */
260
+ function matchErrorFrame(behavior, frames) {
261
+ const match = findErrorFrame(frames, behavior.code, behavior.requestId);
262
+ if (match === undefined) {
263
+ return {
264
+ kind: 'fail',
265
+ expected: {
266
+ type: 'error',
267
+ payload: { code: behavior.code },
268
+ ...(behavior.requestId !== undefined ? { requestId: behavior.requestId } : {}),
269
+ },
270
+ received: frames.map((f) => (f.kind === 'frame' ? f.parsed : f)),
271
+ message: `expected an \`error\` frame with \`payload.code === ${behavior.code}\`${behavior.requestId !== undefined ? ` echoing requestId '${behavior.requestId}'` : ''}; received none.`,
272
+ };
273
+ }
274
+ return { kind: 'pass' };
275
+ }
276
+ /**
277
+ * `stream-update` — matches the canonical channel-3 delivery frame
278
+ * (SPEC §12.2): `{type: 'data', payload: StreamEnvelope}`. The
279
+ * {@link StreamEnvelope} (from `@ggui-ai/protocol`) carries the
280
+ * channel identity on `channel` and the delivery body on `payload`;
281
+ * the matcher requires a `data` frame whose envelope names the
282
+ * declared channel and whose body matches the declared value —
283
+ * exact deep-equal by default, or declared-keys-subset when the
284
+ * fixture authors `valueMatch: 'subset'` (see
285
+ * {@link deepMatchSubset}).
286
+ *
287
+ * Two near-miss frames deliberately do NOT match:
288
+ * - Any frame whose `type` is not `data` — e.g. the retired
289
+ * `{type: 'stream'}` text-chunk frame (left the protocol union
290
+ * draft-2026-06-11; an implementation still emitting it never
291
+ * satisfies a stream-update expectation).
292
+ * - Any `data` frame whose body fails the {@link StreamEnvelope}
293
+ * shape (missing `sessionId` / `channel` / `mode`) — a
294
+ * non-conformant envelope must not satisfy the assertion.
295
+ */
160
296
  function matchStreamUpdate(behavior, frames) {
161
- const match = frames.find((f) => f.kind === 'frame' &&
162
- f.parsed['type'] === 'stream' &&
163
- isRecord(f.parsed['payload']) &&
164
- f.parsed['payload']['channel'] === behavior.channel &&
165
- deepEqual(f.parsed['payload']['value'], behavior.value));
297
+ const envelopes = frames.flatMap((f) => {
298
+ if (f.kind !== 'frame' || f.parsed['type'] !== 'data')
299
+ return [];
300
+ const body = f.parsed['payload'];
301
+ return isStreamEnvelope(body) ? [body] : [];
302
+ });
303
+ // `valueMatch: 'subset'` relaxes the value comparison to "every
304
+ // declared key is present + matching; extra observed keys ignored".
305
+ // Default ('exact' / absent) is the exact deep-equal.
306
+ const subset = behavior.valueMatch === 'subset';
307
+ const valueMatches = (observed) => subset ? deepMatchSubset(behavior.value, observed) : deepEqual(observed, behavior.value);
308
+ const match = envelopes.find((envelope) => envelope.channel === behavior.channel && valueMatches(envelope.payload));
166
309
  if (match === undefined) {
167
310
  return {
168
311
  kind: 'fail',
169
- expected: { channel: behavior.channel, value: behavior.value },
312
+ expected: {
313
+ type: 'data',
314
+ payload: { channel: behavior.channel, payload: behavior.value },
315
+ valueMatch: subset ? 'subset' : 'exact',
316
+ },
170
317
  received: frames
171
- .filter((f) => f.kind === 'frame' && f.parsed['type'] === 'stream')
172
- .map((f) => (f.kind === 'frame' ? f.parsed['payload'] : f)),
173
- message: `expected a \`stream\` frame on channel '${behavior.channel}' with the declared value shape; none matched.`,
318
+ .filter((f) => f.kind === 'frame' && f.parsed['type'] === 'data')
319
+ .map((f) => (f.kind === 'frame' ? f.parsed : f)),
320
+ message: `expected a \`data\` frame whose StreamEnvelope names channel '${behavior.channel}' and carries the declared value as its payload (${subset ? 'subset match — every declared key present + matching' : 'exact match'}); none matched.`,
174
321
  };
175
322
  }
176
323
  return { kind: 'pass' };
@@ -192,172 +339,47 @@ function matchNoOp(behavior, frames) {
192
339
  }
193
340
  return { kind: 'pass' };
194
341
  }
342
+ // =============================================================================
343
+ // Helpers
344
+ // =============================================================================
195
345
  /**
196
- * Match an observability-event behavior against WS evidence the
197
- * protocol-and-contract bar mandates a conformant host mirror-emit on.
198
- *
199
- * Two arms are matchable on pure WS today; everything else returns
200
- * `unmatchable-on-ws` with a sharp reason — see the file-level
201
- * docstring for the rationale.
346
+ * Find an `error` frame carrying `payload.code === code` and, when
347
+ * `requestId` is given, echoing it. Shared by the generic
348
+ * `error-frame` matcher and the `version-mismatch` arm.
202
349
  */
203
- function matchObservabilityEvent(behavior, frames) {
204
- const { event } = behavior;
205
- if (event.kind === 'wired-tool-invoked') {
206
- return matchWiredToolInvoked(event, frames);
207
- }
208
- if (event.kind === 'contract-error-emitted') {
209
- return matchContractErrorEmitted(event, frames);
210
- }
211
- return {
212
- kind: 'unmatchable-on-ws',
213
- reason: `observability-event of kind '${event.kind}' is a Path-B (browser-host) claim — not grounded in WS-observable evidence by the kit's matcher. Drive via a Path-B browser-host harness (today: \`page.exposeBinding\` in spec-side dispatch; future: packaged Phase-3.2 adapter inside the kit).`,
214
- };
350
+ function findErrorFrame(frames, code, requestId) {
351
+ return frames.find((f) => f.kind === 'frame' &&
352
+ f.parsed['type'] === 'error' &&
353
+ isRecord(f.parsed['payload']) &&
354
+ f.parsed['payload']['code'] === code &&
355
+ (requestId === undefined || f.parsed['requestId'] === requestId));
215
356
  }
216
357
  /**
217
- * `wired-tool-invoked` — the WS evidence is a `stream` frame on the
218
- * canonical `_ggui:wired-tool-invoked` channel carrying
219
- * `value: {toolName, actionName?}`. The reference server's
220
- * `action-router.ts::dispatchAction()` emits this after a successful
221
- * handler resolution; the bar mandates equivalent WS evidence from any
222
- * conformant host on the wired-tool-invoked path.
358
+ * Validating narrower for {@link StreamEnvelope} — the body of a
359
+ * channel-3 `data` frame. Field names come from `@ggui-ai/protocol`
360
+ * (`types/live-channel`): `sessionId` + `channel` are required
361
+ * strings, `mode` is the declared state-folding mode
362
+ * (`'append' | 'replace'`), and the delivery body lives under
363
+ * `payload`.
223
364
  */
224
- function matchWiredToolInvoked(event, frames) {
225
- if (event.toolName === undefined) {
226
- return {
227
- kind: 'fail',
228
- expected: { kind: 'wired-tool-invoked', toolName: '<required>' },
229
- received: { event },
230
- message: 'observability-event of kind `wired-tool-invoked` MUST declare `toolName` for the matcher to assert against; fixture is under-specified.',
231
- };
232
- }
233
- const candidates = frames.filter((f) => f.kind === 'frame' &&
234
- f.parsed['type'] === 'stream' &&
235
- isRecord(f.parsed['payload']) &&
236
- f.parsed['payload']['channel'] === '_ggui:wired-tool-invoked');
237
- if (candidates.length === 0) {
238
- return {
239
- kind: 'fail',
240
- expected: {
241
- channel: '_ggui:wired-tool-invoked',
242
- value: { toolName: event.toolName, actionName: event.actionName },
243
- },
244
- received: frames.map((f) => (f.kind === 'frame' ? f.parsed : f)),
245
- message: 'expected a `stream` frame on `_ggui:wired-tool-invoked` (the WS-observable evidence the protocol bar mandates a conformant host mirror-emit `wired-tool-invoked` on); none observed.',
246
- };
247
- }
248
- const match = candidates.find((f) => {
249
- if (f.kind !== 'frame')
250
- return false;
251
- const payload = f.parsed['payload'];
252
- if (!isRecord(payload))
253
- return false;
254
- const value = payload['value'];
255
- if (!isRecord(value))
256
- return false;
257
- if (value['toolName'] !== event.toolName)
258
- return false;
259
- if (event.actionName !== undefined &&
260
- value['actionName'] !== event.actionName) {
261
- return false;
262
- }
263
- return true;
264
- });
265
- if (match === undefined) {
266
- return {
267
- kind: 'fail',
268
- expected: {
269
- channel: '_ggui:wired-tool-invoked',
270
- value: { toolName: event.toolName, actionName: event.actionName },
271
- },
272
- received: candidates.map((f) => (f.kind === 'frame' ? f.parsed : f)),
273
- message: '`_ggui:wired-tool-invoked` frame observed but value did not match the expected toolName/actionName.',
274
- };
275
- }
276
- return { kind: 'pass' };
365
+ function isStreamEnvelope(value) {
366
+ if (!isRecord(value))
367
+ return false;
368
+ if (typeof value['sessionId'] !== 'string')
369
+ return false;
370
+ if (typeof value['channel'] !== 'string')
371
+ return false;
372
+ if (value['mode'] !== 'append' && value['mode'] !== 'replace')
373
+ return false;
374
+ return 'payload' in value;
277
375
  }
278
376
  /**
279
- * `contract-error-emitted` — the WS evidence is the canonical SPEC
280
- * §4.4 `_ggui:contract-error` envelope itself. Per the bar, every such
281
- * envelope MUST be mirrored as a `contract-error-emitted` observability
282
- * event on the host side; asserting on the WS frame is asserting on
283
- * the cause the bar makes load-bearing for the mirror.
377
+ * Exact deep-equal — objects must have identical key sets, arrays
378
+ * identical length + ordered contents. Exported so the runner grades
379
+ * `session-state` read-backs with the SAME comparison the frame
380
+ * matchers use — one equality, no matcher/runner drift.
284
381
  */
285
- function matchContractErrorEmitted(event, frames) {
286
- if (event.code === undefined || event.toolName === undefined) {
287
- return {
288
- kind: 'fail',
289
- expected: {
290
- kind: 'contract-error-emitted',
291
- code: event.code ?? '<required>',
292
- toolName: event.toolName ?? '<required>',
293
- },
294
- received: { event },
295
- message: 'observability-event of kind `contract-error-emitted` MUST declare both `code` and `toolName` for the matcher to assert against; fixture is under-specified.',
296
- };
297
- }
298
- const candidates = frames.filter((f) => f.kind === 'frame' &&
299
- f.parsed['type'] === 'stream' &&
300
- isRecord(f.parsed['payload']) &&
301
- f.parsed['payload']['channel'] === '_ggui:contract-error');
302
- if (candidates.length === 0) {
303
- return {
304
- kind: 'fail',
305
- expected: {
306
- channel: '_ggui:contract-error',
307
- code: event.code,
308
- toolName: event.toolName,
309
- },
310
- received: frames.map((f) => (f.kind === 'frame' ? f.parsed : f)),
311
- message: 'expected a `stream` frame on `_ggui:contract-error` (the WS-observable evidence the protocol bar mandates a conformant host mirror-emit `contract-error-emitted` on); none observed.',
312
- };
313
- }
314
- // Same canonical-shape reader as `matchContractError` — code is
315
- // nested under `payload.value.error.code`, toolName at the top of
316
- // `payload.value`.
317
- const match = candidates.find((f) => {
318
- if (f.kind !== 'frame')
319
- return false;
320
- const payload = f.parsed['payload'];
321
- if (!isRecord(payload))
322
- return false;
323
- const value = payload['value'];
324
- if (!isRecord(value))
325
- return false;
326
- const error = value['error'];
327
- if (!isRecord(error))
328
- return false;
329
- if (error['code'] !== event.code)
330
- return false;
331
- if (value['toolName'] !== event.toolName)
332
- return false;
333
- if (event.actionName !== undefined &&
334
- value['actionName'] !== event.actionName) {
335
- return false;
336
- }
337
- return true;
338
- });
339
- if (match === undefined) {
340
- return {
341
- kind: 'fail',
342
- expected: {
343
- channel: '_ggui:contract-error',
344
- code: event.code,
345
- toolName: event.toolName,
346
- actionName: event.actionName,
347
- },
348
- received: candidates.map((f) => (f.kind === 'frame' ? f.parsed : f)),
349
- message: '`_ggui:contract-error` frame observed but payload did not match the expected code/tool/action triple for the mandated mirror-emission.',
350
- };
351
- }
352
- return { kind: 'pass' };
353
- }
354
- // =============================================================================
355
- // Helpers
356
- // =============================================================================
357
- function isRecord(value) {
358
- return value !== null && typeof value === 'object' && !Array.isArray(value);
359
- }
360
- function deepEqual(a, b) {
382
+ export function deepEqual(a, b) {
361
383
  if (a === b)
362
384
  return true;
363
385
  if (typeof a !== typeof b)
@@ -373,13 +395,34 @@ function deepEqual(a, b) {
373
395
  return false;
374
396
  return a.every((item, i) => deepEqual(item, b[i]));
375
397
  }
376
- const aObj = a;
377
- const bObj = b;
378
- const aKeys = Object.keys(aObj).sort();
379
- const bKeys = Object.keys(bObj).sort();
398
+ if (!isRecord(a) || !isRecord(b))
399
+ return false;
400
+ const aKeys = Object.keys(a).sort();
401
+ const bKeys = Object.keys(b).sort();
380
402
  if (aKeys.length !== bKeys.length)
381
403
  return false;
382
404
  if (!aKeys.every((k, i) => k === bKeys[i]))
383
405
  return false;
384
- return aKeys.every((k) => deepEqual(aObj[k], bObj[k]));
406
+ return aKeys.every((k) => deepEqual(a[k], b[k]));
407
+ }
408
+ /**
409
+ * Subset deep-match: every key in `expected` MUST be present and
410
+ * recursively matching in `actual`; extra keys on `actual` are
411
+ * ignored. Subset semantics relax ONLY object key sets — primitives
412
+ * and arrays fall back to exact {@link deepEqual} (an array with a
413
+ * missing element is a different array, not a subset).
414
+ *
415
+ * Backs `StreamUpdateBehavior.valueMatch === 'subset'`: a fixture
416
+ * pins only the deterministic keys of a payload that also carries
417
+ * non-deterministic fields (generated ids, timestamps) without
418
+ * wrongly rejecting a correct server over a random id.
419
+ */
420
+ function deepMatchSubset(expected, actual) {
421
+ if (!isRecord(expected)) {
422
+ // Primitives + arrays: exact match.
423
+ return deepEqual(expected, actual);
424
+ }
425
+ if (!isRecord(actual))
426
+ return false;
427
+ return Object.keys(expected).every((k) => k in actual && deepMatchSubset(expected[k], actual[k]));
385
428
  }
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "gate-duplicate-hook",
3
+ "description": "The contract declares the SAME export name (`useCheckout`) under two different packages — `@stripe/ggui-checkout` and `@paypal/ggui-checkout` — and the app registers BOTH `(package, export)` pairs, so registry membership and public-env are clean; the only violation is the cross-package name collision. The boilerplate emits one `import { useCheckout }` per export name, so same-named exports collide in module scope regardless of package (SPEC §7.9). The gate MUST reject with `duplicate_gadget_hook`. Note the wire is package-keyed, so a name cannot repeat WITHIN one package; the hazard is strictly cross-package. Because every pair here resolves cleanly, this case pins NO priority between the duplicate check and the registry/env gates — a conformant implementation may run the checks in any order.",
4
+ "contract": {
5
+ "clientCapabilities": {
6
+ "gadgets": {
7
+ "@stripe/ggui-checkout": { "useCheckout": {} },
8
+ "@paypal/ggui-checkout": { "useCheckout": {} }
9
+ }
10
+ }
11
+ },
12
+ "appGadgets": [
13
+ {
14
+ "package": "@stripe/ggui-checkout",
15
+ "version": "1.0.0",
16
+ "exports": [{ "hook": "useCheckout" }]
17
+ },
18
+ {
19
+ "package": "@paypal/ggui-checkout",
20
+ "version": "1.0.0",
21
+ "exports": [{ "hook": "useCheckout" }]
22
+ }
23
+ ],
24
+ "appPublicEnv": {},
25
+ "expect": { "outcome": "reject", "code": "duplicate_gadget_hook" }
26
+ }