@ggui-ai/protocol-reference-server 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.
@@ -1,123 +1,129 @@
1
+ /**
2
+ * `ConformanceHost` adapter — wires the `@ggui-ai/protocol-conformance`
3
+ * setup/teardown directive dispatcher onto this package's
4
+ * `ReferenceServer` instance.
5
+ *
6
+ * Directives split into "implement" and "throw":
7
+ *
8
+ * Implement:
9
+ * - create-session → `renders.create()` (+
10
+ * `renders.declareActionSpec()` when the directive carries one,
11
+ * mapped onto the protocol's `ActionSpec` — declared entry
12
+ * schemas included, via `toReferenceActionSpec`)
13
+ * - server-version-override → `renders.setVersionOverride()`
14
+ * - emit-envelope → wrap the directive's body in the
15
+ * SPEC §12.2 channel-3 delivery frame `{type:'data', payload:
16
+ * StreamEnvelope}` → `renders.injectFrame()`
17
+ *
18
+ * Throw (kit records SKIP, not FAIL):
19
+ * - renderer-url-override — unimplemented (browser-level)
20
+ * - ui-initialize-response-override — unimplemented
21
+ *
22
+ * The "throw" set matches the conformance kit's `unmatchable-on-ws`
23
+ * skip expectations — browser-level fault injection that requires a
24
+ * richer host harness. Throwing surfaces "directive not implemented"
25
+ * with the error message as the skip reason.
26
+ *
27
+ * Beyond directives, the host implements the kit's `readSessionField`
28
+ * introspection seam — `session-state` fixtures (stateful obligations
29
+ * with no wire response, e.g. `host-context-observed-persists`) grade
30
+ * by reading the GguiSession field back after the observation window.
31
+ * Readable fields: `hostContext`, `appId` (the tenancy column the
32
+ * `absent-appid-defaults` fixture grades identity-default resolution
33
+ * against). Unknown fields throw with a clear message so the kit
34
+ * records an honest SKIP, never a weakened pass.
35
+ *
36
+ * The kit validates every fixture-authored directive against its
37
+ * closed `SetupStep` vocabulary before dispatch, so this adapter only
38
+ * ever receives shape-valid steps of a known kind — narrowing is by
39
+ * the `kind` discriminant; no defensive re-parsing.
40
+ *
41
+ * Note: render-termination directive (`close-render`) is intentionally
42
+ * absent — render lifecycle is implicit (created → active → TTL-expired);
43
+ * there is no agent-facing close tool, and no kit directive to invoke.
44
+ */
45
+ import { DEFAULT_STREAM_CHANNEL_MODE, jsonSchemaSchema, makeStreamEnvelope, } from '@ggui-ai/protocol';
46
+ import { DEPLOYMENT_DEFAULT_APP_ID } from './render.js';
1
47
  /**
2
48
  * Build a `ConformanceHost` bound to the given `ReferenceServer`
3
49
  * instance. Pass the return value to `runConformance({host})` to
4
50
  * drive the kit against the server.
5
51
  *
6
52
  * The server MUST be `start()`-ed before the first dispatch — the
7
- * kit calls `create-render` via `dispatchSetup` before any subscribe,
8
- * so the render store must be reachable. The caller owns the
53
+ * kit calls `create-session` via `dispatchSetup` before any subscribe,
54
+ * so the GguiSession store must be reachable. The caller owns the
9
55
  * server lifecycle (`start()` + `stop()`).
10
56
  */
11
57
  export function createReferenceConformanceHost({ serverInstance, }) {
12
58
  return {
13
59
  async dispatchSetup(step) {
14
- // Discriminant-narrow via explicit casts — the extensibly-closed
15
- // `HostUnknownSetupStep` arm (`kind: string & {}`) widens the
16
- // discriminant and blocks literal-narrowing on the union.
17
- if (step.kind === 'create-render') {
18
- const s = step;
19
- serverInstance.renders.create(s.renderId, s.appId ?? 'conformance');
20
- return;
21
- }
22
- if (step.kind === 'register-tool') {
23
- // Fixture JSON authors the field as `toolName`; the kit's
24
- // runtime narrowing (`run-conformance.ts::narrowSetupStep`)
25
- // only renames `type → kind` and passes other fields verbatim,
26
- // so the runtime object carries `toolName` not `name`.
27
- // Tolerate both for forward-compat with a kit fix.
28
- const raw = step;
29
- const toolName = raw.toolName ?? raw.name;
30
- if (typeof toolName !== 'string' || toolName.length === 0) {
31
- throw new Error(`register-tool directive missing toolName/name: ${JSON.stringify(step)}`);
60
+ if (step.kind === 'create-session') {
61
+ serverInstance.renders.create(step.sessionId, step.appId ?? DEPLOYMENT_DEFAULT_APP_ID);
62
+ if (step.actionSpec !== undefined) {
63
+ serverInstance.renders.declareActionSpec(step.sessionId, toReferenceActionSpec(step.actionSpec));
32
64
  }
33
- serverInstance.tools.register(toolName, raw.handler);
34
65
  return;
35
66
  }
36
- if (step.kind === 'register-actionspec') {
37
- const s = step;
38
- // register-actionspec doesn't carry a renderId in the
39
- // directive shape — it's scoped to the most-recently-created
40
- // render, matching the fixture-authoring convention that
41
- // create-render → register-tool → register-actionspec all
42
- // land in order on the same render.
43
- const lastRenderId = serverInstance.renders.lastCreatedRenderId();
44
- if (lastRenderId === undefined) {
45
- throw new Error('reference-server: register-actionspec invoked before create-render — no render scope to bind to');
46
- }
47
- serverInstance.renders.registerActionSpec(lastRenderId, {
48
- name: s.name,
49
- tool: s.tool,
50
- });
51
- return;
52
- }
53
- if (step.kind === 'register-streamspec') {
54
- // register-streamspec is the streamSpec analogue of register-
55
- // actionspec — binds a stream channel to a refresh tool. The
56
- // kit does not export a `RegisterStreamSpecSetup` type today
57
- // (the directive is reference-server-specific scaffolding for
58
- // Slice I refresh-stream support); the runtime shape is
59
- // narrowed locally, matching the same convention as the
60
- // pre-existing `register-tool` branch above. Same most-
61
- // recently-created render scoping as register-actionspec.
62
- const raw = step;
63
- if (typeof raw.channel !== 'string' || raw.channel.length === 0) {
64
- throw new Error(`register-streamspec directive missing channel: ${JSON.stringify(step)}`);
65
- }
66
- if (typeof raw.tool !== 'string' || raw.tool.length === 0) {
67
- throw new Error(`register-streamspec directive missing tool: ${JSON.stringify(step)}`);
68
- }
69
- const lastRenderId = serverInstance.renders.lastCreatedRenderId();
70
- if (lastRenderId === undefined) {
71
- throw new Error('reference-server: register-streamspec invoked before create-render — no render scope to bind to');
72
- }
73
- serverInstance.renders.registerStreamSpec(lastRenderId, {
74
- channel: raw.channel,
75
- tool: raw.tool,
76
- });
67
+ if (step.kind === 'server-version-override') {
68
+ serverInstance.renders.setVersionOverride(step.sessionId, step.advertiseVersion);
77
69
  return;
78
70
  }
79
71
  if (step.kind === 'emit-envelope') {
80
- // The directive carries `channel` + `payload` but no
81
- // renderId — it's scoped to the most-recently-created
82
- // render, matching the same fixture-authoring convention as
83
- // register-actionspec / register-streamspec / server-version-
84
- // override (the kit's `narrowSetupStep` is a flat `type → kind`
85
- // rename pass-through, so any renderId on the directive JSON
86
- // would survive, but the canonical EmitEnvelopeSetup shape
87
- // doesn't declare one).
88
- //
89
- // Wire-format wrapping: the directive's `payload: unknown` is
90
- // the envelope body; the host wraps it in the SPEC §12.2
91
- // `{type:'stream', payload:{channel, value}}` shape (matching
92
- // the existing reference-server stream emissions in
93
- // action-router.ts) before fan-out. Per the kit type docstring,
94
- // "Host is responsible for wrapping in the wire format
95
- // (sequence stamp, timestamp, etc.)" — the kit does NOT
96
- // expect the directive to carry a fully-formed wire frame.
97
- const s = step;
98
- if (typeof s.channel !== 'string' || s.channel.length === 0) {
72
+ // The directive carries `channel` + `payload` but no sessionId
73
+ // — it's scoped to the most-recently-created render (the
74
+ // fixture-authoring convention is that `create-session`
75
+ // immediately precedes it).
76
+ if (step.channel.length === 0) {
99
77
  throw new Error(`emit-envelope directive missing channel: ${JSON.stringify(step)}`);
100
78
  }
101
- const lastRenderId = serverInstance.renders.lastCreatedRenderId();
102
- if (lastRenderId === undefined) {
103
- throw new Error('reference-server: emit-envelope invoked before create-render — no render scope to bind to');
79
+ // The directive types `payload` as `unknown` (opaque to the
80
+ // runner) but `StreamEnvelope.payload` is `JsonValue`.
81
+ // Fixture-sourced bodies are JSON by construction; direct host
82
+ // embedders could pass anything — validate, never cast.
83
+ const body = step.payload;
84
+ if (!isJsonValue(body)) {
85
+ throw new Error('emit-envelope payload must be a JSON value (string / finite number / boolean / null / array / object)');
86
+ }
87
+ const sessionId = serverInstance.renders.lastCreatedSessionId();
88
+ if (sessionId === undefined) {
89
+ throw new Error('reference-server: emit-envelope invoked before create-session — no render scope to bind to');
104
90
  }
105
- const fanned = serverInstance.renders.injectFrame(lastRenderId, {
106
- type: 'stream',
107
- payload: {
108
- channel: s.channel,
109
- value: s.payload,
110
- },
91
+ // Wire-format wrapping: the directive's `payload` is the
92
+ // envelope body; the host wraps it in the SPEC §12.2 channel-3
93
+ // delivery frame `{type:'data', payload: StreamEnvelope}`
94
+ // before fan-out. Per the kit directive contract, the host
95
+ // owns the wire framing — sequence stamp + schema-version
96
+ // stamp included:
97
+ // - `mode`: the server declares no streamSpec, so each
98
+ // delivery carries the protocol's declared default.
99
+ // - `seq`: per-render monotonic outbound cursor, assigned at
100
+ // emission time (mirrors buffer-backed servers that assign
101
+ // `seq` at append, regardless of who is subscribed).
102
+ // - `schemaVersion`: the version this server advertises for
103
+ // THIS render — same per-render-override precedence the
104
+ // subscribe handler uses.
105
+ const envelope = makeStreamEnvelope({
106
+ sessionId,
107
+ channel: step.channel,
108
+ mode: DEFAULT_STREAM_CHANNEL_MODE,
109
+ payload: body,
110
+ seq: serverInstance.renders.nextStreamSeq(sessionId),
111
+ schemaVersion: serverInstance.renders.get(sessionId)?.versionOverride ??
112
+ serverInstance.advertisedVersion,
113
+ });
114
+ const fanned = serverInstance.renders.injectFrame(sessionId, {
115
+ type: 'data',
116
+ payload: envelope,
111
117
  });
112
118
  if (!fanned) {
113
119
  // No subscribers attached — the directive's emission is
114
120
  // unobservable. Surface for fixture-authoring debuggability
115
- // (the canonical sequence is create-render → subscribe →
121
+ // (the canonical sequence is create-session → subscribe →
116
122
  // emit-envelope; fixtures that swap order silently lose the
117
123
  // injection). Not a throw — the directive itself succeeded;
118
124
  // the unobservability is a fixture concern.
119
125
  // eslint-disable-next-line no-console
120
- console.warn(`[@ggui-ai/protocol-reference-server] emit-envelope on render '${lastRenderId}' channel '${s.channel}' had no subscribers — frame dropped`);
126
+ console.warn(`[@ggui-ai/protocol-reference-server] emit-envelope on render '${sessionId}' channel '${step.channel}' had no subscribers — frame dropped`);
121
127
  }
122
128
  return;
123
129
  }
@@ -127,54 +133,100 @@ export function createReferenceConformanceHost({ serverInstance, }) {
127
133
  if (step.kind === 'ui-initialize-response-override') {
128
134
  throw new Error('reference server does not implement ui-initialize-response-override — MCP Apps host concern, out of scope');
129
135
  }
130
- if (step.kind === 'server-version-override') {
131
- // Fixture JSON authors `advertiseVersion` (matches the
132
- // semantic — "advertise this version on the wire"); the kit's
133
- // exported `ServerVersionOverrideSetup` interface uses
134
- // `version`. Tolerate both for forward-compat with the kit's
135
- // own type, mirroring the same name-tolerance pattern in
136
- // register-tool above. The runtime `narrowSetupStep` only
137
- // renames `type → kind` and passes other fields verbatim, so
138
- // whichever the fixture authors arrives unchanged.
139
- const raw = step;
140
- const advertise = raw.advertiseVersion ?? raw.version;
141
- if (typeof advertise !== 'string' || advertise.length === 0) {
142
- throw new Error(`server-version-override directive missing advertiseVersion/version: ${JSON.stringify(step)}`);
143
- }
144
- // Same most-recently-created render scope as register-
145
- // actionspec / register-streamspec — the fixture authoring
146
- // convention is `create-render` immediately precedes this
147
- // directive, and the kit's narrowSetupStep doesn't surface a
148
- // renderId on the directive object even when the fixture
149
- // JSON includes one (only `type → kind` is renamed; the rest
150
- // is a flat passthrough, so a `renderId` field WOULD survive
151
- // — but the canonical ServerVersionOverrideSetup type doesn't
152
- // declare one, so fixtures may omit it. Falling back to
153
- // `lastCreatedRenderId()` keeps the host robust to either.
154
- const lastRenderId = serverInstance.renders.lastCreatedRenderId();
155
- if (lastRenderId === undefined) {
156
- throw new Error('reference-server: server-version-override invoked before create-render — no render scope to bind to');
157
- }
158
- serverInstance.renders.setVersionOverride(lastRenderId, advertise);
159
- return;
160
- }
161
- // Unknown kind — extensibly-closed. Throw so the kit records
162
- // SKIP with an honest reason.
163
- const unknownKind = step.kind ?? 'unknown';
164
- throw new Error(`reference server does not implement setup kind '${String(unknownKind)}'`);
136
+ return unreachableSetupStep(step);
165
137
  },
166
- async dispatchTeardown(step) {
167
- if (step.kind === 'unregister-tool') {
168
- // Same toolName/name tolerance as register-tool.
169
- const raw = step;
170
- const toolName = raw.toolName ?? raw.name;
171
- if (typeof toolName === 'string' && toolName.length > 0) {
172
- serverInstance.tools.unregister(toolName);
173
- }
174
- return;
138
+ async readSessionField(sessionId, field) {
139
+ // Honest-grade contract: return the GguiSession's TRUE
140
+ // post-dispatch state. A render that never received the
141
+ // observation message returns `undefined` here — the kit's
142
+ // deep-equal then FAILS the fixture (a server that drops the
143
+ // message must not pass), while an unknown field throws so the
144
+ // kit records a SKIP ("cannot observe" is not "observed and
145
+ // matched").
146
+ const render = serverInstance.renders.get(sessionId);
147
+ if (render === undefined) {
148
+ throw new Error(`reference server has no GguiSession '${sessionId}' — readSessionField cannot introspect a render that was never created`);
149
+ }
150
+ if (field === 'hostContext') {
151
+ return render.hostContext;
152
+ }
153
+ if (field === 'appId') {
154
+ // The tenancy column on the live render row — the kit's
155
+ // `absent-appid-defaults` fixture reads it back to grade the
156
+ // SPEC §12.2 identity-default resolution (a subscribe that
157
+ // omits `appId` binds the deployment default, never an
158
+ // undefined tenant).
159
+ return render.appId;
175
160
  }
176
- const unknownKind = step.kind ?? 'unknown';
177
- throw new Error(`reference server does not implement teardown kind '${String(unknownKind)}'`);
161
+ throw new Error(`reference server does not expose GguiSession field '${field}' via readSessionField — readable fields: hostContext, appId`);
162
+ },
163
+ async dispatchTeardown() {
164
+ // The kit's teardown vocabulary is empty (`HostTeardownStep` is
165
+ // `never`) — the runner rejects any fixture-authored teardown
166
+ // directive before dispatch, so this is statically unreachable
167
+ // today. Throw so a future kit version that grows a teardown
168
+ // vocabulary surfaces here as an honest "not implemented"
169
+ // (reporter warning) rather than a silent success.
170
+ throw new Error('reference server does not implement any teardown directive');
178
171
  },
179
172
  };
180
173
  }
174
+ /**
175
+ * Compile-time exhaustiveness lock: the kit's setup vocabulary is a
176
+ * closed union. When a future kit version adds a directive arm, this
177
+ * call stops compiling — forcing this host to either implement the
178
+ * directive or throw "not implemented" explicitly. Silently ignoring
179
+ * a directive (the fall-through default) is the one behavior the
180
+ * `ConformanceHost` contract forbids.
181
+ */
182
+ function unreachableSetupStep(step) {
183
+ throw new Error(`reference server received a setup directive outside the kit's closed vocabulary: ${JSON.stringify(step)}`);
184
+ }
185
+ /**
186
+ * Map the kit's declared actionSpec (name → `ActionSpecEntryDecl`)
187
+ * onto the protocol's `ActionSpec` the server's action router
188
+ * enforces with (`validateActionData`). `ActionEntry.label` is
189
+ * required by the protocol type, so the action name doubles as its
190
+ * label; an entry's declared `schema` installs as the action's
191
+ * payload contract (`ActionEntry.schema`) through the protocol's
192
+ * `jsonSchemaSchema` validating parse. A declared schema the
193
+ * protocol's grammar rejects throws — the kit records the fixture as
194
+ * SKIPPED — rather than silently downgrading the declaration to
195
+ * name-membership.
196
+ */
197
+ function toReferenceActionSpec(decl) {
198
+ const spec = {};
199
+ for (const [action, entry] of Object.entries(decl)) {
200
+ if (entry.schema === undefined) {
201
+ spec[action] = { label: action };
202
+ continue;
203
+ }
204
+ const parsed = jsonSchemaSchema.safeParse(entry.schema);
205
+ if (!parsed.success) {
206
+ throw new Error(`reference server cannot install the entry schema declared on action '${action}' — not a valid protocol JsonSchema node: ${parsed.error.message}`);
207
+ }
208
+ spec[action] = { label: action, schema: parsed.data };
209
+ }
210
+ return spec;
211
+ }
212
+ /**
213
+ * Validating narrower: is `value` representable as a protocol
214
+ * `JsonValue`? Rejects functions, symbols, bigints, `undefined`, and
215
+ * non-finite numbers anywhere in the tree. Used to gate the
216
+ * `emit-envelope` directive's opaque body before it's stamped into a
217
+ * `StreamEnvelope`.
218
+ */
219
+ function isJsonValue(value) {
220
+ if (value === null)
221
+ return true;
222
+ if (typeof value === 'string' || typeof value === 'boolean')
223
+ return true;
224
+ if (typeof value === 'number')
225
+ return Number.isFinite(value);
226
+ if (Array.isArray(value))
227
+ return value.every(isJsonValue);
228
+ if (typeof value === 'object') {
229
+ return Object.values(value).every(isJsonValue);
230
+ }
231
+ return false;
232
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Inbound `host_context_observed` frame handling — parse the canonical
3
+ * Client→Server observation message and persist its
4
+ * `HostContextProjection` onto the named GguiSession.
5
+ *
6
+ * Obligation (mirrors the first-party `@ggui-ai/mcp-server` channel
7
+ * handler): the message is fire-and-forget — NO response frame — and
8
+ * the server persists `payload.hostContext` onto
9
+ * `GguiSession.hostContext` as an idempotent overwrite (no merge), so
10
+ * later agent-facing reads (`ggui_handshake` / `ggui_consume` in a
11
+ * full server) surface the host's capabilities. The conformance kit
12
+ * grades exactly this persistence: the `host-context-observed-persists`
13
+ * fixture reads the field back via
14
+ * `ConformanceHost.readSessionField('hostContext')` after the
15
+ * observation window and deep-equals it against the authored
16
+ * projection.
17
+ *
18
+ * Trust boundary: the frame crosses the wire, so every field is
19
+ * re-validated against the protocol's `HostContextProjection` shape
20
+ * (`@ggui-ai/protocol`, `types/host-context`) before anything is
21
+ * persisted. Malformed frames — wrong payload shape, mistyped fields,
22
+ * keys outside the projection vocabulary (e.g. `theme`, which flows
23
+ * through ggui's theming pipeline, never host context) — drop
24
+ * silently, matching this server's posture for malformed `action`
25
+ * frames (`parseActionFrame` returns `undefined`). A dropped frame
26
+ * persists NOTHING: a partial write from half-valid input would
27
+ * fabricate state the client never coherently observed.
28
+ *
29
+ * Tenancy note: the first-party handler scopes the write through its
30
+ * subscriber binding (`NOT_SUBSCRIBED` / `SESSION_MISMATCH` error
31
+ * frames). The reference server has no auth identity by design
32
+ * (accepts any bearer), so its tenancy scope is the render lookup
33
+ * itself — the caller drops frames whose `payload.sessionId` names an
34
+ * unknown render, mirroring the action path's unknown-render posture.
35
+ */
36
+ import type { HostContextObservedPayload } from '@ggui-ai/protocol';
37
+ import type { GguiSession } from './render.js';
38
+ /**
39
+ * One inbound `host_context_observed` message — the canonical wire
40
+ * shape from `@ggui-ai/protocol` (`transport/websocket`):
41
+ * `{type: 'host_context_observed', payload: {sessionId, hostContext},
42
+ * requestId?}`.
43
+ */
44
+ export interface IncomingHostContextObservedMessage {
45
+ readonly type: 'host_context_observed';
46
+ readonly requestId?: string;
47
+ readonly payload: HostContextObservedPayload;
48
+ }
49
+ /**
50
+ * Parse + validate an inbound `host_context_observed` frame. Returns
51
+ * the normalized typed message on success, `undefined` on ANY
52
+ * malformed input — the server drops malformed frames silently (same
53
+ * posture as {@link parseActionFrame}); fire-and-forget messages have
54
+ * no rejection channel a conformant client would be listening on.
55
+ *
56
+ * Validation is strict against the protocol type: every present
57
+ * `hostContext` field must carry the projection's shape, and unknown
58
+ * keys reject the whole frame.
59
+ */
60
+ export declare function parseHostContextObservedFrame(frame: unknown): IncomingHostContextObservedMessage | undefined;
61
+ /**
62
+ * Handle one parsed `host_context_observed` message: persist the
63
+ * validated projection onto the render. Idempotent overwrite — the
64
+ * protocol declares re-delivery (e.g. after a reconnect) replaces the
65
+ * stored value with no merge logic. No response frame is emitted; the
66
+ * obligation is purely stateful and is graded by the kit through the
67
+ * `readSessionField('hostContext')` introspection seam.
68
+ */
69
+ export declare function handleHostContextObserved(message: IncomingHostContextObservedMessage, render: GguiSession): void;
70
+ //# sourceMappingURL=host-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host-context.d.ts","sourceRoot":"","sources":["../src/host-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,OAAO,KAAK,EACV,0BAA0B,EAG3B,MAAM,mBAAmB,CAAC;AAG3B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C;;;;;GAKG;AACH,MAAM,WAAW,kCAAkC;IACjD,QAAQ,CAAC,IAAI,EAAE,uBAAuB,CAAC;IACvC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,0BAA0B,CAAC;CAC9C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,6BAA6B,CAC3C,KAAK,EAAE,OAAO,GACb,kCAAkC,GAAG,SAAS,CAgBhD;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,kCAAkC,EAC3C,MAAM,EAAE,WAAW,GAClB,IAAI,CAEN"}
@@ -0,0 +1,139 @@
1
+ import { isRecord } from '@ggui-ai/protocol';
2
+ /**
3
+ * Parse + validate an inbound `host_context_observed` frame. Returns
4
+ * the normalized typed message on success, `undefined` on ANY
5
+ * malformed input — the server drops malformed frames silently (same
6
+ * posture as {@link parseActionFrame}); fire-and-forget messages have
7
+ * no rejection channel a conformant client would be listening on.
8
+ *
9
+ * Validation is strict against the protocol type: every present
10
+ * `hostContext` field must carry the projection's shape, and unknown
11
+ * keys reject the whole frame.
12
+ */
13
+ export function parseHostContextObservedFrame(frame) {
14
+ if (!isRecord(frame))
15
+ return undefined;
16
+ if (frame['type'] !== 'host_context_observed')
17
+ return undefined;
18
+ const requestId = frame['requestId'];
19
+ if (requestId !== undefined && typeof requestId !== 'string')
20
+ return undefined;
21
+ const payload = frame['payload'];
22
+ if (!isRecord(payload))
23
+ return undefined;
24
+ const sessionId = payload['sessionId'];
25
+ if (typeof sessionId !== 'string' || sessionId.length === 0)
26
+ return undefined;
27
+ const hostContext = parseHostContextProjection(payload['hostContext']);
28
+ if (hostContext === undefined)
29
+ return undefined;
30
+ return {
31
+ type: 'host_context_observed',
32
+ ...(requestId !== undefined ? { requestId } : {}),
33
+ payload: { sessionId, hostContext },
34
+ };
35
+ }
36
+ /**
37
+ * Handle one parsed `host_context_observed` message: persist the
38
+ * validated projection onto the render. Idempotent overwrite — the
39
+ * protocol declares re-delivery (e.g. after a reconnect) replaces the
40
+ * stored value with no merge logic. No response frame is emitted; the
41
+ * obligation is purely stateful and is graded by the kit through the
42
+ * `readSessionField('hostContext')` introspection seam.
43
+ */
44
+ export function handleHostContextObserved(message, render) {
45
+ render.hostContext = message.payload.hostContext;
46
+ }
47
+ /**
48
+ * Validating narrower for the wire `hostContext` body against the live
49
+ * `HostContextProjection` (`@ggui-ai/protocol`, `types/host-context`).
50
+ * Every field is optional, so presence is never required — but a
51
+ * present field MUST carry the projection's shape, and unknown keys
52
+ * reject the value (returns `undefined`): a key outside the projection
53
+ * is state no conformant server is obligated to hold, and persisting
54
+ * it would require erasing the protocol type.
55
+ */
56
+ function parseHostContextProjection(value) {
57
+ if (!isRecord(value))
58
+ return undefined;
59
+ const out = {};
60
+ for (const [key, field] of Object.entries(value)) {
61
+ switch (key) {
62
+ case 'availableDisplayModes': {
63
+ if (!Array.isArray(field))
64
+ return undefined;
65
+ const modes = [];
66
+ for (const item of field) {
67
+ if (!isDisplayMode(item))
68
+ return undefined;
69
+ modes.push(item);
70
+ }
71
+ out.availableDisplayModes = modes;
72
+ break;
73
+ }
74
+ case 'currentDisplayMode': {
75
+ if (!isDisplayMode(field))
76
+ return undefined;
77
+ out.currentDisplayMode = field;
78
+ break;
79
+ }
80
+ case 'containerDimensions': {
81
+ if (!isRecord(field))
82
+ return undefined;
83
+ const dims = {};
84
+ for (const [dimKey, dimValue] of Object.entries(field)) {
85
+ if (dimKey !== 'width' &&
86
+ dimKey !== 'maxWidth' &&
87
+ dimKey !== 'height' &&
88
+ dimKey !== 'maxHeight') {
89
+ return undefined;
90
+ }
91
+ if (typeof dimValue !== 'number')
92
+ return undefined;
93
+ dims[dimKey] = dimValue;
94
+ }
95
+ out.containerDimensions = dims;
96
+ break;
97
+ }
98
+ case 'platform': {
99
+ if (field !== 'web' && field !== 'desktop' && field !== 'mobile') {
100
+ return undefined;
101
+ }
102
+ out.platform = field;
103
+ break;
104
+ }
105
+ case 'deviceCapabilities': {
106
+ if (!isRecord(field))
107
+ return undefined;
108
+ const caps = {};
109
+ for (const [capKey, capValue] of Object.entries(field)) {
110
+ if (capKey !== 'touch' && capKey !== 'hover')
111
+ return undefined;
112
+ if (typeof capValue !== 'boolean')
113
+ return undefined;
114
+ caps[capKey] = capValue;
115
+ }
116
+ out.deviceCapabilities = caps;
117
+ break;
118
+ }
119
+ case 'locale': {
120
+ if (typeof field !== 'string' || field.length === 0)
121
+ return undefined;
122
+ out.locale = field;
123
+ break;
124
+ }
125
+ case 'timeZone': {
126
+ if (typeof field !== 'string' || field.length === 0)
127
+ return undefined;
128
+ out.timeZone = field;
129
+ break;
130
+ }
131
+ default:
132
+ return undefined;
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+ function isDisplayMode(value) {
138
+ return value === 'inline' || value === 'fullscreen' || value === 'pip';
139
+ }
package/dist/index.d.ts CHANGED
@@ -16,5 +16,6 @@
16
16
  export declare const REFERENCE_SERVER_VERSION = "0.1.0";
17
17
  export { ReferenceServer } from './server.js';
18
18
  export type { ReferenceServerOptions } from './server.js';
19
+ export { DEPLOYMENT_DEFAULT_APP_ID } from './render.js';
19
20
  export { createReferenceConformanceHost, type CreateReferenceConformanceHostInput, } from './conformance-host.js';
20
21
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,wBAAwB,UAAU,CAAC;AAahD,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,YAAY,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EACL,8BAA8B,EAC9B,KAAK,mCAAmC,GACzC,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,wBAAwB,UAAU,CAAC;AAahD,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,YAAY,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAK1D,OAAO,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,EACL,8BAA8B,EAC9B,KAAK,mCAAmC,GACzC,MAAM,uBAAuB,CAAC"}
package/dist/index.js CHANGED
@@ -26,4 +26,9 @@ export const REFERENCE_SERVER_VERSION = '0.1.0';
26
26
  // `ConformanceHost` to pass into `runConformance({host})`.
27
27
  // - Throws on unimplemented directives — kit maps them to SKIP.
28
28
  export { ReferenceServer } from './server.js';
29
+ // Deployment-level identity-default app id (SPEC §12.2: a subscribe
30
+ // MAY omit `appId`; this no-auth server resolves every caller to this
31
+ // deployment-wide tenant). Exported so external runners can assert
32
+ // the bound default without restating the literal.
33
+ export { DEPLOYMENT_DEFAULT_APP_ID } from './render.js';
29
34
  export { createReferenceConformanceHost, } from './conformance-host.js';