@guuey/agent-client 0.3.1 → 0.5.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.
Files changed (47) hide show
  1. package/README.md +54 -7
  2. package/dist/error-codes.d.ts +87 -0
  3. package/dist/error-codes.d.ts.map +1 -0
  4. package/dist/error-codes.js +82 -0
  5. package/dist/errors.d.ts +39 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/errors.js +36 -0
  8. package/dist/history.d.ts +1 -1
  9. package/dist/history.d.ts.map +1 -1
  10. package/dist/index.d.ts +13 -7
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +31 -6
  13. package/dist/invoke-turn.d.ts +101 -0
  14. package/dist/invoke-turn.d.ts.map +1 -0
  15. package/dist/invoke-turn.js +124 -0
  16. package/dist/react.d.ts +1 -1
  17. package/dist/react.d.ts.map +1 -1
  18. package/dist/react.js +1 -1
  19. package/dist/saturation-retry.d.ts +101 -0
  20. package/dist/saturation-retry.d.ts.map +1 -0
  21. package/dist/saturation-retry.js +207 -0
  22. package/dist/sse.d.ts +1 -1
  23. package/dist/sse.d.ts.map +1 -1
  24. package/dist/transport.d.ts +90 -0
  25. package/dist/transport.d.ts.map +1 -0
  26. package/dist/transport.js +189 -0
  27. package/dist/types.d.ts +101 -3
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/useAgentInvoke.d.ts +29 -1
  30. package/dist/useAgentInvoke.d.ts.map +1 -1
  31. package/dist/useAgentInvoke.js +297 -86
  32. package/dist/web-adapters.d.ts +30 -34
  33. package/dist/web-adapters.d.ts.map +1 -1
  34. package/dist/web-adapters.js +72 -123
  35. package/package.json +11 -5
  36. package/src/error-codes.ts +89 -0
  37. package/src/errors.ts +35 -0
  38. package/src/history.ts +1 -1
  39. package/src/index.ts +50 -10
  40. package/src/invoke-turn.ts +187 -0
  41. package/src/react.ts +7 -1
  42. package/src/saturation-retry.ts +247 -0
  43. package/src/sse.ts +1 -1
  44. package/src/transport.ts +260 -0
  45. package/src/types.ts +102 -3
  46. package/src/useAgentInvoke.ts +288 -86
  47. package/src/web-adapters.ts +92 -134
@@ -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,10 @@
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 { invokeTurn, toInvokeUrl } from "./invoke-turn.js";
27
+ import { AgentResponseError } from "./errors.js";
28
+ import { withActivityObserver } from "./transport.js";
29
+ import { CLIENT_ERROR_CODES } from "./error-codes.js";
28
30
  function threadStorageKey(appId) {
29
31
  return `guuey:thread:${appId ?? "default"}`;
30
32
  }
@@ -42,6 +44,50 @@ export function applyHistoryResult(result, currentMessages) {
42
44
  return { kind: "skip" };
43
45
  return { kind: "seed", messages: result.messages };
44
46
  }
47
+ /** The guuey#192 stall watchdog's resolved tuning (see {@link stallProbeDecision}). */
48
+ export const STALL_RECOVERY_DEFAULTS = { windowMs: 25_000, probeAttempts: 4 };
49
+ function resolveStallRecovery(option) {
50
+ if (option === false)
51
+ return null;
52
+ return {
53
+ windowMs: option?.windowMs ?? STALL_RECOVERY_DEFAULTS.windowMs,
54
+ probeAttempts: option?.probeAttempts ?? STALL_RECOVERY_DEFAULTS.probeAttempts,
55
+ };
56
+ }
57
+ /**
58
+ * Pure decision seam for the guuey#192 stall probe: does a freshly-loaded
59
+ * transcript already contain THIS turn's finished reply?
60
+ *
61
+ * `adopt` requires BOTH signals, because each alone lies in a real case:
62
+ *
63
+ * - **user-count**: history must hold at least as many user turns as the
64
+ * local transcript (which includes the just-sent optimistic one). Without
65
+ * it, a thread whose PREVIOUS turn ended in a completed assistant reply
66
+ * would adopt that OLD transcript and silently drop the in-flight turn.
67
+ * - **finished tail**: history's last message must be a non-empty assistant
68
+ * reply. Without it, a history read that caught the persisted user row
69
+ * before the assistant row would adopt a reply-less transcript.
70
+ *
71
+ * KNOWN LIMIT (documented, accepted): the runtime persists a turn's rows at
72
+ * completion — the guuey#192 evidence (a reload mid-stall renders the FULL
73
+ * reply) is only possible under that model, and the read plane carries no
74
+ * per-row clientMessageId to match against. If persistence ever becomes
75
+ * progressive (partial assistant rows), this heuristic needs the read plane
76
+ * to grow a turn-completion marker — do not "fix" it client-side by text
77
+ * comparison, which cannot distinguish a partial row from a finished one.
78
+ */
79
+ export function stallProbeDecision(history, localUserCount) {
80
+ let historyUserCount = 0;
81
+ for (const m of history)
82
+ if (m.role === "user")
83
+ historyUserCount += 1;
84
+ if (historyUserCount < localUserCount)
85
+ return "in-flight";
86
+ const last = history[history.length - 1];
87
+ if (!last || last.role !== "assistant" || last.text.trim() === "")
88
+ return "in-flight";
89
+ return "adopt";
90
+ }
45
91
  export function useAgentInvoke(opts) {
46
92
  const { endpointUrl, appId } = opts;
47
93
  const [messages, setMessages] = useState([]);
@@ -51,6 +97,10 @@ export function useAgentInvoke(opts) {
51
97
  const [status, setStatus] = useState("ready");
52
98
  const [activeTool, setActiveTool] = useState(null);
53
99
  const [error, setError] = useState(null);
100
+ // The pod's wire code for whatever put `error` there, when the failure
101
+ // carried one (see the return-type contract). Moves in lockstep with
102
+ // `error` — every set/clear of one touches the other.
103
+ const [errorCode, setErrorCode] = useState(null);
54
104
  const [threadId, setThreadId] = useState(null);
55
105
  // Opt-in block-preserving transcript. `reduceResult` follows the
56
106
  // null-until-first-valid-AgEvent contract documented on the return type: it
@@ -70,6 +120,13 @@ export function useAgentInvoke(opts) {
70
120
  // explicit dismiss, same lifecycle as `profileConsentRequest` — the two are
71
121
  // independent (an unlinked-invite vs an already-linked consent ask).
72
122
  const [profileLinkRequest, setProfileLinkRequest] = useState(null);
123
+ // The last turn's ending posture + the optimistic-send ledger — the
124
+ // transcript renderer's inputs (guuey#135 wave 3b; see the return-type
125
+ // contract for each). `aborted` is USER abort only — the #192 watchdog's
126
+ // internal stream abort never sets it.
127
+ const [aborted, setAborted] = useState(false);
128
+ const [adopted, setAdopted] = useState(false);
129
+ const [sendStates, setSendStates] = useState({});
73
130
  const abortRef = useRef(null);
74
131
  // Mirror the latest threadId + adapters into refs so `send` reads fresh
75
132
  // values without depending on them (keeps the callback identity stable and
@@ -77,6 +134,13 @@ export function useAgentInvoke(opts) {
77
134
  const threadIdRef = useRef(null);
78
135
  const adaptersRef = useRef(opts.adapters);
79
136
  adaptersRef.current = opts.adapters;
137
+ // The stall probe (guuey#192) needs the committed transcript's user-turn
138
+ // count long after `send`'s closures captured state — same render-time
139
+ // mirror idiom as `adaptersRef`.
140
+ const messagesRef = useRef(messages);
141
+ messagesRef.current = messages;
142
+ const stallRecoveryRef = useRef(opts.stallRecovery);
143
+ stallRecoveryRef.current = opts.stallRecovery;
80
144
  // The per-conversation AgJSON fold (only built when `preserveBlocks`).
81
145
  // Lazily (re)created on the first valid AgEvent after a fresh start / reset,
82
146
  // so an off run never constructs one and a bypass run never allocates.
@@ -102,6 +166,7 @@ export function useAgentInvoke(opts) {
102
166
  setThreadId(null);
103
167
  setMessages([]);
104
168
  setError(null);
169
+ setErrorCode(null);
105
170
  setStatus("ready");
106
171
  setActiveTool(null);
107
172
  // Fresh conversation → drop the old fold; the reducer is rebuilt lazily on
@@ -112,6 +177,9 @@ export function useAgentInvoke(opts) {
112
177
  // A prior app's consent ask must never leak into the new conversation.
113
178
  setProfileConsentRequest(null);
114
179
  setProfileLinkRequest(null);
180
+ setAborted(false);
181
+ setAdopted(false);
182
+ setSendStates({});
115
183
  let cancelled = false;
116
184
  const key = threadStorageKey(appId);
117
185
  const hydration = Promise.resolve(adaptersRef.current.storage.load(key))
@@ -192,6 +260,7 @@ export function useAgentInvoke(opts) {
192
260
  void adaptersRef.current.storage.save(threadStorageKey(appId), "");
193
261
  setMessages([]);
194
262
  setError(null);
263
+ setErrorCode(null);
195
264
  setStatus("ready");
196
265
  setActiveTool(null);
197
266
  // Re-create the reducer for the new conversation (rebuilt lazily on the
@@ -201,6 +270,9 @@ export function useAgentInvoke(opts) {
201
270
  setHistoryCards([]);
202
271
  setProfileConsentRequest(null);
203
272
  setProfileLinkRequest(null);
273
+ setAborted(false);
274
+ setAdopted(false);
275
+ setSendStates({});
204
276
  }, [appId]);
205
277
  const clearProfileConsentRequest = useCallback(() => {
206
278
  setProfileConsentRequest(null);
@@ -209,13 +281,49 @@ export function useAgentInvoke(opts) {
209
281
  setProfileLinkRequest(null);
210
282
  }, []);
211
283
  const send = useCallback(async (input) => {
212
- if (!endpointUrl || !input.trim() || status !== "ready")
284
+ // An already-aborted external signal refuses the send outright —
285
+ // before the optimistic transcript push, so nothing is left to undo.
286
+ if (!endpointUrl || !input.trim() || status !== "ready" || opts.signal?.aborted)
213
287
  return;
214
288
  setError(null);
289
+ setErrorCode(null);
290
+ setAborted(false);
291
+ setAdopted(false);
215
292
  setStatus("connecting");
216
- setMessages((prev) => [...prev, { role: "user", text: input }, { role: "assistant", text: "" }]);
293
+ // ONE id for the whole turn: the optimistic user entry, the send-state
294
+ // ledger, and the invoke body all carry it — the R0 lifecycle join.
295
+ const clientMessageId = adaptersRef.current.generateId();
296
+ /** Move this turn's ledger entry; `null` removes it (absent = sent). */
297
+ const markSend = (state) => {
298
+ setSendStates((prev) => {
299
+ if (state === null) {
300
+ if (!(clientMessageId in prev))
301
+ return prev;
302
+ const next = { ...prev };
303
+ delete next[clientMessageId];
304
+ return next;
305
+ }
306
+ if (prev[clientMessageId] === state)
307
+ return prev;
308
+ return { ...prev, [clientMessageId]: state };
309
+ });
310
+ };
311
+ markSend("sending");
312
+ let admitted = false;
313
+ setMessages((prev) => [
314
+ ...prev,
315
+ { role: "user", text: input, clientMessageId },
316
+ { role: "assistant", text: "" },
317
+ ]);
217
318
  const controller = new AbortController();
218
319
  abortRef.current = controller;
320
+ // Compose the host's external abort authority (opts.signal) with the
321
+ // per-turn controller: an external abort stops this turn exactly as
322
+ // `abort()` would. Listener removed in `finally` — the signal outlives
323
+ // the turn, the subscription must not.
324
+ const externalSignal = opts.signal;
325
+ const onExternalAbort = () => controller.abort();
326
+ externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
219
327
  const adapters = adaptersRef.current;
220
328
  // Wait for the persisted threadId to load before deciding whether to
221
329
  // replay it — otherwise a fast first send mints a new orphan thread and
@@ -238,102 +346,193 @@ export function useAgentInvoke(opts) {
238
346
  return next;
239
347
  });
240
348
  };
349
+ // ── guuey#192 stall watchdog ─────────────────────────────────────
350
+ // A half-dead connection (TCP alive, zero bytes, no error, no `done`)
351
+ // never resolves the read below, so a parallel clock watches byte
352
+ // activity: armed by the FIRST chunk (so a silent cold start never
353
+ // trips it), reset by every chunk, and on expiry it probes history
354
+ // WITHOUT touching the stream — killing a live-but-quiet stream on a
355
+ // timer would trade a frozen cursor for a lost turn. Only two things
356
+ // end the turn early: adoption (history already holds the finished
357
+ // reply — the reload the user would have done, minus the reload) and
358
+ // the bounded give-up (STREAM_STALLED after `probeAttempts` fruitless
359
+ // probes with still-zero bytes).
360
+ const stall = resolveStallRecovery(stallRecoveryRef.current);
361
+ let turnEnded = false;
362
+ let probeInFlight = false;
363
+ let fruitlessProbes = 0;
364
+ let activityCount = 0;
365
+ let stallTimer = null;
366
+ const clearStallTimer = () => {
367
+ if (stallTimer !== null) {
368
+ clearTimeout(stallTimer);
369
+ stallTimer = null;
370
+ }
371
+ };
372
+ const armStallTimer = () => {
373
+ if (!stall || turnEnded || controller.signal.aborted)
374
+ return;
375
+ clearStallTimer();
376
+ stallTimer = setTimeout(() => {
377
+ void onStallWindow();
378
+ }, stall.windowMs);
379
+ };
380
+ const endTurnWith = (apply) => {
381
+ turnEnded = true;
382
+ clearStallTimer();
383
+ apply();
384
+ // Unwinds the suspended read; the catch sees `aborted` and stays
385
+ // silent, so whatever `apply` decided IS the turn's outcome.
386
+ controller.abort();
387
+ };
388
+ const onStallWindow = async () => {
389
+ if (!stall || turnEnded || controller.signal.aborted || probeInFlight)
390
+ return;
391
+ const tid = threadIdRef.current;
392
+ const history = adaptersRef.current.history;
393
+ if (tid && history) {
394
+ probeInFlight = true;
395
+ const countAtProbe = activityCount;
396
+ let result = null;
397
+ try {
398
+ result = await history.load(tid);
399
+ }
400
+ catch {
401
+ result = null; // transient read failure = one fruitless probe
402
+ }
403
+ probeInFlight = false;
404
+ if (turnEnded || controller.signal.aborted)
405
+ return;
406
+ // Bytes resumed while the probe was in flight: the stream is alive
407
+ // — discard the now-stale read; the chunk observer already reset
408
+ // the count and re-armed the clock.
409
+ if (activityCount !== countAtProbe)
410
+ return;
411
+ if (result && !("gone" in result)) {
412
+ let localUserCount = 0;
413
+ for (const m of messagesRef.current)
414
+ if (m.role === "user")
415
+ localUserCount += 1;
416
+ if (stallProbeDecision(result.messages, localUserCount) === "adopt") {
417
+ const adoptedResult = result;
418
+ endTurnWith(() => {
419
+ setMessages(adoptedResult.messages);
420
+ if ("cards" in adoptedResult && adoptedResult.cards && adoptedResult.cards.length > 0) {
421
+ setHistoryCards(adoptedResult.cards);
422
+ }
423
+ // The renderer's #192 signal: calm renders the adopted turn
424
+ // identically; debug may mark it (guuey#135 3b).
425
+ setAdopted(true);
426
+ });
427
+ return;
428
+ }
429
+ }
430
+ }
431
+ // No probe possible (no threadId yet / no history adapter), a failed
432
+ // read, or history says the turn is still in flight — all count the
433
+ // same: one fruitless window.
434
+ fruitlessProbes += 1;
435
+ if (fruitlessProbes >= stall.probeAttempts) {
436
+ endTurnWith(() => {
437
+ setError("The response stream stalled and the finished reply was not found in history.");
438
+ setErrorCode(CLIENT_ERROR_CODES.STREAM_STALLED);
439
+ });
440
+ return;
441
+ }
442
+ armStallTimer();
443
+ };
444
+ const transport = stall
445
+ ? withActivityObserver(adapters.transport, () => {
446
+ activityCount += 1;
447
+ fruitlessProbes = 0;
448
+ armStallTimer();
449
+ })
450
+ : adapters.transport;
241
451
  try {
242
- // The endpointUrl may be a pod base (`https://host`) or the full
243
- // invoke URL the deploy-controller records (`https://host/agent/invoke`).
244
- // Normalize to exactly one `/agent/invoke`.
245
- const base = endpointUrl.replace(/\/+$/, "");
246
- const invokeUrl = base.endsWith("/agent/invoke") ? base : `${base}/agent/invoke`;
452
+ const invokeUrl = toInvokeUrl(endpointUrl);
247
453
  const body = {
248
454
  input,
249
455
  ...(threadIdRef.current ? { threadId: threadIdRef.current } : {}),
250
- clientMessageId: adapters.generateId(),
456
+ clientMessageId,
251
457
  };
252
- let buffer = "";
253
- for await (const chunk of adapters.transport({ url: invokeUrl, body, signal: controller.signal })) {
254
- buffer += chunk;
255
- const { events, rest } = parseSseEvents(buffer);
256
- buffer = rest;
257
- for (const ev of events) {
258
- if (ev.event === "session") {
259
- // The pod is awake and the turn is admitted 'connecting' ends
260
- // here (this frame arrives within ~1s of a warm pod; a cold
261
- // scale-to-zero start is exactly the long 'connecting' phase).
262
- setStatus("thinking");
263
- const tid = stringField(ev.data, "threadId");
264
- if (tid) {
265
- threadIdRef.current = tid;
266
- setThreadId(tid);
267
- void adapters.storage.save(threadStorageKey(appId), tid);
268
- }
269
- }
270
- else if (ev.event === "message") {
271
- // Status derivation (guuey#91) — read the frame's `type` before
272
- // the text fold. Silver frames announce tools + text explicitly;
273
- // bypass frames ('text' / 'assistant' SDKMessages) only ever
274
- // carry assistant text, so they map to 'responding'. Unknown
275
- // types deliberately leave the status untouched.
276
- const frameType = stringField(ev.data, "type");
277
- if (frameType === "tool.start") {
278
- setStatus("using-tool");
279
- setActiveTool(stringField(ev.data, "name") ?? null);
280
- }
281
- else if (frameType === "tool.done") {
282
- setStatus("thinking");
283
- setActiveTool(null);
284
- }
285
- else if (frameType === "text.start" ||
286
- frameType === "text.delta" ||
287
- frameType === "text" ||
288
- frameType === "assistant") {
289
- setStatus("responding");
290
- }
291
- renderAssistant(reduceAssistantText(assistantText, ev.data));
292
- // Additively fold the SAME frame into the AgJSON reducer when
293
- // opted in. The text surface above is untouched; only VALID
294
- // AgEvents advance the reducer (bypass frames ingest to [] and
295
- // leave `reduceResult` null — see the return-type contract).
296
- if (preserveBlocksRef.current) {
297
- const agEvents = ingestMessageFrame(ev.data);
298
- if (agEvents.length > 0) {
299
- if (!reducerRef.current)
300
- reducerRef.current = new Reducer();
301
- for (const agEvent of agEvents)
302
- reducerRef.current.push(agEvent);
303
- setReduceResult(reducerRef.current.result());
304
- }
305
- }
306
- }
307
- else if (ev.event === "error") {
308
- setError(stringField(ev.data, "message") ?? "agent error");
309
- }
310
- else if (ev.event === "profile-consent-needed") {
311
- // Cross-app profile consent ask (T6). Only a well-formed payload
312
- // updates state; a malformed one is dropped, leaving any prior
313
- // valid request untouched (never clobbered to null).
314
- const parsed = parseConsentRequest(ev.data);
315
- if (parsed)
316
- setProfileConsentRequest(parsed);
458
+ // The wire walk lives in `invokeTurn` (the pure per-turn generator —
459
+ // its docblock owns the switch semantics); this hook only maps each
460
+ // semantic event onto React state.
461
+ for await (const ev of invokeTurn({ url: invokeUrl, body, signal: controller.signal }, transport)) {
462
+ if (ev.kind === "session") {
463
+ // The pod is awake and the turn is admitted — 'connecting' ends
464
+ // here, and so does the R0 "sending" state (absent = sent).
465
+ admitted = true;
466
+ markSend(null);
467
+ setStatus("thinking");
468
+ if (ev.threadId) {
469
+ threadIdRef.current = ev.threadId;
470
+ setThreadId(ev.threadId);
471
+ void adapters.storage.save(threadStorageKey(appId), ev.threadId);
317
472
  }
318
- else if (ev.event === "profile-link-needed") {
319
- // Cross-app profile LINK invite (linkcoh T3) for an unlinked byo
320
- // caller. Same drop-if-malformed contract as consent above.
321
- const parsed = parseLinkRequest(ev.data);
322
- if (parsed)
323
- setProfileLinkRequest(parsed);
473
+ }
474
+ else if (ev.kind === "message") {
475
+ // Absent status/activeTool mean "no change" never touched, so
476
+ // an unknown frame type leaves both standing (guuey#91 rule).
477
+ if (ev.status !== undefined)
478
+ setStatus(ev.status);
479
+ if (ev.activeTool !== undefined)
480
+ setActiveTool(ev.activeTool);
481
+ renderAssistant(ev.assistantText);
482
+ // Additively fold the frame's AgEvents into the AgJSON reducer
483
+ // when opted in. The text surface above is untouched; only VALID
484
+ // AgEvents advance the reducer (bypass frames carry [] and leave
485
+ // `reduceResult` null — see the return-type contract).
486
+ if (preserveBlocksRef.current && ev.agEvents.length > 0) {
487
+ if (!reducerRef.current)
488
+ reducerRef.current = new Reducer();
489
+ for (const agEvent of ev.agEvents)
490
+ reducerRef.current.push(agEvent);
491
+ setReduceResult(reducerRef.current.result());
324
492
  }
325
- // `done` needs no handling — the stream closes after it. Any other
326
- // (unknown) event falls through silently — there is no default
327
- // branch, so a consumer that never renders a field is unaffected.
328
493
  }
494
+ else if (ev.kind === "error") {
495
+ // In-band failure frame — the code moves in lockstep with the
496
+ // message (an event without one carries null rather than leaving
497
+ // a previous turn's code standing).
498
+ setError(ev.message);
499
+ setErrorCode(ev.code);
500
+ }
501
+ else if (ev.kind === "profile-consent") {
502
+ setProfileConsentRequest(ev.request);
503
+ }
504
+ else if (ev.kind === "profile-link") {
505
+ setProfileLinkRequest(ev.request);
506
+ }
507
+ // `done` needs no handling here — the stream closes after it.
329
508
  }
330
509
  }
331
510
  catch (e) {
332
511
  if (!controller.signal.aborted) {
333
512
  setError(e instanceof Error ? e.message : "failed to reach agent");
513
+ // Pre-stream refusals arrive as a thrown AgentResponseError carrying
514
+ // the pod's structured code (a transport-level saturation retry has
515
+ // already happened and failed by the time one surfaces here). Any
516
+ // other throw — a network drop, a host-adapter failure — has no wire
517
+ // code, so the field stays null beside the message.
518
+ setErrorCode(e instanceof AgentResponseError ? (e.code ?? null) : null);
519
+ // A failure BEFORE admission means the message never reached the
520
+ // agent — the R0 failed-to-send state. Post-admission failures
521
+ // leave the entry removed (the send itself succeeded).
522
+ if (!admitted)
523
+ markSend("failed");
334
524
  }
335
525
  }
336
526
  finally {
527
+ // The turn is over however it ended — no probe may fire after this,
528
+ // and the pending timer must not leak past the turn. `turnEnded`
529
+ // already true here ⟺ the #192 watchdog ended the turn (adoption or
530
+ // stall give-up) — its internal `controller.abort()` must not read
531
+ // as a USER abort below.
532
+ const endedByWatchdog = turnEnded;
533
+ turnEnded = true;
534
+ clearStallTimer();
535
+ externalSignal?.removeEventListener("abort", onExternalAbort);
337
536
  setStatus("ready");
338
537
  setActiveTool(null);
339
538
  abortRef.current = null;
@@ -348,14 +547,23 @@ export function useAgentInvoke(opts) {
348
547
  : prev;
349
548
  });
350
549
  }
550
+ if (controller.signal.aborted && !endedByWatchdog) {
551
+ // USER abort (abort() or the external signal): surface it, and a
552
+ // pre-admission cancel clears the "sending" entry — a turn the
553
+ // user stopped is not a failed send.
554
+ setAborted(true);
555
+ if (!admitted)
556
+ markSend(null);
557
+ }
351
558
  }
352
- }, [endpointUrl, appId, status]);
559
+ }, [endpointUrl, appId, status, opts.signal]);
353
560
  return {
354
561
  messages,
355
562
  send,
356
563
  status,
357
564
  activeTool,
358
565
  error,
566
+ errorCode,
359
567
  threadId,
360
568
  abort,
361
569
  reset,
@@ -365,5 +573,8 @@ export function useAgentInvoke(opts) {
365
573
  clearProfileConsentRequest,
366
574
  profileLinkRequest,
367
575
  clearProfileLinkRequest,
576
+ aborted,
577
+ adopted,
578
+ sendStates,
368
579
  };
369
580
  }
@@ -6,44 +6,12 @@
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, ThreadIdStore } from "./types.js";
23
11
  /** Persists the threadId in `window.localStorage` (synchronously). */
24
12
  export declare const localStorageThreadStore: ThreadIdStore;
25
13
  /** Crypto-strong client-message id, with a non-crypto fallback. */
26
14
  export declare function webGenerateId(): string;
27
- /**
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).
45
- */
46
- export declare function fetchStreamTransport(req: InvokeRequest, accessToken?: string | null, guestSecret?: string | null): AsyncGenerator<string>;
47
15
  export interface CreateWebAdaptersOptions {
48
16
  /**
49
17
  * Public read-plane base (ending in `/v1`) for transcript history. When
@@ -151,4 +119,32 @@ export interface CreateUiResourceReaderOptions {
151
119
  * renders the host's placeholder, never an error surface.
152
120
  */
153
121
  export declare function createUiResourceReader(options: CreateUiResourceReaderOptions): (resourceUri: string) => Promise<ResolvedViewMount | undefined>;
122
+ /** Options for {@link createUiActionRelay} — same credential surface as the reader. */
123
+ export interface CreateUiActionRelayOptions {
124
+ /** The guuey public API base (`…/v1`). */
125
+ apiBaseUrl: string;
126
+ /** The thread whose persisted cards this relay may act for. */
127
+ threadId: string;
128
+ /** Signed-in bearer — wins over the guest secret (same rule as the transport). */
129
+ getAccessToken?: (opts?: {
130
+ forceRefresh?: boolean;
131
+ }) => Promise<string | null>;
132
+ /** Caller-owned anonymous guest secret (widget / guest chat). */
133
+ guestSecret?: string | null;
134
+ /** Injectable for tests. */
135
+ fetchImpl?: typeof fetch;
136
+ }
137
+ /**
138
+ * Build the card action relay over guuey's authenticated `tools/call` proxy
139
+ * (guuey#158: `POST /v1/threads/:threadId/ui-action`) — the mirror of
140
+ * {@link createUiResourceReader}. Allowlisting, arm narrowing, and the
141
+ * never-reject contract live in `@guuey/mcp-apps-host`'s
142
+ * `createMcpUiActionRelay`; only the transport is guuey-shaped. The proxy
143
+ * owns EVERYTHING trust-shaped (identity, thread ownership, the
144
+ * locator-to-thread guard, its own server-side allowlist, the per-user
145
+ * federation mint) — and every non-OK here collapses to `undefined`, which
146
+ * the host relay answers in-band as an `isError` result, never a thrown
147
+ * error into the sandbox bridge.
148
+ */
149
+ export declare function createUiActionRelay(options: CreateUiActionRelayOptions): (request: UiActionRequest) => Promise<McpToolCallResult>;
154
150
  //# 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,EAAE,mBAAmB,EAAmB,aAAa,EAAE,MAAM,YAAY,CAAC;AAItF,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,EAAE,aAiBrC,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,aAAa,IAAI,MAAM,CAKtC;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,CA8ErB;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"}