@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.
@@ -15,6 +15,7 @@ import {
15
15
  type ResolvedViewMount,
16
16
  type UiActionRequest,
17
17
  } from "@guuey/mcp-apps-host";
18
+ import type { AgHitlAnswer } from "@silverprotocol/core";
18
19
  import type { AgentInvokeAdapters, InvokeTransport, ThreadIdStore } from "./types.js";
19
20
  import { fetchThreadHistory, HistoryUnauthorizedError } from "./history.js";
20
21
  import { fetchStreamTransport, sendableGuestSecret, GUEST_HEADER } from "./transport.js";
@@ -252,6 +253,18 @@ function toUiResourceUrl(endpointUrl: string): string {
252
253
  return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/ui-resource");
253
254
  }
254
255
 
256
+ /** `<pod base>/agent/ui-action` — the live ACTION door (guuey#222), the read door's twin. */
257
+ function toUiActionUrl(endpointUrl: string): string {
258
+ return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/ui-action");
259
+ }
260
+
261
+ /** Warn once per module load — sibling of the reader's flag; per-surface, not per-click. */
262
+ let relayEndpointWarned = false;
263
+ /** @internal test seam — the once-flag is module state; suites reset it between cases. */
264
+ export function __resetRelayEndpointWarning(): void {
265
+ relayEndpointWarned = false;
266
+ }
267
+
255
268
  /**
256
269
  * Build a `UiResourceReader` over guuey's authenticated resources/read
257
270
  * doors — the pod door for LIVE turns (guuey#209 C1:
@@ -370,6 +383,24 @@ export interface CreateUiActionRelayOptions {
370
383
  apiBaseUrl: string;
371
384
  /** The thread whose persisted cards this relay may act for. */
372
385
  threadId: string;
386
+ /**
387
+ * The surface's invoke endpoint (pod base URL or full `/agent/invoke`
388
+ * URL). When set, actions POST to the POD's live door first
389
+ * (`POST <pod>/agent/ui-action`, guuey#222) — the only authority that
390
+ * can relay a click for a card whose turn is still streaming (persisted
391
+ * `kind:'card'` rows land at turn COMPLETION, so the platform door 404s
392
+ * mid-turn by construction). A pod 404 (not live, or past the ledger's
393
+ * grace window) falls through to the platform door; every other pod
394
+ * answer is terminal for the same reason it would be on the platform
395
+ * door. Absent → platform door only (pre-#222 behavior): **a click on a
396
+ * card produced mid-turn cannot reach the agent until its turn
397
+ * completes** — the exact "no moment where a click both resolves AND
398
+ * finds a live consumer" defect. A live surface MUST pass it; omitting it
399
+ * is only correct for a pure history viewer with no pod. The relay warns
400
+ * once at construction when a platform door is configured without a pod
401
+ * door (same guardrail as {@link createUiResourceReader}).
402
+ */
403
+ endpointUrl?: string | null;
373
404
  /** Signed-in bearer — wins over the guest secret (same rule as the transport). */
374
405
  getAccessToken?: (opts?: { forceRefresh?: boolean }) => Promise<string | null>;
375
406
  /** Caller-owned anonymous guest secret (widget / guest chat). */
@@ -394,29 +425,61 @@ export function createUiActionRelay(
394
425
  options: CreateUiActionRelayOptions,
395
426
  ): (request: UiActionRequest) => Promise<McpToolCallResult> {
396
427
  const fetchImpl = options.fetchImpl ?? fetch;
397
- const callTool = async (
398
- uri: string,
399
- name: string,
400
- args: McpToolStructuredContent | undefined,
401
- ): Promise<unknown> => {
428
+ // A platform door without a pod door is almost always a live surface
429
+ // that forgot `endpointUrl` — its cards' clicks would go nowhere for the
430
+ // whole mid-turn window (guuey#222). `null` is the explicit "history-only
431
+ // viewer, there is no pod" opt-out; `undefined` is the forgotten case.
432
+ if (options.endpointUrl === undefined && !relayEndpointWarned) {
433
+ relayEndpointWarned = true;
434
+ console.warn(
435
+ "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.",
436
+ );
437
+ }
438
+
439
+ /**
440
+ * One door: POST + the reader's 401-forceRefresh recovery. Returns the
441
+ * parsed result on 2xx, `"miss"` on 404 (the pod's "not live / not yours /
442
+ * past grace" — deny==miss, so the NEXT door may still answer), and
443
+ * `undefined` for every other failure (terminal: the host relay answers
444
+ * in-band as an `isError` result, never a thrown error into the sandbox
445
+ * bridge). A pod 502 UPSTREAM_UNAVAILABLE is a real failure, not a miss —
446
+ * the persisted door cannot relay a mid-turn click either, so falling
447
+ * through would only trade one honest error for a misleading 404.
448
+ */
449
+ const postDoor = async (
450
+ requestUrl: string,
451
+ body: string,
452
+ ): Promise<{ kind: "result"; value: unknown } | "miss" | undefined> => {
453
+ // Exactly ONE identity carrier per call — the reader's rule verbatim:
454
+ // bearer → guest header → else cookie credentials (the HttpOnly
455
+ // `guuey_guest` cookie the pod mints for anonymous browser callers).
456
+ // Without the third arm a cookie-mode guest POSTed identity-less and
457
+ // every click failed auth (the guuey#221 class, on the relay). A JSON
458
+ // POST is always preflighted, so unlike the reader's GET this arm can
459
+ // never be a CORS "simple request" — which is fine because both doors
460
+ // answer a credentialed preflight: the pod echoes origin +
461
+ // `Access-Control-Allow-Credentials` on OPTIONS and every status, and
462
+ // the platform door's own OPTIONS branch does the same (guuey#224).
402
463
  const headers: Record<string, string> = { "content-type": "application/json" };
464
+ const init: RequestInit = { method: "POST", headers, body };
403
465
  const token = options.getAccessToken ? await options.getAccessToken() : null;
404
466
  const guest = sendableGuestSecret(options.guestSecret);
405
467
  if (token) {
406
468
  headers["authorization"] = `Bearer ${token}`;
407
469
  } else if (guest) {
408
470
  headers[GUEST_HEADER] = guest;
471
+ } else {
472
+ init.credentials = "include";
409
473
  }
410
- const requestUrl = `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-action`;
411
- const body = JSON.stringify({ uri, name, ...(args !== undefined ? { arguments: args } : {}) });
412
474
  let res: Response;
413
475
  try {
414
- res = await fetchImpl(requestUrl, { method: "POST", headers, body });
476
+ res = await fetchImpl(requestUrl, init);
415
477
  } catch {
416
478
  return undefined; // transport failure — the host relay answers in-band
417
479
  }
418
480
  // One forceRefresh retry on 401 with a bearer in play — the same
419
- // expired-but-refreshable recovery the reader performs.
481
+ // expired-but-refreshable recovery the reader performs. The retry
482
+ // carries the fresh bearer and nothing else (same one-carrier rule).
420
483
  if (res.status === 401 && options.getAccessToken) {
421
484
  const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
422
485
  if (fresh) {
@@ -431,12 +494,147 @@ export function createUiActionRelay(
431
494
  }
432
495
  }
433
496
  }
497
+ if (res.status === 404) return "miss";
434
498
  if (!res.ok) return undefined;
435
499
  try {
436
- return (await res.json()) as unknown;
500
+ return { kind: "result", value: (await res.json()) as unknown };
437
501
  } catch {
438
502
  return undefined;
439
503
  }
440
504
  };
505
+
506
+ const podUrl = options.endpointUrl ? toUiActionUrl(options.endpointUrl) : null;
507
+ const callTool = async (
508
+ uri: string,
509
+ name: string,
510
+ args: McpToolStructuredContent | undefined,
511
+ ): Promise<unknown> => {
512
+ // The kit sends only what the click carries; the pod overwrites any
513
+ // sessionId/appId from the authorized locator + its own binding.
514
+ const body = JSON.stringify({ uri, name, ...(args !== undefined ? { arguments: args } : {}) });
515
+ if (podUrl !== null) {
516
+ const live = await postDoor(podUrl, body);
517
+ if (live === undefined) return undefined; // terminal on the pod — no fall-through
518
+ if (live !== "miss") return live.value;
519
+ // 404 → not live here (completed turn past grace, or never live):
520
+ // the persisted door owns it.
521
+ }
522
+ const persisted = await postDoor(
523
+ `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-action`,
524
+ body,
525
+ );
526
+ return persisted === undefined || persisted === "miss" ? undefined : persisted.value;
527
+ };
441
528
  return createMcpUiActionRelay({ callTool });
442
529
  }
530
+
531
+ /** `<pod base>/agent/hitl-answer` — the AgJSON HITL answer door (guuey#207). */
532
+ function toHitlAnswerUrl(endpointUrl: string): string {
533
+ return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/hitl-answer");
534
+ }
535
+
536
+ /** Options for {@link createHitlAnswerRelay} — the same credential surface as the card relays. */
537
+ export interface CreateHitlAnswerRelayOptions {
538
+ /** The surface's invoke endpoint (pod base URL or full `/agent/invoke` URL) — the answer door lives on the pod. */
539
+ endpointUrl: string;
540
+ /** Signed-in bearer — wins over the guest secret (same rule as the transport). */
541
+ getAccessToken?: (opts?: { forceRefresh?: boolean }) => Promise<string | null>;
542
+ /** Caller-owned anonymous guest secret (widget / guest chat). */
543
+ guestSecret?: string | null;
544
+ /** Injectable for tests. */
545
+ fetchImpl?: typeof fetch;
546
+ }
547
+
548
+ /**
549
+ * The pod's answer to a delivered {@link AgHitlAnswer}. `ok` carries the
550
+ * body the door returns (`askId`, echoed `status`, and — for a recorded
551
+ * consent — the grant `mode` written); every non-2xx collapses to the pod's
552
+ * `{ code, message }` envelope (the same vocabulary as `AGENT_ERROR_CODES`,
553
+ * e.g. `NOT_FOUND` for an ask this pod did not mint, `INVALID_REQUEST` for a
554
+ * spec-invalid answer) with the HTTP status; a transport failure is
555
+ * `status: 0` with a null code.
556
+ */
557
+ export type HitlAnswerRelayResult =
558
+ | { ok: true; body: { askId: string; status: AgHitlAnswer["status"]; mode?: string } }
559
+ | { ok: false; status: number; code: string | null; message: string };
560
+
561
+ /**
562
+ * Build the client→pod channel for AgJSON HITL answers (guuey#207): `POST
563
+ * <pod>/agent/hitl-answer` with the spec {@link AgHitlAnswer} the kit's
564
+ * `answerHitlPrompt` constructed (already validated against the ask's
565
+ * persisted declaration). The pod owns EVERYTHING trust-shaped — caller
566
+ * identity (the same three families as the invoke), which ask it minted,
567
+ * the thread a `once` grant binds to, the access level written — this
568
+ * transport only carries the surface's existing credential under the
569
+ * one-carrier rule (bearer → guest header → cookie), with the card relays'
570
+ * single 401 forceRefresh retry.
571
+ *
572
+ * Today the only producer is the pod's cross-app profile consent ask (the
573
+ * three-mode grant), whose answer resolves into the caller's own
574
+ * `ProfileGrant` row; the channel is generic by construction — any future
575
+ * `hitl.ask` the runtime emits is answered through this same door.
576
+ */
577
+ export function createHitlAnswerRelay(
578
+ options: CreateHitlAnswerRelayOptions,
579
+ ): (answer: AgHitlAnswer) => Promise<HitlAnswerRelayResult> {
580
+ const fetchImpl = options.fetchImpl ?? fetch;
581
+ const url = toHitlAnswerUrl(options.endpointUrl);
582
+ return async (answer) => {
583
+ const headers: Record<string, string> = { "content-type": "application/json" };
584
+ const body = JSON.stringify(answer);
585
+ const init: RequestInit = { method: "POST", headers, body };
586
+ const token = options.getAccessToken ? await options.getAccessToken() : null;
587
+ const guest = sendableGuestSecret(options.guestSecret);
588
+ if (token) {
589
+ headers["authorization"] = `Bearer ${token}`;
590
+ } else if (guest) {
591
+ headers[GUEST_HEADER] = guest;
592
+ } else {
593
+ init.credentials = "include";
594
+ }
595
+ let res: Response;
596
+ try {
597
+ res = await fetchImpl(url, init);
598
+ } catch (err) {
599
+ return { ok: false, status: 0, code: null, message: err instanceof Error ? err.message : String(err) };
600
+ }
601
+ if (res.status === 401 && options.getAccessToken) {
602
+ const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
603
+ if (fresh) {
604
+ try {
605
+ res = await fetchImpl(url, {
606
+ method: "POST",
607
+ headers: { ...headers, authorization: `Bearer ${fresh}` },
608
+ body,
609
+ });
610
+ } catch (err) {
611
+ return { ok: false, status: 0, code: null, message: err instanceof Error ? err.message : String(err) };
612
+ }
613
+ }
614
+ }
615
+ let parsed: unknown = undefined;
616
+ try {
617
+ parsed = await res.json();
618
+ } catch {
619
+ parsed = undefined;
620
+ }
621
+ if (res.ok) {
622
+ const b = (parsed ?? {}) as { askId?: unknown; status?: unknown; mode?: unknown };
623
+ return {
624
+ ok: true,
625
+ body: {
626
+ askId: typeof b.askId === "string" ? b.askId : answer.askId,
627
+ status: b.status === "resolved" || b.status === "declined" || b.status === "cancelled" ? b.status : answer.status,
628
+ ...(typeof b.mode === "string" ? { mode: b.mode } : {}),
629
+ },
630
+ };
631
+ }
632
+ const env = (parsed ?? {}) as { code?: unknown; message?: unknown };
633
+ return {
634
+ ok: false,
635
+ status: res.status,
636
+ code: typeof env.code === "string" ? env.code : null,
637
+ message: typeof env.message === "string" ? env.message : `hitl-answer failed (${res.status})`,
638
+ };
639
+ };
640
+ }