@ggui-ai/protocol-conformance 0.14.0 → 0.16.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 (59) hide show
  1. package/README.md +33 -16
  2. package/dist/cli.d.ts +27 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +269 -23
  5. package/dist/domain-error-conformance/cases/amend-unknown-session.json +9 -0
  6. package/dist/domain-error-conformance/cases/consume-unknown-session.json +9 -0
  7. package/dist/domain-error-conformance/cases/emit-unknown-session.json +9 -0
  8. package/dist/domain-error-conformance/cases/get-session-unknown-session.json +6 -0
  9. package/dist/domain-error-conformance/cases/render-unknown-handshake.json +6 -0
  10. package/dist/domain-error-conformance/cases/update-unknown-session.json +9 -0
  11. package/dist/domain-error-conformance/index.d.ts +97 -0
  12. package/dist/domain-error-conformance/index.d.ts.map +1 -0
  13. package/dist/domain-error-conformance/index.js +97 -0
  14. package/dist/fixtures/index.d.ts +8 -6
  15. package/dist/fixtures/index.d.ts.map +1 -1
  16. package/dist/fixtures/index.js +9 -7
  17. package/dist/fixtures/{subscribe-tenancy → subscribe-app-scope}/absent-appid-defaults.json +1 -1
  18. package/dist/fixtures/{subscribe-tenancy → subscribe-app-scope}/app-mismatch.json +1 -1
  19. package/dist/fixtures/subscribe-app-scope/index.d.ts +6 -0
  20. package/dist/fixtures/subscribe-app-scope/index.d.ts.map +1 -0
  21. package/dist/fixtures/{subscribe-tenancy → subscribe-app-scope}/index.js +5 -5
  22. package/dist/index.d.ts +7 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +11 -1
  25. package/dist/json-equal.d.ts +6 -0
  26. package/dist/json-equal.d.ts.map +1 -0
  27. package/dist/json-equal.js +23 -0
  28. package/dist/refusal-envelope-conformance/cases/refuse-after-fix-caller.json +3 -3
  29. package/dist/refusal-envelope-conformance/cases/refuse-non-render-surface.json +4 -4
  30. package/dist/refusal-envelope-conformance/index.d.ts.map +1 -1
  31. package/dist/refusal-envelope-conformance/index.js +1 -20
  32. package/dist/run-conformance.d.ts +22 -3
  33. package/dist/run-conformance.d.ts.map +1 -1
  34. package/dist/run-conformance.js +86 -9
  35. package/dist/theme-binding-conformance/cases/app-theme-v1-base.json +18 -0
  36. package/dist/theme-binding-conformance/cases/app-theme-v1-one-palette.json +11 -0
  37. package/dist/theme-binding-conformance/cases/app-theme-v2-accepted.json +18 -0
  38. package/dist/theme-binding-conformance/cases/app-theme-v2-keyframes-accepted.json +16 -0
  39. package/dist/theme-binding-conformance/cases/app-theme-v2-keyframes-not-keyframes.json +15 -0
  40. package/dist/theme-binding-conformance/cases/app-theme-v2-label-too-long.json +17 -0
  41. package/dist/theme-binding-conformance/cases/app-theme-v2-missing-dark.json +13 -0
  42. package/dist/theme-binding-conformance/cases/app-theme-v2-no-hash.json +15 -0
  43. package/dist/theme-binding-conformance/cases/app-theme-v2-platform-pin.json +17 -0
  44. package/dist/theme-binding-conformance/cases/overlay-hash-canonical.json +17 -0
  45. package/dist/theme-binding-conformance/cases/theme-mode-absence.json +10 -0
  46. package/dist/theme-binding-conformance/cases/theme-mode-default-when-silent-host.json +10 -0
  47. package/dist/theme-binding-conformance/cases/theme-mode-order.json +10 -0
  48. package/dist/theme-binding-conformance/index.d.ts +59 -0
  49. package/dist/theme-binding-conformance/index.d.ts.map +1 -0
  50. package/dist/theme-binding-conformance/index.js +110 -0
  51. package/dist/transport-refusal-conformance/cases/refuse-deprovisioned-endpoint.json +27 -0
  52. package/dist/transport-refusal-conformance/cases/refuse-render-only-code.json +12 -0
  53. package/dist/transport-refusal-conformance/index.d.ts +56 -0
  54. package/dist/transport-refusal-conformance/index.d.ts.map +1 -0
  55. package/dist/transport-refusal-conformance/index.js +52 -0
  56. package/dist/types.d.ts +1 -1
  57. package/package.json +18 -3
  58. package/dist/fixtures/subscribe-tenancy/index.d.ts +0 -6
  59. package/dist/fixtures/subscribe-tenancy/index.d.ts.map +0 -1
package/README.md CHANGED
@@ -19,7 +19,7 @@ A fifth surface — the **Path-B browser-host driver** for DOM-level claims (`bo
19
19
 
20
20
  **The behavioral fixture catalog is WebSocket-only.** The canonical ggui live-channel transport is WS (see SPEC §12 Transport Bindings). `TransportConfig` is shaped as an extensibly-closed union so later live-channel transports (HTTP long-poll) can be added without breaking the public API.
21
21
 
22
- The MCP surface is graded separately, by catalog rather than by transport config — see `resource-read-conformance` below. Its cases are deliberately NOT registered in `fixturesByContract`: everything in that map is driven over a WebSocket, so an MCP case there would be a permanent skip on every WS run, which is the false gate the kit's exact skip-set pinning exists to prevent. `resources/read` is the only MCP method bound today; `tools/call` has no driver yet.
22
+ The MCP surface is graded separately, by catalog rather than by transport config — see `resource-read-conformance` below. Its cases are deliberately NOT registered in `fixturesByContract`: everything in that map is driven over a WebSocket, so an MCP case there would be a permanent skip on every WS run, which is the false gate the kit's exact skip-set pinning exists to prevent. `resources/read` and — since ggui#880 — one `tools/call` catalog (`domain-error`, SPEC §7.9 Plane 2) are the MCP methods bound today.
23
23
 
24
24
  ## Path-A vs Path-B matchability
25
25
 
@@ -51,7 +51,7 @@ One declared grading gap on the action loop: `action-ack` proves the append half
51
51
 
52
52
  The behavioral fixture catalog above asserts what an implementation _does_ over the wire. Some protocol obligations aren't transport-observable at all — they are deterministic _validation functions_: given an input, accept or reject it. SPEC §7.7.2's gadget obligations are exactly this. Modeling them as WebSocket fixtures would mean faking wire frames the protocol never emits; instead the kit ships **pure-function catalogs** — accept/reject cases graded against a caller-supplied function, with no host, render, or transport.
53
53
 
54
- Each catalog ships its cases as raw JSON so a non-TypeScript implementer can grade their own implementation, and each runner takes the implementation as a callback — the kit never hard-binds a concrete one. Eight catalogs ship today:
54
+ Each catalog ships its cases as raw JSON so a non-TypeScript implementer can grade their own implementation, and each runner takes the implementation as a callback — the kit never hard-binds a concrete one. Nine catalogs ship today:
55
55
 
56
56
  - **`@ggui-ai/protocol-conformance/schema-conformance`** — which `DataContract.clientCapabilities` payloads a conformant parser MUST accept / reject (the gadget wire shape).
57
57
  - **`@ggui-ai/protocol-conformance/registration-conformance`** — which `(contract, appGadgets, appPublicEnv)` triples the push-time gadget gate stack MUST accept / reject, and with which precise SPEC §7.9 reject code (`gadget_not_registered` / `gadget_package_mismatch` / `gadget_public_env_missing` / `duplicate_gadget_hook`).
@@ -61,6 +61,7 @@ Each catalog ships its cases as raw JSON so a non-TypeScript implementer can gra
61
61
  - **`@ggui-ai/protocol-conformance/host-helper-conformance`** — which render coordinates a HOST-HELPER library MUST extract from a `tools/call` result, and which malformed results it MUST decline rather than guess at.
62
62
  - **`@ggui-ai/protocol-conformance/refusal-envelope-conformance`** — the tool result a server MUST project for a PRE-GENERATION refusal (SPEC §7.1's refused arm): an in-result `isError` (never a throw), `content[0].text` leading with the code, `structuredContent` carrying `{outcome: 'refused', refusal}` and nothing else, no `_meta` — and no envelope at all for a registered code whose surfaces exclude the render gate.
63
63
  - **`@ggui-ai/protocol-conformance/registry-completeness`** — the structural obligations a deployment's closed refusal-code registry MUST satisfy: `surfaces` non-empty, `code === key`, every `after-fix` entry naming who acts (`fixBy`), and `retry` inside the closed four-value set. Grades DATA rather than a function — the caller passes their registry.
64
+ - **`@ggui-ai/protocol-conformance/transport-refusal-conformance`** — the JSON-RPC error a per-app MCP endpoint MUST answer with when it refuses a request for a typed reason (SPEC §7.1's endpoint-level refusal, ggui#825): HTTP 403 with `{ code: -32003, message: 'App not found', data: { refusal } }`, where `refusal` is the registry projection without the render-only fields (`code`, `message`, `fix`, `retry`) — and no envelope at all for a registered code whose surfaces exclude `mcp-endpoint` (an untyped authorization failure answers 403 with `-32007` and no `data`, by contract — ggui#836). Runner: `runTransportRefusalConformance(project)`; folded into `runConformance()` via `transportRefusalProjector`.
64
65
 
65
66
  ```ts
66
67
  import { runSchemaConformance } from "@ggui-ai/protocol-conformance/schema-conformance";
@@ -115,24 +116,33 @@ The catalog grades all four failure classes (`NOT_FOUND` on `-32002`; `BLUEPRINT
115
116
 
116
117
  It deliberately does **not** grade: the order in which a substrate-less server answers (`NOT_SUPPORTED` describes the deployment, so answering it immediately is correct); `detail` wording on any code; the `NOT_FOUND` message literal (its _constancy_ is what is normative); internal-error message text; the number a URI naming no locator receives (that belongs to the transport binding — only the negative is graded, that it must not be one of the four); and the shell's markup (success is graded on the projected render meta, never on DOM shape).
117
118
 
119
+ ## Domain-error conformance — the `tools/call` binding (SPEC §7.9 Plane 2, ggui#880)
120
+
121
+ The kit's first `tools/call` driver. A Plane-2 failure — a missing session or handshake, props that do not satisfy the contract, an undeclared channel — is a tool-execution error, so it is a tool RESULT with `isError: true` (never a JSON-RPC error frame) whose `content[0].text` LEADS with the registered slug: `<code>: <detail>`. No `structuredContent`, no `_meta`. A raw MCP agent branches on `text.startsWith(code + ': ')` for a code in `@ggui-ai/protocol`'s `DOMAIN_ERROR_CODES` and on nothing else.
122
+
123
+ Vendor-neutral like `resources/read`: the kit imports no server. The adopter supplies a **driver** — `toolCallDriver` on `runConformance()`, or `--tool-call-driver <module>` on the CLI (an ES module exporting `drive(scenario)` or a default) — that performs ONE `tools/call` against the deployment and returns the raw result the MCP client received, or `null` when the tool is not bound there (that case is SKIPPED, named). The kit owns the scenarios and every assertion. Six no-setup cases under `src/domain-error-conformance/cases/`: an unknown `handshakeId` on `ggui_render` (`handshake_not_found`) and an unknown `sessionId` on `ggui_consume` / `ggui_get_session` / `ggui_update` / `ggui_amend` / `ggui_emit` (`session_not_found`). Each is graded on the raw result — `isError`, `content-text`, `slug-leads`, `no-structuredContent`, `no-meta` — and reported as `domain-error/<case>`; a driver that throws fails that case only.
124
+
125
+ The catalog exists for one receipt: before the slug led the text, every first-party server failed every case on `slug-leads` (executed, ggui#880). `src/cli-samples/tool-call-driver.mjs` is a spec-correct sample of what the flag imports.
126
+
118
127
  ## Conformance status
119
128
 
120
129
  What is actually graded against shipping code today, and what is still awaiting a driver. This is an inventory, not a roadmap — a mechanism is listed as proven only if a test in the repo drives it against the real implementation on every push.
121
130
 
122
- Every test below is a plain `*.test.ts` inside a package's `src/`, so it runs in that package's ordinary vitest suite. The ggui repo's per-push CI runs `turbo run test` across all non-e2e packages, so **all ten rows below are gated on every push** — the kit needs no dedicated workflow, and adopters wiring it into their own CI need nothing more than their existing unit-test task.
123
-
124
- | Obligation | Graded against | Driver |
125
- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
126
- | Gadget wire schema (§7.7.2) | the shipping `@ggui-ai/protocol` `clientCapabilitiesSpecSchema` | this kit's own `src/schema-conformance/schema-conformance.test.ts` |
127
- | Registration gate (§7.7.3, §7.9) | the shipping gate trio in `@ggui-ai/mcp-server-handlers` | `mcp-server-handlers/src/renders/assert-gadgets.conformance.test.ts` |
128
- | Bundle-URL resolution (§7.7.2) | the shipping `resolveGadgetUrls` in `@ggui-ai/mcp-server-handlers` | `mcp-server-handlers/src/renders/resolve-gadget-urls.conformance.test.ts` |
129
- | Path-A WS fixtures — first-party | the real `@ggui-ai/mcp-server` GguiSession channel, booted in-process on an ephemeral port | `mcp-server/src/ggui-session-channel.conformance.test.ts` — **8 pass / 4 skip / 0 fail** |
130
- | Path-A WS fixtures — vendor-neutral | `@ggui-ai/protocol-reference-server` | `protocol-reference-server/src/conformance.test.ts` — **9 pass / 3 skip / 0 fail** |
131
- | `resources/read` typed failures + mount invariant | the shipping `registerGguiRenderResourceTemplate` in `@ggui-ai/mcp-server` | `mcp-server/src/mcp-apps-outbound.resource-read.conformance.test.ts` — **12 pass / 0 skip / 0 fail** |
132
- | Tool-binding resolution — declared wins, contract derivation (§7.7.4.1) | the shipping `resolveMcpToolBindings` in `@ggui-ai/artifact-manifest` | `artifact-manifest/src/mcp-tool-bindings.conformance.test.ts` |
133
- | Tool-binding search filters — `tool=` / `server=` semantics (§7.7.4.1) | the shipping `matchesMcpToolFilters` in `@ggui-ai/registry-core` | `registry-core/src/mcp-tool-filters.conformance.test.ts` |
134
- | Pre-generation refusal envelope (§7.1, refused arm) | the shipping `ggui_render` refusal projection in `@ggui-ai/mcp-server-handlers` | `mcp-server-handlers/src/renders/render-refusal-projection.conformance.test.ts` |
135
- | Refusal-registry completeness (ggui#786 ruling 5b) | the shipping `PRE_GENERATION_REFUSAL_CODES` in `@ggui-ai/protocol` | this kit's own `src/registry-completeness/registry-completeness.test.ts` |
131
+ Every test below is a plain `*.test.ts` inside a package's `src/`, so it runs in that package's ordinary vitest suite. The ggui repo's per-push CI runs `turbo run test` across all non-e2e packages, so **all twelve rows below are gated on every push** — the kit needs no dedicated workflow, and adopters wiring it into their own CI need nothing more than their existing unit-test task.
132
+
133
+ | Obligation | Graded against | Driver |
134
+ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
135
+ | Gadget wire schema (§7.7.2) | the shipping `@ggui-ai/protocol` `clientCapabilitiesSpecSchema` | this kit's own `src/schema-conformance/schema-conformance.test.ts` |
136
+ | Registration gate (§7.7.3, §7.9) | the shipping gate trio in `@ggui-ai/mcp-server-handlers` | `mcp-server-handlers/src/renders/assert-gadgets.conformance.test.ts` |
137
+ | Bundle-URL resolution (§7.7.2) | the shipping `resolveGadgetUrls` in `@ggui-ai/mcp-server-handlers` | `mcp-server-handlers/src/renders/resolve-gadget-urls.conformance.test.ts` |
138
+ | Path-A WS fixtures — first-party | the real `@ggui-ai/mcp-server` GguiSession channel, booted in-process on an ephemeral port | `mcp-server/src/ggui-session-channel.conformance.test.ts` — **24 pass / 12 skip / 0 fail** |
139
+ | Path-A WS fixtures — vendor-neutral | `@ggui-ai/protocol-reference-server` | `protocol-reference-server/src/conformance.test.ts` — **19 pass / 5 skip / 0 fail** |
140
+ | `resources/read` typed failures + mount invariant | the shipping `registerGguiRenderResourceTemplate` in `@ggui-ai/mcp-server` | `mcp-server/src/mcp-apps-outbound.resource-read.conformance.test.ts` — **12 pass / 0 skip / 0 fail** |
141
+ | Tool-binding resolution — declared wins, contract derivation (§7.7.4.1) | the shipping `resolveMcpToolBindings` in `@ggui-ai/artifact-manifest` | `artifact-manifest/src/mcp-tool-bindings.conformance.test.ts` |
142
+ | Tool-binding search filters — `tool=` / `server=` semantics (§7.7.4.1) | the shipping `matchesMcpToolFilters` in `@ggui-ai/registry-core` | `registry-core/src/mcp-tool-filters.conformance.test.ts` |
143
+ | Pre-generation refusal envelope (§7.1, refused arm) | `projectRenderRefusal` in `@ggui-ai/protocol` — the one source both the shipping `ggui_render` handler (`@ggui-ai/mcp-server-handlers`) and `@ggui-ai/protocol-reference-server` answer through | `mcp-server-handlers/src/renders/render-refusal-projection.conformance.test.ts` |
144
+ | Refusal-registry completeness (ggui#786 ruling 5b) | the shipping `PRE_GENERATION_REFUSAL_CODES` in `@ggui-ai/protocol` | this kit's own `src/registry-completeness/registry-completeness.test.ts` |
145
+ | Endpoint-level refusal (§7.1, `mcp-endpoint` surface, ggui#825) | the catalog's kit-local reference projector only — no shipping deployment projector is graded yet: the OSS route is generic (`errorMapper`), the first-party server and the reference server supply no projector (SKIPPED, named), and a hosted deployment's projector lands after this slice | this kit's own `src/transport-refusal-conformance/transport-refusal-conformance.test.ts` (coherence + discrimination) |
136
146
 
137
147
  Both WS drivers pin their pass set _and_ their skip set as exact sets, and the resource-read driver pins its skip set as exactly empty. A fixture silently degrading to a skip fails the build, and so does a skipped fixture that quietly starts passing — a skip set that can grow unnoticed is a false gate, and re-pinning it is meant to be a deliberate act.
138
148
 
@@ -170,6 +180,13 @@ npx @ggui-ai/protocol-conformance --url ws://localhost:3000/ws --auth bearer:$TO
170
180
 
171
181
  The CLI exits `0` only when at least one fixture executed and none failed; `1` on any fixture failure; `2` on invocation errors **or** when every fixture skipped (a zero-executed run never reads as success in CI).
172
182
 
183
+ The three pure-function catalogs (`refusal-envelope`, `registry-completeness`, `transport-refusal`) grade an in-process function and a data table, not the wire, so a URL alone cannot reach them. The CLI hands them their inputs by flag — `--registry <file.json>` (the deployment's refusal registry, a JSON object keyed by code), `--projector <module>` (an ES module whose `project(refusal)` — named or default export — returns the SPEC §7.1 tool result, or `null` for a code with no render envelope) and `--transport-projector <module>` (same shape, returning the endpoint's `{ httpStatus, error }` or `null`). A flag left out leaves its catalog on the scorecard as SKIPPED with the flag named. Catalog rows count as executed fixtures, so a run given a catalog flag can exit 0 while every wire fixture is SKIPPED — read the scorecard for the wire signal. Minimal spec-correct samples of each input live in the repository at [`src/cli-samples/`](https://github.com/ggui-ai/ggui/tree/main/packages/protocol-conformance/src/cli-samples) (not in the npm tarball); the test suite pins that they pass their catalogs.
184
+
185
+ ```
186
+ npx @ggui-ai/protocol-conformance --url ws://localhost:3000/ws --auth bearer:$TOKEN \
187
+ --registry ./refusal-registry.json --projector ./project.mjs --transport-projector ./endpoint.mjs
188
+ ```
189
+
173
190
  To grade setup-dependent fixtures (and any `session-state` fixture), pass a `host` implementing the `ConformanceHost` adapter — `dispatchSetup` / `dispatchTeardown` for the directive vocabulary, plus the optional `readSessionField(sessionId, field)` introspection seam. Fixtures whose requirements the host doesn't meet skip with a precise reason; they never silently pass.
174
191
 
175
192
  ## License
package/dist/cli.d.ts CHANGED
@@ -1,16 +1,42 @@
1
1
  #!/usr/bin/env node
2
- import { type ConformanceResult } from './run-conformance.js';
2
+ import { type ConformanceResult, type RunConformanceConfig } from './run-conformance.js';
3
3
  import type { AuthConfig } from './types.js';
4
4
  interface ParsedArgs {
5
5
  readonly url?: string;
6
6
  readonly auth?: string;
7
7
  readonly only?: readonly string[];
8
8
  readonly timeoutMs?: number;
9
+ /** Path to a JSON file holding the deployment's refusal registry (code → row). */
10
+ readonly registry?: string;
11
+ /** Path to an ES module exporting the SPEC §7.1 refusal projector. */
12
+ readonly projector?: string;
13
+ /** Path to an ES module exporting the endpoint-level refusal projector. */
14
+ readonly transportProjector?: string;
15
+ /** Path to an ES module exporting a `tools/call` driver (SPEC §7.9 Plane 2, ggui#880). */
16
+ readonly toolCallDriver?: string;
9
17
  readonly verbose: boolean;
10
18
  readonly help: boolean;
11
19
  }
12
20
  declare function parseArgs(argv: readonly string[]): ParsedArgs;
13
21
  declare function parseAuth(spec: string): AuthConfig;
22
+ /** The three optional inputs a CLI run can hand the pure-function catalogs. */
23
+ export type CatalogInputs = Pick<RunConformanceConfig, 'refusalRegistry' | 'refusalProjector' | 'transportRefusalProjector' | 'toolCallDriver'>;
24
+ /** The flag values that name where each catalog input comes from. */
25
+ export interface CatalogInputPaths {
26
+ readonly registry?: string | undefined;
27
+ readonly projector?: string | undefined;
28
+ readonly transportProjector?: string | undefined;
29
+ readonly toolCallDriver?: string | undefined;
30
+ }
31
+ /**
32
+ * Resolve the `--registry` / `--projector` / `--transport-projector` /
33
+ * `--tool-call-driver`
34
+ * flags into the runner's catalog inputs. A flag that is absent yields
35
+ * no input, and the runner reports that catalog SKIPPED with the flag
36
+ * named. Every failure names the flag and the path.
37
+ */
38
+ export declare function loadCatalogInputs(paths: CatalogInputPaths): Promise<CatalogInputs>;
39
+ export declare const USAGE: string;
14
40
  declare function main(argv: readonly string[]): Promise<number>;
15
41
  /**
16
42
  * Map a {@link ConformanceResult} to the CLI exit code:
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AA+BA,OAAO,EAAkB,KAAK,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAC9E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAM7C,UAAU,UAAU;IAClB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;CACxB;AAED,iBAAS,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,UAAU,CA8CtD;AAED,iBAAS,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAY3C;AA6CD,iBAAe,IAAI,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA0D5D;AAED;;;;;;;GAOG;AACH,iBAAS,iBAAiB,CAAC,MAAM,EAAE,iBAAiB,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAI/D;AA+BD,OAAO,EAAE,iBAAiB,EAAE,IAAI,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AA6DA,OAAO,EAEL,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EAC1B,MAAM,sBAAsB,CAAC;AAK9B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAM7C,UAAU,UAAU;IAClB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,kFAAkF;IAClF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,sEAAsE;IACtE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,2EAA2E;IAC3E,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC,0FAA0F;IAC1F,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;CACxB;AAED,iBAAS,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,UAAU,CA8DtD;AAOD,iBAAS,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAY3C;AAMD,+EAA+E;AAC/E,MAAM,MAAM,aAAa,GAAG,IAAI,CAC9B,oBAAoB,EACpB,iBAAiB,GAAG,kBAAkB,GAAG,2BAA2B,GAAG,gBAAgB,CACxF,CAAC;AAEF,qEAAqE;AACrE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjD,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC9C;AA6KD;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,aAAa,CAAC,CAqCxF;AAMD,eAAO,MAAM,KAAK,QA8DL,CAAC;AAEd,iBAAe,IAAI,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAoE5D;AAED;;;;;;;GAOG;AACH,iBAAS,iBAAiB,CAAC,MAAM,EAAE,iBAAiB,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAI/D;AA+BD,OAAO,EAAE,iBAAiB,EAAE,IAAI,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC"}
package/dist/cli.js CHANGED
@@ -16,25 +16,46 @@
16
16
  * may legitimately not provide every `ConformanceHost` directive in
17
17
  * v1.0 — but a run that is ALL skips carries no signal.
18
18
  *
19
+ * The three PURE-FUNCTION catalogs (refusal-envelope,
20
+ * registry-completeness, transport-refusal) grade an in-process
21
+ * function and a data table, not the wire: `--registry <file.json>`,
22
+ * `--projector <module>`, `--transport-projector <module>` and
23
+ * `--tool-call-driver <module>` (the domain-error catalog, the kit's
24
+ * first tools/call driver, ggui#880) hand
25
+ * them their inputs (ggui#803 leg 3). A flag left out leaves its
26
+ * catalog SKIPPED with the flag named, never silently absent. Catalog
27
+ * rows count as executed fixtures, so a run given a catalog flag can
28
+ * exit 0 while every wire fixture is SKIPPED — read the scorecard,
29
+ * not only the exit code, for a wire signal.
30
+ *
19
31
  * Honest scope of the bundled CLI: no `host` wiring. The CLI runs
20
32
  * the runner with an absent host, meaning every fixture with non-empty
21
33
  * `setup` skips cleanly with the "no host provided" reason — and
22
34
  * since every fixture in the current catalog declares setup, a
23
- * hostless CLI run skips them all and exits 2 by design. To get real
24
- * pass/fail signal against an implementation, consumers call
25
- * `runConformance({ host: myImpl })` programmatically and apply the
26
- * same guard: `result.failed.length > 0 || result.passed.length === 0`
27
- * is a red build. The CLI remains the lowest-friction wire-level
28
- * smoke check for setups where the server under test is provisioned
29
- * out-of-band rather than through a `ConformanceHost` injection.
35
+ * hostless CLI run with no catalog flag skips them all and exits 2 by
36
+ * design. To get real pass/fail signal against an implementation,
37
+ * consumers call `runConformance({ host: myImpl })` programmatically
38
+ * and apply the same guard:
39
+ * `result.failed.length > 0 || result.passed.length === 0` is a red
40
+ * build. The CLI remains the lowest-friction wire-level smoke check
41
+ * for setups where the server under test is provisioned out-of-band
42
+ * rather than through a `ConformanceHost` injection.
30
43
  */
44
+ import { readFile } from 'node:fs/promises';
45
+ import { resolve } from 'node:path';
46
+ import { pathToFileURL } from 'node:url';
47
+ import { isRawToolCallResult, } from './domain-error-conformance/index.js';
31
48
  import { createDefaultReporter, formatFailures, formatSkips } from './reporter.js';
32
- import { runConformance } from './run-conformance.js';
49
+ import { runConformance, } from './run-conformance.js';
33
50
  function parseArgs(argv) {
34
51
  let url;
35
52
  let auth;
36
53
  const only = [];
37
54
  let timeoutMs;
55
+ let registry;
56
+ let projector;
57
+ let toolCallDriver;
58
+ let transportProjector;
38
59
  let verbose = false;
39
60
  let help = false;
40
61
  for (let i = 0; i < argv.length; i += 1) {
@@ -62,6 +83,18 @@ function parseArgs(argv) {
62
83
  if (Number.isNaN(timeoutMs))
63
84
  throw new Error('--timeout-ms must be an integer');
64
85
  break;
86
+ case '--registry':
87
+ registry = next();
88
+ break;
89
+ case '--projector':
90
+ projector = next();
91
+ break;
92
+ case '--transport-projector':
93
+ transportProjector = next();
94
+ break;
95
+ case '--tool-call-driver':
96
+ toolCallDriver = next();
97
+ break;
65
98
  case '--verbose':
66
99
  case '-v':
67
100
  verbose = true;
@@ -74,7 +107,11 @@ function parseArgs(argv) {
74
107
  throw new Error(`unknown flag '${flag}' — run with --help for usage`);
75
108
  }
76
109
  }
77
- return { url, auth, only, timeoutMs, verbose, help };
110
+ return { url, auth, only, timeoutMs, registry, projector, transportProjector, toolCallDriver, verbose, help };
111
+ }
112
+ /** The message of a thrown value, without pretending every throw is an Error. */
113
+ function errorMessage(err) {
114
+ return err instanceof Error ? err.message : String(err);
78
115
  }
79
116
  function parseAuth(spec) {
80
117
  const idx = spec.indexOf(':');
@@ -89,10 +126,184 @@ function parseAuth(spec) {
89
126
  return { kind: 'session-cookie', cookie: value };
90
127
  throw new Error(`--auth kind '${kind}' is not recognized; supported: 'bearer', 'session-cookie'`);
91
128
  }
129
+ function isRecord(value) {
130
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
131
+ }
132
+ function isStringArray(value) {
133
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
134
+ }
135
+ /**
136
+ * The runtime twin of {@link RefusalRegistryRow} — a JSON registry is
137
+ * held to exactly the shape a programmatic caller has to satisfy at
138
+ * compile time. Value-level rules (recognized retry class, `fixBy` on
139
+ * `after-fix`, code equals key, non-empty surfaces) are NOT checked
140
+ * here: those are the registry-completeness catalog's pins, and a
141
+ * deliberately broken registry must stay expressible so they can
142
+ * grade it.
143
+ */
144
+ function isRegistryRow(value) {
145
+ return (isRecord(value) &&
146
+ typeof value.code === 'string' &&
147
+ isStringArray(value.surfaces) &&
148
+ typeof value.retry === 'string' &&
149
+ (value.fixBy === undefined || typeof value.fixBy === 'string') &&
150
+ typeof value.emitter === 'string' &&
151
+ typeof value.description === 'string');
152
+ }
153
+ function parseRegistryView(text, path) {
154
+ let parsed;
155
+ try {
156
+ parsed = JSON.parse(text);
157
+ }
158
+ catch (err) {
159
+ throw new Error(`--registry ${path} is not JSON: ${errorMessage(err)}`);
160
+ }
161
+ if (!isRecord(parsed)) {
162
+ throw new Error(`--registry ${path} must be a JSON object of rows keyed by code`);
163
+ }
164
+ const rows = [];
165
+ for (const [code, row] of Object.entries(parsed)) {
166
+ if (!isRegistryRow(row)) {
167
+ throw new Error(`--registry ${path}: row '${code}' is not a registry row (needs string code, string[] surfaces, string retry, string emitter, string description, optional string fixBy)`);
168
+ }
169
+ rows.push([code, row]);
170
+ }
171
+ // fromEntries defines OWN properties — a row keyed `__proto__` stays a row.
172
+ return Object.fromEntries(rows);
173
+ }
174
+ function isRefusalInput(value) {
175
+ return (isRecord(value) &&
176
+ typeof value.code === 'string' &&
177
+ typeof value.message === 'string' &&
178
+ typeof value.fix === 'string' &&
179
+ typeof value.retry === 'string' &&
180
+ typeof value.handshake === 'string' &&
181
+ (value.balanceCentsAtCheck === undefined || typeof value.balanceCentsAtCheck === 'number'));
182
+ }
183
+ function isProjectedRefusalResult(value) {
184
+ return (isRecord(value) &&
185
+ typeof value.isError === 'boolean' &&
186
+ typeof value.text === 'string' &&
187
+ isRecord(value.structuredContent) &&
188
+ typeof value.structuredContent.outcome === 'string' &&
189
+ isRefusalInput(value.structuredContent.refusal) &&
190
+ typeof value.hasMeta === 'boolean' &&
191
+ isStringArray(value.identityFields));
192
+ }
193
+ function isTransportRefusalInput(value) {
194
+ return (isRecord(value) &&
195
+ typeof value.code === 'string' &&
196
+ typeof value.message === 'string' &&
197
+ typeof value.fix === 'string' &&
198
+ typeof value.retry === 'string' &&
199
+ typeof value.appId === 'string');
200
+ }
201
+ function isProjectedTransportRefusal(value) {
202
+ return (isRecord(value) &&
203
+ typeof value.httpStatus === 'number' &&
204
+ isRecord(value.error) &&
205
+ typeof value.error.code === 'number' &&
206
+ typeof value.error.message === 'string' &&
207
+ isRecord(value.error.data) &&
208
+ isTransportRefusalInput(value.error.data.refusal));
209
+ }
210
+ /**
211
+ * Import an ES module at `path` (resolved against the working
212
+ * directory) and return the projector it exports —
213
+ * `export function project(...)` or the default export. The module is
214
+ * an external boundary: its export is checked to be a function here,
215
+ * and every value it returns is checked against the projection shape
216
+ * by `guard` before the catalog sees it, so a module that returns the
217
+ * wrong shape fails by flag name instead of typing its way into a
218
+ * grade. The catalogs grade that throw as a FAIL on the case in hand
219
+ * and keep grading the rest — never a crash of the run.
220
+ */
221
+ async function loadProjector(flag, path, guard) {
222
+ const href = pathToFileURL(resolve(path)).href;
223
+ let mod;
224
+ try {
225
+ mod = await import(/* @vite-ignore */ href);
226
+ }
227
+ catch (err) {
228
+ throw new Error(`${flag} ${path} could not be imported: ${errorMessage(err)}`);
229
+ }
230
+ const candidate = mod.project ?? mod.default;
231
+ if (typeof candidate !== 'function') {
232
+ throw new Error(`${flag} ${path} must export \`project\` (or a default export) as a function`);
233
+ }
234
+ return (input) => {
235
+ const out = candidate(input);
236
+ if (out === null)
237
+ return null;
238
+ if (!guard(out)) {
239
+ throw new Error(`${flag} ${path} returned a value that is not a projection: ${JSON.stringify(out)}`);
240
+ }
241
+ return out;
242
+ };
243
+ }
244
+ /**
245
+ * Like {@link loadProjector} for a `tools/call` DRIVER: the export is
246
+ * `drive` (or the default export), it may be async, and `null` means
247
+ * "the tool is not bound on this deployment" (the catalog SKIPS that
248
+ * case, named). Every other value is held to the raw-result shape.
249
+ */
250
+ async function loadDriver(flag, path, guard) {
251
+ const href = pathToFileURL(resolve(path)).href;
252
+ let mod;
253
+ try {
254
+ mod = await import(/* @vite-ignore */ href);
255
+ }
256
+ catch (err) {
257
+ throw new Error(`${flag} ${path} could not be imported: ${errorMessage(err)}`);
258
+ }
259
+ const candidate = mod.drive ?? mod.default;
260
+ if (typeof candidate !== 'function') {
261
+ throw new Error(`${flag} ${path} must export \`drive\` (or a default export) as a function`);
262
+ }
263
+ return async (input) => {
264
+ const out = await candidate(input);
265
+ if (out === null)
266
+ return null;
267
+ if (!guard(out)) {
268
+ throw new Error(`${flag} ${path} returned a value that is not a raw tools/call result: ${JSON.stringify(out)}`);
269
+ }
270
+ return out;
271
+ };
272
+ }
273
+ /**
274
+ * Resolve the `--registry` / `--projector` / `--transport-projector` /
275
+ * `--tool-call-driver`
276
+ * flags into the runner's catalog inputs. A flag that is absent yields
277
+ * no input, and the runner reports that catalog SKIPPED with the flag
278
+ * named. Every failure names the flag and the path.
279
+ */
280
+ export async function loadCatalogInputs(paths) {
281
+ const inputs = {};
282
+ if (paths.registry !== undefined) {
283
+ let text;
284
+ try {
285
+ text = await readFile(resolve(paths.registry), 'utf8');
286
+ }
287
+ catch (err) {
288
+ throw new Error(`--registry ${paths.registry} could not be read: ${errorMessage(err)}`);
289
+ }
290
+ inputs.refusalRegistry = parseRegistryView(text, paths.registry);
291
+ }
292
+ if (paths.projector !== undefined) {
293
+ inputs.refusalProjector = await loadProjector('--projector', paths.projector, isProjectedRefusalResult);
294
+ }
295
+ if (paths.transportProjector !== undefined) {
296
+ inputs.transportRefusalProjector = await loadProjector('--transport-projector', paths.transportProjector, isProjectedTransportRefusal);
297
+ }
298
+ if (paths.toolCallDriver !== undefined) {
299
+ inputs.toolCallDriver = await loadDriver('--tool-call-driver', paths.toolCallDriver, isRawToolCallResult);
300
+ }
301
+ return inputs;
302
+ }
92
303
  // =============================================================================
93
304
  // Entry point
94
305
  // =============================================================================
95
- const USAGE = `
306
+ export const USAGE = `
96
307
  ggui-protocol-conformance — run the protocol conformance kit against a live server
97
308
 
98
309
  Usage:
@@ -113,22 +324,47 @@ Options:
113
324
  filters the pure-function catalog rows, whose
114
325
  names are prefixed <catalog>/<case>.
115
326
  --timeout-ms <N> Per-fixture observation window. Default 2000.
327
+ --registry <file.json> The deployment's refusal-code registry, a JSON
328
+ object keyed by code (grades registry-completeness).
329
+ --projector <module> ES module whose \`project(refusal)\` — a named or
330
+ default export — returns the SPEC §7.1 tool result
331
+ for a refusal, or null when the code has no
332
+ envelope on the render-gate surface (grades
333
+ refusal-envelope).
334
+ --transport-projector <module>
335
+ ES module whose \`project(refusal)\` — named or
336
+ default export — returns the endpoint's
337
+ { httpStatus, error } for a refusal, or null when
338
+ the code has no transport envelope (grades
339
+ transport-refusal).
340
+ --tool-call-driver <module>
341
+ ES module exporting drive(scenario) (or default):
342
+ performs ONE tools/call and returns the raw result
343
+ (null = tool not bound). Grades the domain-error
344
+ catalog — SPEC §7.9 Plane 2 on the wire (ggui#880).
116
345
  --verbose, -v Print failure details + skip reasons at the end.
117
346
  --help, -h Show this help.
118
347
 
119
- Pure-function catalogs (refusal-envelope, registry-completeness):
348
+ Pure-function catalogs (refusal-envelope, registry-completeness, transport-refusal):
120
349
  These grade an in-process function and a data table, not the wire, so
121
- the CLI cannot supply them over a URL. They appear on every scorecard
122
- as SKIPPED with the reason — an ungraded obligation stays visible
123
- rather than vanishing. To GRADE them, call runConformance()
124
- programmatically with refusalProjector / refusalRegistry.
350
+ a URL alone cannot reach them: the four flags above hand them their
351
+ inputs. A flag left out leaves its catalog on the scorecard as SKIPPED
352
+ with the flag named — an ungraded obligation stays visible rather
353
+ than vanishing. File and module paths resolve against the working
354
+ directory. A projector is synchronous and a tools/call driver may be async; one that throws, returns
355
+ undefined, or returns a non-projection is graded as a FAIL on that
356
+ case (exit 1) and the remaining cases are still graded — never a
357
+ crash. Catalog rows count as executed fixtures: a run given a
358
+ catalog flag can exit 0 while every wire fixture is SKIPPED, so read
359
+ the scorecard, not only the exit code, for a wire signal.
125
360
 
126
361
  Exit codes:
127
362
  0 — at least one fixture executed and none failed.
128
363
  1 — at least one fixture failed.
129
- 2 — invocation error (bad args, cannot reach server, etc.), or the
130
- run executed zero fixtures (every fixture skipped) — a run that
131
- proved nothing never reads as success.
364
+ 2 — invocation error (bad args, unreadable registry or projector,
365
+ cannot reach server, etc.), or the run executed zero fixtures
366
+ (every fixture skipped) — a run that proved nothing never reads
367
+ as success.
132
368
  `.trimStart();
133
369
  async function main(argv) {
134
370
  let parsed;
@@ -136,7 +372,7 @@ async function main(argv) {
136
372
  parsed = parseArgs(argv);
137
373
  }
138
374
  catch (err) {
139
- process.stderr.write(`error: ${String(err.message ?? err)}\n\n`);
375
+ process.stderr.write(`error: ${errorMessage(err)}\n\n`);
140
376
  process.stderr.write(USAGE);
141
377
  return 2;
142
378
  }
@@ -159,7 +395,16 @@ async function main(argv) {
159
395
  auth = parseAuth(parsed.auth);
160
396
  }
161
397
  catch (err) {
162
- process.stderr.write(`error: ${String(err.message ?? err)}\n\n`);
398
+ process.stderr.write(`error: ${errorMessage(err)}\n\n`);
399
+ process.stderr.write(USAGE);
400
+ return 2;
401
+ }
402
+ let catalogInputs;
403
+ try {
404
+ catalogInputs = await loadCatalogInputs(parsed);
405
+ }
406
+ catch (err) {
407
+ process.stderr.write(`error: ${errorMessage(err)}\n\n`);
163
408
  process.stderr.write(USAGE);
164
409
  return 2;
165
410
  }
@@ -170,6 +415,7 @@ async function main(argv) {
170
415
  only: parsed.only !== undefined && parsed.only.length > 0 ? parsed.only : undefined,
171
416
  observationTimeoutMs: parsed.timeoutMs,
172
417
  reporter,
418
+ ...catalogInputs,
173
419
  });
174
420
  if (parsed.verbose) {
175
421
  const failures = formatFailures(result.failed);
@@ -201,11 +447,11 @@ function exitCodeForResult(result) {
201
447
  return 0;
202
448
  }
203
449
  // Immediately-invoked — this module IS the bin entry. Tests import
204
- // `parseArgs` / `parseAuth` / `exitCodeForResult` / `main` via named
205
- // exports without triggering the run.
450
+ // `parseArgs` / `parseAuth` / `loadCatalogInputs` / `exitCodeForResult`
451
+ // / `main` via named exports without triggering the run.
206
452
  if (isEntryPoint()) {
207
453
  main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
208
- process.stderr.write(`fatal: ${String(err.message ?? err)}\n`);
454
+ process.stderr.write(`fatal: ${errorMessage(err)}\n`);
209
455
  process.exit(2);
210
456
  });
211
457
  }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "amend-unknown-session",
3
+ "description": "An in-place amend on an unknown sessionId: `session_not_found` leads the text; nothing was mutated.",
4
+ "scenario": {
5
+ "tool": "ggui_amend",
6
+ "args": { "sessionId": "render_does_not_exist", "kind": "replace", "props": {} }
7
+ },
8
+ "expect": { "code": "session_not_found" }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "consume-unknown-session",
3
+ "description": "A sessionId no session this caller can reach carries: `session_not_found` leads the text of the consume result — the same slug for never-minted, expired, closed and cross-app ids.",
4
+ "scenario": {
5
+ "tool": "ggui_consume",
6
+ "args": { "sessionId": "render_does_not_exist", "timeout": 0 }
7
+ },
8
+ "expect": { "code": "session_not_found" }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "emit-unknown-session",
3
+ "description": "A stream emit on an unknown sessionId: `session_not_found` leads the text before any channel is checked.",
4
+ "scenario": {
5
+ "tool": "ggui_emit",
6
+ "args": { "sessionId": "render_does_not_exist", "channel": "x", "payload": {} }
7
+ },
8
+ "expect": { "code": "session_not_found" }
9
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "get-session-unknown-session",
3
+ "description": "The projection read on an unknown sessionId: `session_not_found` leads the text; no JSON-RPC error frame, no structuredContent.",
4
+ "scenario": { "tool": "ggui_get_session", "args": { "sessionId": "render_does_not_exist" } },
5
+ "expect": { "code": "session_not_found" }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "render-unknown-handshake",
3
+ "description": "A handshakeId nothing minted: the render fails as not found — `handshake_not_found` leads the text. No handshake is consumed, nothing is committed; the caller re-handshakes.",
4
+ "scenario": { "tool": "ggui_render", "args": { "handshakeId": "h_does_not_exist", "props": {} } },
5
+ "expect": { "code": "handshake_not_found" }
6
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "update-unknown-session",
3
+ "description": "A history-advancing update on an unknown sessionId: `session_not_found` leads the text; nothing was mutated.",
4
+ "scenario": {
5
+ "tool": "ggui_update",
6
+ "args": { "sessionId": "render_does_not_exist", "kind": "replace", "props": {} }
7
+ },
8
+ "expect": { "code": "session_not_found" }
9
+ }