@ggui-ai/protocol-conformance 0.2.0-alpha.4 → 0.4.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.
- package/README.md +32 -13
- package/dist/cli.d.ts +11 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +48 -20
- package/dist/conformance-host.d.ts +81 -76
- package/dist/conformance-host.d.ts.map +1 -1
- package/dist/conformance-host.js +0 -44
- package/dist/fixtures/bootstrap-protocol/bootstrap-bundle-fetch-failed.json +3 -3
- package/dist/fixtures/bootstrap-protocol/bootstrap-meta-missing.json +3 -3
- package/dist/fixtures/bootstrap-protocol/bootstrap-success.json +3 -3
- package/dist/fixtures/consume-buffer/action-ack-sequence.json +28 -0
- package/dist/fixtures/consume-buffer/action-payload-schema-violation.json +37 -0
- package/dist/fixtures/consume-buffer/index.d.ts +6 -0
- package/dist/fixtures/consume-buffer/index.d.ts.map +1 -0
- package/dist/fixtures/consume-buffer/index.js +41 -0
- package/dist/fixtures/consume-buffer/undeclared-action-rejected.json +29 -0
- package/dist/fixtures/host-context/host-context-observed-persists.json +34 -0
- package/dist/fixtures/host-context/index.d.ts +5 -0
- package/dist/fixtures/host-context/index.d.ts.map +1 -0
- package/dist/fixtures/host-context/index.js +40 -0
- package/dist/fixtures/index.d.ts +28 -9
- package/dist/fixtures/index.d.ts.map +1 -1
- package/dist/fixtures/index.js +33 -14
- package/dist/fixtures/reserved-channel-authority/index.d.ts.map +1 -1
- package/dist/fixtures/reserved-channel-authority/index.js +17 -7
- package/dist/fixtures/reserved-channel-authority/props-update-roundtrip.json +4 -4
- package/dist/fixtures/schema-version-handshake/index.d.ts.map +1 -1
- package/dist/fixtures/schema-version-handshake/index.js +12 -4
- package/dist/fixtures/schema-version-handshake/version-match.json +7 -5
- package/dist/fixtures/schema-version-handshake/version-mismatch.json +9 -10
- package/dist/fixtures/subscribe-tenancy/absent-appid-defaults.json +18 -0
- package/dist/fixtures/subscribe-tenancy/app-mismatch.json +20 -0
- package/dist/fixtures/subscribe-tenancy/index.d.ts +6 -0
- package/dist/fixtures/subscribe-tenancy/index.d.ts.map +1 -0
- package/dist/fixtures/subscribe-tenancy/index.js +62 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/match-behavior.d.ts +19 -100
- package/dist/match-behavior.d.ts.map +1 -1
- package/dist/match-behavior.js +289 -246
- package/dist/registration-conformance/cases/gate-duplicate-hook.json +26 -0
- package/dist/registration-conformance/cases/gate-export-not-registered.json +1 -0
- package/dist/registration-conformance/cases/gate-multi-package-distinct-names-accept.json +26 -0
- package/dist/registration-conformance/cases/gate-no-gadgets-accept.json +1 -0
- package/dist/registration-conformance/cases/gate-package-mismatch.json +1 -0
- package/dist/registration-conformance/cases/gate-priority-not-registered-outranks-mismatch.json +1 -0
- package/dist/registration-conformance/cases/gate-priority-registry-outranks-public-env.json +22 -0
- package/dist/registration-conformance/cases/gate-public-env-missing.json +21 -0
- package/dist/registration-conformance/cases/gate-public-env-satisfied-accept.json +21 -0
- package/dist/registration-conformance/cases/gate-public-env-undeclared-wrapper-accept.json +26 -0
- package/dist/registration-conformance/cases/gate-registered-resolves-accept.json +1 -0
- package/dist/registration-conformance/cases/gate-stdlib-hook-accept.json +1 -0
- package/dist/registration-conformance/cases/gate-typo-classifies-as-not-registered.json +1 -0
- package/dist/registration-conformance/index.d.ts +64 -33
- package/dist/registration-conformance/index.d.ts.map +1 -1
- package/dist/registration-conformance/index.js +24 -8
- package/dist/run-conformance.d.ts +145 -6
- package/dist/run-conformance.d.ts.map +1 -1
- package/dist/run-conformance.js +531 -93
- package/dist/types.d.ts +319 -165
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +27 -23
- package/dist/ws-transport.d.ts.map +1 -1
- package/dist/ws-transport.js +3 -2
- package/package.json +4 -4
- package/dist/fixtures/observability-events/index.d.ts +0 -5
- package/dist/fixtures/observability-events/index.d.ts.map +0 -1
- package/dist/fixtures/observability-events/index.js +0 -22
- package/dist/fixtures/observability-events/observability-contract-error-emitted.json +0 -28
- package/dist/fixtures/observability-events/observability-wired-tool-invoked.json +0 -27
- package/dist/fixtures/refresh-semantics/index.d.ts +0 -5
- package/dist/fixtures/refresh-semantics/index.d.ts.map +0 -1
- package/dist/fixtures/refresh-semantics/index.js +0 -19
- package/dist/fixtures/refresh-semantics/stream-refresh-success.json +0 -39
- package/dist/fixtures/reserved-channel-authority/stream-schema-violation.json +0 -44
- package/dist/fixtures/wired-action-dispatch/index.d.ts +0 -5
- package/dist/fixtures/wired-action-dispatch/index.d.ts.map +0 -1
- package/dist/fixtures/wired-action-dispatch/index.js +0 -29
- package/dist/fixtures/wired-action-dispatch/wired-action-success.json +0 -35
- package/dist/fixtures/wired-action-dispatch/wired-action-tool-not-found.json +0 -30
- package/dist/fixtures/wired-action-dispatch/wired-action-tool-threw.json +0 -31
- package/dist/fixtures/wired-action-dispatch/wired-action-tool-timeout.json +0 -31
package/dist/match-behavior.js
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
if (behavior
|
|
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
|
|
112
|
+
if (behaviorIs(behavior, 'version-mismatch')) {
|
|
10
113
|
return matchVersionMismatch(behavior, frames);
|
|
11
114
|
}
|
|
12
|
-
if (behavior
|
|
13
|
-
return
|
|
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
|
|
121
|
+
if (behaviorIs(behavior, 'stream-update')) {
|
|
16
122
|
return matchStreamUpdate(behavior, frames);
|
|
17
123
|
}
|
|
18
|
-
if (behavior
|
|
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.
|
|
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".
|
|
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(
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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:
|
|
233
|
+
message: `expected an \`ack\` frame echoing requestId '${behavior.requestId}' after action dispatch; received none.`,
|
|
103
234
|
};
|
|
104
235
|
}
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
sourceAction: behavior.sourceAction,
|
|
246
|
+
type: 'ack',
|
|
247
|
+
requestId: behavior.requestId,
|
|
248
|
+
payload: { sequence: '<number>' },
|
|
153
249
|
},
|
|
154
|
-
received:
|
|
155
|
-
message: '`
|
|
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
|
|
162
|
-
f.parsed['type']
|
|
163
|
-
|
|
164
|
-
f.parsed['payload']
|
|
165
|
-
|
|
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: {
|
|
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'] === '
|
|
172
|
-
.map((f) => (f.kind === 'frame' ? f.parsed
|
|
173
|
-
message: `expected a \`
|
|
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
|
-
*
|
|
197
|
-
*
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* `
|
|
220
|
-
* `
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
|
225
|
-
if (
|
|
226
|
-
return
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
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
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
const aKeys = Object.keys(
|
|
379
|
-
const bKeys = Object.keys(
|
|
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(
|
|
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
|
+
}
|