@guuey/agent-client 0.1.0 → 0.2.1

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,4 +1,4 @@
1
- import { fetchThreadHistory } from "./history";
1
+ import { fetchThreadHistory, HistoryUnauthorizedError } from "./history";
2
2
  /**
3
3
  * Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
4
4
  * SSE stream opens). Carries the pod's structured `{ code, message }` when
@@ -47,14 +47,60 @@ export function webGenerateId() {
47
47
  return `cmid-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
48
48
  }
49
49
  /**
50
- * Web SSE transport. When `accessToken` is present the pod identifies the
51
- * caller by their verified Cognito access token (the same identity the
52
- * history read plane uses, so persisted threads round-trip on reload).
53
- * Otherwise it falls back to `credentials: "include"`, which round-trips the
54
- * HttpOnly `guuey_guest` cookie the pod mints for anonymous browser callers.
50
+ * Header carrying a caller-owned anonymous guest secret. A LOCAL MIRROR of the
51
+ * two server-side constants the pod's `GUEST_HEADER_NAME`
52
+ * (`backend/services/nocode-runtime/src/identity.ts`) and the read plane's
53
+ * `GUEST_HEADER` (`backend/amplify/functions/publicApi/identity.ts`) because
54
+ * this is a published npm package and cannot take a `@guuey-private` dep (same
55
+ * arrangement as `@guuey/host`'s mirrored fs-contract constants). The string is
56
+ * a wire contract: both planes already advertise it in
57
+ * `Access-Control-Allow-Headers`, so changing it is a breaking protocol change,
58
+ * not a rename.
59
+ */
60
+ const GUEST_HEADER = "x-guuey-guest";
61
+ /**
62
+ * A well-formed guest secret: exactly 32 bytes as 64 LOWERCASE hex chars —
63
+ * the shape `crypto.getRandomValues` + hex-encoding mints.
64
+ *
65
+ * Deliberately stricter than the server's `/^[a-f0-9]{64}$/i` (pod
66
+ * `identity.ts`, publicApi `identity.ts`): both sides lowercase before
67
+ * hashing, so an uppercase secret would in fact be accepted, but the only
68
+ * supported mint path emits lowercase and a non-canonical value means the
69
+ * caller's storage is not what this adapter expects. Anything that fails is
70
+ * IGNORED — the request falls through to cookie mode rather than sending a
71
+ * secret the two identity planes might key differently.
72
+ */
73
+ const GUEST_SECRET_RE = /^[0-9a-f]{64}$/;
74
+ /**
75
+ * Narrow a caller-supplied guest secret to a value that is safe to put on the
76
+ * wire, or `null`. The single gate for the header: every write of
77
+ * {@link GUEST_HEADER} in this module goes through it, so a malformed secret
78
+ * can never reach a request. The value is never logged (here or anywhere on
79
+ * this path) — it IS the anonymous identity, so a leak is an impersonation.
80
+ */
81
+ function sendableGuestSecret(secret) {
82
+ return typeof secret === "string" && GUEST_SECRET_RE.test(secret) ? secret : null;
83
+ }
84
+ /**
85
+ * Web SSE transport. Exactly ONE identity carrier per request, in order:
86
+ *
87
+ * 1. `accessToken` → `Authorization: Bearer` — the pod identifies the caller
88
+ * by their verified access token (the same identity the history read
89
+ * plane uses, so persisted threads round-trip on reload).
90
+ * 2. a well-formed `guestSecret` → `x-guuey-guest` — the caller owns and
91
+ * persists its own anonymous secret. The path for hosts with no usable
92
+ * cookie jar: React-Native, and the embedded widget, whose third-party
93
+ * iframe cannot rely on the pod's cookie surviving browser partitioning.
94
+ * The pod never mints a cookie for a header client.
95
+ * 3. neither → `credentials: "include"`, which round-trips the HttpOnly
96
+ * `guuey_guest` cookie the pod mints for anonymous browser callers.
97
+ *
98
+ * Never two at once: a bearer wins over a guest secret, and a request that
99
+ * carries either header does NOT also send cookie credentials.
100
+ *
55
101
  * Reads the body via `ReadableStream.getReader()` (browser).
56
102
  */
57
- export async function* fetchStreamTransport(req, accessToken) {
103
+ export async function* fetchStreamTransport(req, accessToken, guestSecret) {
58
104
  const headers = {
59
105
  "Content-Type": "application/json",
60
106
  Accept: "text/event-stream",
@@ -65,9 +111,13 @@ export async function* fetchStreamTransport(req, accessToken) {
65
111
  headers,
66
112
  body: JSON.stringify(req.body),
67
113
  };
114
+ const guest = sendableGuestSecret(guestSecret);
68
115
  if (accessToken) {
69
116
  headers.Authorization = `Bearer ${accessToken}`;
70
117
  }
118
+ else if (guest) {
119
+ headers[GUEST_HEADER] = guest;
120
+ }
71
121
  else {
72
122
  init.credentials = "include";
73
123
  }
@@ -100,34 +150,78 @@ export async function* fetchStreamTransport(req, accessToken) {
100
150
  }
101
151
  /**
102
152
  * Build the web host-adapter bundle for {@link useAgentInvoke}. Pass an
103
- * access-token resolver (and the read-plane base) to authenticate the chat
104
- * transport and enable transcript restore on reload; omit them for an
105
- * anonymous, history-less bundle.
153
+ * access-token resolver and/or a guest-secret resolver (plus the read-plane
154
+ * base) to give the chat transport an identity the read plane can also see,
155
+ * which is what enables transcript restore on reload; omit both for a
156
+ * cookie-only, history-less bundle.
106
157
  */
107
158
  export function createWebAdapters(opts = {}) {
108
- const { apiBaseUrl, getAccessToken } = opts;
159
+ const { apiBaseUrl, getAccessToken, getGuestSecret } = opts;
109
160
  const transport = async function* (req) {
110
161
  const token = getAccessToken ? await getAccessToken() : null;
111
- yield* fetchStreamTransport(req, token);
162
+ // Both candidates go to the transport; it owns the precedence (and the
163
+ // never-two-carriers rule) so there is exactly one place that decides.
164
+ yield* fetchStreamTransport(req, token, getGuestSecret ? getGuestSecret() : null);
112
165
  };
113
166
  const adapters = {
114
167
  storage: localStorageThreadStore,
115
168
  generateId: webGenerateId,
116
169
  transport,
117
170
  };
118
- if (apiBaseUrl && getAccessToken) {
171
+ // History needs an identity the READ plane can resolve: a Bearer or the
172
+ // `x-guuey-guest` header. Either resolver can supply one, so either one
173
+ // installs the adapter; a cookie-only caller is unidentifiable there and
174
+ // gets no adapter at all.
175
+ if (apiBaseUrl && (getAccessToken || getGuestSecret)) {
119
176
  adapters.history = {
120
177
  load: async (threadId) => {
121
- const token = await getAccessToken();
178
+ // Same precedence, and the same one-carrier rule, as the transport:
179
+ // a bearer wins over the guest header, and the two never combine.
180
+ if (getAccessToken) {
181
+ const token = await getAccessToken();
182
+ if (token) {
183
+ try {
184
+ return await fetchThreadHistory({
185
+ baseUrl: apiBaseUrl,
186
+ threadId,
187
+ includeCards: true,
188
+ requestInit: { headers: { Authorization: `Bearer ${token}` } },
189
+ });
190
+ }
191
+ catch (err) {
192
+ if (!(err instanceof HistoryUnauthorizedError))
193
+ throw err;
194
+ // The one retry the send path already gets on a 401
195
+ // (`withIdentifiedToken`): this read runs from a mount effect,
196
+ // before the send path has asked anyone for anything, so a
197
+ // token cached earlier can be the exact stale value that just
198
+ // failed. `forceRefresh` is the signal that asks past whatever
199
+ // cache the resolver keeps instead of returning that same dead
200
+ // value.
201
+ const fresh = await getAccessToken({ forceRefresh: true });
202
+ if (!fresh)
203
+ throw err; // nothing fresher to retry with — the ORIGINAL 401 is the honest cause
204
+ return fetchThreadHistory({
205
+ baseUrl: apiBaseUrl,
206
+ threadId,
207
+ includeCards: true,
208
+ requestInit: { headers: { Authorization: `Bearer ${fresh}` } },
209
+ });
210
+ }
211
+ }
212
+ }
213
+ const guest = sendableGuestSecret(getGuestSecret?.());
214
+ if (guest) {
215
+ return fetchThreadHistory({
216
+ baseUrl: apiBaseUrl,
217
+ threadId,
218
+ includeCards: true,
219
+ requestInit: { headers: { [GUEST_HEADER]: guest } },
220
+ });
221
+ }
122
222
  // No readable identity → leave the chat empty (skip) rather than
123
223
  // `gone`, which would clear the persisted threadId.
124
- if (!token)
125
- return { messages: [] };
126
- return fetchThreadHistory({
127
- baseUrl: apiBaseUrl,
128
- threadId,
129
- requestInit: { headers: { Authorization: `Bearer ${token}` } },
130
- });
224
+ return { messages: [] };
131
225
  },
132
226
  };
133
227
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guuey/agent-client",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Client SDK for Guuey's agent runtime: the `useAgentInvoke` React hook + pure SSE helpers that speak the /agent/invoke streaming contract, plus the paginated thread-history read plane. Host adapters (storage / id / transport) are injected, so it runs on web (Next) and React Native alike.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  }
29
29
  },
30
30
  "dependencies": {
31
- "@silverprotocol/core": "0.3.2"
31
+ "@silverprotocol/core": "0.4.1"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "react": ">=18"
package/src/block-ui.ts CHANGED
@@ -86,7 +86,12 @@ export function scanProviderRawForUiResource(
86
86
  * Extract a mountable UI resource from an opaque AgBlock-shaped `JsonValue`
87
87
  * (used for persisted card snapshot parts, which arrive untyped). Dispatches
88
88
  * by `block.type`:
89
- * - `tool-result` → its `uiData` surface channel
89
+ * - `tool-result` → its `uiData` surface channel, then a `ui://` resource
90
+ * degraded into a `provider-raw` content part — the SAME two channels
91
+ * the live path ({@link toolResultUiResource}) mounts. The write side
92
+ * (`nocode-runtime`'s `uiCardArtifactsFromMessages`, guuey#86) persists
93
+ * card rows for both, so the snapshot arm must mount both or
94
+ * provider-raw-only cards rehydrate as placeholders.
90
95
  * - `provider-raw` → a `ui://` resource hiding in `raw`
91
96
  * - `resource` → a first-class embedded resource (gated on `ui://`)
92
97
  * Everything else → `undefined`.
@@ -94,8 +99,19 @@ export function scanProviderRawForUiResource(
94
99
  export function blockUiResource(block: JsonValue): McpUiResourcePayload | undefined {
95
100
  if (!isJsonObject(block)) return undefined;
96
101
  switch (block.type) {
97
- case "tool-result":
98
- return asUiResource(block.uiData);
102
+ case "tool-result": {
103
+ const fromUiData = asUiResource(block.uiData);
104
+ if (fromUiData) return fromUiData;
105
+ if (Array.isArray(block.content)) {
106
+ for (const part of block.content) {
107
+ if (isJsonObject(part) && part.type === "provider-raw") {
108
+ const found = scanProviderRawForUiResource(part.raw);
109
+ if (found) return found;
110
+ }
111
+ }
112
+ }
113
+ return undefined;
114
+ }
99
115
  case "provider-raw":
100
116
  return scanProviderRawForUiResource(block.raw);
101
117
  case "resource": {
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The card-mount dispatcher: ONE narrowing that answers "what, if anything,
3
+ * does this block mount?" across BOTH generative-UI channels a guuey pod
4
+ * emits.
5
+ *
6
+ * 1. **inline mcp-ui resource** — `{uri, text|blob}` on `uiData`, or a
7
+ * `ui://` resource degraded into a `provider-raw` content part. Handled
8
+ * verbatim by `block-ui.ts`; this module does not touch that path, it
9
+ * only tries it FIRST.
10
+ * 2. **ggui render** — `uiData.resourceUri` + the `_meta["ai.ggui/render"]`
11
+ * bootstrap, mounted through ggui's self-contained shell. See
12
+ * `ggui-render.ts`.
13
+ *
14
+ * Both channels land on the SAME `McpUiResourcePayload`, which is the whole
15
+ * point: a host that already mounts inline resources through
16
+ * `@mcp-ui/client`'s `AppRenderer` in a second-origin sandbox gains ggui cards
17
+ * without a second mount mechanism, a second iframe contract, or a second
18
+ * security posture to review.
19
+ *
20
+ * Precedence is inline-first and deliberate: an inline resource is the
21
+ * server's explicit, self-sufficient HTML. A ggui render only ever wins when
22
+ * there is no inline resource to prefer, so this dispatcher can never change
23
+ * what an existing inline card renders.
24
+ *
25
+ * ## Why the CHANNEL is returned alongside the resource
26
+ *
27
+ * The payload alone cannot say where it came from — a ggui shell is a string
28
+ * of HTML like any other. But a host has one decision that genuinely depends
29
+ * on the origin of that HTML: WHICH sandbox host page to mount it in. A ggui
30
+ * shell must load ggui's runtime bundle and open its WSS, so it needs a page
31
+ * whose CSP names the ggui origins; an inline card is arbitrary tenant HTML
32
+ * and must keep the self-only page it has always had. Handing back the channel
33
+ * keeps that one narrowing in one place — the alternative was for every host
34
+ * to re-run `toolResultGguiRender` beside this call and ask again.
35
+ */
36
+ import { cardUiResource, toolResultUiResource, type McpUiResourcePayload } from "./block-ui";
37
+ import { blockGguiRender, gguiRenderResource, toolResultGguiRender } from "./ggui-render";
38
+ import type { AgBlock, JsonValue } from "@silverprotocol/core";
39
+
40
+ /** Which generative-UI channel produced a mount. See this module's header. */
41
+ export type CardMountChannel = "inline" | "ggui";
42
+
43
+ /** A mountable card: the resource to hand the host, and where it came from. */
44
+ export interface CardMount {
45
+ /** The payload an mcp-ui host mounts, identical in shape for both channels. */
46
+ resource: McpUiResourcePayload;
47
+ /**
48
+ * `"inline"` — the server's own HTML, untrusted tenant content.
49
+ * `"ggui"` — a shell that boots the ggui runtime from a platform-pinned
50
+ * origin, and therefore needs a host page whose CSP allows that origin.
51
+ */
52
+ channel: CardMountChannel;
53
+ }
54
+
55
+ /**
56
+ * A live `tool-result` block → the card to mount, across both channels.
57
+ * `undefined` when the block carries no generative UI at all (or carries a
58
+ * ggui render whose bootstrap did not reach us — see `ggui-render.ts`).
59
+ */
60
+ export function toolResultCardMount(
61
+ block: Extract<AgBlock, { type: "tool-result" }>,
62
+ ): CardMount | undefined {
63
+ const inline = toolResultUiResource(block);
64
+ if (inline) return { resource: inline, channel: "inline" };
65
+ const ggui = toolResultGguiRender(block);
66
+ const resource = ggui ? gguiRenderResource(ggui) : undefined;
67
+ return resource ? { resource, channel: "ggui" } : undefined;
68
+ }
69
+
70
+ /**
71
+ * A persisted `HistoryCard`'s `cardSnapshot` → the card to mount, across both
72
+ * channels.
73
+ *
74
+ * The ggui arm is reached only for a snapshot that stored the render's
75
+ * `_meta`; a bootstrap old enough to have been persisted has an expired
76
+ * `wsToken` anyway, so in practice a ggui history card resolves to `undefined`
77
+ * and the host renders its placeholder — honest, and NOT a broken mount.
78
+ */
79
+ export function cardCardMount(cardSnapshot: JsonValue): CardMount | undefined {
80
+ const inline = cardUiResource(cardSnapshot);
81
+ if (inline) return { resource: inline, channel: "inline" };
82
+ for (const block of snapshotBlocks(cardSnapshot)) {
83
+ const ggui = blockGguiRender(block);
84
+ const resource = ggui ? gguiRenderResource(ggui) : undefined;
85
+ if (resource) return { resource, channel: "ggui" };
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ /**
91
+ * The blocks to scan inside a card snapshot: the stored `AgArtifact`'s `parts`
92
+ * when present, then the snapshot root itself — exactly `cardUiResource`'s own
93
+ * walk order, so both channels see the same candidates in the same order.
94
+ */
95
+ function snapshotBlocks(cardSnapshot: JsonValue): JsonValue[] {
96
+ if (typeof cardSnapshot !== "object" || cardSnapshot === null || Array.isArray(cardSnapshot)) {
97
+ return [];
98
+ }
99
+ const parts = cardSnapshot.parts;
100
+ return Array.isArray(parts) ? [...parts, cardSnapshot] : [cardSnapshot];
101
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The **ggui render** channel: narrowing + self-contained shell construction
3
+ * for a generative-UI card produced by the ggui MCP server (`ggui_render`).
4
+ *
5
+ * ## Why this is a second channel and not the existing one
6
+ *
7
+ * `block-ui.ts`'s original narrowing accepts exactly one shape — an mcp-ui
8
+ * embedded resource that carries its HTML INLINE (`{uri, text|blob}`). A ggui
9
+ * render carries no HTML at all. Its `tool.done` looks like this on the wire
10
+ * (shaped identically to the production capture the widget's fixtures
11
+ * replay, but with the SAME synthetic ids those redacted fixtures use —
12
+ * `apps/widget/src/fixtures/issue2627-render-capture.sse.txt` seq 48):
13
+ *
14
+ * ```jsonc
15
+ * {"type":"tool.done", "toolCallId":"toolu_0000…0005",
16
+ * "uiData":{"sessionId":"render_0000…0001",
17
+ * "resourceUri":"ui://ggui/render/render_0000…0001/c10a2055…", … },
18
+ * "_meta":{"ai.ggui/render":{"sessionId":"render_0000…0001","appId":"APP00000",
19
+ * "runtimeUrl":"https://dev.mcp.sandbox.ggui.ai/_ggui/iframe-runtime.js",
20
+ * "wsUrl":"wss://…/ws","wsToken":"eyJ…","expiresAt":"…",
21
+ * "propsJson":"{…}"},
22
+ * "ui":{"resourceUri":"ui://ggui/render/…"}}}
23
+ * ```
24
+ *
25
+ * Two facts follow, and they shape everything below:
26
+ *
27
+ * 1. **`uiData.resourceUri` is the RECOGNITION signal.** It is the only part
28
+ * of the render's identity that survives `@silverprotocol/core`'s fold
29
+ * (the reducer copies `uiData` — and, as of `@silverprotocol/core`
30
+ * 0.4.1 (workspace#9), `_meta` — onto the `tool-result` block).
31
+ * 2. **`_meta["ai.ggui/render"]` is the MOUNT MATERIAL.** Everything needed
32
+ * to boot the card — which runtime bundle to load, which live-channel to
33
+ * open, which props to seed — lives there and nowhere else.
34
+ *
35
+ * ## How the card mounts (the ggui-documented self-contained shell)
36
+ *
37
+ * ggui's iframe runtime accepts its bootstrap from three delivery channels;
38
+ * the highest-priority one is the **self-contained shell**: HTML that inlines
39
+ * the slice envelope at `globalThis.__GGUI_META__` synchronously BEFORE the
40
+ * runtime bundle's `<script type="module">` evaluates, after which the runtime
41
+ * autostarts, creates its own mount container and renders — no postMessage
42
+ * round-trip, no host-side ggui code. That contract is stated verbatim by the
43
+ * runtime's own reader (`@ggui-ai/iframe-runtime`'s `parseMetaFromGlobal`:
44
+ * *"The global carries the SAME slice envelope shape as the wire `_meta`
45
+ * (`{ "ai.ggui/render": {...} }`) … per-render shells populate this
46
+ * synchronously BEFORE the runtime bundle's `<script type="module">`
47
+ * evaluates"*), and by its boot resolver (`runtime.js`'s autostart:
48
+ * `readSelfContainedMeta()` first, postMessage channels after).
49
+ *
50
+ * {@link gguiShellHtml} builds exactly that shell. Because the shell IS a
51
+ * string of HTML, the ggui card then rides the host's EXISTING mcp-ui mount
52
+ * path unchanged: it narrows to the same `McpUiResourcePayload` an inline
53
+ * resource does, so `@mcp-ui/client`'s `AppRenderer` posts it as `srcdoc` into
54
+ * the second-origin `mcp-app-sandbox.html` page — same double-iframe rule,
55
+ * same sandbox origin, same opaque inner frame. No second mount mechanism.
56
+ *
57
+ * The slice is inlined **verbatim**: `runtimeUrl` is honored as given (ggui's
58
+ * host checklist item 8 — "no fallback URL, no substitution"), and every other
59
+ * field is passed through untouched for the runtime's own projector to
60
+ * validate. This module reads exactly one field (`runtimeUrl`) and only to
61
+ * prove the slice is mountable at all.
62
+ *
63
+ * NOT in scope here: rehydrating a ggui card from persisted history. The
64
+ * bootstrap's `wsToken` expires minutes after the render (`expiresAt` in the
65
+ * capture above), so a stored bootstrap is dead on arrival — a history card
66
+ * without a live bootstrap correctly resolves to `undefined` and renders the
67
+ * host's placeholder rather than a broken mount.
68
+ */
69
+ import type { AgBlock, JsonValue } from "@silverprotocol/core";
70
+ import { isJsonObject, type McpUiResourcePayload } from "./block-ui";
71
+
72
+ /** The `_meta` key the ggui render bootstrap rides on (MCP-Apps slice convention). */
73
+ export const GGUI_RENDER_META_KEY = "ai.ggui/render";
74
+
75
+ /** The `ui://` scheme prefix every ggui render resource uri carries. */
76
+ const UI_SCHEME = "ui://";
77
+
78
+ /**
79
+ * A ggui render bootstrap: the runtime bundle URL this module reads, plus the
80
+ * WHOLE `ai.ggui/render` slice, verbatim, for the shell to inline.
81
+ */
82
+ export interface GguiRenderBootstrap {
83
+ /** `runtimeUrl` — the ESM bundle the shell loads. Honored as given. */
84
+ runtimeUrl: string;
85
+ /**
86
+ * The verbatim slice. Open-ended by construction: it is ggui's wire
87
+ * contract, not one this package owns, and the runtime's own projector is
88
+ * the authority on every field. Re-declaring it here would duplicate a
89
+ * contract we do not own and rot at ggui's next field addition.
90
+ */
91
+ slice: { [key: string]: JsonValue };
92
+ }
93
+
94
+ /** A ggui render recognised on a tool result: its resource uri + mount material. */
95
+ export interface GguiRenderDescriptor {
96
+ /** `uiData.resourceUri` — `ui://ggui/render/<sessionId>/<contractHash>`. */
97
+ resourceUri: string;
98
+ /** `uiData.sessionId`, when present. */
99
+ sessionId?: string;
100
+ /**
101
+ * The `_meta["ai.ggui/render"]` slice, when it reached us. Absent for a
102
+ * persisted history card and for any consumer folding without `fold.ts`'s
103
+ * `_meta` carriage — such a descriptor is recognised but NOT mountable.
104
+ */
105
+ bootstrap?: GguiRenderBootstrap;
106
+ }
107
+
108
+ /** A non-empty JSON string field. */
109
+ function isNonEmptyString(value: JsonValue | undefined): value is string {
110
+ return typeof value === "string" && value.length > 0;
111
+ }
112
+
113
+ /**
114
+ * Does this slice carry at least one MOUNT MODE discriminator?
115
+ *
116
+ * Mirrors `@ggui-ai/iframe-runtime`'s `validateMeta` (see
117
+ * `node_modules/@ggui-ai/iframe-runtime/dist/meta-parse.d.ts`) and the
118
+ * `McpAppAiGguiRenderMeta` doc comment it implements
119
+ * (`@ggui-ai/protocol/integrations/mcp-apps`): the runtime needs `runtimeUrl`
120
+ * PLUS one of live mode (`wsUrl` + `wsToken` together), `codeUrl`, or `kind` —
121
+ * without one of those three the iframe has nothing to mount.
122
+ */
123
+ function hasModeDiscriminator(slice: { [key: string]: JsonValue }): boolean {
124
+ if (isNonEmptyString(slice.wsUrl) && isNonEmptyString(slice.wsToken)) return true;
125
+ if (isNonEmptyString(slice.codeUrl)) return true;
126
+ if (isNonEmptyString(slice.kind)) return true;
127
+ return false;
128
+ }
129
+
130
+ /**
131
+ * A `_meta` container → the ggui render bootstrap, or `undefined`.
132
+ *
133
+ * Two hard requirements, both the runtime's own `validateMeta` enforces
134
+ * (`MALFORMED_BOOTSTRAP`): a non-empty `runtimeUrl`, AND at least one mode
135
+ * discriminator (see {@link hasModeDiscriminator}). A slice with `runtimeUrl`
136
+ * alone has a bundle to load but nothing for it to mount — the runtime would
137
+ * boot into a blank shell rather than a card, so this guard treats that shape
138
+ * as unmountable too and returns `undefined`.
139
+ */
140
+ export function asGguiRenderBootstrap(meta: JsonValue | undefined): GguiRenderBootstrap | undefined {
141
+ if (!isJsonObject(meta)) return undefined;
142
+ const slice = meta[GGUI_RENDER_META_KEY];
143
+ if (!isJsonObject(slice)) return undefined;
144
+ const runtimeUrl = slice.runtimeUrl;
145
+ if (typeof runtimeUrl !== "string" || runtimeUrl.length === 0) return undefined;
146
+ if (!hasModeDiscriminator(slice)) return undefined;
147
+ return { runtimeUrl, slice };
148
+ }
149
+
150
+ /**
151
+ * A tool result's `uiData` (+ its `_meta`, when carried) → a ggui render
152
+ * descriptor, or `undefined` for anything that is not one.
153
+ *
154
+ * The `ui://` scheme gate is deliberate: `uiData` is a general-purpose channel
155
+ * (every `structuredContent` of a `_meta.ui`-stamped tool lands there), so a
156
+ * bare `resourceUri` string is not on its own a claim of generative UI.
157
+ */
158
+ export function asGguiRender(
159
+ uiData: JsonValue | undefined,
160
+ meta: JsonValue | undefined,
161
+ ): GguiRenderDescriptor | undefined {
162
+ if (!isJsonObject(uiData)) return undefined;
163
+ const resourceUri = uiData.resourceUri;
164
+ if (typeof resourceUri !== "string" || !resourceUri.startsWith(UI_SCHEME)) return undefined;
165
+ const bootstrap = asGguiRenderBootstrap(meta);
166
+ return {
167
+ resourceUri,
168
+ ...(typeof uiData.sessionId === "string" ? { sessionId: uiData.sessionId } : {}),
169
+ ...(bootstrap ? { bootstrap } : {}),
170
+ };
171
+ }
172
+
173
+ /** A live `tool-result` AgBlock → its ggui render descriptor, if it is one. */
174
+ export function toolResultGguiRender(
175
+ block: Extract<AgBlock, { type: "tool-result" }>,
176
+ ): GguiRenderDescriptor | undefined {
177
+ return asGguiRender(block.uiData, block._meta);
178
+ }
179
+
180
+ /** An untyped (persisted-snapshot) block → its ggui render descriptor, if it is one. */
181
+ export function blockGguiRender(block: JsonValue): GguiRenderDescriptor | undefined {
182
+ if (!isJsonObject(block)) return undefined;
183
+ if (block.type !== "tool-result") return undefined;
184
+ return asGguiRender(block.uiData, block._meta);
185
+ }
186
+
187
+ /**
188
+ * Embed a JSON value inside an inline `<script>` safely.
189
+ *
190
+ * `</script` inside a string literal terminates the element in the HTML
191
+ * parser regardless of JS quoting, and U+2028/U+2029 are line terminators in
192
+ * JS source but not in JSON — both are escaped at the `<`/codepoint level so
193
+ * the emitted text is still exactly the same JSON value.
194
+ */
195
+ function inlineJson(value: JsonValue): string {
196
+ return JSON.stringify(value)
197
+ .replace(/</g, "\\u003c")
198
+ .replace(/\u2028/g, "\\u2028")
199
+ .replace(/\u2029/g, "\\u2029");
200
+ }
201
+
202
+ /** Escape a string for use inside a double-quoted HTML attribute. */
203
+ function attr(value: string): string {
204
+ return value
205
+ .replace(/&/g, "&amp;")
206
+ .replace(/"/g, "&quot;")
207
+ .replace(/</g, "&lt;")
208
+ .replace(/>/g, "&gt;");
209
+ }
210
+
211
+ /**
212
+ * The ggui **self-contained shell** for a render bootstrap — see this module's
213
+ * header for the contract it implements.
214
+ *
215
+ * Ordering is guaranteed twice over: the classic `<script>` runs during parse,
216
+ * and the runtime's `<script type="module">` is deferred by definition, so the
217
+ * global is always populated before the bundle evaluates.
218
+ */
219
+ export function gguiShellHtml(bootstrap: GguiRenderBootstrap): string {
220
+ const envelope = inlineJson({ [GGUI_RENDER_META_KEY]: bootstrap.slice });
221
+ return [
222
+ "<!doctype html>",
223
+ '<html lang="en">',
224
+ "<head>",
225
+ '<meta charset="utf-8">',
226
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
227
+ '<meta name="color-scheme" content="light dark">',
228
+ "<title>ggui card</title>",
229
+ "<style>html,body{margin:0;height:100%;background:transparent}</style>",
230
+ `<script>globalThis.__GGUI_META__=${envelope};</script>`,
231
+ `<script type="module" src="${attr(bootstrap.runtimeUrl)}"></script>`,
232
+ "</head>",
233
+ "<body></body>",
234
+ "</html>",
235
+ "",
236
+ ].join("\n");
237
+ }
238
+
239
+ /**
240
+ * A ggui render descriptor → the mountable resource the host's existing
241
+ * mcp-ui path already knows how to mount, or `undefined` when the descriptor
242
+ * carries no bootstrap (history cards, and any fold that dropped `_meta`).
243
+ *
244
+ * The `uri` is the render's REAL `resourceUri` — the shell is the payload, not
245
+ * a renaming of the resource.
246
+ *
247
+ * **On `_meta` being required to MOUNT (but never to RECOGNISE).** Recognition
248
+ * — "this tool result is a ggui card" — is keyed on `uiData.resourceUri` alone
249
+ * and never waits for anything (see {@link asGguiRender}); nothing in this
250
+ * package is blocked on an upstream change. Mounting is different, and the
251
+ * requirement is ggui's, not ours: its runtime rejects a slice without
252
+ * `runtimeUrl` AND without at least one mode discriminator (`wsUrl`+`wsToken`,
253
+ * `codeUrl`, or `kind`) as `MALFORMED_BOOTSTRAP` and renders nothing. `uiData`
254
+ * carries none of those fields — it has `sessionId`, `resourceUri`, `action`,
255
+ * `contractHash`, `blueprintId`, `variantKey`, `cache`, `nextStep`, and that is
256
+ * all. So a bootstrap-less descriptor could only ever produce a blank frame;
257
+ * returning `undefined` and letting the host show its own placeholder is the
258
+ * honest answer, not a deferral. `@silverprotocol/core`'s `Reducer` is what
259
+ * puts `_meta` on the block for a live turn, in-repo, today.
260
+ */
261
+ export function gguiRenderResource(
262
+ render: GguiRenderDescriptor,
263
+ ): McpUiResourcePayload | undefined {
264
+ if (!render.bootstrap) return undefined;
265
+ return {
266
+ uri: render.resourceUri,
267
+ mimeType: "text/html",
268
+ text: gguiShellHtml(render.bootstrap),
269
+ };
270
+ }
package/src/history.ts CHANGED
@@ -33,6 +33,22 @@ interface ThreadMessagesResponse {
33
33
  nextToken: string | null;
34
34
  }
35
35
 
36
+ /**
37
+ * Thrown when the read plane returns 401 on a transcript fetch — distinct
38
+ * from the generic non-OK throw below so a caller holding a token can
39
+ * `instanceof`-match it and retry once with a freshly-refreshed one
40
+ * (`createWebAdapters`'s history adapter is the concrete retry). A cached
41
+ * bearer that has expired since the caller last checked it is exactly the
42
+ * shape a refresh can fix; a 500 or a malformed request is not, and gets no
43
+ * such retry.
44
+ */
45
+ export class HistoryUnauthorizedError extends Error {
46
+ constructor(message = "history load failed: 401") {
47
+ super(message);
48
+ this.name = "HistoryUnauthorizedError";
49
+ }
50
+ }
51
+
36
52
  /** Rows requested per history page. */
37
53
  const HISTORY_PAGE_LIMIT = 100;
38
54
 
@@ -112,6 +128,7 @@ export async function fetchThreadHistory({
112
128
  (nextToken ? `&nextToken=${encodeURIComponent(nextToken)}` : "");
113
129
  const res = await fetchImpl(url, requestInit);
114
130
  if (res.status === 403 || res.status === 404) return { gone: true };
131
+ if (res.status === 401) throw new HistoryUnauthorizedError();
115
132
  if (!res.ok) throw new Error(`history load failed: ${res.status}`);
116
133
  const body: ThreadMessagesResponse = await res.json();
117
134
  rows.push(...body.rows);