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