@guuey/agent-client 0.7.1 → 0.8.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.
@@ -135,6 +135,16 @@ export function __resetReaderEndpointWarning() {
135
135
  function toUiResourceUrl(endpointUrl) {
136
136
  return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/ui-resource");
137
137
  }
138
+ /** `<pod base>/agent/ui-action` — the live ACTION door (guuey#222), the read door's twin. */
139
+ function toUiActionUrl(endpointUrl) {
140
+ return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/ui-action");
141
+ }
142
+ /** Warn once per module load — sibling of the reader's flag; per-surface, not per-click. */
143
+ let relayEndpointWarned = false;
144
+ /** @internal test seam — the once-flag is module state; suites reset it between cases. */
145
+ export function __resetRelayEndpointWarning() {
146
+ relayEndpointWarned = false;
147
+ }
138
148
  /**
139
149
  * Build a `UiResourceReader` over guuey's authenticated resources/read
140
150
  * doors — the pod door for LIVE turns (guuey#209 C1:
@@ -261,8 +271,37 @@ export function createUiResourceReader(options) {
261
271
  */
262
272
  export function createUiActionRelay(options) {
263
273
  const fetchImpl = options.fetchImpl ?? fetch;
264
- const callTool = async (uri, name, args) => {
274
+ // A platform door without a pod door is almost always a live surface
275
+ // that forgot `endpointUrl` — its cards' clicks would go nowhere for the
276
+ // whole mid-turn window (guuey#222). `null` is the explicit "history-only
277
+ // viewer, there is no pod" opt-out; `undefined` is the forgotten case.
278
+ if (options.endpointUrl === undefined && !relayEndpointWarned) {
279
+ relayEndpointWarned = true;
280
+ console.warn("createUiActionRelay: no `endpointUrl` — a click on a card produced mid-turn cannot reach the agent until the turn completes (the pod door is the only authority while a turn streams; post-turn clicks reach the platform door). Pass the surface's invoke endpoint, or `endpointUrl: null` to declare a history-only viewer.");
281
+ }
282
+ /**
283
+ * One door: POST + the reader's 401-forceRefresh recovery. Returns the
284
+ * parsed result on 2xx, `"miss"` on 404 (the pod's "not live / not yours /
285
+ * past grace" — deny==miss, so the NEXT door may still answer), and
286
+ * `undefined` for every other failure (terminal: the host relay answers
287
+ * in-band as an `isError` result, never a thrown error into the sandbox
288
+ * bridge). A pod 502 UPSTREAM_UNAVAILABLE is a real failure, not a miss —
289
+ * the persisted door cannot relay a mid-turn click either, so falling
290
+ * through would only trade one honest error for a misleading 404.
291
+ */
292
+ const postDoor = async (requestUrl, body) => {
293
+ // Exactly ONE identity carrier per call — the reader's rule verbatim:
294
+ // bearer → guest header → else cookie credentials (the HttpOnly
295
+ // `guuey_guest` cookie the pod mints for anonymous browser callers).
296
+ // Without the third arm a cookie-mode guest POSTed identity-less and
297
+ // every click failed auth (the guuey#221 class, on the relay). A JSON
298
+ // POST is always preflighted, so unlike the reader's GET this arm can
299
+ // never be a CORS "simple request" — which is fine because both doors
300
+ // answer a credentialed preflight: the pod echoes origin +
301
+ // `Access-Control-Allow-Credentials` on OPTIONS and every status, and
302
+ // the platform door's own OPTIONS branch does the same (guuey#224).
265
303
  const headers = { "content-type": "application/json" };
304
+ const init = { method: "POST", headers, body };
266
305
  const token = options.getAccessToken ? await options.getAccessToken() : null;
267
306
  const guest = sendableGuestSecret(options.guestSecret);
268
307
  if (token) {
@@ -271,17 +310,19 @@ export function createUiActionRelay(options) {
271
310
  else if (guest) {
272
311
  headers[GUEST_HEADER] = guest;
273
312
  }
274
- const requestUrl = `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-action`;
275
- const body = JSON.stringify({ uri, name, ...(args !== undefined ? { arguments: args } : {}) });
313
+ else {
314
+ init.credentials = "include";
315
+ }
276
316
  let res;
277
317
  try {
278
- res = await fetchImpl(requestUrl, { method: "POST", headers, body });
318
+ res = await fetchImpl(requestUrl, init);
279
319
  }
280
320
  catch {
281
321
  return undefined; // transport failure — the host relay answers in-band
282
322
  }
283
323
  // One forceRefresh retry on 401 with a bearer in play — the same
284
- // expired-but-refreshable recovery the reader performs.
324
+ // expired-but-refreshable recovery the reader performs. The retry
325
+ // carries the fresh bearer and nothing else (same one-carrier rule).
285
326
  if (res.status === 401 && options.getAccessToken) {
286
327
  const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
287
328
  if (fresh) {
@@ -297,14 +338,120 @@ export function createUiActionRelay(options) {
297
338
  }
298
339
  }
299
340
  }
341
+ if (res.status === 404)
342
+ return "miss";
300
343
  if (!res.ok)
301
344
  return undefined;
302
345
  try {
303
- return (await res.json());
346
+ return { kind: "result", value: (await res.json()) };
304
347
  }
305
348
  catch {
306
349
  return undefined;
307
350
  }
308
351
  };
352
+ const podUrl = options.endpointUrl ? toUiActionUrl(options.endpointUrl) : null;
353
+ const callTool = async (uri, name, args) => {
354
+ // The kit sends only what the click carries; the pod overwrites any
355
+ // sessionId/appId from the authorized locator + its own binding.
356
+ const body = JSON.stringify({ uri, name, ...(args !== undefined ? { arguments: args } : {}) });
357
+ if (podUrl !== null) {
358
+ const live = await postDoor(podUrl, body);
359
+ if (live === undefined)
360
+ return undefined; // terminal on the pod — no fall-through
361
+ if (live !== "miss")
362
+ return live.value;
363
+ // 404 → not live here (completed turn past grace, or never live):
364
+ // the persisted door owns it.
365
+ }
366
+ const persisted = await postDoor(`${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-action`, body);
367
+ return persisted === undefined || persisted === "miss" ? undefined : persisted.value;
368
+ };
309
369
  return createMcpUiActionRelay({ callTool });
310
370
  }
371
+ /** `<pod base>/agent/hitl-answer` — the AgJSON HITL answer door (guuey#207). */
372
+ function toHitlAnswerUrl(endpointUrl) {
373
+ return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/hitl-answer");
374
+ }
375
+ /**
376
+ * Build the client→pod channel for AgJSON HITL answers (guuey#207): `POST
377
+ * <pod>/agent/hitl-answer` with the spec {@link AgHitlAnswer} the kit's
378
+ * `answerHitlPrompt` constructed (already validated against the ask's
379
+ * persisted declaration). The pod owns EVERYTHING trust-shaped — caller
380
+ * identity (the same three families as the invoke), which ask it minted,
381
+ * the thread a `once` grant binds to, the access level written — this
382
+ * transport only carries the surface's existing credential under the
383
+ * one-carrier rule (bearer → guest header → cookie), with the card relays'
384
+ * single 401 forceRefresh retry.
385
+ *
386
+ * Today the only producer is the pod's cross-app profile consent ask (the
387
+ * three-mode grant), whose answer resolves into the caller's own
388
+ * `ProfileGrant` row; the channel is generic by construction — any future
389
+ * `hitl.ask` the runtime emits is answered through this same door.
390
+ */
391
+ export function createHitlAnswerRelay(options) {
392
+ const fetchImpl = options.fetchImpl ?? fetch;
393
+ const url = toHitlAnswerUrl(options.endpointUrl);
394
+ return async (answer) => {
395
+ const headers = { "content-type": "application/json" };
396
+ const body = JSON.stringify(answer);
397
+ const init = { method: "POST", headers, body };
398
+ const token = options.getAccessToken ? await options.getAccessToken() : null;
399
+ const guest = sendableGuestSecret(options.guestSecret);
400
+ if (token) {
401
+ headers["authorization"] = `Bearer ${token}`;
402
+ }
403
+ else if (guest) {
404
+ headers[GUEST_HEADER] = guest;
405
+ }
406
+ else {
407
+ init.credentials = "include";
408
+ }
409
+ let res;
410
+ try {
411
+ res = await fetchImpl(url, init);
412
+ }
413
+ catch (err) {
414
+ return { ok: false, status: 0, code: null, message: err instanceof Error ? err.message : String(err) };
415
+ }
416
+ if (res.status === 401 && options.getAccessToken) {
417
+ const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
418
+ if (fresh) {
419
+ try {
420
+ res = await fetchImpl(url, {
421
+ method: "POST",
422
+ headers: { ...headers, authorization: `Bearer ${fresh}` },
423
+ body,
424
+ });
425
+ }
426
+ catch (err) {
427
+ return { ok: false, status: 0, code: null, message: err instanceof Error ? err.message : String(err) };
428
+ }
429
+ }
430
+ }
431
+ let parsed = undefined;
432
+ try {
433
+ parsed = await res.json();
434
+ }
435
+ catch {
436
+ parsed = undefined;
437
+ }
438
+ if (res.ok) {
439
+ const b = (parsed ?? {});
440
+ return {
441
+ ok: true,
442
+ body: {
443
+ askId: typeof b.askId === "string" ? b.askId : answer.askId,
444
+ status: b.status === "resolved" || b.status === "declined" || b.status === "cancelled" ? b.status : answer.status,
445
+ ...(typeof b.mode === "string" ? { mode: b.mode } : {}),
446
+ },
447
+ };
448
+ }
449
+ const env = (parsed ?? {});
450
+ return {
451
+ ok: false,
452
+ status: res.status,
453
+ code: typeof env.code === "string" ? env.code : null,
454
+ message: typeof env.message === "string" ? env.message : `hitl-answer failed (${res.status})`,
455
+ };
456
+ };
457
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guuey/agent-client",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Client SDK for Guuey's agent runtime: the `useAgentInvoke` React hook + pure SSE helpers that speak the /agent/invoke streaming contract, plus the paginated thread-history read plane. Host adapters (storage / id / transport) are injected, so it runs on web (Next) and React Native alike.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@silverprotocol/core": "0.5.0",
38
- "@guuey/mcp-apps-host": "0.7.1"
38
+ "@guuey/mcp-apps-host": "0.8.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": ">=18"
@@ -53,6 +53,12 @@ export const AGENT_ERROR_CODES = {
53
53
  DRAINING: "DRAINING",
54
54
  /** Refused for this caller — e.g. the link-prompt dismiss route's byo-only rule. */
55
55
  FORBIDDEN: "FORBIDDEN",
56
+ /**
57
+ * The consent-answer door's deny==miss (guuey#207): the `AgHitlAnswer` names
58
+ * an ask this pod did not mint (wrong app, a thread the caller does not
59
+ * own, or a pod that takes no profile consent). Never an existence oracle.
60
+ */
61
+ NOT_FOUND: "NOT_FOUND",
56
62
  /** The turn ran past the pod's wall-clock budget. */
57
63
  TIMEOUT: "TIMEOUT",
58
64
  /** A guuey-side dependency failed (not the agent's own code). */
package/src/index.ts CHANGED
@@ -3,7 +3,6 @@ export {
3
3
  extractAssistantText,
4
4
  reduceAssistantText,
5
5
  stringField,
6
- parseConsentRequest,
7
6
  parseLinkRequest,
8
7
  type ParsedSseEvent,
9
8
  } from "./sse.js";
@@ -12,6 +11,9 @@ export { dismissLinkPrompt } from "./link-prompt.js";
12
11
  // wraps, for hosts that drive their own turn state machine (guuey#186 G5).
13
12
  export { invokeTurn, toInvokeUrl, type InvokeTurnEvent } from "./invoke-turn.js";
14
13
  export {
14
+ createHitlAnswerRelay,
15
+ type CreateHitlAnswerRelayOptions,
16
+ type HitlAnswerRelayResult,
15
17
  createUiActionRelay,
16
18
  type CreateUiActionRelayOptions,
17
19
  createUiResourceReader,
@@ -74,11 +76,20 @@ export { sortHistoryCards, toolNameFor } from "./history.js";
74
76
  // host folding `invokeTurn`'s agEvents outside the hook builds its transcript
75
77
  // on the same terms (the types alone forced the direct dep back, guuey#186 G4).
76
78
  export { Reducer } from "@silverprotocol/core";
77
- export type { AgEvent, AgReduceResult, AgMessage, AgBlock } from "@silverprotocol/core";
79
+ export type {
80
+ AgEvent,
81
+ AgReduceResult,
82
+ AgMessage,
83
+ AgBlock,
84
+ // The client→pod capability advertisement + the HITL answer the relay
85
+ // delivers (guuey#207) — re-exported so a host names them without a direct
86
+ // `@silverprotocol/core` import.
87
+ AgClientCapabilities,
88
+ AgHitlAnswer,
89
+ } from "@silverprotocol/core";
78
90
  export type {
79
91
  AgentMessage,
80
92
  HistoryCard,
81
- ProfileConsentRequest,
82
93
  ProfileLinkRequest,
83
94
  ThreadIdStore,
84
95
  GenerateId,
@@ -23,21 +23,9 @@
23
23
  * fall-through, so new wire events are additive for every consumer.
24
24
  */
25
25
  import type { AgEvent } from "@silverprotocol/core";
26
- import {
27
- parseConsentRequest,
28
- parseLinkRequest,
29
- parseSseEvents,
30
- reduceAssistantText,
31
- stringField,
32
- } from "./sse.js";
26
+ import { parseLinkRequest, parseSseEvents, reduceAssistantText, stringField } from "./sse.js";
33
27
  import { ingestMessageFrame } from "./blocks.js";
34
- import type {
35
- AgentInvokeStatus,
36
- InvokeRequest,
37
- InvokeTransport,
38
- ProfileConsentRequest,
39
- ProfileLinkRequest,
40
- } from "./types.js";
28
+ import type { AgentInvokeStatus, InvokeRequest, InvokeTransport, ProfileLinkRequest } from "./types.js";
41
29
 
42
30
  /**
43
31
  * One semantic step of a turn. Field conventions:
@@ -63,7 +51,6 @@ export type InvokeTurnEvent =
63
51
  agEvents: AgEvent[];
64
52
  }
65
53
  | { kind: "error"; message: string; code: string | null }
66
- | { kind: "profile-consent"; request: ProfileConsentRequest }
67
54
  | { kind: "profile-link"; request: ProfileLinkRequest }
68
55
  | { kind: "done"; stopReason: string | null };
69
56
 
@@ -164,15 +151,12 @@ export async function* invokeTurn(
164
151
  message: stringField(ev.data, "message") ?? "agent error",
165
152
  code: stringField(ev.data, "code") ?? null,
166
153
  };
167
- } else if (ev.event === "profile-consent-needed") {
168
- // Cross-app profile consent ask (T6). Only a well-formed payload
169
- // yields; a malformed one is dropped, leaving any prior valid
170
- // request untouched (never clobbered to null).
171
- const parsed = parseConsentRequest(ev.data);
172
- if (parsed) yield { kind: "profile-consent", request: parsed };
173
154
  } else if (ev.event === "profile-link-needed") {
174
155
  // Cross-app profile LINK invite (linkcoh T3) for an unlinked byo
175
- // caller. Same drop-if-malformed contract as consent above.
156
+ // caller. Only a well-formed payload yields; a malformed one is
157
+ // dropped, leaving any prior valid request untouched (never clobbered
158
+ // to null). (Consent is NOT a bespoke event: it rides the AgJSON fold
159
+ // as `hitl.ask` + `turn.done outcome:"paused"` — guuey#207.)
176
160
  const parsed = parseLinkRequest(ev.data);
177
161
  if (parsed) yield { kind: "profile-link", request: parsed };
178
162
  } else if (ev.event === "done") {
package/src/react.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  type HistoryApplication,
13
13
  stallProbeDecision,
14
14
  STALL_RECOVERY_DEFAULTS,
15
+ DEFAULT_BLOCK_PRESERVING_CAPABILITIES,
15
16
  } from "./useAgentInvoke.js";
16
17
  // The block-preserving transcript surfaces `AgReduceResult`; re-export it (and
17
18
  // `AgEvent`) here so `./react` consumers can type `reduceResult` without a
package/src/sse.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * verbatim across web (Studio) and React-Native (Portal).
5
5
  */
6
6
 
7
- import type { ProfileConsentRequest, ProfileLinkRequest } from "./types.js";
7
+ import type { ProfileLinkRequest } from "./types.js";
8
8
 
9
9
  export interface ParsedSseEvent {
10
10
  event: string;
@@ -140,30 +140,13 @@ export function stringField(data: unknown, key: string): string | undefined {
140
140
  return typeof v === "string" ? v : undefined;
141
141
  }
142
142
 
143
- /**
144
- * Parse a `profile-consent-needed` SSE payload into a typed
145
- * {@link ProfileConsentRequest}, or `null` if it does not conform. `appId`
146
- * must be a non-empty string and `requested` exactly `"read"` or
147
- * `"read-write"`; extra keys are tolerated (ignored). Returns a fresh
148
- * normalized object so callers get exactly the typed shape, never the raw
149
- * wire payload with unknown extras.
150
- */
151
- export function parseConsentRequest(data: unknown): ProfileConsentRequest | null {
152
- if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
153
- const appId = (data as { appId?: unknown }).appId;
154
- const requested = (data as { requested?: unknown }).requested;
155
- if (typeof appId !== "string" || appId.length === 0) return null;
156
- if (requested !== "read" && requested !== "read-write") return null;
157
- return { appId, requested };
158
- }
159
-
160
143
  /**
161
144
  * Parse a `profile-link-needed` SSE payload into a typed
162
- * {@link ProfileLinkRequest}, or `null` if it does not conform. Same shape +
163
- * validation as {@link parseConsentRequest} (`appId` non-empty string,
164
- * `requested` exactly `"read"` or `"read-write"`, extra keys tolerated) the
165
- * pod emits an identically-shaped payload for both events; only the event
166
- * NAME (and what it means to the consumer) differs.
145
+ * {@link ProfileLinkRequest}, or `null` if it does not conform. `appId` must
146
+ * be a non-empty string and `requested` exactly `"read"` or `"read-write"`;
147
+ * extra keys are tolerated (ignored). Returns a fresh normalized object so
148
+ * callers get exactly the typed shape, never the raw wire payload with
149
+ * unknown extras.
167
150
  */
168
151
  export function parseLinkRequest(data: unknown): ProfileLinkRequest | null {
169
152
  if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
package/src/types.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * `MessageStorageAdapter` injection pattern.
14
14
  */
15
15
 
16
- import type { AgReduceResult, JsonValue } from "@silverprotocol/core";
16
+ import type { AgClientCapabilities, AgReduceResult, JsonValue } from "@silverprotocol/core";
17
17
 
18
18
  /** A flat chat turn as rendered by the consumer UI. */
19
19
  export interface AgentMessage {
@@ -28,20 +28,6 @@ export interface AgentMessage {
28
28
  clientMessageId?: string;
29
29
  }
30
30
 
31
- /**
32
- * A cross-app profile consent request surfaced mid-stream by the pod's
33
- * `profile-consent-needed` SSE event (nocode-runtime T6). Emitted when the
34
- * agent declares a profile intent the caller has NOT yet granted for this app,
35
- * so the consumer UI can prompt the user to authorize `read` or `read-write`
36
- * access. `requested` mirrors the pod's `ProfileAccess` posture verbatim; the
37
- * literal union is inlined rather than imported to keep this client SDK free of
38
- * any backend-package dependency.
39
- */
40
- export interface ProfileConsentRequest {
41
- appId: string;
42
- requested: "read" | "read-write";
43
- }
44
-
45
31
  /**
46
32
  * A cross-app profile LINK invite surfaced mid-stream by the pod's
47
33
  * `profile-link-needed` SSE event (nocode-runtime linkcoh T3). Emitted when an
@@ -49,8 +35,10 @@ export interface ProfileConsentRequest {
49
35
  * link their guuey account (via the named `/link` ceremony) so they earn the
50
36
  * guuey-wide cross-app profile. `requested` mirrors the pod's `ProfileAccess`
51
37
  * posture verbatim (the builder's declared access, not a live ask) — the
52
- * literal union is inlined rather than imported, same rationale as
53
- * {@link ProfileConsentRequest}.
38
+ * literal union is inlined rather than imported to keep this client SDK free
39
+ * of any backend-package dependency. (Consent itself is no longer a bespoke
40
+ * event: it rides AgJSON `hitl.ask` + `turn.done outcome:"paused"` in the
41
+ * fold and is answered through `createHitlAnswerRelay` — guuey#207.)
54
42
  */
55
43
  export interface ProfileLinkRequest {
56
44
  appId: string;
@@ -87,7 +75,7 @@ export type GenerateId = () => string;
87
75
  export interface InvokeRequest {
88
76
  /** Fully-resolved POST target (already normalised to end in `/agent/invoke`). */
89
77
  url: string;
90
- /** JSON request body: `{ input, threadId?, clientMessageId }`. */
78
+ /** JSON request body: `{ input, threadId?, clientMessageId, capabilities? }`. */
91
79
  body: unknown;
92
80
  /** Aborts the in-flight stream. */
93
81
  signal: AbortSignal;
@@ -165,6 +153,18 @@ export interface UseAgentInvokeOptions {
165
153
  * reducer is never constructed and the text behaviour is byte-identical.
166
154
  */
167
155
  preserveBlocks?: boolean;
156
+ /**
157
+ * The AgJSON client capabilities advertised on every invoke body (spec §3
158
+ * `AgClientCapabilities`, guuey#207). The pod reads them to decide what it
159
+ * may ask: today it declares consent `grantModes` (the three-mode profile
160
+ * grant the `@guuey/chat` card renders) ONLY to a client that advertised
161
+ * `hitl.grantModes` — a client that cannot render the decision surface is
162
+ * simply not asked. Default: when {@link preserveBlocks} is on,
163
+ * `{ hitl: { ask: true, grantModes: true } }` (a block-preserving consumer
164
+ * folds the paused turn the ask rides on — the kit's exact path); when off,
165
+ * nothing is advertised. Pass an explicit object to override either way.
166
+ */
167
+ capabilities?: AgClientCapabilities;
168
168
  /**
169
169
  * Stall recovery for a half-dead stream (guuey#192). A connection that dies
170
170
  * WITHOUT erroring (TCP alive, zero bytes, no `done`) would otherwise leave
@@ -283,26 +283,16 @@ export interface UseAgentInvokeReturn {
283
283
  * `reset()` clears it back to `[]`.
284
284
  */
285
285
  historyCards: HistoryCard[];
286
- /**
287
- * The latest cross-app profile consent request the pod asked for on THIS
288
- * conversation, or `null`. Set from a well-formed `profile-consent-needed`
289
- * SSE event (see {@link ProfileConsentRequest}); malformed payloads are
290
- * dropped and leave the field untouched. `reset()` and an app switch clear
291
- * it back to `null`. Consumers that never render a consent prompt (e.g.
292
- * Studio) simply ignore this field.
293
- */
294
- profileConsentRequest: ProfileConsentRequest | null;
295
- /** Dismiss the pending {@link profileConsentRequest} (back to `null`). */
296
- clearProfileConsentRequest: () => void;
297
286
  /**
298
287
  * The latest cross-app profile LINK invite the pod asked for on THIS
299
288
  * conversation, or `null`. Set from a well-formed `profile-link-needed`
300
289
  * SSE event (see {@link ProfileLinkRequest}); malformed payloads are
301
290
  * dropped and leave the field untouched. `reset()` and an app switch clear
302
291
  * it back to `null`. Consumers that never render a link prompt simply
303
- * ignore this field. Distinct from {@link profileConsentRequest}: this one
304
- * invites an UNLINKED byo user to link their account; consent asks an
305
- * already-linked user to grant an app read/read-write access.
292
+ * ignore this field. Distinct from consent (an AgJSON `hitl.ask` in the
293
+ * fold, guuey#207): this one invites an UNLINKED byo user to link their
294
+ * account; consent asks an already-linked user to grant an app
295
+ * read/read-write access.
306
296
  */
307
297
  profileLinkRequest: ProfileLinkRequest | null;
308
298
  /** Dismiss the pending {@link profileLinkRequest} (back to `null`). */
@@ -5,7 +5,7 @@
5
5
  * ggui generative-UI protocol that `@ggui-ai/mcp-apps-react`'s useInvoke targets):
6
6
  *
7
7
  * POST {endpointUrl}/agent/invoke
8
- * body: { input, threadId?, clientMessageId }
8
+ * body: { input, threadId?, clientMessageId, capabilities? }
9
9
  * ← SSE:
10
10
  * event: session { sessionId, userId, threadId? }
11
11
  * event: message <SDKMessage JSON> (assistant turns + result)
@@ -22,7 +22,7 @@
22
22
  * `./web-adapters` for the web (Studio) bundle; Portal supplies RN adapters.
23
23
  */
24
24
  import { useCallback, useEffect, useRef, useState } from "react";
25
- import { Reducer, type AgReduceResult } from "@silverprotocol/core";
25
+ import { Reducer, type AgClientCapabilities, type AgReduceResult } from "@silverprotocol/core";
26
26
  import { invokeTurn, toInvokeUrl } from "./invoke-turn.js";
27
27
  import { AgentResponseError } from "./errors.js";
28
28
  import { withActivityObserver } from "./transport.js";
@@ -33,7 +33,6 @@ import type {
33
33
  AgentMessage,
34
34
  HistoryCard,
35
35
  HistoryLoadResult,
36
- ProfileConsentRequest,
37
36
  ProfileLinkRequest,
38
37
  StallRecoveryOptions,
39
38
  UseAgentInvokeOptions,
@@ -44,6 +43,16 @@ function threadStorageKey(appId: string | undefined): string {
44
43
  return `guuey:thread:${appId ?? "default"}`;
45
44
  }
46
45
 
46
+ /**
47
+ * What a block-preserving consumer advertises by default (guuey#207): it
48
+ * folds `turn.done outcome:"paused"` records, so it can render the AgJSON
49
+ * hitl card with declared grant modes — the `@guuey/chat` path. See
50
+ * `UseAgentInvokeOptions.capabilities`.
51
+ */
52
+ export const DEFAULT_BLOCK_PRESERVING_CAPABILITIES: AgClientCapabilities = {
53
+ hitl: { ask: true, grantModes: true },
54
+ };
55
+
47
56
  /** The decision `applyHistoryResult` reaches for a loaded transcript. */
48
57
  export type HistoryApplication =
49
58
  | { kind: "seed"; messages: AgentMessage[] }
@@ -136,14 +145,11 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
136
145
  // contract). Independent of the live `reduceResult` fold — populated only
137
146
  // when a card-carrying history load seeds the transcript.
138
147
  const [historyCards, setHistoryCards] = useState<HistoryCard[]>([]);
139
- // The pod's latest cross-app profile consent ask on this conversation (T6's
140
- // `profile-consent-needed` SSE event), or null. Cleared on app switch /
141
- // reset / explicit dismiss. Consumers with no consent UI just ignore it.
142
- const [profileConsentRequest, setProfileConsentRequest] = useState<ProfileConsentRequest | null>(null);
143
148
  // The pod's latest cross-app profile LINK invite on this conversation (T3's
144
149
  // `profile-link-needed` SSE event), or null. Cleared on app switch / reset /
145
- // explicit dismiss, same lifecycle as `profileConsentRequest` the two are
146
- // independent (an unlinked-invite vs an already-linked consent ask).
150
+ // explicit dismiss. (Consent is NOT hook state: it rides the AgJSON fold as
151
+ // a paused turn `reduceResult` and is answered through
152
+ // `createHitlAnswerRelay`, guuey#207.)
147
153
  const [profileLinkRequest, setProfileLinkRequest] = useState<ProfileLinkRequest | null>(null);
148
154
  // The last turn's ending posture + the optimistic-send ledger — the
149
155
  // transcript renderer's inputs (guuey#135 wave 3b; see the return-type
@@ -201,8 +207,7 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
201
207
  reducerRef.current = null;
202
208
  setReduceResult(null);
203
209
  setHistoryCards([]);
204
- // A prior app's consent ask must never leak into the new conversation.
205
- setProfileConsentRequest(null);
210
+ // A prior app's link invite must never leak into the new conversation.
206
211
  setProfileLinkRequest(null);
207
212
  setAborted(false);
208
213
  setAdopted(false);
@@ -295,17 +300,12 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
295
300
  reducerRef.current = null;
296
301
  setReduceResult(null);
297
302
  setHistoryCards([]);
298
- setProfileConsentRequest(null);
299
303
  setProfileLinkRequest(null);
300
304
  setAborted(false);
301
305
  setAdopted(false);
302
306
  setSendStates({});
303
307
  }, [appId]);
304
308
 
305
- const clearProfileConsentRequest = useCallback(() => {
306
- setProfileConsentRequest(null);
307
- }, []);
308
-
309
309
  const clearProfileLinkRequest = useCallback(() => {
310
310
  setProfileLinkRequest(null);
311
311
  }, []);
@@ -474,10 +474,17 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
474
474
 
475
475
  try {
476
476
  const invokeUrl = toInvokeUrl(endpointUrl);
477
+ // The advertised AgJSON client capabilities (spec §3, guuey#207): an
478
+ // explicit option wins; else a block-preserving consumer advertises
479
+ // the hitl grant-mode card it can render, and a text-only one nothing.
480
+ const capabilities =
481
+ opts.capabilities ??
482
+ (preserveBlocksRef.current ? DEFAULT_BLOCK_PRESERVING_CAPABILITIES : undefined);
477
483
  const body = {
478
484
  input,
479
485
  ...(threadIdRef.current ? { threadId: threadIdRef.current } : {}),
480
486
  clientMessageId,
487
+ ...(capabilities !== undefined ? { capabilities } : {}),
481
488
  };
482
489
 
483
490
  // The wire walk lives in `invokeTurn` (the pure per-turn generator —
@@ -519,8 +526,6 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
519
526
  // a previous turn's code standing).
520
527
  setError(ev.message);
521
528
  setErrorCode(ev.code);
522
- } else if (ev.kind === "profile-consent") {
523
- setProfileConsentRequest(ev.request);
524
529
  } else if (ev.kind === "profile-link") {
525
530
  setProfileLinkRequest(ev.request);
526
531
  }
@@ -588,8 +593,6 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
588
593
  reset,
589
594
  reduceResult,
590
595
  historyCards,
591
- profileConsentRequest,
592
- clearProfileConsentRequest,
593
596
  profileLinkRequest,
594
597
  clearProfileLinkRequest,
595
598
  aborted,