@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,14 +23,10 @@
23
23
  */
24
24
  import { useCallback, useEffect, useRef, useState } from "react";
25
25
  import { Reducer, type AgReduceResult } from "@silverprotocol/core";
26
- import {
27
- parseConsentRequest,
28
- parseLinkRequest,
29
- parseSseEvents,
30
- reduceAssistantText,
31
- stringField,
32
- } from "./sse";
33
- 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";
34
30
  import type {
35
31
  AgentInvokeAdapters,
36
32
  AgentInvokeStatus,
@@ -39,9 +35,10 @@ import type {
39
35
  HistoryLoadResult,
40
36
  ProfileConsentRequest,
41
37
  ProfileLinkRequest,
38
+ StallRecoveryOptions,
42
39
  UseAgentInvokeOptions,
43
40
  UseAgentInvokeReturn,
44
- } from "./types";
41
+ } from "./types.js";
45
42
 
46
43
  function threadStorageKey(appId: string | undefined): string {
47
44
  return `guuey:thread:${appId ?? "default"}`;
@@ -69,6 +66,53 @@ export function applyHistoryResult(
69
66
  return { kind: "seed", messages: result.messages };
70
67
  }
71
68
 
69
+ /** The guuey#192 stall watchdog's resolved tuning (see {@link stallProbeDecision}). */
70
+ export const STALL_RECOVERY_DEFAULTS = { windowMs: 25_000, probeAttempts: 4 } as const;
71
+
72
+ function resolveStallRecovery(
73
+ option: false | StallRecoveryOptions | undefined,
74
+ ): { windowMs: number; probeAttempts: number } | null {
75
+ if (option === false) return null;
76
+ return {
77
+ windowMs: option?.windowMs ?? STALL_RECOVERY_DEFAULTS.windowMs,
78
+ probeAttempts: option?.probeAttempts ?? STALL_RECOVERY_DEFAULTS.probeAttempts,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Pure decision seam for the guuey#192 stall probe: does a freshly-loaded
84
+ * transcript already contain THIS turn's finished reply?
85
+ *
86
+ * `adopt` requires BOTH signals, because each alone lies in a real case:
87
+ *
88
+ * - **user-count**: history must hold at least as many user turns as the
89
+ * local transcript (which includes the just-sent optimistic one). Without
90
+ * it, a thread whose PREVIOUS turn ended in a completed assistant reply
91
+ * would adopt that OLD transcript and silently drop the in-flight turn.
92
+ * - **finished tail**: history's last message must be a non-empty assistant
93
+ * reply. Without it, a history read that caught the persisted user row
94
+ * before the assistant row would adopt a reply-less transcript.
95
+ *
96
+ * KNOWN LIMIT (documented, accepted): the runtime persists a turn's rows at
97
+ * completion — the guuey#192 evidence (a reload mid-stall renders the FULL
98
+ * reply) is only possible under that model, and the read plane carries no
99
+ * per-row clientMessageId to match against. If persistence ever becomes
100
+ * progressive (partial assistant rows), this heuristic needs the read plane
101
+ * to grow a turn-completion marker — do not "fix" it client-side by text
102
+ * comparison, which cannot distinguish a partial row from a finished one.
103
+ */
104
+ export function stallProbeDecision(
105
+ history: AgentMessage[],
106
+ localUserCount: number,
107
+ ): "adopt" | "in-flight" {
108
+ let historyUserCount = 0;
109
+ for (const m of history) if (m.role === "user") historyUserCount += 1;
110
+ if (historyUserCount < localUserCount) return "in-flight";
111
+ const last = history[history.length - 1];
112
+ if (!last || last.role !== "assistant" || last.text.trim() === "") return "in-flight";
113
+ return "adopt";
114
+ }
115
+
72
116
  export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeReturn {
73
117
  const { endpointUrl, appId } = opts;
74
118
  const [messages, setMessages] = useState<AgentMessage[]>([]);
@@ -78,6 +122,10 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
78
122
  const [status, setStatus] = useState<AgentInvokeStatus>("ready");
79
123
  const [activeTool, setActiveTool] = useState<string | null>(null);
80
124
  const [error, setError] = useState<string | null>(null);
125
+ // The pod's wire code for whatever put `error` there, when the failure
126
+ // carried one (see the return-type contract). Moves in lockstep with
127
+ // `error` — every set/clear of one touches the other.
128
+ const [errorCode, setErrorCode] = useState<string | null>(null);
81
129
  const [threadId, setThreadId] = useState<string | null>(null);
82
130
  // Opt-in block-preserving transcript. `reduceResult` follows the
83
131
  // null-until-first-valid-AgEvent contract documented on the return type: it
@@ -97,6 +145,13 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
97
145
  // explicit dismiss, same lifecycle as `profileConsentRequest` — the two are
98
146
  // independent (an unlinked-invite vs an already-linked consent ask).
99
147
  const [profileLinkRequest, setProfileLinkRequest] = useState<ProfileLinkRequest | null>(null);
148
+ // The last turn's ending posture + the optimistic-send ledger — the
149
+ // transcript renderer's inputs (guuey#135 wave 3b; see the return-type
150
+ // contract for each). `aborted` is USER abort only — the #192 watchdog's
151
+ // internal stream abort never sets it.
152
+ const [aborted, setAborted] = useState(false);
153
+ const [adopted, setAdopted] = useState(false);
154
+ const [sendStates, setSendStates] = useState<Readonly<Record<string, "sending" | "failed">>>({});
100
155
 
101
156
  const abortRef = useRef<AbortController | null>(null);
102
157
  // Mirror the latest threadId + adapters into refs so `send` reads fresh
@@ -105,6 +160,13 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
105
160
  const threadIdRef = useRef<string | null>(null);
106
161
  const adaptersRef = useRef<AgentInvokeAdapters>(opts.adapters);
107
162
  adaptersRef.current = opts.adapters;
163
+ // The stall probe (guuey#192) needs the committed transcript's user-turn
164
+ // count long after `send`'s closures captured state — same render-time
165
+ // mirror idiom as `adaptersRef`.
166
+ const messagesRef = useRef<AgentMessage[]>(messages);
167
+ messagesRef.current = messages;
168
+ const stallRecoveryRef = useRef(opts.stallRecovery);
169
+ stallRecoveryRef.current = opts.stallRecovery;
108
170
  // The per-conversation AgJSON fold (only built when `preserveBlocks`).
109
171
  // Lazily (re)created on the first valid AgEvent after a fresh start / reset,
110
172
  // so an off run never constructs one and a bypass run never allocates.
@@ -131,6 +193,7 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
131
193
  setThreadId(null);
132
194
  setMessages([]);
133
195
  setError(null);
196
+ setErrorCode(null);
134
197
  setStatus("ready");
135
198
  setActiveTool(null);
136
199
  // Fresh conversation → drop the old fold; the reducer is rebuilt lazily on
@@ -141,6 +204,9 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
141
204
  // A prior app's consent ask must never leak into the new conversation.
142
205
  setProfileConsentRequest(null);
143
206
  setProfileLinkRequest(null);
207
+ setAborted(false);
208
+ setAdopted(false);
209
+ setSendStates({});
144
210
 
145
211
  let cancelled = false;
146
212
  const key = threadStorageKey(appId);
@@ -221,6 +287,7 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
221
287
  void adaptersRef.current.storage.save(threadStorageKey(appId), "");
222
288
  setMessages([]);
223
289
  setError(null);
290
+ setErrorCode(null);
224
291
  setStatus("ready");
225
292
  setActiveTool(null);
226
293
  // Re-create the reducer for the new conversation (rebuilt lazily on the
@@ -230,6 +297,9 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
230
297
  setHistoryCards([]);
231
298
  setProfileConsentRequest(null);
232
299
  setProfileLinkRequest(null);
300
+ setAborted(false);
301
+ setAdopted(false);
302
+ setSendStates({});
233
303
  }, [appId]);
234
304
 
235
305
  const clearProfileConsentRequest = useCallback(() => {
@@ -242,13 +312,47 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
242
312
 
243
313
  const send = useCallback(
244
314
  async (input: string) => {
245
- if (!endpointUrl || !input.trim() || status !== "ready") return;
315
+ // An already-aborted external signal refuses the send outright —
316
+ // before the optimistic transcript push, so nothing is left to undo.
317
+ if (!endpointUrl || !input.trim() || status !== "ready" || opts.signal?.aborted) return;
246
318
  setError(null);
319
+ setErrorCode(null);
320
+ setAborted(false);
321
+ setAdopted(false);
247
322
  setStatus("connecting");
248
- setMessages((prev) => [...prev, { role: "user", text: input }, { role: "assistant", text: "" }]);
323
+ // ONE id for the whole turn: the optimistic user entry, the send-state
324
+ // ledger, and the invoke body all carry it — the R0 lifecycle join.
325
+ const clientMessageId = adaptersRef.current.generateId();
326
+ /** Move this turn's ledger entry; `null` removes it (absent = sent). */
327
+ const markSend = (state: "sending" | "failed" | null): void => {
328
+ setSendStates((prev) => {
329
+ if (state === null) {
330
+ if (!(clientMessageId in prev)) return prev;
331
+ const next: Record<string, "sending" | "failed"> = { ...prev };
332
+ delete next[clientMessageId];
333
+ return next;
334
+ }
335
+ if (prev[clientMessageId] === state) return prev;
336
+ return { ...prev, [clientMessageId]: state };
337
+ });
338
+ };
339
+ markSend("sending");
340
+ let admitted = false;
341
+ setMessages((prev) => [
342
+ ...prev,
343
+ { role: "user", text: input, clientMessageId },
344
+ { role: "assistant", text: "" },
345
+ ]);
249
346
 
250
347
  const controller = new AbortController();
251
348
  abortRef.current = controller;
349
+ // Compose the host's external abort authority (opts.signal) with the
350
+ // per-turn controller: an external abort stops this turn exactly as
351
+ // `abort()` would. Listener removed in `finally` — the signal outlives
352
+ // the turn, the subscription must not.
353
+ const externalSignal = opts.signal;
354
+ const onExternalAbort = (): void => controller.abort();
355
+ externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
252
356
  const adapters = adaptersRef.current;
253
357
  // Wait for the persisted threadId to load before deciding whether to
254
358
  // replay it — otherwise a fast first send mints a new orphan thread and
@@ -272,93 +376,180 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
272
376
  });
273
377
  };
274
378
 
379
+ // ── guuey#192 stall watchdog ─────────────────────────────────────
380
+ // A half-dead connection (TCP alive, zero bytes, no error, no `done`)
381
+ // never resolves the read below, so a parallel clock watches byte
382
+ // activity: armed by the FIRST chunk (so a silent cold start never
383
+ // trips it), reset by every chunk, and on expiry it probes history
384
+ // WITHOUT touching the stream — killing a live-but-quiet stream on a
385
+ // timer would trade a frozen cursor for a lost turn. Only two things
386
+ // end the turn early: adoption (history already holds the finished
387
+ // reply — the reload the user would have done, minus the reload) and
388
+ // the bounded give-up (STREAM_STALLED after `probeAttempts` fruitless
389
+ // probes with still-zero bytes).
390
+ const stall = resolveStallRecovery(stallRecoveryRef.current);
391
+ let turnEnded = false;
392
+ let probeInFlight = false;
393
+ let fruitlessProbes = 0;
394
+ let activityCount = 0;
395
+ let stallTimer: ReturnType<typeof setTimeout> | null = null;
396
+ const clearStallTimer = (): void => {
397
+ if (stallTimer !== null) {
398
+ clearTimeout(stallTimer);
399
+ stallTimer = null;
400
+ }
401
+ };
402
+ const armStallTimer = (): void => {
403
+ if (!stall || turnEnded || controller.signal.aborted) return;
404
+ clearStallTimer();
405
+ stallTimer = setTimeout(() => {
406
+ void onStallWindow();
407
+ }, stall.windowMs);
408
+ };
409
+ const endTurnWith = (apply: () => void): void => {
410
+ turnEnded = true;
411
+ clearStallTimer();
412
+ apply();
413
+ // Unwinds the suspended read; the catch sees `aborted` and stays
414
+ // silent, so whatever `apply` decided IS the turn's outcome.
415
+ controller.abort();
416
+ };
417
+ const onStallWindow = async (): Promise<void> => {
418
+ if (!stall || turnEnded || controller.signal.aborted || probeInFlight) return;
419
+ const tid = threadIdRef.current;
420
+ const history = adaptersRef.current.history;
421
+ if (tid && history) {
422
+ probeInFlight = true;
423
+ const countAtProbe = activityCount;
424
+ let result: HistoryLoadResult | null = null;
425
+ try {
426
+ result = await history.load(tid);
427
+ } catch {
428
+ result = null; // transient read failure = one fruitless probe
429
+ }
430
+ probeInFlight = false;
431
+ if (turnEnded || controller.signal.aborted) return;
432
+ // Bytes resumed while the probe was in flight: the stream is alive
433
+ // — discard the now-stale read; the chunk observer already reset
434
+ // the count and re-armed the clock.
435
+ if (activityCount !== countAtProbe) return;
436
+ if (result && !("gone" in result)) {
437
+ let localUserCount = 0;
438
+ for (const m of messagesRef.current) if (m.role === "user") localUserCount += 1;
439
+ if (stallProbeDecision(result.messages, localUserCount) === "adopt") {
440
+ const adoptedResult = result;
441
+ endTurnWith(() => {
442
+ setMessages(adoptedResult.messages);
443
+ if ("cards" in adoptedResult && adoptedResult.cards && adoptedResult.cards.length > 0) {
444
+ setHistoryCards(adoptedResult.cards);
445
+ }
446
+ // The renderer's #192 signal: calm renders the adopted turn
447
+ // identically; debug may mark it (guuey#135 3b).
448
+ setAdopted(true);
449
+ });
450
+ return;
451
+ }
452
+ }
453
+ }
454
+ // No probe possible (no threadId yet / no history adapter), a failed
455
+ // read, or history says the turn is still in flight — all count the
456
+ // same: one fruitless window.
457
+ fruitlessProbes += 1;
458
+ if (fruitlessProbes >= stall.probeAttempts) {
459
+ endTurnWith(() => {
460
+ setError("The response stream stalled and the finished reply was not found in history.");
461
+ setErrorCode(CLIENT_ERROR_CODES.STREAM_STALLED);
462
+ });
463
+ return;
464
+ }
465
+ armStallTimer();
466
+ };
467
+ const transport = stall
468
+ ? withActivityObserver(adapters.transport, () => {
469
+ activityCount += 1;
470
+ fruitlessProbes = 0;
471
+ armStallTimer();
472
+ })
473
+ : adapters.transport;
474
+
275
475
  try {
276
- // The endpointUrl may be a pod base (`https://host`) or the full
277
- // invoke URL the deploy-controller records (`https://host/agent/invoke`).
278
- // Normalize to exactly one `/agent/invoke`.
279
- const base = endpointUrl.replace(/\/+$/, "");
280
- const invokeUrl = base.endsWith("/agent/invoke") ? base : `${base}/agent/invoke`;
476
+ const invokeUrl = toInvokeUrl(endpointUrl);
281
477
  const body = {
282
478
  input,
283
479
  ...(threadIdRef.current ? { threadId: threadIdRef.current } : {}),
284
- clientMessageId: adapters.generateId(),
480
+ clientMessageId,
285
481
  };
286
482
 
287
- let buffer = "";
288
- for await (const chunk of adapters.transport({ url: invokeUrl, body, signal: controller.signal })) {
289
- buffer += chunk;
290
- const { events, rest } = parseSseEvents(buffer);
291
- buffer = rest;
292
- for (const ev of events) {
293
- if (ev.event === "session") {
294
- // The pod is awake and the turn is admitted — 'connecting' ends
295
- // here (this frame arrives within ~1s of a warm pod; a cold
296
- // scale-to-zero start is exactly the long 'connecting' phase).
297
- setStatus("thinking");
298
- const tid = stringField(ev.data, "threadId");
299
- if (tid) {
300
- threadIdRef.current = tid;
301
- setThreadId(tid);
302
- void adapters.storage.save(threadStorageKey(appId), tid);
303
- }
304
- } else if (ev.event === "message") {
305
- // Status derivation (guuey#91) read the frame's `type` before
306
- // the text fold. Silver frames announce tools + text explicitly;
307
- // bypass frames ('text' / 'assistant' SDKMessages) only ever
308
- // carry assistant text, so they map to 'responding'. Unknown
309
- // types deliberately leave the status untouched.
310
- const frameType = stringField(ev.data, "type");
311
- if (frameType === "tool.start") {
312
- setStatus("using-tool");
313
- setActiveTool(stringField(ev.data, "name") ?? null);
314
- } else if (frameType === "tool.done") {
315
- setStatus("thinking");
316
- setActiveTool(null);
317
- } else if (
318
- frameType === "text.start" ||
319
- frameType === "text.delta" ||
320
- frameType === "text" ||
321
- frameType === "assistant"
322
- ) {
323
- setStatus("responding");
324
- }
325
- renderAssistant(reduceAssistantText(assistantText, ev.data));
326
- // Additively fold the SAME frame into the AgJSON reducer when
327
- // opted in. The text surface above is untouched; only VALID
328
- // AgEvents advance the reducer (bypass frames ingest to [] and
329
- // leave `reduceResult` null — see the return-type contract).
330
- if (preserveBlocksRef.current) {
331
- const agEvents = ingestMessageFrame(ev.data);
332
- if (agEvents.length > 0) {
333
- if (!reducerRef.current) reducerRef.current = new Reducer();
334
- for (const agEvent of agEvents) reducerRef.current.push(agEvent);
335
- setReduceResult(reducerRef.current.result());
336
- }
337
- }
338
- } else if (ev.event === "error") {
339
- setError(stringField(ev.data, "message") ?? "agent error");
340
- } else if (ev.event === "profile-consent-needed") {
341
- // Cross-app profile consent ask (T6). Only a well-formed payload
342
- // updates state; a malformed one is dropped, leaving any prior
343
- // valid request untouched (never clobbered to null).
344
- const parsed = parseConsentRequest(ev.data);
345
- if (parsed) setProfileConsentRequest(parsed);
346
- } else if (ev.event === "profile-link-needed") {
347
- // Cross-app profile LINK invite (linkcoh T3) for an unlinked byo
348
- // caller. Same drop-if-malformed contract as consent above.
349
- const parsed = parseLinkRequest(ev.data);
350
- if (parsed) setProfileLinkRequest(parsed);
483
+ // The wire walk lives in `invokeTurn` (the pure per-turn generator —
484
+ // its docblock owns the switch semantics); this hook only maps each
485
+ // semantic event onto React state.
486
+ for await (const ev of invokeTurn(
487
+ { url: invokeUrl, body, signal: controller.signal },
488
+ transport,
489
+ )) {
490
+ if (ev.kind === "session") {
491
+ // The pod is awake and the turn is admitted 'connecting' ends
492
+ // here, and so does the R0 "sending" state (absent = sent).
493
+ admitted = true;
494
+ markSend(null);
495
+ setStatus("thinking");
496
+ if (ev.threadId) {
497
+ threadIdRef.current = ev.threadId;
498
+ setThreadId(ev.threadId);
499
+ void adapters.storage.save(threadStorageKey(appId), ev.threadId);
500
+ }
501
+ } else if (ev.kind === "message") {
502
+ // Absent status/activeTool mean "no change" never touched, so
503
+ // an unknown frame type leaves both standing (guuey#91 rule).
504
+ if (ev.status !== undefined) setStatus(ev.status);
505
+ if (ev.activeTool !== undefined) setActiveTool(ev.activeTool);
506
+ renderAssistant(ev.assistantText);
507
+ // Additively fold the frame's AgEvents into the AgJSON reducer
508
+ // when opted in. The text surface above is untouched; only VALID
509
+ // AgEvents advance the reducer (bypass frames carry [] and leave
510
+ // `reduceResult` null see the return-type contract).
511
+ if (preserveBlocksRef.current && ev.agEvents.length > 0) {
512
+ if (!reducerRef.current) reducerRef.current = new Reducer();
513
+ for (const agEvent of ev.agEvents) reducerRef.current.push(agEvent);
514
+ setReduceResult(reducerRef.current.result());
351
515
  }
352
- // `done` needs no handling the stream closes after it. Any other
353
- // (unknown) event falls through silently there is no default
354
- // branch, so a consumer that never renders a field is unaffected.
516
+ } else if (ev.kind === "error") {
517
+ // In-band failure frame the code moves in lockstep with the
518
+ // message (an event without one carries null rather than leaving
519
+ // a previous turn's code standing).
520
+ setError(ev.message);
521
+ setErrorCode(ev.code);
522
+ } else if (ev.kind === "profile-consent") {
523
+ setProfileConsentRequest(ev.request);
524
+ } else if (ev.kind === "profile-link") {
525
+ setProfileLinkRequest(ev.request);
355
526
  }
527
+ // `done` needs no handling here — the stream closes after it.
356
528
  }
357
529
  } catch (e) {
358
530
  if (!controller.signal.aborted) {
359
531
  setError(e instanceof Error ? e.message : "failed to reach agent");
532
+ // Pre-stream refusals arrive as a thrown AgentResponseError carrying
533
+ // the pod's structured code (a transport-level saturation retry has
534
+ // already happened and failed by the time one surfaces here). Any
535
+ // other throw — a network drop, a host-adapter failure — has no wire
536
+ // code, so the field stays null beside the message.
537
+ setErrorCode(e instanceof AgentResponseError ? (e.code ?? null) : null);
538
+ // A failure BEFORE admission means the message never reached the
539
+ // agent — the R0 failed-to-send state. Post-admission failures
540
+ // leave the entry removed (the send itself succeeded).
541
+ if (!admitted) markSend("failed");
360
542
  }
361
543
  } finally {
544
+ // The turn is over however it ended — no probe may fire after this,
545
+ // and the pending timer must not leak past the turn. `turnEnded`
546
+ // already true here ⟺ the #192 watchdog ended the turn (adoption or
547
+ // stall give-up) — its internal `controller.abort()` must not read
548
+ // as a USER abort below.
549
+ const endedByWatchdog = turnEnded;
550
+ turnEnded = true;
551
+ clearStallTimer();
552
+ externalSignal?.removeEventListener("abort", onExternalAbort);
362
553
  setStatus("ready");
363
554
  setActiveTool(null);
364
555
  abortRef.current = null;
@@ -373,9 +564,16 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
373
564
  : prev;
374
565
  });
375
566
  }
567
+ if (controller.signal.aborted && !endedByWatchdog) {
568
+ // USER abort (abort() or the external signal): surface it, and a
569
+ // pre-admission cancel clears the "sending" entry — a turn the
570
+ // user stopped is not a failed send.
571
+ setAborted(true);
572
+ if (!admitted) markSend(null);
573
+ }
376
574
  }
377
575
  },
378
- [endpointUrl, appId, status],
576
+ [endpointUrl, appId, status, opts.signal],
379
577
  );
380
578
 
381
579
  return {
@@ -384,6 +582,7 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
384
582
  status,
385
583
  activeTool,
386
584
  error,
585
+ errorCode,
387
586
  threadId,
388
587
  abort,
389
588
  reset,
@@ -393,5 +592,8 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
393
592
  clearProfileConsentRequest,
394
593
  profileLinkRequest,
395
594
  clearProfileLinkRequest,
595
+ aborted,
596
+ adopted,
597
+ sendStates,
396
598
  };
397
599
  }