@guuey/agent-client 0.3.0 → 0.4.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.
@@ -2,7 +2,7 @@
2
2
  * useAgentInvoke — the base-platform chat client.
3
3
  *
4
4
  * Speaks the nocode-runtime pod's Bedrock-style SSE contract (NOT the parked
5
- * ggui generative-UI protocol that `@ggui-ai/react`'s useInvoke targets):
5
+ * ggui generative-UI protocol that `@ggui-ai/mcp-apps-react`'s useInvoke targets):
6
6
  *
7
7
  * POST {endpointUrl}/agent/invoke
8
8
  * body: { input, threadId?, clientMessageId }
@@ -23,8 +23,9 @@
23
23
  */
24
24
  import { useCallback, useEffect, useRef, useState } from "react";
25
25
  import { Reducer } from "@silverprotocol/core";
26
- import { parseConsentRequest, parseLinkRequest, parseSseEvents, reduceAssistantText, stringField, } from "./sse";
27
- import { ingestMessageFrame } from "./blocks";
26
+ import { parseConsentRequest, parseLinkRequest, parseSseEvents, reduceAssistantText, stringField, } from "./sse.js";
27
+ import { ingestMessageFrame } from "./blocks.js";
28
+ import { AgentResponseError } from "./errors.js";
28
29
  function threadStorageKey(appId) {
29
30
  return `guuey:thread:${appId ?? "default"}`;
30
31
  }
@@ -51,6 +52,10 @@ export function useAgentInvoke(opts) {
51
52
  const [status, setStatus] = useState("ready");
52
53
  const [activeTool, setActiveTool] = useState(null);
53
54
  const [error, setError] = useState(null);
55
+ // The pod's wire code for whatever put `error` there, when the failure
56
+ // carried one (see the return-type contract). Moves in lockstep with
57
+ // `error` — every set/clear of one touches the other.
58
+ const [errorCode, setErrorCode] = useState(null);
54
59
  const [threadId, setThreadId] = useState(null);
55
60
  // Opt-in block-preserving transcript. `reduceResult` follows the
56
61
  // null-until-first-valid-AgEvent contract documented on the return type: it
@@ -102,6 +107,7 @@ export function useAgentInvoke(opts) {
102
107
  setThreadId(null);
103
108
  setMessages([]);
104
109
  setError(null);
110
+ setErrorCode(null);
105
111
  setStatus("ready");
106
112
  setActiveTool(null);
107
113
  // Fresh conversation → drop the old fold; the reducer is rebuilt lazily on
@@ -192,6 +198,7 @@ export function useAgentInvoke(opts) {
192
198
  void adaptersRef.current.storage.save(threadStorageKey(appId), "");
193
199
  setMessages([]);
194
200
  setError(null);
201
+ setErrorCode(null);
195
202
  setStatus("ready");
196
203
  setActiveTool(null);
197
204
  // Re-create the reducer for the new conversation (rebuilt lazily on the
@@ -212,6 +219,7 @@ export function useAgentInvoke(opts) {
212
219
  if (!endpointUrl || !input.trim() || status !== "ready")
213
220
  return;
214
221
  setError(null);
222
+ setErrorCode(null);
215
223
  setStatus("connecting");
216
224
  setMessages((prev) => [...prev, { role: "user", text: input }, { role: "assistant", text: "" }]);
217
225
  const controller = new AbortController();
@@ -305,7 +313,12 @@ export function useAgentInvoke(opts) {
305
313
  }
306
314
  }
307
315
  else if (ev.event === "error") {
316
+ // In-band failure frame — one of the two channels that carry the
317
+ // pod's wire code (the other is the pre-stream refusal caught
318
+ // below). A frame without a `code` clears it rather than leaving
319
+ // a previous turn's code standing beside a new message.
308
320
  setError(stringField(ev.data, "message") ?? "agent error");
321
+ setErrorCode(stringField(ev.data, "code") ?? null);
309
322
  }
310
323
  else if (ev.event === "profile-consent-needed") {
311
324
  // Cross-app profile consent ask (T6). Only a well-formed payload
@@ -331,6 +344,12 @@ export function useAgentInvoke(opts) {
331
344
  catch (e) {
332
345
  if (!controller.signal.aborted) {
333
346
  setError(e instanceof Error ? e.message : "failed to reach agent");
347
+ // Pre-stream refusals arrive as a thrown AgentResponseError carrying
348
+ // the pod's structured code (a transport-level saturation retry has
349
+ // already happened and failed by the time one surfaces here). Any
350
+ // other throw — a network drop, a host-adapter failure — has no wire
351
+ // code, so the field stays null beside the message.
352
+ setErrorCode(e instanceof AgentResponseError ? (e.code ?? null) : null);
334
353
  }
335
354
  }
336
355
  finally {
@@ -356,6 +375,7 @@ export function useAgentInvoke(opts) {
356
375
  status,
357
376
  activeTool,
358
377
  error,
378
+ errorCode,
359
379
  threadId,
360
380
  abort,
361
381
  reset,
@@ -6,43 +6,22 @@
6
6
  * functions — never at module load — so this file is import-safe under SSR
7
7
  * (the functions guard on `typeof window`).
8
8
  */
9
- import type { AgentInvokeAdapters, InvokeRequest, ThreadIdStore } from "./types";
10
- /**
11
- * Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
12
- * SSE stream opens). Carries the pod's structured `{ code, message }` when
13
- * present — e.g. a `QUOTA_EXCEEDED` 429 whose message ("…reached its plan
14
- * generation limit…") the chat UI should surface — falling back to the bare
15
- * status for non-JSON failures.
16
- */
17
- export declare class AgentResponseError extends Error {
18
- readonly status: number;
19
- readonly code?: string | undefined;
20
- constructor(message: string, status: number, code?: string | undefined);
21
- }
9
+ import { type McpToolCallResult, type ResolvedViewMount, type UiActionRequest } from "@guuey/mcp-apps-host";
10
+ import type { AgentInvokeAdapters, InvokeRequest, ThreadIdStore } from "./types.js";
11
+ import { type SaturationRetryOptions } from "./saturation-retry.js";
22
12
  /** Persists the threadId in `window.localStorage` (synchronously). */
23
13
  export declare const localStorageThreadStore: ThreadIdStore;
24
14
  /** Crypto-strong client-message id, with a non-crypto fallback. */
25
15
  export declare function webGenerateId(): string;
26
16
  /**
27
- * Web SSE transport. Exactly ONE identity carrier per request, in order:
28
- *
29
- * 1. `accessToken` `Authorization: Bearer` the pod identifies the caller
30
- * by their verified access token (the same identity the history read
31
- * plane uses, so persisted threads round-trip on reload).
32
- * 2. a well-formed `guestSecret` `x-guuey-guest` the caller owns and
33
- * persists its own anonymous secret. The path for hosts with no usable
34
- * cookie jar: React-Native, and the embedded widget, whose third-party
35
- * iframe cannot rely on the pod's cookie surviving browser partitioning.
36
- * The pod never mints a cookie for a header client.
37
- * 3. neither → `credentials: "include"`, which round-trips the HttpOnly
38
- * `guuey_guest` cookie the pod mints for anonymous browser callers.
39
- *
40
- * Never two at once: a bearer wins over a guest secret, and a request that
41
- * carries either header does NOT also send cookie credentials.
42
- *
43
- * Reads the body via `ReadableStream.getReader()` (browser).
17
+ * The web SSE transport: {@link streamInvokeOnce} under the shared
18
+ * {@link withSaturationRetry} wrapper. Every consumer of this transport
19
+ * (Studio, the widget, anything built on {@link createWebAdapters}) therefore
20
+ * inherits the single `POD_SATURATED` retry, and inherits the SAME one Portal's
21
+ * React-Native transport wears see that wrapper's docblock for which refusals
22
+ * retry, which deliberately do not, and why the retry is invisible to the hook.
44
23
  */
45
- export declare function fetchStreamTransport(req: InvokeRequest, accessToken?: string | null, guestSecret?: string | null): AsyncGenerator<string>;
24
+ export declare function fetchStreamTransport(req: InvokeRequest, accessToken?: string | null, guestSecret?: string | null, options?: SaturationRetryOptions): AsyncIterable<string>;
46
25
  export interface CreateWebAdaptersOptions {
47
26
  /**
48
27
  * Public read-plane base (ending in `/v1`) for transcript history. When
@@ -136,36 +115,46 @@ export interface CreateUiResourceReaderOptions {
136
115
  fetchImpl?: typeof fetch;
137
116
  }
138
117
  /**
139
- * Build a `@guuey/mcp-apps-host` `UiResourceReader` over guuey's
140
- * authenticated resources/read proxy (guuey#122 Gap 1:
141
- * `GET /v1/threads/:threadId/ui-resource?uri=…`).
118
+ * Build a `UiResourceReader` over guuey's authenticated resources/read proxy
119
+ * (guuey#122 Gap 1: `GET /v1/threads/:threadId/ui-resource?uri=…`).
142
120
  *
121
+ * This is `@guuey/mcp-apps-host`'s `createMcpUiResourceReader` assembly over
122
+ * a guuey-platform transport (guuey#127) — channel resolution and payload
123
+ * narrowing live in the host package; only the transport is guuey-shaped.
143
124
  * The proxy owns EVERYTHING trust-shaped: caller identity (the same three
144
125
  * families as the history read), thread ownership, the locator-to-thread
145
- * scope guard, and the per-user federation mint. This reader only carries
126
+ * scope guard, and the per-user federation mint. This transport only carries
146
127
  * the surface's existing credential and maps EVERY non-OK — 401/403/404/502
147
128
  * alike — to `undefined`: deny is byte-identical to a miss, and a miss
148
129
  * renders the host's placeholder, never an error surface.
149
- *
150
- * Channel resolution is the phase-1 heuristic: a `ui://ggui/…` uri mounts in
151
- * the ggui-CSP sandbox page (`channel: "ggui"`), anything else in the
152
- * self-only page (`channel: "inline"`). Phase 2 (per-resource declared-CSP
153
- * construction from the response `_meta.ui.csp`) retires the heuristic —
154
- * conformance map, retirement step 3.
155
130
  */
131
+ export declare function createUiResourceReader(options: CreateUiResourceReaderOptions): (resourceUri: string) => Promise<ResolvedViewMount | undefined>;
132
+ /** Options for {@link createUiActionRelay} — same credential surface as the reader. */
133
+ export interface CreateUiActionRelayOptions {
134
+ /** The guuey public API base (`…/v1`). */
135
+ apiBaseUrl: string;
136
+ /** The thread whose persisted cards this relay may act for. */
137
+ threadId: string;
138
+ /** Signed-in bearer — wins over the guest secret (same rule as the transport). */
139
+ getAccessToken?: (opts?: {
140
+ forceRefresh?: boolean;
141
+ }) => Promise<string | null>;
142
+ /** Caller-owned anonymous guest secret (widget / guest chat). */
143
+ guestSecret?: string | null;
144
+ /** Injectable for tests. */
145
+ fetchImpl?: typeof fetch;
146
+ }
156
147
  /**
157
- * The mount the reader resolves structurally assignable to
158
- * `@guuey/mcp-apps-host`'s `ViewMount` mountable arms (typed inline so this
159
- * package carries no dependency on the host package; hosts that consume
160
- * both see one structural type).
148
+ * Build the card action relay over guuey's authenticated `tools/call` proxy
149
+ * (guuey#158: `POST /v1/threads/:threadId/ui-action`) the mirror of
150
+ * {@link createUiResourceReader}. Allowlisting, arm narrowing, and the
151
+ * never-reject contract live in `@guuey/mcp-apps-host`'s
152
+ * `createMcpUiActionRelay`; only the transport is guuey-shaped. The proxy
153
+ * owns EVERYTHING trust-shaped (identity, thread ownership, the
154
+ * locator-to-thread guard, its own server-side allowlist, the per-user
155
+ * federation mint) — and every non-OK here collapses to `undefined`, which
156
+ * the host relay answers in-band as an `isError` result, never a thrown
157
+ * error into the sandbox bridge.
161
158
  */
162
- export interface ResolvedUiResourceMount {
163
- channel: "inline" | "ggui";
164
- resource: {
165
- uri: string;
166
- mimeType?: string;
167
- text: string;
168
- };
169
- }
170
- export declare function createUiResourceReader(options: CreateUiResourceReaderOptions): (resourceUri: string) => Promise<ResolvedUiResourceMount | undefined>;
159
+ export declare function createUiActionRelay(options: CreateUiActionRelayOptions): (request: UiActionRequest) => Promise<McpToolCallResult>;
171
160
  //# sourceMappingURL=web-adapters.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"web-adapters.d.ts","sourceRoot":"","sources":["../src/web-adapters.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EACV,mBAAmB,EACnB,aAAa,EAEb,aAAa,EACd,MAAM,SAAS,CAAC;AAGjB;;;;;;GAMG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAGzC,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM;gBAFtB,OAAO,EAAE,MAAM,EACN,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM,YAAA;CAKzB;AAED,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,EAAE,aAiBrC,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,aAAa,IAAI,MAAM,CAKtC;AAwCD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAuB,oBAAoB,CACzC,GAAG,EAAE,aAAa,EAClB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,EAC3B,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,GAC1B,cAAc,CAAC,MAAM,CAAC,CA4CxB;AAED,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;;;;;;;OAiBG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuCG;IACH,cAAc,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,GAAE,wBAA6B,GAClC,mBAAmB,CAwErB;AAED,mGAAmG;AACnG,MAAM,WAAW,6BAA6B;IAC5C,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC3B,QAAQ,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;CAC5D;AAED,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,6BAA6B,GACrC,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,uBAAuB,GAAG,SAAS,CAAC,CAkDvE"}
1
+ {"version":3,"file":"web-adapters.d.ts","sourceRoot":"","sources":["../src/web-adapters.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAIL,KAAK,iBAAiB,EAEtB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EACV,mBAAmB,EACnB,aAAa,EAEb,aAAa,EACd,MAAM,YAAY,CAAC;AAGpB,OAAO,EAGL,KAAK,sBAAsB,EAC5B,MAAM,uBAAuB,CAAC;AAE/B,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,EAAE,aAiBrC,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,aAAa,IAAI,MAAM,CAKtC;AAsHD;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,aAAa,EAClB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,EAC3B,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,EAC3B,OAAO,GAAE,sBAA2B,GACnC,aAAa,CAAC,MAAM,CAAC,CAKvB;AAED,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;;;;;;;OAiBG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuCG;IACH,cAAc,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,GAAE,wBAA6B,GAClC,mBAAmB,CAwErB;AAED,mGAAmG;AACnG,MAAM,WAAW,6BAA6B;IAC5C,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,6BAA6B,GACrC,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAoDjE;AAED,uFAAuF;AACvF,MAAM,WAAW,0BAA0B;IACzC,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,0BAA0B,GAClC,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,iBAAiB,CAAC,CA+C1D"}
@@ -1,21 +1,15 @@
1
- import { fetchThreadHistory, HistoryUnauthorizedError } from "./history";
2
1
  /**
3
- * Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
4
- * SSE stream opens). Carries the pod's structured `{ code, message }` when
5
- * present e.g. a `QUOTA_EXCEEDED` 429 whose message ("…reached its plan
6
- * generation limit…") the chat UI should surface — falling back to the bare
7
- * status for non-JSON failures.
2
+ * Web (browser / Next.js) host adapters for {@link useAgentInvoke}.
3
+ *
4
+ * Studio builds its bundle via {@link createWebAdapters}. The implementations
5
+ * touch `window.localStorage`, `crypto`, and `fetch` only inside their
6
+ * functions never at module load — so this file is import-safe under SSR
7
+ * (the functions guard on `typeof window`).
8
8
  */
9
- export class AgentResponseError extends Error {
10
- status;
11
- code;
12
- constructor(message, status, code) {
13
- super(message);
14
- this.status = status;
15
- this.code = code;
16
- this.name = "AgentResponseError";
17
- }
18
- }
9
+ import { createMcpUiActionRelay, createMcpUiResourceReader, } from "@guuey/mcp-apps-host";
10
+ import { fetchThreadHistory, HistoryUnauthorizedError } from "./history.js";
11
+ import { AgentResponseError } from "./errors.js";
12
+ import { parseRetryAfterSeconds, withSaturationRetry, } from "./saturation-retry.js";
19
13
  /** Persists the threadId in `window.localStorage` (synchronously). */
20
14
  export const localStorageThreadStore = {
21
15
  load(key) {
@@ -82,7 +76,11 @@ function sendableGuestSecret(secret) {
82
76
  return typeof secret === "string" && GUEST_SECRET_RE.test(secret) ? secret : null;
83
77
  }
84
78
  /**
85
- * Web SSE transport. Exactly ONE identity carrier per request, in order:
79
+ * One invoke attempt: opens the request and yields decoded SSE chunks.
80
+ * {@link fetchStreamTransport} wraps this with the shared saturation retry —
81
+ * every behaviour below is per-attempt.
82
+ *
83
+ * Exactly ONE identity carrier per request, in order:
86
84
  *
87
85
  * 1. `accessToken` → `Authorization: Bearer` — the pod identifies the caller
88
86
  * by their verified access token (the same identity the history read
@@ -100,7 +98,7 @@ function sendableGuestSecret(secret) {
100
98
  *
101
99
  * Reads the body via `ReadableStream.getReader()` (browser).
102
100
  */
103
- export async function* fetchStreamTransport(req, accessToken, guestSecret) {
101
+ async function* streamInvokeOnce(req, accessToken, guestSecret) {
104
102
  const headers = {
105
103
  "Content-Type": "application/json",
106
104
  Accept: "text/event-stream",
@@ -137,7 +135,7 @@ export async function* fetchStreamTransport(req, accessToken, guestSecret) {
137
135
  code = body.code;
138
136
  }
139
137
  }
140
- throw new AgentResponseError(message, resp.status, code);
138
+ throw new AgentResponseError(message, resp.status, code, parseRetryAfterSeconds(resp.headers.get("Retry-After")));
141
139
  }
142
140
  const reader = resp.body.getReader();
143
141
  const decoder = new TextDecoder();
@@ -148,6 +146,17 @@ export async function* fetchStreamTransport(req, accessToken, guestSecret) {
148
146
  yield decoder.decode(value, { stream: true });
149
147
  }
150
148
  }
149
+ /**
150
+ * The web SSE transport: {@link streamInvokeOnce} under the shared
151
+ * {@link withSaturationRetry} wrapper. Every consumer of this transport
152
+ * (Studio, the widget, anything built on {@link createWebAdapters}) therefore
153
+ * inherits the single `POD_SATURATED` retry, and inherits the SAME one Portal's
154
+ * React-Native transport wears — see that wrapper's docblock for which refusals
155
+ * retry, which deliberately do not, and why the retry is invisible to the hook.
156
+ */
157
+ export function fetchStreamTransport(req, accessToken, guestSecret, options = {}) {
158
+ return withSaturationRetry((attempt) => streamInvokeOnce(attempt, accessToken, guestSecret), options)(req);
159
+ }
151
160
  /**
152
161
  * Build the web host-adapter bundle for {@link useAgentInvoke}. Pass an
153
162
  * access-token resolver and/or a guest-secret resolver (plus the read-plane
@@ -227,9 +236,23 @@ export function createWebAdapters(opts = {}) {
227
236
  }
228
237
  return adapters;
229
238
  }
239
+ /**
240
+ * Build a `UiResourceReader` over guuey's authenticated resources/read proxy
241
+ * (guuey#122 Gap 1: `GET /v1/threads/:threadId/ui-resource?uri=…`).
242
+ *
243
+ * This is `@guuey/mcp-apps-host`'s `createMcpUiResourceReader` assembly over
244
+ * a guuey-platform transport (guuey#127) — channel resolution and payload
245
+ * narrowing live in the host package; only the transport is guuey-shaped.
246
+ * The proxy owns EVERYTHING trust-shaped: caller identity (the same three
247
+ * families as the history read), thread ownership, the locator-to-thread
248
+ * scope guard, and the per-user federation mint. This transport only carries
249
+ * the surface's existing credential and maps EVERY non-OK — 401/403/404/502
250
+ * alike — to `undefined`: deny is byte-identical to a miss, and a miss
251
+ * renders the host's placeholder, never an error surface.
252
+ */
230
253
  export function createUiResourceReader(options) {
231
254
  const fetchImpl = options.fetchImpl ?? fetch;
232
- return async (resourceUri) => {
255
+ const readResource = async (resourceUri) => {
233
256
  const headers = {};
234
257
  const token = options.getAccessToken ? await options.getAccessToken() : null;
235
258
  const guest = sendableGuestSecret(options.guestSecret);
@@ -272,15 +295,79 @@ export function createUiResourceReader(options) {
272
295
  catch {
273
296
  return undefined;
274
297
  }
275
- if (typeof body.uri !== "string" || typeof body.text !== "string")
298
+ // The proxy passes the blob arm through (a blob-only resource is not
299
+ // silently a miss — its route contract); mirror that here.
300
+ if (typeof body.uri !== "string")
301
+ return undefined;
302
+ if (typeof body.text !== "string" && typeof body.blob !== "string")
276
303
  return undefined;
277
304
  return {
278
- channel: resourceUri.startsWith("ui://ggui/") ? "ggui" : "inline",
279
- resource: {
280
- uri: body.uri,
281
- ...(typeof body.mimeType === "string" ? { mimeType: body.mimeType } : {}),
282
- text: body.text,
283
- },
305
+ uri: body.uri,
306
+ ...(typeof body.mimeType === "string" ? { mimeType: body.mimeType } : {}),
307
+ ...(typeof body.text === "string" ? { text: body.text } : {}),
308
+ ...(typeof body.blob === "string" ? { blob: body.blob } : {}),
284
309
  };
285
310
  };
311
+ return createMcpUiResourceReader({ readResource });
312
+ }
313
+ /**
314
+ * Build the card action relay over guuey's authenticated `tools/call` proxy
315
+ * (guuey#158: `POST /v1/threads/:threadId/ui-action`) — the mirror of
316
+ * {@link createUiResourceReader}. Allowlisting, arm narrowing, and the
317
+ * never-reject contract live in `@guuey/mcp-apps-host`'s
318
+ * `createMcpUiActionRelay`; only the transport is guuey-shaped. The proxy
319
+ * owns EVERYTHING trust-shaped (identity, thread ownership, the
320
+ * locator-to-thread guard, its own server-side allowlist, the per-user
321
+ * federation mint) — and every non-OK here collapses to `undefined`, which
322
+ * the host relay answers in-band as an `isError` result, never a thrown
323
+ * error into the sandbox bridge.
324
+ */
325
+ export function createUiActionRelay(options) {
326
+ const fetchImpl = options.fetchImpl ?? fetch;
327
+ const callTool = async (uri, name, args) => {
328
+ const headers = { "content-type": "application/json" };
329
+ const token = options.getAccessToken ? await options.getAccessToken() : null;
330
+ const guest = sendableGuestSecret(options.guestSecret);
331
+ if (token) {
332
+ headers["authorization"] = `Bearer ${token}`;
333
+ }
334
+ else if (guest) {
335
+ headers[GUEST_HEADER] = guest;
336
+ }
337
+ const requestUrl = `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-action`;
338
+ const body = JSON.stringify({ uri, name, ...(args !== undefined ? { arguments: args } : {}) });
339
+ let res;
340
+ try {
341
+ res = await fetchImpl(requestUrl, { method: "POST", headers, body });
342
+ }
343
+ catch {
344
+ return undefined; // transport failure — the host relay answers in-band
345
+ }
346
+ // One forceRefresh retry on 401 with a bearer in play — the same
347
+ // expired-but-refreshable recovery the reader performs.
348
+ if (res.status === 401 && options.getAccessToken) {
349
+ const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
350
+ if (fresh) {
351
+ try {
352
+ res = await fetchImpl(requestUrl, {
353
+ method: "POST",
354
+ headers: { ...headers, authorization: `Bearer ${fresh}` },
355
+ body,
356
+ });
357
+ }
358
+ catch {
359
+ return undefined;
360
+ }
361
+ }
362
+ }
363
+ if (!res.ok)
364
+ return undefined;
365
+ try {
366
+ return (await res.json());
367
+ }
368
+ catch {
369
+ return undefined;
370
+ }
371
+ };
372
+ return createMcpUiActionRelay({ callTool });
286
373
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guuey/agent-client",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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,8 @@
28
28
  }
29
29
  },
30
30
  "dependencies": {
31
- "@silverprotocol/core": "0.4.1"
31
+ "@silverprotocol/core": "0.4.1",
32
+ "@guuey/mcp-apps-host": "0.4.0"
32
33
  },
33
34
  "peerDependencies": {
34
35
  "react": ">=18"
@@ -55,14 +56,14 @@
55
56
  "client"
56
57
  ],
57
58
  "homepage": "https://guuey.com",
58
- "bugs": {
59
- "url": "https://github.com/loqu-co/guuey/issues"
60
- },
61
59
  "repository": {
62
60
  "type": "git",
63
61
  "url": "git+https://github.com/withguuey/guuey-sdks.git",
64
62
  "directory": "packages/agent-client"
65
63
  },
64
+ "bugs": {
65
+ "url": "https://github.com/withguuey/guuey-sdks/issues"
66
+ },
66
67
  "scripts": {
67
68
  "build": "tsc -p tsconfig.build.json",
68
69
  "dev": "tsc --watch",
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The pod's error-envelope wire codes, TRANSCRIBED.
3
+ *
4
+ * The source of truth is the runtime's own private module
5
+ * (`backend/services/nocode-runtime/src/error-codes.ts`). This package is
6
+ * published to npm and cannot take a `@guuey-private` dependency, so it keeps
7
+ * its own copy — the same arrangement as {@link GUEST_HEADER} in
8
+ * `./web-adapters.ts` and `@guuey/host`'s mirrored fs-contract constants. The
9
+ * copies are not trusted to prose alone: `agent-client-codes.sync.test.ts` in
10
+ * the runtime package (which can import BOTH) asserts they stay identical, so
11
+ * renaming a code on either side fails that test rather than silently breaking
12
+ * a client branch.
13
+ *
14
+ * WIRE CONTRACT (what these codes appear in):
15
+ *
16
+ * - a pre-stream refusal — `{ "code": …, "message": … }` with an HTTP status,
17
+ * parsed into {@link AgentResponseError};
18
+ * - an in-band failure — `event: error` / `data: { code, message }`, surfaced
19
+ * as `useAgentInvoke`'s `errorCode`.
20
+ *
21
+ * Both channels carry the SAME vocabulary, which is why one mirror serves them.
22
+ */
23
+ export const AGENT_ERROR_CODES = {
24
+ /** No usable identity on a surface that requires one. */
25
+ UNAUTHORIZED: "UNAUTHORIZED",
26
+ /** The invoke body did not parse / validate. */
27
+ INVALID_REQUEST: "INVALID_REQUEST",
28
+ /** The builder turned anonymous access off for this agent. */
29
+ GUEST_ACCESS_DISABLED: "GUEST_ACCESS_DISABLED",
30
+ /** The caller (or the app) is out of plan allowance — the upgrade prompt. */
31
+ QUOTA_EXCEEDED: "QUOTA_EXCEEDED",
32
+ /** The app hit its builder-set managed spend cap. */
33
+ MANAGED_SPEND_CAP: "MANAGED_SPEND_CAP",
34
+ /**
35
+ * The pod is at its concurrent-turn cap (scaling S1-F3). A 503 carrying a
36
+ * `Retry-After` hint, and the ONE code {@link fetchStreamTransport} retries
37
+ * by itself — see its docblock for the single-attempt rule.
38
+ */
39
+ POD_SATURATED: "POD_SATURATED",
40
+ /**
41
+ * The pod took SIGTERM and refuses NEW turns while in-flight ones finish.
42
+ * Also a 503 + `Retry-After`, but deliberately NOT auto-retried (the
43
+ * endpoint pull re-routes the next request; retrying into the same pod is
44
+ * the one thing guaranteed not to help).
45
+ */
46
+ DRAINING: "DRAINING",
47
+ /** Refused for this caller — e.g. the link-prompt dismiss route's byo-only rule. */
48
+ FORBIDDEN: "FORBIDDEN",
49
+ /** The turn ran past the pod's wall-clock budget. */
50
+ TIMEOUT: "TIMEOUT",
51
+ /** A guuey-side dependency failed (not the agent's own code). */
52
+ PLATFORM_ERROR: "PLATFORM_ERROR",
53
+ /** Unclassified pod failure. */
54
+ INTERNAL: "INTERNAL",
55
+ } as const;
56
+
57
+ /** One of the pod's wire codes — see {@link AGENT_ERROR_CODES}. */
58
+ export type AgentErrorCode = (typeof AGENT_ERROR_CODES)[keyof typeof AGENT_ERROR_CODES];
package/src/errors.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Error types the transports throw and the hook branches on.
3
+ *
4
+ * Its own module (rather than living in `./web-adapters.ts`) so `useAgentInvoke`
5
+ * — which must stay platform-agnostic — can `instanceof`-narrow a caught error
6
+ * without pulling the web adapter bundle (`fetch`, the history reader,
7
+ * `@guuey/mcp-apps-host`) into a React-Native build.
8
+ */
9
+
10
+ /**
11
+ * Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
12
+ * SSE stream opens). Carries the pod's structured `{ code, message }` when
13
+ * present — e.g. a `QUOTA_EXCEEDED` 429 whose message ("…reached its plan
14
+ * generation limit…") the chat UI should surface — falling back to the bare
15
+ * status for non-JSON failures. See `AGENT_ERROR_CODES` for the vocabulary.
16
+ */
17
+ export class AgentResponseError extends Error {
18
+ constructor(
19
+ message: string,
20
+ readonly status: number,
21
+ readonly code?: string,
22
+ /**
23
+ * The response's `Retry-After` hint in whole seconds, when it sent a
24
+ * parseable one. The pod attaches it to its two 503 refusals
25
+ * (`POD_SATURATED`, `DRAINING`) and exposes the header across origins via
26
+ * `Access-Control-Expose-Headers`, so a browser client can actually read
27
+ * it. `undefined` when the header was absent, malformed, or in the
28
+ * HTTP-date form the pod never emits.
29
+ */
30
+ readonly retryAfterSeconds?: number,
31
+ ) {
32
+ super(message);
33
+ this.name = "AgentResponseError";
34
+ }
35
+ }
package/src/history.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * has its own copy today and can migrate onto this later.
12
12
  */
13
13
  import type { AgMessage, JsonValue } from "@silverprotocol/core";
14
- import type { AgentMessage, HistoryCard, HistoryLoadResult } from "./types";
14
+ import type { AgentMessage, HistoryCard, HistoryLoadResult } from "./types.js";
15
15
 
16
16
  /** One row of `GET /v1/threads/:id/messages`. */
17
17
  export interface ThreadHistoryRow {
package/src/index.ts CHANGED
@@ -6,19 +6,34 @@ export {
6
6
  parseConsentRequest,
7
7
  parseLinkRequest,
8
8
  type ParsedSseEvent,
9
- } from "./sse";
10
- export { dismissLinkPrompt } from "./link-prompt";
9
+ } from "./sse.js";
10
+ export { dismissLinkPrompt } from "./link-prompt.js";
11
11
  export {
12
+ createUiActionRelay,
13
+ type CreateUiActionRelayOptions,
12
14
  createUiResourceReader,
13
15
  type CreateUiResourceReaderOptions,
14
- type ResolvedUiResourceMount,
15
16
  createWebAdapters,
16
17
  localStorageThreadStore,
17
18
  webGenerateId,
18
19
  fetchStreamTransport,
19
- AgentResponseError,
20
20
  type CreateWebAdaptersOptions,
21
- } from "./web-adapters";
21
+ } from "./web-adapters.js";
22
+ // The `POD_SATURATED` single-retry wrapper, transport-agnostic: a host that
23
+ // brings its own `fetch` (Portal's React-Native transport) wraps it to wear the
24
+ // same semantics as the web transport instead of hand-rolling a second copy.
25
+ // `parseRetryAfterSeconds` ships with it because filling
26
+ // `AgentResponseError.retryAfterSeconds` the same way is what makes the wrapper
27
+ // honour the pod's hint.
28
+ export {
29
+ withSaturationRetry,
30
+ parseRetryAfterSeconds,
31
+ type SaturationRetryOptions,
32
+ } from "./saturation-retry.js";
33
+ export { AgentResponseError } from "./errors.js";
34
+ // The pod's wire-code vocabulary, mirrored — branch on these instead of
35
+ // re-typing the string literals (see the module docblock for the sync guard).
36
+ export { AGENT_ERROR_CODES, type AgentErrorCode } from "./error-codes.js";
22
37
  export {
23
38
  fetchThreadHistory,
24
39
  threadHistoryRowsToMessages,
@@ -26,13 +41,13 @@ export {
26
41
  HistoryUnauthorizedError,
27
42
  type ThreadHistoryRow,
28
43
  type ThreadHistoryFetchOptions,
29
- } from "./history";
30
- export { ingestMessageFrame } from "./blocks";
44
+ } from "./history.js";
45
+ export { ingestMessageFrame } from "./blocks.js";
31
46
  // Pure block-walk / resource-narrowing helpers for a block-preserving renderer
32
47
  // (shared by Studio's `AgentBlocks` and Portal-web's agent chat). React-free.
33
48
  // Transcript labeling/ordering helpers (mount narrowing itself moved to
34
49
  // @guuey/mcp-apps-host — the SEP-1865 Host role package; import it directly).
35
- export { sortHistoryCards, toolNameFor } from "./history";
50
+ export { sortHistoryCards, toolNameFor } from "./history.js";
36
51
  // Re-export the AgJSON types the block-preserving transcript surfaces, so
37
52
  // consumers can name `reduceResult` / block types without a direct
38
53
  // `@silverprotocol/core` import.
@@ -52,4 +67,4 @@ export type {
52
67
  HistoryLoadResult,
53
68
  UseAgentInvokeOptions,
54
69
  UseAgentInvokeReturn,
55
- } from "./types";
70
+ } from "./types.js";
package/src/react.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * history reader, and the web adapters). Consumers that only need those never
7
7
  * import React at all; consumers that render chat import the hook from here.
8
8
  */
9
- export { useAgentInvoke, applyHistoryResult, type HistoryApplication } from "./useAgentInvoke";
9
+ export { useAgentInvoke, applyHistoryResult, type HistoryApplication } from "./useAgentInvoke.js";
10
10
  // The block-preserving transcript surfaces `AgReduceResult`; re-export it (and
11
11
  // `AgEvent`) here so `./react` consumers can type `reduceResult` without a
12
12
  // direct `@silverprotocol/core` import.