@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.
- package/README.md +54 -7
- package/dist/error-codes.d.ts +87 -0
- package/dist/error-codes.d.ts.map +1 -0
- package/dist/error-codes.js +82 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +36 -0
- package/dist/history.d.ts +1 -1
- package/dist/history.d.ts.map +1 -1
- package/dist/index.d.ts +13 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -6
- package/dist/invoke-turn.d.ts +101 -0
- package/dist/invoke-turn.d.ts.map +1 -0
- package/dist/invoke-turn.js +124 -0
- package/dist/react.d.ts +1 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +1 -1
- package/dist/saturation-retry.d.ts +101 -0
- package/dist/saturation-retry.d.ts.map +1 -0
- package/dist/saturation-retry.js +207 -0
- package/dist/sse.d.ts +1 -1
- package/dist/sse.d.ts.map +1 -1
- package/dist/transport.d.ts +90 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +189 -0
- package/dist/types.d.ts +101 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/useAgentInvoke.d.ts +29 -1
- package/dist/useAgentInvoke.d.ts.map +1 -1
- package/dist/useAgentInvoke.js +297 -86
- package/dist/web-adapters.d.ts +30 -34
- package/dist/web-adapters.d.ts.map +1 -1
- package/dist/web-adapters.js +72 -123
- package/package.json +11 -5
- package/src/error-codes.ts +89 -0
- package/src/errors.ts +35 -0
- package/src/history.ts +1 -1
- package/src/index.ts +50 -10
- package/src/invoke-turn.ts +187 -0
- package/src/react.ts +7 -1
- package/src/saturation-retry.ts +247 -0
- package/src/sse.ts +1 -1
- package/src/transport.ts +260 -0
- package/src/types.ts +102 -3
- package/src/useAgentInvoke.ts +288 -86
- package/src/web-adapters.ts +92 -134
package/src/useAgentInvoke.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
480
|
+
clientMessageId,
|
|
285
481
|
};
|
|
286
482
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
353
|
-
//
|
|
354
|
-
//
|
|
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
|
}
|