@guuey/agent-client 0.1.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/src/react.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * React entry point (`@guuey/agent-client/react`).
3
+ *
4
+ * The `useAgentInvoke` hook is the only React-coupled surface — the root
5
+ * subpath (`@guuey/agent-client`) stays React-free (pure SSE helpers, the
6
+ * history reader, and the web adapters). Consumers that only need those never
7
+ * import React at all; consumers that render chat import the hook from here.
8
+ */
9
+ export { useAgentInvoke, applyHistoryResult, type HistoryApplication } from "./useAgentInvoke";
10
+ // The block-preserving transcript surfaces `AgReduceResult`; re-export it (and
11
+ // `AgEvent`) here so `./react` consumers can type `reduceResult` without a
12
+ // direct `@silverprotocol/core` import.
13
+ export type { AgEvent, AgReduceResult } from "@silverprotocol/core";
package/src/sse.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Pure SSE helpers for the base-platform invoke contract. Zero React / DOM /
3
+ * platform dependencies — unit-tested in isolation (`sse.test.ts`) and shared
4
+ * verbatim across web (Studio) and React-Native (Portal).
5
+ */
6
+
7
+ export interface ParsedSseEvent {
8
+ event: string;
9
+ data: unknown;
10
+ }
11
+
12
+ /**
13
+ * Parse complete `event:`/`data:` frames out of an SSE buffer. Returns the
14
+ * parsed events plus the unparsed remainder (a partial trailing frame). Frames
15
+ * are separated by a blank line; `data:` JSON is parsed best-effort.
16
+ */
17
+ export function parseSseEvents(buffer: string): { events: ParsedSseEvent[]; rest: string } {
18
+ const events: ParsedSseEvent[] = [];
19
+ const parts = buffer.split("\n\n");
20
+ const rest = parts.pop() ?? "";
21
+ for (const block of parts) {
22
+ let event = "message";
23
+ const dataLines: string[] = [];
24
+ for (const line of block.split("\n")) {
25
+ if (line.startsWith("event:")) event = line.slice(6).trim();
26
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
27
+ }
28
+ if (dataLines.length === 0) continue;
29
+ let data: unknown = dataLines.join("\n");
30
+ try {
31
+ data = JSON.parse(dataLines.join("\n"));
32
+ } catch {
33
+ /* leave as raw string */
34
+ }
35
+ events.push({ event, data });
36
+ }
37
+ return { events, rest };
38
+ }
39
+
40
+ function isResultMessage(data: unknown): boolean {
41
+ return (
42
+ typeof data === "object" &&
43
+ data !== null &&
44
+ (data as { type?: unknown }).type === "result"
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Fold one `message` SSE payload into the running assistant text. `assistant`
50
+ * messages APPEND (streaming). The success `result` message carries the SAME
51
+ * final text, so it REPLACES rather than appends — otherwise the answer renders
52
+ * twice. The result is also the fallback for result-only turns.
53
+ */
54
+ export function reduceAssistantText(current: string, data: unknown): string {
55
+ if (isResultMessage(data)) {
56
+ const result = extractAssistantText(data);
57
+ return result || current;
58
+ }
59
+ return current + extractAssistantText(data);
60
+ }
61
+
62
+ /**
63
+ * Best-effort assistant text from one `message` payload — BOTH protocols:
64
+ *
65
+ * - **silver (the pod default)**: AgJSON events — `text.delta` carries the
66
+ * streamed text in `.delta`; every other event family (turn/message/tool
67
+ * lifecycle) contributes "". AgJSON never resends the final text, so
68
+ * deltas are append-only (no result-replacement leg).
69
+ * - **bypass**: SDKMessage shapes — text blocks off an `assistant` message;
70
+ * the success `result` string as fallback/replacement.
71
+ *
72
+ * The silver arm was MISSING while silver became the pod default — live
73
+ * portal/studio chat rendered EMPTY assistant messages (0.3.2 coverage
74
+ * audit G14, confirmed by reducing a live wire capture to "").
75
+ */
76
+ export function extractAssistantText(data: unknown): string {
77
+ if (typeof data !== "object" || data === null) return "";
78
+ const type = (data as { type?: unknown }).type;
79
+ if (type === "text.delta") {
80
+ const delta = (data as { delta?: unknown }).delta;
81
+ return typeof delta === "string" ? delta : "";
82
+ }
83
+ if (type === "assistant") {
84
+ const message = (data as { message?: unknown }).message;
85
+ if (typeof message === "object" && message !== null && "content" in message) {
86
+ const content = (message as { content?: unknown }).content;
87
+ if (Array.isArray(content)) {
88
+ return content
89
+ .filter(
90
+ (b): b is { type: "text"; text: string } =>
91
+ typeof b === "object" &&
92
+ b !== null &&
93
+ (b as { type?: unknown }).type === "text" &&
94
+ typeof (b as { text?: unknown }).text === "string",
95
+ )
96
+ .map((b) => b.text)
97
+ .join("");
98
+ }
99
+ }
100
+ return "";
101
+ }
102
+ if (type === "result") {
103
+ const subtype = (data as { subtype?: unknown }).subtype;
104
+ const result = (data as { result?: unknown }).result;
105
+ if (subtype === "success" && typeof result === "string") return result;
106
+ }
107
+ return "";
108
+ }
109
+
110
+ /** Read a string field off an SSE `data` object, or undefined. */
111
+ export function stringField(data: unknown, key: string): string | undefined {
112
+ if (typeof data !== "object" || data === null) return undefined;
113
+ const v = (data as Record<string, unknown>)[key];
114
+ return typeof v === "string" ? v : undefined;
115
+ }
package/src/types.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Public types for the base-platform chat client.
3
+ *
4
+ * The hook's behaviour is platform-agnostic; its three host couplings —
5
+ * thread-id storage, client-message-id generation, and the network transport
6
+ * (which also carries anonymous identity) — are INJECTED by the consumer via
7
+ * {@link AgentInvokeAdapters}. Web (Studio) passes localStorage / crypto /
8
+ * credentialed-cookie fetch; React-Native (Portal) passes AsyncStorage /
9
+ * getRandomValues / header-identity SSE fetch. This mirrors the ggui
10
+ * `MessageStorageAdapter` injection pattern.
11
+ */
12
+
13
+ import type { AgReduceResult, JsonValue } from "@silverprotocol/core";
14
+
15
+ /** A flat chat turn as rendered by the consumer UI. */
16
+ export interface AgentMessage {
17
+ role: "user" | "assistant";
18
+ text: string;
19
+ }
20
+
21
+ /**
22
+ * A persisted generative-UI card rehydrated from thread history — the verbatim
23
+ * `AgArtifact` snapshot the pod stored on a `kind: "card"` row, tagged with its
24
+ * transcript position. A block-preserving renderer interleaves these with
25
+ * {@link AgentMessage}s (and the live {@link UseAgentInvokeReturn.reduceResult}
26
+ * fold) by ascending `seq`. `cardSnapshot` is forwarded opaquely from the read
27
+ * plane — the SDK does not re-parse it into AgEvents.
28
+ */
29
+ export interface HistoryCard {
30
+ seq: number;
31
+ at: string;
32
+ cardSnapshot: JsonValue;
33
+ }
34
+
35
+ /**
36
+ * Persists the durable `threadId` per app so a reload continues the same
37
+ * conversation. `load` may be sync (localStorage) or async (AsyncStorage).
38
+ */
39
+ export interface ThreadIdStore {
40
+ load(key: string): string | null | Promise<string | null>;
41
+ save(key: string, threadId: string): void | Promise<void>;
42
+ }
43
+
44
+ /** Generates a fresh client-message id (idempotency key for B1 dedup). */
45
+ export type GenerateId = () => string;
46
+
47
+ /** One invoke request handed to the transport. */
48
+ export interface InvokeRequest {
49
+ /** Fully-resolved POST target (already normalised to end in `/agent/invoke`). */
50
+ url: string;
51
+ /** JSON request body: `{ input, threadId?, clientMessageId }`. */
52
+ body: unknown;
53
+ /** Aborts the in-flight stream. */
54
+ signal: AbortSignal;
55
+ }
56
+
57
+ /**
58
+ * Opens an invoke request and yields decoded UTF-8 text chunks of the SSE
59
+ * stream (the hook accumulates + parses frames itself). MUST throw on a
60
+ * non-OK response or network failure. Owns headers + identity entirely, so
61
+ * the hook never sees cookies or bearer tokens.
62
+ */
63
+ export type InvokeTransport = (req: InvokeRequest) => AsyncIterable<string>;
64
+
65
+ /**
66
+ * The persisted transcript for a thread, or a signal that it no longer exists.
67
+ *
68
+ * `cards` is populated ONLY when the loader opts in (see
69
+ * `fetchThreadHistory`'s `includeCards`); the default text-only mapping omits
70
+ * it so existing text-only consumers (portal-native) are unaffected. When
71
+ * present it carries the thread's persisted {@link HistoryCard}s for a
72
+ * block-preserving renderer to interleave by `seq`.
73
+ */
74
+ export type HistoryLoadResult =
75
+ | { messages: AgentMessage[]; cards?: HistoryCard[] }
76
+ | { gone: true };
77
+
78
+ /**
79
+ * Optional seam for rehydrating a chat transcript from a server-side read
80
+ * after the persisted `threadId` hydrates (see `useAgentInvoke`'s mount
81
+ * effect). Best-effort: a rejected `load` is logged and skipped, never
82
+ * blocks chat.
83
+ */
84
+ export interface AgentInvokeHistoryAdapter {
85
+ /** Fetch the persisted transcript for a thread. `gone` = 403/404 (stale local id). */
86
+ load(threadId: string): Promise<HistoryLoadResult>;
87
+ }
88
+
89
+ /** The host couplings the hook needs, injected by the consumer. */
90
+ export interface AgentInvokeAdapters {
91
+ storage: ThreadIdStore;
92
+ generateId: GenerateId;
93
+ transport: InvokeTransport;
94
+ /** Optional: rehydrate the transcript for a hydrated threadId. See {@link AgentInvokeHistoryAdapter}. */
95
+ history?: AgentInvokeHistoryAdapter;
96
+ }
97
+
98
+ export interface UseAgentInvokeOptions {
99
+ /** Pod base URL (with or without a trailing `/agent/invoke`). Chat is disabled when null. */
100
+ endpointUrl: string | null;
101
+ /** Owning app id — namespaces the persisted threadId. */
102
+ appId?: string;
103
+ /** Platform host couplings (storage / crypto / transport). */
104
+ adapters: AgentInvokeAdapters;
105
+ /**
106
+ * Opt-in: ALSO fold the full AgJSON (silver-mode) stream into a
107
+ * block-preserving transcript exposed as {@link UseAgentInvokeReturn.reduceResult},
108
+ * alongside the always-on flat text surface. Off by default; when off the
109
+ * reducer is never constructed and the text behaviour is byte-identical.
110
+ */
111
+ preserveBlocks?: boolean;
112
+ }
113
+
114
+ export interface UseAgentInvokeReturn {
115
+ messages: AgentMessage[];
116
+ send: (input: string) => Promise<void>;
117
+ isStreaming: boolean;
118
+ error: string | null;
119
+ threadId: string | null;
120
+ /** Abort the in-flight turn (the stream stops; partial text is kept). */
121
+ abort: () => void;
122
+ reset: () => void;
123
+ /**
124
+ * The folded AgJSON transcript, or `null`.
125
+ *
126
+ * Contract — **null-until-first-valid-AgEvent** (the documented choice for
127
+ * the ambiguous "which protocol?" case; see {@link UseAgentInvokeOptions.preserveBlocks}):
128
+ *
129
+ * - `null` whenever `preserveBlocks` is off (the reducer is never built);
130
+ * - when `preserveBlocks` is on, `null` UNTIL the per-conversation `Reducer`
131
+ * has folded at least one VALID AgEvent, then the reducer's live
132
+ * `result()` snapshot (a fresh object on each fold, so it re-renders).
133
+ *
134
+ * The hook cannot know a priori whether the pod is in silver or bypass mode.
135
+ * In **bypass mode** the `message` frames are SDKMessage shapes that never
136
+ * validate as AgEvents, so nothing folds and `reduceResult` stays `null` for
137
+ * the whole conversation — the reducer only makes sense for silver AgJSON
138
+ * frames. `reset()` returns it to `null`. History rehydrate does NOT populate
139
+ * it — persisted cards surface via {@link historyCards} instead (Task 4), so
140
+ * a snapshot never has to be lied back into the live reducer.
141
+ */
142
+ reduceResult: AgReduceResult | null;
143
+ /**
144
+ * Persisted generative-UI cards rehydrated from thread history, ascending by
145
+ * `seq`. Empty (`[]`) unless the injected history adapter opted into cards
146
+ * (`fetchThreadHistory({ includeCards: true })`) AND the rehydrate seeded a
147
+ * transcript. Independent of {@link reduceResult}: `reduceResult` is the
148
+ * LIVE turn's fold, `historyCards` is the persisted PRIOR turns' cards — a
149
+ * block-preserving renderer interleaves both (and {@link messages}) by `seq`.
150
+ * `reset()` clears it back to `[]`.
151
+ */
152
+ historyCards: HistoryCard[];
153
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * useAgentInvoke — the base-platform chat client.
3
+ *
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):
6
+ *
7
+ * POST {endpointUrl}/agent/invoke
8
+ * body: { input, threadId?, clientMessageId }
9
+ * ← SSE:
10
+ * event: session { sessionId, userId, threadId? }
11
+ * event: message <SDKMessage JSON> (assistant turns + result)
12
+ * event: done { stopReason, threadId?, userSeq?, agentSeq? }
13
+ * event: error { code, message }
14
+ *
15
+ * History persistence (B1) is server-side: the pod resolves a durable Thread
16
+ * from the `threadId` we replay and persists each turn. The threadId is kept
17
+ * in consumer-provided storage (per app) so a reload continues the same
18
+ * conversation.
19
+ *
20
+ * Platform-agnostic: storage, id generation, and the network transport (which
21
+ * also carries anonymous identity) are injected via `opts.adapters`. See
22
+ * `./web-adapters` for the web (Studio) bundle; Portal supplies RN adapters.
23
+ */
24
+ import { useCallback, useEffect, useRef, useState } from "react";
25
+ import { Reducer, type AgReduceResult } from "@silverprotocol/core";
26
+ import { parseSseEvents, reduceAssistantText, stringField } from "./sse";
27
+ import { ingestMessageFrame } from "./blocks";
28
+ import type {
29
+ AgentInvokeAdapters,
30
+ AgentMessage,
31
+ HistoryCard,
32
+ HistoryLoadResult,
33
+ UseAgentInvokeOptions,
34
+ UseAgentInvokeReturn,
35
+ } from "./types";
36
+
37
+ function threadStorageKey(appId: string | undefined): string {
38
+ return `guuey:thread:${appId ?? "default"}`;
39
+ }
40
+
41
+ /** The decision `applyHistoryResult` reaches for a loaded transcript. */
42
+ export type HistoryApplication =
43
+ | { kind: "seed"; messages: AgentMessage[] }
44
+ | { kind: "skip" }
45
+ | { kind: "clear" };
46
+
47
+ /**
48
+ * Pure decision seam for post-hydration history application (see
49
+ * `AgentInvokeHistoryAdapter` in `./types`). `gone` always clears the
50
+ * persisted thread. Otherwise a non-empty transcript seeds the chat UNLESS
51
+ * the chat has already been touched (`currentMessages.length > 0`) — a
52
+ * mid-flight send always beats late-arriving history.
53
+ */
54
+ export function applyHistoryResult(
55
+ result: HistoryLoadResult,
56
+ currentMessages: AgentMessage[],
57
+ ): HistoryApplication {
58
+ if ("gone" in result) return { kind: "clear" };
59
+ if (currentMessages.length > 0 || result.messages.length === 0) return { kind: "skip" };
60
+ return { kind: "seed", messages: result.messages };
61
+ }
62
+
63
+ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeReturn {
64
+ const { endpointUrl, appId } = opts;
65
+ const [messages, setMessages] = useState<AgentMessage[]>([]);
66
+ const [isStreaming, setIsStreaming] = useState(false);
67
+ const [error, setError] = useState<string | null>(null);
68
+ const [threadId, setThreadId] = useState<string | null>(null);
69
+ // Opt-in block-preserving transcript. `reduceResult` follows the
70
+ // null-until-first-valid-AgEvent contract documented on the return type: it
71
+ // starts null and only becomes non-null once the per-conversation reducer
72
+ // folds a valid AgEvent (so it stays null forever in bypass mode).
73
+ const [reduceResult, setReduceResult] = useState<AgReduceResult | null>(null);
74
+ // Persisted generative-UI cards rehydrated from history (see return-type
75
+ // contract). Independent of the live `reduceResult` fold — populated only
76
+ // when a card-carrying history load seeds the transcript.
77
+ const [historyCards, setHistoryCards] = useState<HistoryCard[]>([]);
78
+
79
+ const abortRef = useRef<AbortController | null>(null);
80
+ // Mirror the latest threadId + adapters into refs so `send` reads fresh
81
+ // values without depending on them (keeps the callback identity stable and
82
+ // sidesteps the async-hydration race).
83
+ const threadIdRef = useRef<string | null>(null);
84
+ const adaptersRef = useRef<AgentInvokeAdapters>(opts.adapters);
85
+ adaptersRef.current = opts.adapters;
86
+ // The per-conversation AgJSON reducer (only built when `preserveBlocks`).
87
+ // Lazily (re)created on the first valid AgEvent after a fresh start / reset,
88
+ // so an off run never constructs one and a bypass run never allocates.
89
+ const reducerRef = useRef<Reducer | null>(null);
90
+ const preserveBlocksRef = useRef<boolean>(opts.preserveBlocks ?? false);
91
+ preserveBlocksRef.current = opts.preserveBlocks ?? false;
92
+ // The in-flight threadId hydration for the current appId. `send` awaits it
93
+ // so a fast first send replays the persisted thread instead of minting a
94
+ // new (orphan) one — critical on async stores (AsyncStorage).
95
+ const hydrationRef = useRef<Promise<void> | null>(null);
96
+
97
+ // Hydrate the persisted threadId on mount / app change. Switching apps
98
+ // starts a FRESH session: reset everything first so one agent's thread +
99
+ // transcript never leaks into another (the hook is shared + appId-keyed,
100
+ // and a consumer may swap appId in place without remounting). Tolerates a
101
+ // sync (localStorage) or async (AsyncStorage) store.
102
+ useEffect(() => {
103
+ abortRef.current?.abort();
104
+ threadIdRef.current = null;
105
+ setThreadId(null);
106
+ setMessages([]);
107
+ setError(null);
108
+ setIsStreaming(false);
109
+ // Fresh conversation → drop the old fold; the reducer is rebuilt lazily on
110
+ // the next valid AgEvent. Persisted cards are re-seeded below from history.
111
+ reducerRef.current = null;
112
+ setReduceResult(null);
113
+ setHistoryCards([]);
114
+
115
+ let cancelled = false;
116
+ const key = threadStorageKey(appId);
117
+ const hydration = Promise.resolve(adaptersRef.current.storage.load(key))
118
+ .then((id) => {
119
+ if (!cancelled && id && !threadIdRef.current) {
120
+ threadIdRef.current = id;
121
+ setThreadId(id);
122
+ }
123
+ })
124
+ .catch(() => {
125
+ // Storage unavailable (private mode / keychain error) — no persisted
126
+ // thread; the session simply starts fresh.
127
+ });
128
+ // `hydrationRef` (which `send` awaits) resolves at threadId-load time —
129
+ // history rehydration below is a SEPARATE, un-awaited continuation so a
130
+ // slow history endpoint never gates the user's first send.
131
+ hydrationRef.current = hydration;
132
+
133
+ void hydration.then(async () => {
134
+ // Best-effort transcript rehydration: only runs when a persisted
135
+ // threadId was actually found and a history adapter was supplied.
136
+ // Never throws — a failed/missing history load simply leaves the
137
+ // chat empty and the user starts fresh.
138
+ const tid = threadIdRef.current;
139
+ const history = adaptersRef.current.history;
140
+ if (cancelled || !tid || !history) return;
141
+ let result: HistoryLoadResult;
142
+ try {
143
+ result = await history.load(tid);
144
+ } catch {
145
+ return; // best-effort: offline / transient — chat continues without history
146
+ }
147
+ if (cancelled) return;
148
+ if ("gone" in result) {
149
+ // Ordering intent: only clear if no concurrent send() has since
150
+ // established a fresh threadId (a session event mutates
151
+ // `threadIdRef` + saves it). Clearing/overwriting storage here after
152
+ // that would clobber a freshly-valid id with '' on async stores.
153
+ if (threadIdRef.current !== tid) return;
154
+ threadIdRef.current = null;
155
+ setThreadId(null);
156
+ void adaptersRef.current.storage.save(threadStorageKey(appId), "");
157
+ return;
158
+ }
159
+ // Single decision authority: `applyHistoryResult` runs INSIDE the
160
+ // functional update against the live `prev`, so a mid-flight send()'s
161
+ // optimistic messages always beat late-arriving history.
162
+ setMessages((prev) => {
163
+ const application = applyHistoryResult(result, prev);
164
+ return application.kind === "seed" ? application.messages : prev;
165
+ });
166
+ // Surface any persisted cards the loader opted to include (independent of
167
+ // the text seed decision — cards are their own render lane, never
168
+ // optimistically added by send(), so there is nothing to clobber). Empty
169
+ // when the adapter is text-only (no `cards` key on the result).
170
+ if ("cards" in result && result.cards && result.cards.length > 0) {
171
+ setHistoryCards(result.cards);
172
+ }
173
+ });
174
+ return () => {
175
+ cancelled = true;
176
+ };
177
+ }, [appId]);
178
+
179
+ const abort = useCallback(() => {
180
+ abortRef.current?.abort();
181
+ }, []);
182
+
183
+ const reset = useCallback(() => {
184
+ abortRef.current?.abort();
185
+ // Start a genuinely fresh conversation: forget the durable thread so the
186
+ // next send() mints a new one (not append to the old), clear the
187
+ // persisted key so a reload doesn't rehydrate the old transcript, and
188
+ // wipe the visible state.
189
+ threadIdRef.current = null;
190
+ setThreadId(null);
191
+ void adaptersRef.current.storage.save(threadStorageKey(appId), "");
192
+ setMessages([]);
193
+ setError(null);
194
+ setIsStreaming(false);
195
+ // Re-create the reducer for the new conversation (rebuilt lazily on the
196
+ // next valid AgEvent) and clear the exposed fold + any rehydrated cards.
197
+ reducerRef.current = null;
198
+ setReduceResult(null);
199
+ setHistoryCards([]);
200
+ }, [appId]);
201
+
202
+ const send = useCallback(
203
+ async (input: string) => {
204
+ if (!endpointUrl || !input.trim() || isStreaming) return;
205
+ setError(null);
206
+ setIsStreaming(true);
207
+ setMessages((prev) => [...prev, { role: "user", text: input }, { role: "assistant", text: "" }]);
208
+
209
+ const controller = new AbortController();
210
+ abortRef.current = controller;
211
+ const adapters = adaptersRef.current;
212
+ // Wait for the persisted threadId to load before deciding whether to
213
+ // replay it — otherwise a fast first send mints a new orphan thread and
214
+ // clobbers the stored id. `hydrationRef` never rejects (it self-catches).
215
+ if (hydrationRef.current) {
216
+ await hydrationRef.current;
217
+ }
218
+ if (controller.signal.aborted) {
219
+ setIsStreaming(false);
220
+ abortRef.current = null;
221
+ return;
222
+ }
223
+ let assistantText = "";
224
+ const renderAssistant = (text: string) => {
225
+ assistantText = text;
226
+ setMessages((prev) => {
227
+ const next = prev.slice();
228
+ // The trailing entry is the assistant bubble we just pushed.
229
+ next[next.length - 1] = { role: "assistant", text };
230
+ return next;
231
+ });
232
+ };
233
+
234
+ try {
235
+ // The endpointUrl may be a pod base (`https://host`) or the full
236
+ // invoke URL the deploy-controller records (`https://host/agent/invoke`).
237
+ // Normalize to exactly one `/agent/invoke`.
238
+ const base = endpointUrl.replace(/\/+$/, "");
239
+ const invokeUrl = base.endsWith("/agent/invoke") ? base : `${base}/agent/invoke`;
240
+ const body = {
241
+ input,
242
+ ...(threadIdRef.current ? { threadId: threadIdRef.current } : {}),
243
+ clientMessageId: adapters.generateId(),
244
+ };
245
+
246
+ let buffer = "";
247
+ for await (const chunk of adapters.transport({ url: invokeUrl, body, signal: controller.signal })) {
248
+ buffer += chunk;
249
+ const { events, rest } = parseSseEvents(buffer);
250
+ buffer = rest;
251
+ for (const ev of events) {
252
+ if (ev.event === "session") {
253
+ const tid = stringField(ev.data, "threadId");
254
+ if (tid) {
255
+ threadIdRef.current = tid;
256
+ setThreadId(tid);
257
+ void adapters.storage.save(threadStorageKey(appId), tid);
258
+ }
259
+ } else if (ev.event === "message") {
260
+ renderAssistant(reduceAssistantText(assistantText, ev.data));
261
+ // Additively fold the SAME frame into the AgJSON reducer when
262
+ // opted in. The text surface above is untouched; only VALID
263
+ // AgEvents advance the reducer (bypass frames ingest to [] and
264
+ // leave `reduceResult` null — see the return-type contract).
265
+ if (preserveBlocksRef.current) {
266
+ const agEvents = ingestMessageFrame(ev.data);
267
+ if (agEvents.length > 0) {
268
+ if (!reducerRef.current) reducerRef.current = new Reducer();
269
+ for (const agEvent of agEvents) reducerRef.current.push(agEvent);
270
+ setReduceResult(reducerRef.current.result());
271
+ }
272
+ }
273
+ } else if (ev.event === "error") {
274
+ setError(stringField(ev.data, "message") ?? "agent error");
275
+ }
276
+ // `done` needs no handling — the stream closes after it.
277
+ }
278
+ }
279
+ } catch (e) {
280
+ if (!controller.signal.aborted) {
281
+ setError(e instanceof Error ? e.message : "failed to reach agent");
282
+ }
283
+ } finally {
284
+ setIsStreaming(false);
285
+ abortRef.current = null;
286
+ // A turn aborted before any assistant text streamed leaves an empty
287
+ // placeholder bubble — drop it so a stopped turn doesn't linger as a
288
+ // blank assistant message.
289
+ if (controller.signal.aborted && assistantText === "") {
290
+ setMessages((prev) => {
291
+ const last = prev[prev.length - 1];
292
+ return last && last.role === "assistant" && last.text === ""
293
+ ? prev.slice(0, -1)
294
+ : prev;
295
+ });
296
+ }
297
+ }
298
+ },
299
+ [endpointUrl, appId, isStreaming],
300
+ );
301
+
302
+ return { messages, send, isStreaming, error, threadId, abort, reset, reduceResult, historyCards };
303
+ }