@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/server.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `ReferenceServer` — the minimal WS live-channel server this package
|
|
3
|
-
* exports. Honest scope: SPEC §12.2 wire
|
|
4
|
-
*
|
|
3
|
+
* exports. Honest scope: SPEC §12.2 wire (subscribe incl. the §12.2
|
|
4
|
+
* appId-tenancy MUST → APP_MISMATCH), version handshake, the single
|
|
5
|
+
* action-routing model (action → consume-buffer append → ack with
|
|
6
|
+
* sequence; undeclared actions AND schema-violating payloads rejected
|
|
7
|
+
* with CONTRACT_VIOLATION per SPEC §4.6 receipt validation), and
|
|
8
|
+
* `host_context_observed` persistence onto `GguiSession.hostContext`.
|
|
9
|
+
* That's it.
|
|
5
10
|
*
|
|
6
11
|
* No auth (accepts any bearer). No persistence. No bundle loading.
|
|
7
12
|
* The whole point is to be narrow enough that the vendor-neutral
|
|
@@ -9,22 +14,19 @@
|
|
|
9
14
|
* server passes `@ggui-ai/protocol-conformance`, the protocol has
|
|
10
15
|
* no implicit `@ggui-ai/mcp-server` coupling.
|
|
11
16
|
*
|
|
12
|
-
* Wire-field note: the
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the consumer contract by reading that field name verbatim, then
|
|
16
|
-
* binds the value to a `renderId` internally — see {@link Render}
|
|
17
|
-
* for the canonical identity name used throughout this package.
|
|
17
|
+
* Wire-field note: the GguiSession identity field is the canonical SPEC
|
|
18
|
+
* field `sessionId` — see {@link GguiSession} for the identity name used
|
|
19
|
+
* throughout this package.
|
|
18
20
|
*/
|
|
19
21
|
import { createServer } from 'node:http';
|
|
20
22
|
import { PROTOCOL_SCHEMA_VERSION } from '@ggui-ai/protocol';
|
|
21
23
|
import { WebSocketServer } from 'ws';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
24
|
+
import { handleAction, parseActionFrame } from './action-router.js';
|
|
25
|
+
import { handleHostContextObserved, parseHostContextObservedFrame, } from './host-context.js';
|
|
26
|
+
import { isRecord } from '@ggui-ai/protocol';
|
|
27
|
+
import { DEPLOYMENT_DEFAULT_APP_ID, GguiSessionStore, } from './render.js';
|
|
25
28
|
export class ReferenceServer {
|
|
26
|
-
renders = new
|
|
27
|
-
tools = new ToolRegistry();
|
|
29
|
+
renders = new GguiSessionStore();
|
|
28
30
|
options;
|
|
29
31
|
http = null;
|
|
30
32
|
wss = null;
|
|
@@ -104,7 +106,7 @@ export class ReferenceServer {
|
|
|
104
106
|
handleConnection(socket) {
|
|
105
107
|
// Subscribe state is per-connection — one WS may subscribe to
|
|
106
108
|
// one render at a time. Re-subscribe overwrites.
|
|
107
|
-
let
|
|
109
|
+
let subscribedSessionId = null;
|
|
108
110
|
const subscriber = {
|
|
109
111
|
send: (frame) => {
|
|
110
112
|
try {
|
|
@@ -116,26 +118,26 @@ export class ReferenceServer {
|
|
|
116
118
|
},
|
|
117
119
|
};
|
|
118
120
|
socket.on('message', (raw) => {
|
|
119
|
-
|
|
121
|
+
this.handleMessage(raw.toString('utf8'), {
|
|
120
122
|
socket,
|
|
121
123
|
subscriber,
|
|
122
|
-
onSubscribed: (
|
|
124
|
+
onSubscribed: (sessionId) => {
|
|
123
125
|
// If previously subscribed to a different render, unsub
|
|
124
126
|
// from it first.
|
|
125
|
-
if (
|
|
126
|
-
this.renders.removeSubscriber(
|
|
127
|
+
if (subscribedSessionId !== null && subscribedSessionId !== sessionId) {
|
|
128
|
+
this.renders.removeSubscriber(subscribedSessionId, subscriber);
|
|
127
129
|
}
|
|
128
|
-
|
|
130
|
+
subscribedSessionId = sessionId;
|
|
129
131
|
},
|
|
130
132
|
});
|
|
131
133
|
});
|
|
132
134
|
socket.on('close', () => {
|
|
133
|
-
if (
|
|
134
|
-
this.renders.removeSubscriber(
|
|
135
|
+
if (subscribedSessionId !== null) {
|
|
136
|
+
this.renders.removeSubscriber(subscribedSessionId, subscriber);
|
|
135
137
|
}
|
|
136
138
|
});
|
|
137
139
|
}
|
|
138
|
-
|
|
140
|
+
handleMessage(text, ctx) {
|
|
139
141
|
let frame;
|
|
140
142
|
try {
|
|
141
143
|
frame = JSON.parse(text);
|
|
@@ -146,25 +148,43 @@ export class ReferenceServer {
|
|
|
146
148
|
// server keeps it silent so `no-op` fixtures pass.
|
|
147
149
|
return;
|
|
148
150
|
}
|
|
149
|
-
if (frame
|
|
151
|
+
if (!isRecord(frame))
|
|
150
152
|
return;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
this.handleSubscribe(f, {
|
|
153
|
+
if (frame['type'] === 'subscribe') {
|
|
154
|
+
this.handleSubscribe(frame, {
|
|
154
155
|
subscriber: ctx.subscriber,
|
|
155
156
|
onSubscribed: ctx.onSubscribed,
|
|
156
157
|
socket: ctx.socket,
|
|
157
158
|
});
|
|
158
159
|
return;
|
|
159
160
|
}
|
|
160
|
-
if (
|
|
161
|
+
if (frame['type'] === 'action') {
|
|
161
162
|
const parsed = parseActionFrame(frame);
|
|
162
163
|
if (parsed === undefined)
|
|
163
164
|
return; // malformed — silently drop
|
|
164
|
-
const render = this.renders.get(parsed.
|
|
165
|
+
const render = this.renders.get(parsed.payload.sessionId);
|
|
165
166
|
if (render === undefined)
|
|
166
167
|
return; // drop actions for unknown renders
|
|
167
|
-
|
|
168
|
+
// Ack + contract rejections reply to the SENDING socket — the
|
|
169
|
+
// dispatcher gets the persistence proof, not the broadcast set.
|
|
170
|
+
handleAction(parsed, { render, reply: ctx.subscriber });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (frame['type'] === 'host_context_observed') {
|
|
174
|
+
// Fire-and-forget observation message — no response frame. The
|
|
175
|
+
// obligation is purely stateful: persist the validated
|
|
176
|
+
// projection onto the named render (idempotent overwrite). The
|
|
177
|
+
// first-party server scopes the write through its subscriber
|
|
178
|
+
// binding; this no-auth server's tenancy scope is the render
|
|
179
|
+
// lookup itself — malformed frames and unknown renders drop,
|
|
180
|
+
// mirroring the action path.
|
|
181
|
+
const parsed = parseHostContextObservedFrame(frame);
|
|
182
|
+
if (parsed === undefined)
|
|
183
|
+
return; // malformed — silently drop
|
|
184
|
+
const render = this.renders.get(parsed.payload.sessionId);
|
|
185
|
+
if (render === undefined)
|
|
186
|
+
return; // drop observations for unknown renders
|
|
187
|
+
handleHostContextObserved(parsed, render);
|
|
168
188
|
return;
|
|
169
189
|
}
|
|
170
190
|
// Unrecognized type — silently drop (extensibly-closed; third
|
|
@@ -172,24 +192,31 @@ export class ReferenceServer {
|
|
|
172
192
|
}
|
|
173
193
|
handleSubscribe(frame, ctx) {
|
|
174
194
|
const payload = frame['payload'];
|
|
175
|
-
if (payload
|
|
195
|
+
if (!isRecord(payload))
|
|
196
|
+
return;
|
|
197
|
+
// GguiSession-identity field: the canonical SPEC field `sessionId`.
|
|
198
|
+
const sessionId = typeof payload['sessionId'] === 'string' ? payload['sessionId'] : undefined;
|
|
199
|
+
if (sessionId === undefined)
|
|
176
200
|
return;
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
201
|
+
// `appId` is OPTIONAL on the subscribe payload (SPEC §12.2 field
|
|
202
|
+
// table): absent ⇒ the server resolves the caller's
|
|
203
|
+
// identity-default app. This server's identity model is no-auth —
|
|
204
|
+
// every caller is the same anonymous identity — so the
|
|
205
|
+
// identity-default collapses to the deployment-level
|
|
206
|
+
// {@link DEPLOYMENT_DEFAULT_APP_ID}; the resolved value flows
|
|
207
|
+
// through the SAME tenancy gate + provision-on-subscribe path a
|
|
208
|
+
// client-supplied appId takes. A PRESENT-but-non-string `appId` is
|
|
209
|
+
// still a malformed frame and takes the missing-`sessionId`
|
|
210
|
+
// posture: silently dropped, no error frame, no render
|
|
211
|
+
// provisioned.
|
|
212
|
+
const rawAppId = payload['appId'];
|
|
213
|
+
if (rawAppId !== undefined && typeof rawAppId !== 'string')
|
|
188
214
|
return;
|
|
189
|
-
const appId =
|
|
215
|
+
const appId = rawAppId ?? DEPLOYMENT_DEFAULT_APP_ID;
|
|
190
216
|
const requestId = typeof frame['requestId'] === 'string' ? frame['requestId'] : undefined;
|
|
191
|
-
const
|
|
192
|
-
|
|
217
|
+
const rawVersions = payload['supportedVersions'];
|
|
218
|
+
const supportedVersions = Array.isArray(rawVersions)
|
|
219
|
+
? rawVersions.filter((v) => typeof v === 'string')
|
|
193
220
|
: undefined;
|
|
194
221
|
// Version handshake — if the client declared `supportedVersions`
|
|
195
222
|
// AND our current schema-version is not in the list, emit
|
|
@@ -205,7 +232,7 @@ export class ReferenceServer {
|
|
|
205
232
|
// subscribe landed, advertise that value instead of the instance-
|
|
206
233
|
// level default. Lets parallel kit fixtures share one server while
|
|
207
234
|
// mismatching version on exactly one render.
|
|
208
|
-
const existingRender = this.renders.get(
|
|
235
|
+
const existingRender = this.renders.get(sessionId);
|
|
209
236
|
const advertised = existingRender?.versionOverride ?? this.options.versionOverride;
|
|
210
237
|
if (supportedVersions !== undefined && !supportedVersions.includes(advertised)) {
|
|
211
238
|
ctx.subscriber.send({
|
|
@@ -227,12 +254,39 @@ export class ReferenceServer {
|
|
|
227
254
|
}
|
|
228
255
|
return;
|
|
229
256
|
}
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
257
|
+
// SPEC §12.2 tenancy MUST: the subscribe's `appId` MUST match the
|
|
258
|
+
// GguiSession's bound appId or the subscribe fails APP_MISMATCH
|
|
259
|
+
// (§12.2.3). The code is deliberately distinct from
|
|
260
|
+
// SESSION_NOT_FOUND — the GguiSession EXISTS, it is reachable only
|
|
261
|
+
// from a different app, so the client's recovery is "fix your
|
|
262
|
+
// appId / API key", not "re-handshake". Only an existing render
|
|
263
|
+
// can mismatch: an unknown sessionId falls through to the
|
|
264
|
+
// provision-on-subscribe path below, which binds the subscribe's
|
|
265
|
+
// own appId (mirroring the first-party dev-mode posture: look up
|
|
266
|
+
// first; if not present, create). The mismatch rejects without
|
|
267
|
+
// registering a subscriber and without an ack; the socket stays
|
|
268
|
+
// open (matching the first-party handler).
|
|
269
|
+
if (existingRender !== undefined && existingRender.appId !== appId) {
|
|
270
|
+
ctx.subscriber.send({
|
|
271
|
+
type: 'error',
|
|
272
|
+
payload: {
|
|
273
|
+
code: 'APP_MISMATCH',
|
|
274
|
+
message: `GguiSession '${sessionId}' belongs to a different app`,
|
|
275
|
+
},
|
|
276
|
+
...(requestId !== undefined ? { requestId } : {}),
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// Bind the render BEFORE registering the subscriber: create() is a
|
|
281
|
+
// no-op when the render already exists (preserving the appId an
|
|
282
|
+
// earlier directive bound), and for provision-on-subscribe it
|
|
283
|
+
// binds the subscribe payload's own appId. Ordering matters —
|
|
284
|
+
// `addSubscriber`'s create-if-missing fallback binds the default
|
|
285
|
+
// app, which would make this render reject the SAME client's next
|
|
286
|
+
// subscribe with APP_MISMATCH.
|
|
287
|
+
this.renders.create(sessionId, appId);
|
|
288
|
+
this.renders.addSubscriber(sessionId, ctx.subscriber);
|
|
289
|
+
ctx.onSubscribed(sessionId);
|
|
236
290
|
ctx.subscriber.send({
|
|
237
291
|
type: 'ack',
|
|
238
292
|
payload: { serverVersion: advertised },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggui-ai/protocol-reference-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-rc.0",
|
|
4
4
|
"description": "Minimal reference implementation of the ggui protocol. Implements exactly enough of the live-channel WebSocket wire to pass the @ggui-ai/protocol-conformance kit. Not a production server — it exists to prove the protocol is vendor-neutral: an independent, from-scratch implementation passing the kit grounds that claim empirically.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ggui",
|
|
@@ -29,14 +29,14 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"ws": "^8.20.1",
|
|
32
|
-
"@ggui-ai/protocol": "0.
|
|
32
|
+
"@ggui-ai/protocol": "0.3.0-rc.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^22.0.0",
|
|
36
36
|
"@types/ws": "^8.5.10",
|
|
37
37
|
"typescript": "^5.0.0",
|
|
38
38
|
"vitest": "^3.0.0",
|
|
39
|
-
"@ggui-ai/protocol-conformance": "0.
|
|
39
|
+
"@ggui-ai/protocol-conformance": "0.3.0-rc.0"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"author": "ggui contributors <hello@ggui.ai>",
|
|
57
57
|
"scripts": {
|
|
58
|
-
"build": "tsc -p tsconfig.build.json && node ../scripts/fix-esm-imports.mjs dist && chmod +x dist/cli.js",
|
|
58
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && node ../scripts/fix-esm-imports.mjs dist && chmod +x dist/cli.js",
|
|
59
59
|
"dev": "tsc --watch",
|
|
60
60
|
"typecheck": "tsc --noEmit",
|
|
61
61
|
"test": "vitest run --passWithNoTests",
|
package/dist/tool-registry.d.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tool registry — the 4 handler kinds the reference server wires
|
|
3
|
-
* through wired-action dispatch. Names match the `handler` enumeration
|
|
4
|
-
* in `packages/protocol-conformance/src/conformance-host.ts`'s
|
|
5
|
-
* `RegisterToolSetup` so the conformance kit's `register-tool`
|
|
6
|
-
* directive drops through cleanly.
|
|
7
|
-
*
|
|
8
|
-
* The four handler kinds:
|
|
9
|
-
*
|
|
10
|
-
* - `echo` — returns `{received: args}`.
|
|
11
|
-
* - `throw` — rejects with `Error('tool_threw_for_fixture')`.
|
|
12
|
-
* - `timeout` — never resolves; router enforces a 500ms timeout.
|
|
13
|
-
* - `malformed`— returns `{wrong: 'shape'}` to exercise
|
|
14
|
-
* SCHEMA_VIOLATION.
|
|
15
|
-
*
|
|
16
|
-
* `TOOL_NOT_FOUND` is the 5th failure path — exercised by dispatching
|
|
17
|
-
* to an action whose tool is NOT in the registry; no handler needed.
|
|
18
|
-
*
|
|
19
|
-
* Handlers are declarative: they return `{status: 'resolved', value}`
|
|
20
|
-
* or throw. The router consults the return shape against the
|
|
21
|
-
* declared channel schema (minimal — currently only `malformed` is
|
|
22
|
-
* flagged) and maps unsupported cases to `_ggui:contract-error` with
|
|
23
|
-
* the matching ContractErrorCode.
|
|
24
|
-
*/
|
|
25
|
-
/**
|
|
26
|
-
* One tool's executable behavior. Async so `timeout` can return a
|
|
27
|
-
* never-resolving promise the router bounds with a timer.
|
|
28
|
-
*/
|
|
29
|
-
export type ToolHandler = (args: unknown) => Promise<unknown>;
|
|
30
|
-
export type ToolHandlerKind = 'echo' | 'throw' | 'timeout' | 'malformed' | 'malformed-stream' | 'list-snapshot' | (string & {});
|
|
31
|
-
/**
|
|
32
|
-
* Registered tool — the handler + its declared behavior kind so
|
|
33
|
-
* the router can match against fixture expectations.
|
|
34
|
-
*/
|
|
35
|
-
export interface RegisteredTool {
|
|
36
|
-
readonly name: string;
|
|
37
|
-
readonly kind: ToolHandlerKind;
|
|
38
|
-
readonly handler: ToolHandler;
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Build one of the four canonical handlers by kind. Unknown kinds
|
|
42
|
-
* throw — the caller (ConformanceHost adapter or setup-step
|
|
43
|
-
* dispatcher) MUST surface this as an honest "handler not implemented"
|
|
44
|
-
* error so the kit records a SKIP with the error message as reason.
|
|
45
|
-
*/
|
|
46
|
-
export declare function buildHandler(kind: ToolHandlerKind): ToolHandler;
|
|
47
|
-
/**
|
|
48
|
-
* In-memory tool registry. Scoped to a render via the action router
|
|
49
|
-
* (the plan's register-tool directive wires a handler under the
|
|
50
|
-
* render's tool namespace). No-persistence by design.
|
|
51
|
-
*/
|
|
52
|
-
export declare class ToolRegistry {
|
|
53
|
-
private readonly tools;
|
|
54
|
-
register(name: string, kind: ToolHandlerKind): void;
|
|
55
|
-
unregister(name: string): boolean;
|
|
56
|
-
get(name: string): RegisteredTool | undefined;
|
|
57
|
-
has(name: string): boolean;
|
|
58
|
-
/** Name of the first tool registered on this registry, or
|
|
59
|
-
* `undefined` if none. Used by the action-router as a fallback
|
|
60
|
-
* when no explicit action→tool binding exists. */
|
|
61
|
-
firstRegistered(): string | undefined;
|
|
62
|
-
}
|
|
63
|
-
//# sourceMappingURL=tool-registry.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"tool-registry.d.ts","sourceRoot":"","sources":["../src/tool-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAE9D,MAAM,MAAM,eAAe,GACvB,MAAM,GACN,OAAO,GACP,SAAS,GACT,WAAW,GACX,kBAAkB,GAClB,eAAe,GACf,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAElB;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;CAC/B;AAYD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,eAAe,GAAG,WAAW,CAkC/D;AAED;;;;GAIG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqC;IAE3D,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,GAAG,IAAI;IAInD,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAIjC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS;IAI7C,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAI1B;;uDAEmD;IACnD,eAAe,IAAI,MAAM,GAAG,SAAS;CAItC"}
|
package/dist/tool-registry.js
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tool registry — the 4 handler kinds the reference server wires
|
|
3
|
-
* through wired-action dispatch. Names match the `handler` enumeration
|
|
4
|
-
* in `packages/protocol-conformance/src/conformance-host.ts`'s
|
|
5
|
-
* `RegisterToolSetup` so the conformance kit's `register-tool`
|
|
6
|
-
* directive drops through cleanly.
|
|
7
|
-
*
|
|
8
|
-
* The four handler kinds:
|
|
9
|
-
*
|
|
10
|
-
* - `echo` — returns `{received: args}`.
|
|
11
|
-
* - `throw` — rejects with `Error('tool_threw_for_fixture')`.
|
|
12
|
-
* - `timeout` — never resolves; router enforces a 500ms timeout.
|
|
13
|
-
* - `malformed`— returns `{wrong: 'shape'}` to exercise
|
|
14
|
-
* SCHEMA_VIOLATION.
|
|
15
|
-
*
|
|
16
|
-
* `TOOL_NOT_FOUND` is the 5th failure path — exercised by dispatching
|
|
17
|
-
* to an action whose tool is NOT in the registry; no handler needed.
|
|
18
|
-
*
|
|
19
|
-
* Handlers are declarative: they return `{status: 'resolved', value}`
|
|
20
|
-
* or throw. The router consults the return shape against the
|
|
21
|
-
* declared channel schema (minimal — currently only `malformed` is
|
|
22
|
-
* flagged) and maps unsupported cases to `_ggui:contract-error` with
|
|
23
|
-
* the matching ContractErrorCode.
|
|
24
|
-
*/
|
|
25
|
-
/**
|
|
26
|
-
* Returns a never-resolving promise. The router pairs it with a
|
|
27
|
-
* timer to emit `TOOL_TIMEOUT` contract-error after N ms.
|
|
28
|
-
*/
|
|
29
|
-
function neverResolve() {
|
|
30
|
-
return new Promise(() => {
|
|
31
|
-
/* deliberate: never settles */
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Build one of the four canonical handlers by kind. Unknown kinds
|
|
36
|
-
* throw — the caller (ConformanceHost adapter or setup-step
|
|
37
|
-
* dispatcher) MUST surface this as an honest "handler not implemented"
|
|
38
|
-
* error so the kit records a SKIP with the error message as reason.
|
|
39
|
-
*/
|
|
40
|
-
export function buildHandler(kind) {
|
|
41
|
-
switch (kind) {
|
|
42
|
-
case 'echo':
|
|
43
|
-
return async (args) => ({ received: args });
|
|
44
|
-
case 'throw':
|
|
45
|
-
return async () => {
|
|
46
|
-
throw new Error('tool_threw_for_fixture');
|
|
47
|
-
};
|
|
48
|
-
case 'timeout':
|
|
49
|
-
return () => neverResolve();
|
|
50
|
-
case 'malformed':
|
|
51
|
-
case 'malformed-stream':
|
|
52
|
-
// Both kinds return a shape that does not match the declared
|
|
53
|
-
// channel schema — router maps to SCHEMA_VIOLATION.
|
|
54
|
-
// `malformed-stream` is the fixture-authored alias used by
|
|
55
|
-
// `stream-schema-violation` in the kit.
|
|
56
|
-
return async () => ({ wrong: 'shape' });
|
|
57
|
-
case 'list-snapshot':
|
|
58
|
-
// Refresh-tool kind: returns a deterministic empty list snapshot
|
|
59
|
-
// (`{ items: [] }`). Models the "list-fresh-state" role of a
|
|
60
|
-
// streamSpec refresh tool (real ggui blueprints' `tasks_list` /
|
|
61
|
-
// `notes_list` etc.). The reference server runs handlers
|
|
62
|
-
// statelessly — the snapshot intentionally does NOT reflect
|
|
63
|
-
// prior `tasks_create` calls, since modeling stateful in-memory
|
|
64
|
-
// stores is out of scope for the smallest conformant impl. The
|
|
65
|
-
// kit's `stream-refresh-success` matcher only asserts the
|
|
66
|
-
// channel-update arrived with the declared shape, not that the
|
|
67
|
-
// payload reflects mutation history.
|
|
68
|
-
return async () => ({ items: [] });
|
|
69
|
-
default:
|
|
70
|
-
throw new Error(`reference-server: tool handler kind '${String(kind)}' is not recognized — supported: echo, throw, timeout, malformed, malformed-stream, list-snapshot`);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* In-memory tool registry. Scoped to a render via the action router
|
|
75
|
-
* (the plan's register-tool directive wires a handler under the
|
|
76
|
-
* render's tool namespace). No-persistence by design.
|
|
77
|
-
*/
|
|
78
|
-
export class ToolRegistry {
|
|
79
|
-
tools = new Map();
|
|
80
|
-
register(name, kind) {
|
|
81
|
-
this.tools.set(name, { name, kind, handler: buildHandler(kind) });
|
|
82
|
-
}
|
|
83
|
-
unregister(name) {
|
|
84
|
-
return this.tools.delete(name);
|
|
85
|
-
}
|
|
86
|
-
get(name) {
|
|
87
|
-
return this.tools.get(name);
|
|
88
|
-
}
|
|
89
|
-
has(name) {
|
|
90
|
-
return this.tools.has(name);
|
|
91
|
-
}
|
|
92
|
-
/** Name of the first tool registered on this registry, or
|
|
93
|
-
* `undefined` if none. Used by the action-router as a fallback
|
|
94
|
-
* when no explicit action→tool binding exists. */
|
|
95
|
-
firstRegistered() {
|
|
96
|
-
const it = this.tools.keys().next();
|
|
97
|
-
return it.done === true ? undefined : it.value;
|
|
98
|
-
}
|
|
99
|
-
}
|