@guuey/agent-client 0.1.0 → 0.2.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.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Client for the pod's `POST /v1/link-prompt/dismiss` route (nocode-runtime
3
+ * linkcoh T4) — the "don't ask again" action on the
4
+ * {@link ProfileLinkRequest} prompt (see `./sse`'s `parseLinkRequest` and
5
+ * `useAgentInvoke`'s `profileLinkRequest` state). Host-agnostic like
6
+ * {@link fetchThreadHistory} in `./history`: takes a bearer directly (the
7
+ * route authenticates ONLY off the verified bearer — the request body is
8
+ * never read server-side, so there is nothing else to send) and an
9
+ * injectable `fetchImpl` for tests, defaulting to the global `fetch`.
10
+ */
11
+
12
+ /**
13
+ * Dismiss the pending cross-app profile link prompt for the calling byo
14
+ * end-user. `baseUrl` is the public read-plane base already ending in `/v1`
15
+ * (same convention as {@link fetchThreadHistory}'s `baseUrl`); `bearer` is the
16
+ * caller's byo access token, sent as `Authorization: Bearer <bearer>` — the
17
+ * ONLY thing the pod's route reads to determine the dismissal subject.
18
+ *
19
+ * Resolves on a 204 (the route's only success response); throws on any
20
+ * non-2xx status (401 unauthenticated, 403 non-byo caller, 500 write failure).
21
+ */
22
+ export async function dismissLinkPrompt(
23
+ baseUrl: string,
24
+ bearer: string,
25
+ fetchImpl: typeof fetch = fetch,
26
+ ): Promise<void> {
27
+ const res = await fetchImpl(`${baseUrl}/link-prompt/dismiss`, {
28
+ method: "POST",
29
+ headers: { Authorization: `Bearer ${bearer}` },
30
+ });
31
+ if (!res.ok) {
32
+ throw new Error(`link prompt dismiss failed: ${res.status}`);
33
+ }
34
+ }
package/src/sse.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * verbatim across web (Studio) and React-Native (Portal).
5
5
  */
6
6
 
7
+ import type { ProfileConsentRequest, ProfileLinkRequest } from "./types";
8
+
7
9
  export interface ParsedSseEvent {
8
10
  event: string;
9
11
  data: unknown;
@@ -113,3 +115,37 @@ export function stringField(data: unknown, key: string): string | undefined {
113
115
  const v = (data as Record<string, unknown>)[key];
114
116
  return typeof v === "string" ? v : undefined;
115
117
  }
118
+
119
+ /**
120
+ * Parse a `profile-consent-needed` SSE payload into a typed
121
+ * {@link ProfileConsentRequest}, or `null` if it does not conform. `appId`
122
+ * must be a non-empty string and `requested` exactly `"read"` or
123
+ * `"read-write"`; extra keys are tolerated (ignored). Returns a fresh
124
+ * normalized object so callers get exactly the typed shape, never the raw
125
+ * wire payload with unknown extras.
126
+ */
127
+ export function parseConsentRequest(data: unknown): ProfileConsentRequest | null {
128
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
129
+ const appId = (data as { appId?: unknown }).appId;
130
+ const requested = (data as { requested?: unknown }).requested;
131
+ if (typeof appId !== "string" || appId.length === 0) return null;
132
+ if (requested !== "read" && requested !== "read-write") return null;
133
+ return { appId, requested };
134
+ }
135
+
136
+ /**
137
+ * Parse a `profile-link-needed` SSE payload into a typed
138
+ * {@link ProfileLinkRequest}, or `null` if it does not conform. Same shape +
139
+ * validation as {@link parseConsentRequest} (`appId` non-empty string,
140
+ * `requested` exactly `"read"` or `"read-write"`, extra keys tolerated) — the
141
+ * pod emits an identically-shaped payload for both events; only the event
142
+ * NAME (and what it means to the consumer) differs.
143
+ */
144
+ export function parseLinkRequest(data: unknown): ProfileLinkRequest | null {
145
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
146
+ const appId = (data as { appId?: unknown }).appId;
147
+ const requested = (data as { requested?: unknown }).requested;
148
+ if (typeof appId !== "string" || appId.length === 0) return null;
149
+ if (requested !== "read" && requested !== "read-write") return null;
150
+ return { appId, requested };
151
+ }
package/src/types.ts CHANGED
@@ -6,7 +6,10 @@
6
6
  * (which also carries anonymous identity) — are INJECTED by the consumer via
7
7
  * {@link AgentInvokeAdapters}. Web (Studio) passes localStorage / crypto /
8
8
  * credentialed-cookie fetch; React-Native (Portal) passes AsyncStorage /
9
- * getRandomValues / header-identity SSE fetch. This mirrors the ggui
9
+ * getRandomValues / header-identity SSE fetch. Anonymous identity is per-host,
10
+ * not per-platform: a web host with no usable cookie jar (an embedded
11
+ * third-party iframe) carries its own guest secret in a header too — see
12
+ * `createWebAdapters`'s `getGuestSecret`. This mirrors the ggui
10
13
  * `MessageStorageAdapter` injection pattern.
11
14
  */
12
15
 
@@ -18,6 +21,35 @@ export interface AgentMessage {
18
21
  text: string;
19
22
  }
20
23
 
24
+ /**
25
+ * A cross-app profile consent request surfaced mid-stream by the pod's
26
+ * `profile-consent-needed` SSE event (nocode-runtime T6). Emitted when the
27
+ * agent declares a profile intent the caller has NOT yet granted for this app,
28
+ * so the consumer UI can prompt the user to authorize `read` or `read-write`
29
+ * access. `requested` mirrors the pod's `ProfileAccess` posture verbatim; the
30
+ * literal union is inlined rather than imported to keep this client SDK free of
31
+ * any backend-package dependency.
32
+ */
33
+ export interface ProfileConsentRequest {
34
+ appId: string;
35
+ requested: "read" | "read-write";
36
+ }
37
+
38
+ /**
39
+ * A cross-app profile LINK invite surfaced mid-stream by the pod's
40
+ * `profile-link-needed` SSE event (nocode-runtime linkcoh T3). Emitted when an
41
+ * unlinked byo end-user's declared profile posture booted, inviting them to
42
+ * link their guuey account (via the named `/link` ceremony) so they earn the
43
+ * guuey-wide cross-app profile. `requested` mirrors the pod's `ProfileAccess`
44
+ * posture verbatim (the builder's declared access, not a live ask) — the
45
+ * literal union is inlined rather than imported, same rationale as
46
+ * {@link ProfileConsentRequest}.
47
+ */
48
+ export interface ProfileLinkRequest {
49
+ appId: string;
50
+ requested: "read" | "read-write";
51
+ }
52
+
21
53
  /**
22
54
  * A persisted generative-UI card rehydrated from thread history — the verbatim
23
55
  * `AgArtifact` snapshot the pod stored on a `kind: "card"` row, tagged with its
@@ -58,7 +90,7 @@ export interface InvokeRequest {
58
90
  * Opens an invoke request and yields decoded UTF-8 text chunks of the SSE
59
91
  * stream (the hook accumulates + parses frames itself). MUST throw on a
60
92
  * non-OK response or network failure. Owns headers + identity entirely, so
61
- * the hook never sees cookies or bearer tokens.
93
+ * the hook never sees cookies, bearer tokens, or guest secrets.
62
94
  */
63
95
  export type InvokeTransport = (req: InvokeRequest) => AsyncIterable<string>;
64
96
 
@@ -111,10 +143,36 @@ export interface UseAgentInvokeOptions {
111
143
  preserveBlocks?: boolean;
112
144
  }
113
145
 
146
+ /**
147
+ * The per-turn lifecycle (guuey#91), derived ENTIRELY from frames the pod
148
+ * already emits — no protocol addition:
149
+ *
150
+ * - `ready` — no turn in flight (initial, after `done`/failure/abort).
151
+ * - `connecting` — `send()` fired, no `session` frame yet. With
152
+ * scale-to-zero pods this phase can span a cold start, so hosts typically
153
+ * swap to "waking your agent" copy after a few seconds.
154
+ * - `thinking` — the pod is awake and the turn is running, but no text is
155
+ * flowing and no tool is announced (between `session` and the first
156
+ * content, and between a `tool.done` and whatever follows it).
157
+ * - `using-tool` — a `tool.start` frame arrived; {@link UseAgentInvokeReturn.activeTool}
158
+ * carries the wire tool name until the matching `tool.done`.
159
+ * - `responding` — assistant text is arriving (`text.start`/`text.delta`
160
+ * silver frames, or bypass text/assistant frames).
161
+ *
162
+ * Failure keeps its own channel ({@link UseAgentInvokeReturn.error}) — there
163
+ * is deliberately no `error` status: after any terminal outcome the status
164
+ * returns to `ready` so the composer re-enables.
165
+ */
166
+ export type AgentInvokeStatus = "ready" | "connecting" | "thinking" | "using-tool" | "responding";
167
+
114
168
  export interface UseAgentInvokeReturn {
115
169
  messages: AgentMessage[];
116
170
  send: (input: string) => Promise<void>;
117
- isStreaming: boolean;
171
+ /** The per-turn lifecycle — see {@link AgentInvokeStatus}. Anything other
172
+ * than `ready` means a turn is in flight (the old `isStreaming === true`). */
173
+ status: AgentInvokeStatus;
174
+ /** The active tool's wire name while `status === 'using-tool'`, else null. */
175
+ activeTool: string | null;
118
176
  error: string | null;
119
177
  threadId: string | null;
120
178
  /** Abort the in-flight turn (the stream stops; partial text is kept). */
@@ -150,4 +208,28 @@ export interface UseAgentInvokeReturn {
150
208
  * `reset()` clears it back to `[]`.
151
209
  */
152
210
  historyCards: HistoryCard[];
211
+ /**
212
+ * The latest cross-app profile consent request the pod asked for on THIS
213
+ * conversation, or `null`. Set from a well-formed `profile-consent-needed`
214
+ * SSE event (see {@link ProfileConsentRequest}); malformed payloads are
215
+ * dropped and leave the field untouched. `reset()` and an app switch clear
216
+ * it back to `null`. Consumers that never render a consent prompt (e.g.
217
+ * Studio) simply ignore this field.
218
+ */
219
+ profileConsentRequest: ProfileConsentRequest | null;
220
+ /** Dismiss the pending {@link profileConsentRequest} (back to `null`). */
221
+ clearProfileConsentRequest: () => void;
222
+ /**
223
+ * The latest cross-app profile LINK invite the pod asked for on THIS
224
+ * conversation, or `null`. Set from a well-formed `profile-link-needed`
225
+ * SSE event (see {@link ProfileLinkRequest}); malformed payloads are
226
+ * dropped and leave the field untouched. `reset()` and an app switch clear
227
+ * it back to `null`. Consumers that never render a link prompt simply
228
+ * ignore this field. Distinct from {@link profileConsentRequest}: this one
229
+ * invites an UNLINKED byo user to link their account; consent asks an
230
+ * already-linked user to grant an app read/read-write access.
231
+ */
232
+ profileLinkRequest: ProfileLinkRequest | null;
233
+ /** Dismiss the pending {@link profileLinkRequest} (back to `null`). */
234
+ clearProfileLinkRequest: () => void;
153
235
  }
@@ -23,13 +23,22 @@
23
23
  */
24
24
  import { useCallback, useEffect, useRef, useState } from "react";
25
25
  import { Reducer, type AgReduceResult } from "@silverprotocol/core";
26
- import { parseSseEvents, reduceAssistantText, stringField } from "./sse";
26
+ import {
27
+ parseConsentRequest,
28
+ parseLinkRequest,
29
+ parseSseEvents,
30
+ reduceAssistantText,
31
+ stringField,
32
+ } from "./sse";
27
33
  import { ingestMessageFrame } from "./blocks";
28
34
  import type {
29
35
  AgentInvokeAdapters,
36
+ AgentInvokeStatus,
30
37
  AgentMessage,
31
38
  HistoryCard,
32
39
  HistoryLoadResult,
40
+ ProfileConsentRequest,
41
+ ProfileLinkRequest,
33
42
  UseAgentInvokeOptions,
34
43
  UseAgentInvokeReturn,
35
44
  } from "./types";
@@ -63,7 +72,11 @@ export function applyHistoryResult(
63
72
  export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeReturn {
64
73
  const { endpointUrl, appId } = opts;
65
74
  const [messages, setMessages] = useState<AgentMessage[]>([]);
66
- const [isStreaming, setIsStreaming] = useState(false);
75
+ // Per-turn lifecycle (guuey#91) — derived purely from the frames below; see
76
+ // the `AgentInvokeStatus` doc for the state meanings. `activeTool` carries
77
+ // the wire tool name only while status is 'using-tool'.
78
+ const [status, setStatus] = useState<AgentInvokeStatus>("ready");
79
+ const [activeTool, setActiveTool] = useState<string | null>(null);
67
80
  const [error, setError] = useState<string | null>(null);
68
81
  const [threadId, setThreadId] = useState<string | null>(null);
69
82
  // Opt-in block-preserving transcript. `reduceResult` follows the
@@ -75,6 +88,15 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
75
88
  // contract). Independent of the live `reduceResult` fold — populated only
76
89
  // when a card-carrying history load seeds the transcript.
77
90
  const [historyCards, setHistoryCards] = useState<HistoryCard[]>([]);
91
+ // The pod's latest cross-app profile consent ask on this conversation (T6's
92
+ // `profile-consent-needed` SSE event), or null. Cleared on app switch /
93
+ // reset / explicit dismiss. Consumers with no consent UI just ignore it.
94
+ const [profileConsentRequest, setProfileConsentRequest] = useState<ProfileConsentRequest | null>(null);
95
+ // The pod's latest cross-app profile LINK invite on this conversation (T3's
96
+ // `profile-link-needed` SSE event), or null. Cleared on app switch / reset /
97
+ // explicit dismiss, same lifecycle as `profileConsentRequest` — the two are
98
+ // independent (an unlinked-invite vs an already-linked consent ask).
99
+ const [profileLinkRequest, setProfileLinkRequest] = useState<ProfileLinkRequest | null>(null);
78
100
 
79
101
  const abortRef = useRef<AbortController | null>(null);
80
102
  // Mirror the latest threadId + adapters into refs so `send` reads fresh
@@ -83,9 +105,13 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
83
105
  const threadIdRef = useRef<string | null>(null);
84
106
  const adaptersRef = useRef<AgentInvokeAdapters>(opts.adapters);
85
107
  adaptersRef.current = opts.adapters;
86
- // The per-conversation AgJSON reducer (only built when `preserveBlocks`).
108
+ // The per-conversation AgJSON fold (only built when `preserveBlocks`).
87
109
  // Lazily (re)created on the first valid AgEvent after a fresh start / reset,
88
110
  // so an off run never constructs one and a bypass run never allocates.
111
+ // The core Reducer carries `_meta` onto tool-result blocks as of
112
+ // `@silverprotocol/core` 0.4.1 (workspace#9), so BOTH generative-UI channels
113
+ // (MCP-Apps `_meta.ui`, ggui's render bootstrap) survive the fold natively —
114
+ // the old guuey-side `BlockFold` carriage wrapper is deleted.
89
115
  const reducerRef = useRef<Reducer | null>(null);
90
116
  const preserveBlocksRef = useRef<boolean>(opts.preserveBlocks ?? false);
91
117
  preserveBlocksRef.current = opts.preserveBlocks ?? false;
@@ -105,12 +131,16 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
105
131
  setThreadId(null);
106
132
  setMessages([]);
107
133
  setError(null);
108
- setIsStreaming(false);
134
+ setStatus("ready");
135
+ setActiveTool(null);
109
136
  // Fresh conversation → drop the old fold; the reducer is rebuilt lazily on
110
137
  // the next valid AgEvent. Persisted cards are re-seeded below from history.
111
138
  reducerRef.current = null;
112
139
  setReduceResult(null);
113
140
  setHistoryCards([]);
141
+ // A prior app's consent ask must never leak into the new conversation.
142
+ setProfileConsentRequest(null);
143
+ setProfileLinkRequest(null);
114
144
 
115
145
  let cancelled = false;
116
146
  const key = threadStorageKey(appId);
@@ -191,19 +221,30 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
191
221
  void adaptersRef.current.storage.save(threadStorageKey(appId), "");
192
222
  setMessages([]);
193
223
  setError(null);
194
- setIsStreaming(false);
224
+ setStatus("ready");
225
+ setActiveTool(null);
195
226
  // Re-create the reducer for the new conversation (rebuilt lazily on the
196
227
  // next valid AgEvent) and clear the exposed fold + any rehydrated cards.
197
228
  reducerRef.current = null;
198
229
  setReduceResult(null);
199
230
  setHistoryCards([]);
231
+ setProfileConsentRequest(null);
232
+ setProfileLinkRequest(null);
200
233
  }, [appId]);
201
234
 
235
+ const clearProfileConsentRequest = useCallback(() => {
236
+ setProfileConsentRequest(null);
237
+ }, []);
238
+
239
+ const clearProfileLinkRequest = useCallback(() => {
240
+ setProfileLinkRequest(null);
241
+ }, []);
242
+
202
243
  const send = useCallback(
203
244
  async (input: string) => {
204
- if (!endpointUrl || !input.trim() || isStreaming) return;
245
+ if (!endpointUrl || !input.trim() || status !== "ready") return;
205
246
  setError(null);
206
- setIsStreaming(true);
247
+ setStatus("connecting");
207
248
  setMessages((prev) => [...prev, { role: "user", text: input }, { role: "assistant", text: "" }]);
208
249
 
209
250
  const controller = new AbortController();
@@ -216,7 +257,7 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
216
257
  await hydrationRef.current;
217
258
  }
218
259
  if (controller.signal.aborted) {
219
- setIsStreaming(false);
260
+ setStatus("ready");
220
261
  abortRef.current = null;
221
262
  return;
222
263
  }
@@ -250,6 +291,10 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
250
291
  buffer = rest;
251
292
  for (const ev of events) {
252
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");
253
298
  const tid = stringField(ev.data, "threadId");
254
299
  if (tid) {
255
300
  threadIdRef.current = tid;
@@ -257,6 +302,26 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
257
302
  void adapters.storage.save(threadStorageKey(appId), tid);
258
303
  }
259
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
+ }
260
325
  renderAssistant(reduceAssistantText(assistantText, ev.data));
261
326
  // Additively fold the SAME frame into the AgJSON reducer when
262
327
  // opted in. The text surface above is untouched; only VALID
@@ -272,8 +337,21 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
272
337
  }
273
338
  } else if (ev.event === "error") {
274
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);
275
351
  }
276
- // `done` needs no handling — the stream closes after it.
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.
277
355
  }
278
356
  }
279
357
  } catch (e) {
@@ -281,7 +359,8 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
281
359
  setError(e instanceof Error ? e.message : "failed to reach agent");
282
360
  }
283
361
  } finally {
284
- setIsStreaming(false);
362
+ setStatus("ready");
363
+ setActiveTool(null);
285
364
  abortRef.current = null;
286
365
  // A turn aborted before any assistant text streamed leaves an empty
287
366
  // placeholder bubble — drop it so a stopped turn doesn't linger as a
@@ -296,8 +375,23 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
296
375
  }
297
376
  }
298
377
  },
299
- [endpointUrl, appId, isStreaming],
378
+ [endpointUrl, appId, status],
300
379
  );
301
380
 
302
- return { messages, send, isStreaming, error, threadId, abort, reset, reduceResult, historyCards };
381
+ return {
382
+ messages,
383
+ send,
384
+ status,
385
+ activeTool,
386
+ error,
387
+ threadId,
388
+ abort,
389
+ reset,
390
+ reduceResult,
391
+ historyCards,
392
+ profileConsentRequest,
393
+ clearProfileConsentRequest,
394
+ profileLinkRequest,
395
+ clearProfileLinkRequest,
396
+ };
303
397
  }