@guuey/agent-client 0.3.1 → 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,44 +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 ResolvedViewMount } from "@guuey/mcp-apps-host";
10
- import type { AgentInvokeAdapters, InvokeRequest, ThreadIdStore } from "./types";
11
- /**
12
- * Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
13
- * SSE stream opens). Carries the pod's structured `{ code, message }` when
14
- * present — e.g. a `QUOTA_EXCEEDED` 429 whose message ("…reached its plan
15
- * generation limit…") the chat UI should surface — falling back to the bare
16
- * status for non-JSON failures.
17
- */
18
- export declare class AgentResponseError extends Error {
19
- readonly status: number;
20
- readonly code?: string | undefined;
21
- constructor(message: string, status: number, code?: string | undefined);
22
- }
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";
23
12
  /** Persists the threadId in `window.localStorage` (synchronously). */
24
13
  export declare const localStorageThreadStore: ThreadIdStore;
25
14
  /** Crypto-strong client-message id, with a non-crypto fallback. */
26
15
  export declare function webGenerateId(): string;
27
16
  /**
28
- * Web SSE transport. Exactly ONE identity carrier per request, in order:
29
- *
30
- * 1. `accessToken` `Authorization: Bearer` the pod identifies the caller
31
- * by their verified access token (the same identity the history read
32
- * plane uses, so persisted threads round-trip on reload).
33
- * 2. a well-formed `guestSecret` `x-guuey-guest` the caller owns and
34
- * persists its own anonymous secret. The path for hosts with no usable
35
- * cookie jar: React-Native, and the embedded widget, whose third-party
36
- * iframe cannot rely on the pod's cookie surviving browser partitioning.
37
- * The pod never mints a cookie for a header client.
38
- * 3. neither → `credentials: "include"`, which round-trips the HttpOnly
39
- * `guuey_guest` cookie the pod mints for anonymous browser callers.
40
- *
41
- * Never two at once: a bearer wins over a guest secret, and a request that
42
- * carries either header does NOT also send cookie credentials.
43
- *
44
- * 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.
45
23
  */
46
- 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>;
47
25
  export interface CreateWebAdaptersOptions {
48
26
  /**
49
27
  * Public read-plane base (ending in `/v1`) for transcript history. When
@@ -151,4 +129,32 @@ export interface CreateUiResourceReaderOptions {
151
129
  * renders the host's placeholder, never an error surface.
152
130
  */
153
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
+ }
147
+ /**
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.
158
+ */
159
+ export declare function createUiActionRelay(options: CreateUiActionRelayOptions): (request: UiActionRequest) => Promise<McpToolCallResult>;
154
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,EAGL,KAAK,iBAAiB,EACvB,MAAM,sBAAsB,CAAC;AAC9B,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;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,6BAA6B,GACrC,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAoDjE"}
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"}
@@ -6,25 +6,10 @@
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 { createMcpUiResourceReader, } from "@guuey/mcp-apps-host";
10
- import { fetchThreadHistory, HistoryUnauthorizedError } from "./history";
11
- /**
12
- * Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
13
- * SSE stream opens). Carries the pod's structured `{ code, message }` when
14
- * present — e.g. a `QUOTA_EXCEEDED` 429 whose message ("…reached its plan
15
- * generation limit…") the chat UI should surface — falling back to the bare
16
- * status for non-JSON failures.
17
- */
18
- export class AgentResponseError extends Error {
19
- status;
20
- code;
21
- constructor(message, status, code) {
22
- super(message);
23
- this.status = status;
24
- this.code = code;
25
- this.name = "AgentResponseError";
26
- }
27
- }
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";
28
13
  /** Persists the threadId in `window.localStorage` (synchronously). */
29
14
  export const localStorageThreadStore = {
30
15
  load(key) {
@@ -91,7 +76,11 @@ function sendableGuestSecret(secret) {
91
76
  return typeof secret === "string" && GUEST_SECRET_RE.test(secret) ? secret : null;
92
77
  }
93
78
  /**
94
- * 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:
95
84
  *
96
85
  * 1. `accessToken` → `Authorization: Bearer` — the pod identifies the caller
97
86
  * by their verified access token (the same identity the history read
@@ -109,7 +98,7 @@ function sendableGuestSecret(secret) {
109
98
  *
110
99
  * Reads the body via `ReadableStream.getReader()` (browser).
111
100
  */
112
- export async function* fetchStreamTransport(req, accessToken, guestSecret) {
101
+ async function* streamInvokeOnce(req, accessToken, guestSecret) {
113
102
  const headers = {
114
103
  "Content-Type": "application/json",
115
104
  Accept: "text/event-stream",
@@ -146,7 +135,7 @@ export async function* fetchStreamTransport(req, accessToken, guestSecret) {
146
135
  code = body.code;
147
136
  }
148
137
  }
149
- throw new AgentResponseError(message, resp.status, code);
138
+ throw new AgentResponseError(message, resp.status, code, parseRetryAfterSeconds(resp.headers.get("Retry-After")));
150
139
  }
151
140
  const reader = resp.body.getReader();
152
141
  const decoder = new TextDecoder();
@@ -157,6 +146,17 @@ export async function* fetchStreamTransport(req, accessToken, guestSecret) {
157
146
  yield decoder.decode(value, { stream: true });
158
147
  }
159
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
+ }
160
160
  /**
161
161
  * Build the web host-adapter bundle for {@link useAgentInvoke}. Pass an
162
162
  * access-token resolver and/or a guest-secret resolver (plus the read-plane
@@ -310,3 +310,64 @@ export function createUiResourceReader(options) {
310
310
  };
311
311
  return createMcpUiResourceReader({ readResource });
312
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 });
373
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guuey/agent-client",
3
- "version": "0.3.1",
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",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@silverprotocol/core": "0.4.1",
32
- "@guuey/mcp-apps-host": "0.3.1"
32
+ "@guuey/mcp-apps-host": "0.4.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "react": ">=18"
@@ -56,14 +56,14 @@
56
56
  "client"
57
57
  ],
58
58
  "homepage": "https://guuey.com",
59
- "bugs": {
60
- "url": "https://github.com/loqu-co/guuey/issues"
61
- },
62
59
  "repository": {
63
60
  "type": "git",
64
61
  "url": "git+https://github.com/withguuey/guuey-sdks.git",
65
62
  "directory": "packages/agent-client"
66
63
  },
64
+ "bugs": {
65
+ "url": "https://github.com/withguuey/guuey-sdks/issues"
66
+ },
67
67
  "scripts": {
68
68
  "build": "tsc -p tsconfig.build.json",
69
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,18 +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
16
  createWebAdapters,
15
17
  localStorageThreadStore,
16
18
  webGenerateId,
17
19
  fetchStreamTransport,
18
- AgentResponseError,
19
20
  type CreateWebAdaptersOptions,
20
- } 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";
21
37
  export {
22
38
  fetchThreadHistory,
23
39
  threadHistoryRowsToMessages,
@@ -25,13 +41,13 @@ export {
25
41
  HistoryUnauthorizedError,
26
42
  type ThreadHistoryRow,
27
43
  type ThreadHistoryFetchOptions,
28
- } from "./history";
29
- export { ingestMessageFrame } from "./blocks";
44
+ } from "./history.js";
45
+ export { ingestMessageFrame } from "./blocks.js";
30
46
  // Pure block-walk / resource-narrowing helpers for a block-preserving renderer
31
47
  // (shared by Studio's `AgentBlocks` and Portal-web's agent chat). React-free.
32
48
  // Transcript labeling/ordering helpers (mount narrowing itself moved to
33
49
  // @guuey/mcp-apps-host — the SEP-1865 Host role package; import it directly).
34
- export { sortHistoryCards, toolNameFor } from "./history";
50
+ export { sortHistoryCards, toolNameFor } from "./history.js";
35
51
  // Re-export the AgJSON types the block-preserving transcript surfaces, so
36
52
  // consumers can name `reduceResult` / block types without a direct
37
53
  // `@silverprotocol/core` import.
@@ -51,4 +67,4 @@ export type {
51
67
  HistoryLoadResult,
52
68
  UseAgentInvokeOptions,
53
69
  UseAgentInvokeReturn,
54
- } 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.