@ggui-ai/protocol-reference-server 0.2.0-alpha.3 → 0.3.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -10
- package/dist/action-router.d.ts +28 -31
- package/dist/action-router.d.ts.map +1 -1
- package/dist/action-router.js +77 -278
- package/dist/conformance-host.d.ts +2 -32
- package/dist/conformance-host.d.ts.map +1 -1
- package/dist/conformance-host.js +190 -138
- package/dist/host-context.d.ts +70 -0
- package/dist/host-context.d.ts.map +1 -0
- package/dist/host-context.js +139 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/render.d.ts +145 -81
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +87 -68
- package/dist/server.d.ts +2 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +105 -51
- package/package.json +4 -4
- package/dist/tool-registry.d.ts +0 -63
- package/dist/tool-registry.d.ts.map +0 -1
- package/dist/tool-registry.js +0 -99
package/dist/conformance-host.js
CHANGED
|
@@ -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-
|
|
8
|
-
* so 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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 === '
|
|
37
|
-
|
|
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
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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-
|
|
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 '${
|
|
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
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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';
|