@opengeni/react 1.3.0 → 1.4.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.
Files changed (69) hide show
  1. package/README.md +21 -1
  2. package/dist/artifacts.js +2 -2
  3. package/dist/artifacts.js.map +1 -1
  4. package/dist/{chunk-ERGYJIBB.js → chunk-AWZLIMKX.js} +2 -2
  5. package/dist/{chunk-ERGYJIBB.js.map → chunk-AWZLIMKX.js.map} +1 -1
  6. package/dist/{chunk-FZCP527Y.js → chunk-CJUO7J5Z.js} +2 -2
  7. package/dist/{chunk-742CZ3L3.js → chunk-H5X55XH7.js} +16 -200
  8. package/dist/chunk-H5X55XH7.js.map +1 -0
  9. package/dist/{chunk-PGSX7UCP.js → chunk-LJ2UTJJE.js} +1369 -785
  10. package/dist/chunk-LJ2UTJJE.js.map +1 -0
  11. package/dist/{chunk-CY7WK5FD.js → chunk-LN6TCXD3.js} +22 -1
  12. package/dist/chunk-LN6TCXD3.js.map +1 -0
  13. package/dist/{chunk-TMYGL6X5.js → chunk-MBMPHOMA.js} +71 -6
  14. package/dist/chunk-MBMPHOMA.js.map +1 -0
  15. package/dist/{chunk-RC6BZ44Q.js → chunk-OA6HFRQP.js} +2 -2
  16. package/dist/{chunk-WWEUO2PL.js → chunk-WZMYFJD3.js} +190 -11
  17. package/dist/chunk-WZMYFJD3.js.map +1 -0
  18. package/dist/components/composer-transcription-control.d.ts +3 -0
  19. package/dist/components/open-geni-chat.d.ts +6 -2
  20. package/dist/components/session-conversation.d.ts +25 -19
  21. package/dist/components/session-list.d.ts +4 -1
  22. package/dist/components/tool-action-review.d.ts +29 -2
  23. package/dist/components/tool-review-history.d.ts +8 -4
  24. package/dist/composer.js +1 -1
  25. package/dist/connect.js +1 -1
  26. package/dist/hooks/use-client-config-flags.d.ts +37 -0
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +21 -11
  29. package/dist/index.js.map +1 -1
  30. package/dist/{latest-question-YTOBHCWR.js → latest-question-GPYM2JNU.js} +3 -3
  31. package/dist/realtime/realtime-control.d.ts +2 -0
  32. package/dist/realtime.js +25 -4
  33. package/dist/realtime.js.map +1 -1
  34. package/dist/session-capability-policy.d.ts +20 -1
  35. package/dist/session-ui.js +6 -6
  36. package/dist/session.js +6 -6
  37. package/dist/timeline/index.d.ts +2 -0
  38. package/dist/timeline/retained-loaders.d.ts +27 -0
  39. package/package.json +3 -3
  40. package/src/components/approval-surface.tsx +190 -79
  41. package/src/components/artifacts/session-artifact-viewer.tsx +4 -2
  42. package/src/components/composer-transcription-control.tsx +14 -0
  43. package/src/components/composer.tsx +1 -0
  44. package/src/components/open-geni-chat.tsx +53 -13
  45. package/src/components/session-conversation.tsx +229 -63
  46. package/src/components/session-list.tsx +9 -2
  47. package/src/components/tool-action-review.tsx +502 -239
  48. package/src/components/tool-review-history.tsx +18 -43
  49. package/src/hooks/use-client-config-flags.ts +102 -0
  50. package/src/hooks/use-session-events.ts +19 -16
  51. package/src/hooks/use-transcription.ts +6 -0
  52. package/src/hooks/use-voice-input.ts +6 -0
  53. package/src/index.ts +9 -0
  54. package/src/realtime/realtime-control.tsx +28 -1
  55. package/src/session-capability-policy.ts +119 -6
  56. package/src/timeline/index.ts +10 -0
  57. package/src/timeline/retained-loaders.ts +86 -0
  58. package/src/timeline/tool-display-name.ts +5 -1
  59. package/src/timeline/tool-renderers.tsx +143 -10
  60. package/styles/compiled.css +65 -21
  61. package/styles/tokens.css +1 -1
  62. package/dist/chunk-742CZ3L3.js.map +0 -1
  63. package/dist/chunk-CY7WK5FD.js.map +0 -1
  64. package/dist/chunk-PGSX7UCP.js.map +0 -1
  65. package/dist/chunk-TMYGL6X5.js.map +0 -1
  66. package/dist/chunk-WWEUO2PL.js.map +0 -1
  67. /package/dist/{chunk-FZCP527Y.js.map → chunk-CJUO7J5Z.js.map} +0 -0
  68. /package/dist/{chunk-RC6BZ44Q.js.map → chunk-OA6HFRQP.js.map} +0 -0
  69. /package/dist/{latest-question-YTOBHCWR.js.map → latest-question-GPYM2JNU.js.map} +0 -0
@@ -1,7 +1,6 @@
1
1
  import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
2
2
  import type { SessionEvent, ToolActionReview } from "@opengeni/sdk";
3
3
  import { approvalsFromRequiresAction } from "../approvals";
4
- import { ToolActionReviewCard } from "./tool-action-review";
5
4
 
6
5
  type HistoryContext = {
7
6
  approvalIds: ReadonlySet<string>;
@@ -55,59 +54,35 @@ export function useHasToolReview(approvalId: string | null): boolean {
55
54
  return Boolean(approvalId && history?.approvalIds.has(approvalId));
56
55
  }
57
56
 
58
- /** Recorded calls have details, never another decision button. */
59
- export function ToolReviewHistoryReceipt({ approvalId }: { approvalId: string }) {
57
+ /**
58
+ * The saved review for one recorded call, or null while it loads or when it is
59
+ * unavailable (callers then keep their ordinary tool row).
60
+ */
61
+ export function useRecordedToolReview(approvalId: string | null): {
62
+ review: ToolActionReview | null;
63
+ onViewDetails: ((path: string) => void) | undefined;
64
+ } {
60
65
  const history = useContext(History);
61
66
  const [review, setReview] = useState<ToolActionReview | null>(null);
62
- const [failed, setFailed] = useState(false);
63
- const [retry, setRetry] = useState(0);
64
67
  const load = history?.load;
65
68
  const revision = history?.revision;
66
69
  useEffect(() => {
67
- if (!load) return;
70
+ if (!load || !approvalId) return;
68
71
  let active = true;
69
- setFailed(false);
70
72
  void load(approvalId).then(
71
73
  (value) => {
72
- if (active) {
73
- if (value.id === approvalId) setReview(value);
74
- else setFailed(true);
75
- }
76
- },
77
- () => {
78
- if (active) setFailed(true);
74
+ if (active && value.id === approvalId) setReview(value);
79
75
  },
76
+ () => undefined,
80
77
  );
81
78
  return () => {
82
79
  active = false;
83
80
  };
84
- }, [approvalId, load, revision, retry]);
85
- if (!history) return null;
86
- if (failed)
87
- return (
88
- <p className="text-og-sm text-og-fg-muted" role="alert">
89
- Review details unavailable.{" "}
90
- <button
91
- type="button"
92
- className="min-h-11 underline"
93
- onClick={() => setRetry((value) => value + 1)}
94
- >
95
- Try again
96
- </button>
97
- </p>
98
- );
99
- if (!review)
100
- return (
101
- <p role="status" className="text-og-sm text-og-fg-muted">
102
- Loading reviewed action…
103
- </p>
104
- );
105
- return (
106
- <div data-approval-id={approvalId} data-review-origin="history">
107
- <ToolActionReviewCard
108
- review={{ ...review, availableActions: [] }}
109
- onViewDetails={(path) => history.onViewDetails(review, path)}
110
- />
111
- </div>
112
- );
81
+ }, [approvalId, load, revision]);
82
+ const onViewDetails = history?.onViewDetails;
83
+ return {
84
+ review: review && review.id === approvalId ? review : null,
85
+ onViewDetails:
86
+ review && onViewDetails ? (path: string) => onViewDetails(review, path) : undefined,
87
+ };
113
88
  }
@@ -0,0 +1,102 @@
1
+ import type { ClientVoiceInputConfig, ReasoningEffort } from "@opengeni/sdk";
2
+ import { useEffect, useState } from "react";
3
+
4
+ /** The client-config fields the stock conversation and chat read. */
5
+ export type ClientConfigFlagsSource = {
6
+ getClientConfig: () => Promise<{
7
+ fileUploads?: { enabled?: boolean };
8
+ modelSelection?: boolean | undefined;
9
+ sandboxFiles?: boolean | undefined;
10
+ defaultReasoningEffort?: ReasoningEffort | undefined;
11
+ artifacts?: unknown;
12
+ sessionCreation?: boolean | undefined;
13
+ archive?: boolean | undefined;
14
+ voiceInput?: ClientVoiceInputConfig | undefined;
15
+ }>;
16
+ };
17
+
18
+ export type ClientConfigFlags = {
19
+ /** Whether the config has loaded. Proxy capability flags stay `true` until it says otherwise. */
20
+ loaded: boolean;
21
+ uploads: boolean;
22
+ modelSelection: boolean;
23
+ sandboxFiles: boolean;
24
+ defaultReasoningEffort: ReasoningEffort | null;
25
+ /**
26
+ * Inline Site previews and the artifact viewer. `false` only when a session
27
+ * proxy says it does not serve them; native deployments omit the flag.
28
+ */
29
+ artifacts: boolean;
30
+ /** Browser chat creation. `false` only when a session proxy has no `createSession` hook. */
31
+ sessionCreation: boolean;
32
+ /** Chat archive/restore. `false` only when a session proxy disables it. */
33
+ archive: boolean;
34
+ /** Native voice input when the deployment can transcribe, else null. */
35
+ voiceInput: ClientVoiceInputConfig | null;
36
+ };
37
+
38
+ const INITIAL_FLAGS: ClientConfigFlags = {
39
+ loaded: false,
40
+ uploads: false,
41
+ modelSelection: false,
42
+ sandboxFiles: false,
43
+ defaultReasoningEffort: null,
44
+ artifacts: true,
45
+ sessionCreation: true,
46
+ archive: true,
47
+ voiceInput: null,
48
+ };
49
+
50
+ type ClientConfigFlagsInput = Awaited<ReturnType<ClientConfigFlagsSource["getClientConfig"]>>;
51
+
52
+ // One read per client: the chat, its list, the new-chat composer and the
53
+ // conversation all ask, and behind a session proxy each read is a round trip.
54
+ const configReads = new WeakMap<object, Promise<ClientConfigFlagsInput>>();
55
+
56
+ function readClientConfig(client: ClientConfigFlagsSource): Promise<ClientConfigFlagsInput> {
57
+ let read = configReads.get(client);
58
+ if (!read) {
59
+ read = client.getClientConfig();
60
+ configReads.set(client, read);
61
+ // A failed read is retried by the next mount rather than cached.
62
+ read.catch(() => {
63
+ if (configReads.get(client) === read) configReads.delete(client);
64
+ });
65
+ }
66
+ return read;
67
+ }
68
+
69
+ /** Deployment and session-proxy capabilities from the client config. */
70
+ export function useClientConfigFlags(
71
+ client: Partial<ClientConfigFlagsSource> | ClientConfigFlagsSource,
72
+ ): ClientConfigFlags {
73
+ const [flags, setFlags] = useState<ClientConfigFlags>(INITIAL_FLAGS);
74
+ useEffect(() => {
75
+ // Custom clients without a config read keep the defaults.
76
+ if (typeof client.getClientConfig !== "function") return;
77
+ let live = true;
78
+ readClientConfig(client as ClientConfigFlagsSource).then(
79
+ (config) => {
80
+ if (live) {
81
+ setFlags({
82
+ loaded: true,
83
+ uploads: config.fileUploads?.enabled === true,
84
+ // Only an explicit offer shows end users the picker.
85
+ modelSelection: config.modelSelection === true,
86
+ sandboxFiles: config.sandboxFiles !== false,
87
+ defaultReasoningEffort: config.defaultReasoningEffort ?? null,
88
+ artifacts: config.artifacts !== false,
89
+ sessionCreation: config.sessionCreation !== false,
90
+ archive: config.archive !== false,
91
+ voiceInput: config.voiceInput?.available === true ? config.voiceInput : null,
92
+ });
93
+ }
94
+ },
95
+ () => undefined,
96
+ );
97
+ return () => {
98
+ live = false;
99
+ };
100
+ }, [client]);
101
+ return flags;
102
+ }
@@ -225,8 +225,8 @@ export function useSessionEvents(
225
225
  // new stream identity cannot expose the previous session's event log.
226
226
  const [stateStreamKey, setStateStreamKey] = useState(streamKey);
227
227
 
228
- // Reopening SSE after a prepend must not cancel the next history page.
229
- // Navigation belongs to the session/client lifetime, not the transport.
228
+ // Navigation belongs to the session/client lifetime, not the transport: an
229
+ // SSE reconnect must never cancel an in-flight history page.
230
230
  useEffect(() => {
231
231
  navigationGenerationRef.current += 1;
232
232
  setNewerError(null);
@@ -621,9 +621,6 @@ export function useSessionEvents(
621
621
  }
622
622
  const current = eventWindowRef.current;
623
623
  assertPrependOrder(current.events, window.events);
624
- // Freeze the live iterator before replacing its in-memory window. Rows
625
- // pending in the aborted iterator were never cursor-committed and will
626
- // be replayed from the retained high-water mark below.
627
624
  const next = boundBrowserSessionEventWindow([...window.events, ...current.events], {
628
625
  direction: "oldest",
629
626
  });
@@ -633,7 +630,6 @@ export function useSessionEvents(
633
630
  markTailPreserved();
634
631
  return false;
635
632
  }
636
- streamAbortRef.current?.abort();
637
633
  const status = observeSessionStatus(window.events, sessionStatusRef);
638
634
  const retained = {
639
635
  ...next,
@@ -645,21 +641,28 @@ export function useSessionEvents(
645
641
  }
646
642
  const retainedNewest = maxResumeSequenceOrNull(retained.events);
647
643
  const previousNewest = maxResumeSequenceOrNull(current.events);
644
+ const evictedLiveTail =
645
+ previousNewest !== null && retainedNewest !== null && retainedNewest < previousNewest;
646
+ if (evictedLiveTail && viewModeRef.current === "live") {
647
+ // The prepend leaves the live tip. Freeze the iterator before the
648
+ // window is replaced so a late flush cannot append live rows onto
649
+ // (and newest-bound away) the history the reader just requested.
650
+ streamAbortRef.current?.abort();
651
+ }
648
652
  eventWindowRef.current = retained;
649
653
  markCommitted();
650
654
  oldestSequenceRef.current = retainedOldest;
651
655
  newestSequenceRef.current = retainedNewest;
652
- streamResumeSequenceRef.current = maxResumeSequence(retained.events);
656
+ if (evictedLiveTail) {
657
+ streamResumeSequenceRef.current = maxResumeSequence(retained.events);
658
+ }
653
659
  // Oldest-directed eviction can discard newer in-memory rows. That fact
654
660
  // keeps windowTruncated true, but it does not imply older durable rows
655
661
  // exist; only the backward DB page can answer hasOlder truthfully.
656
662
  const olderStillAvailable = window.hasOlder;
657
663
  hasOlderRef.current = olderStillAvailable;
658
- const evictedLiveTail =
659
- previousNewest !== null && retainedNewest !== null && retainedNewest < previousNewest;
660
664
  let newer: boolean | null = null;
661
665
  let enterHistory = false;
662
- let reconnectLive = false;
663
666
  if (viewModeRef.current === "history" || evictedLiveTail) {
664
667
  const highWater = lastSequenceRef.current;
665
668
  newer = retainedNewest !== null && (retainedNewest < highWater || retained.truncated);
@@ -672,11 +675,13 @@ export function useSessionEvents(
672
675
  viewModeRef.current = "history";
673
676
  enterHistory = true;
674
677
  }
675
- } else {
676
- // The retained merge is still one contiguous live suffix. Restart
677
- // SSE from its cursor so events queued during the fetch are replayed.
678
- reconnectLive = true;
679
678
  }
679
+ // Otherwise the merge is still one contiguous live suffix and the open
680
+ // SSE iterator keeps appending to it: every flush reads
681
+ // eventWindowRef, so rows delivered during or after this fetch land
682
+ // once, in order, on the prepended window. Reopening the stream here
683
+ // would flash a connecting state and re-run session reconciliation on
684
+ // every scroll-up for no data benefit.
680
685
  loadingOlderRef.current = false;
681
686
  startTransition(() => {
682
687
  if (status !== undefined) {
@@ -689,8 +694,6 @@ export function useSessionEvents(
689
694
  }
690
695
  if (enterHistory) {
691
696
  setViewMode("history");
692
- } else if (reconnectLive) {
693
- setStreamEpoch((epoch) => epoch + 1);
694
697
  }
695
698
  setLoadingOlder(false);
696
699
  });
@@ -611,6 +611,12 @@ function classifyStartError(error: unknown): TranscriptionErrorCode {
611
611
 
612
612
  export function normalizeTranscriptionErrorCode(code: unknown): TranscriptionErrorCode {
613
613
  switch (code) {
614
+ case "insufficient_credits":
615
+ case "allowance_exhausted":
616
+ case "monthly_model_cost_limit":
617
+ case "unavailable":
618
+ case "too_large":
619
+ case "invalid_audio":
614
620
  case "permission_denied":
615
621
  case "not_supported":
616
622
  case "network":
@@ -1503,6 +1503,12 @@ function errorCode(error: unknown): string {
1503
1503
  }
1504
1504
 
1505
1505
  function isRetryableVoiceInputError(error: unknown): boolean {
1506
+ if (
1507
+ ["insufficient_credits", "allowance_exhausted", "monthly_model_cost_limit"].includes(
1508
+ errorCode(error),
1509
+ )
1510
+ )
1511
+ return false;
1506
1512
  if (error instanceof OpenGeniApiError) {
1507
1513
  return error.retryable && error.status !== 409;
1508
1514
  }
package/src/index.ts CHANGED
@@ -16,6 +16,9 @@ export {
16
16
  export {
17
17
  attachSessionCapability,
18
18
  completeSessionCapabilityOAuth,
19
+ prepareSessionCapabilityAccess,
20
+ applySessionCapabilityAccess,
21
+ type SessionCapabilityAccessPlan,
19
22
  } from "./session-capability-policy";
20
23
  export { retainedImageId } from "./components/markdown";
21
24
  // oxlint-disable-next-line typescript/triple-slash-reference -- package consumers must load the ambient type for this optional untyped peer without emitting a runtime import.
@@ -388,14 +391,20 @@ export type {
388
391
  // Tool-renderer registry + the per-tool renderers (the timeline's extension API)
389
392
  export {
390
393
  createDefaultToolRegistry,
394
+ createSessionRetainedScreenshotLoader,
391
395
  createToolRegistry,
396
+ createWorkspaceRetainedArtifactLoader,
397
+ createWorkspaceRetainedVideoLoader,
392
398
  defaultToolRegistry,
393
399
  rawTypeOf,
394
400
  } from "./timeline";
395
401
  export type {
396
402
  CreateToolRegistryOptions,
397
403
  RetainedArtifactLoader,
404
+ RetainedArtifactLoaderClient,
398
405
  RetainedScreenshotLoader,
406
+ RetainedScreenshotLoaderClient,
407
+ RetainedVideoLoaderClient,
399
408
  VideoArtifactPlaybackLoader,
400
409
  ToolRegistry,
401
410
  ToolRegistryEntry,
@@ -63,9 +63,26 @@ export type RealtimeModelOption = {
63
63
  description: string;
64
64
  available: boolean;
65
65
  unavailableReason: string | null;
66
+ /** Machine-readable reason, e.g. `insufficient_credits` (same codes as voice input). */
67
+ unavailableCode?: string | null | undefined;
66
68
  recommended: boolean;
67
69
  };
68
70
 
71
+ /** Short status label for a credit/availability refusal (shared with the model menu). */
72
+ function realtimeRefusalLabel(code: string | null | undefined): string | null {
73
+ switch (code) {
74
+ case "insufficient_credits":
75
+ return "Out of credits";
76
+ case "allowance_exhausted":
77
+ case "monthly_model_cost_limit":
78
+ return "Usage limit reached";
79
+ case "realtime_voice_unavailable":
80
+ return "Voice unavailable";
81
+ default:
82
+ return null;
83
+ }
84
+ }
85
+
69
86
  const CODEX_LIVE_MODEL: RealtimeModelOption = {
70
87
  id: "gpt-live-1-boulder-alpha",
71
88
  label: "Codex Live",
@@ -822,6 +839,7 @@ export function RealtimeVoiceControl(props: {
822
839
  props.canStart,
823
840
  props.admissionBlocker ?? null,
824
841
  selectedModel.label,
842
+ selectedModel.unavailableCode ?? null,
825
843
  );
826
844
  const modeOwned = props.snapshot.mode?.state === "active";
827
845
  const retryConnection =
@@ -1333,6 +1351,7 @@ function statusContent(
1333
1351
  canStart: boolean,
1334
1352
  admissionBlocker: string | null,
1335
1353
  modelLabel: string,
1354
+ unavailableCode: string | null = null,
1336
1355
  ): { phase: RealtimeVisualPhase; label: string; detail: string } {
1337
1356
  if (snapshot.audibleOutput === "blocked" && !snapshot.outputMuted) {
1338
1357
  return {
@@ -1379,6 +1398,13 @@ function statusContent(
1379
1398
  detail: "Return to the browser that started it, or wait for that connection to expire.",
1380
1399
  };
1381
1400
  case "error":
1401
+ if (snapshot.refusal) {
1402
+ return {
1403
+ phase: "unavailable",
1404
+ label: realtimeRefusalLabel(snapshot.refusal.code) ?? "Voice unavailable",
1405
+ detail: snapshot.refusal.message,
1406
+ };
1407
+ }
1382
1408
  return {
1383
1409
  phase: "error",
1384
1410
  label: "Voice unavailable",
@@ -1388,7 +1414,7 @@ function statusContent(
1388
1414
  if (!modelAvailable) {
1389
1415
  return {
1390
1416
  phase: "unavailable",
1391
- label: "Voice model unavailable",
1417
+ label: realtimeRefusalLabel(unavailableCode) ?? "Voice model unavailable",
1392
1418
  detail: admissionBlocker ?? "Choose another voice model.",
1393
1419
  };
1394
1420
  }
@@ -1580,6 +1606,7 @@ function writeRealtimeModelPreference(workspaceId: string, model: SessionRealtim
1580
1606
 
1581
1607
  function isRealtimeModel(value: string | null): value is SessionRealtimeModel {
1582
1608
  return (
1609
+ value === "opengeni-azure/gpt-live-1" ||
1583
1610
  value === "gpt-live-1-boulder-alpha" ||
1584
1611
  value === "opengeni-gateway/openai/gpt-realtime-2.1" ||
1585
1612
  value === "opengeni-gateway/openai/gpt-realtime-mini" ||
@@ -1,4 +1,9 @@
1
- import type { FirstPartyMcpToolName, CapabilityCatalogItem } from "@opengeni/sdk";
1
+ import type {
2
+ FirstPartyMcpToolName,
3
+ CapabilityCatalogItem,
4
+ Session,
5
+ UpdateSessionToolPolicyRequest,
6
+ } from "@opengeni/sdk";
2
7
  import type { OpenGeniBrowserClient as OpenGeniClient } from "@opengeni/sdk/browser";
3
8
 
4
9
  /** Catalog tool discovery may be lazy; selecting the installed server does not
@@ -25,6 +30,16 @@ export async function attachSessionCapability(
25
30
  if (!stillCurrent()) throw new Error("Connection setup was interrupted.");
26
31
  const session = await client.getSession(workspaceId, sessionId);
27
32
  if (!stillCurrent()) throw new Error("Connection setup was interrupted.");
33
+ const request = capabilityToolPolicyUpdate(session, item);
34
+ if (request) await client.updateSessionToolPolicy(workspaceId, sessionId, request);
35
+ }
36
+
37
+ function capabilityToolPolicyUpdate(
38
+ session: Session,
39
+ item: CapabilityCatalogItem,
40
+ ): UpdateSessionToolPolicyRequest | null {
41
+ const tools = sessionCapabilityTools(item);
42
+ if (item.kind === "skill" || tools.length === 0) return null;
28
43
  // Session.tools includes mandatory runtime servers, which must never be
29
44
  // echoed into the human's explicit selection.
30
45
  let existing = session.tools.filter(
@@ -42,10 +57,19 @@ export async function attachSessionCapability(
42
57
  // automatic selection. Compare against the resolved defaults instead.
43
58
  existing = policy.effectiveIds
44
59
  .filter((id) => !policy.mandatoryIds.includes(id))
45
- .map((id) => ({ kind: "mcp" as const, id }));
60
+ .map(
61
+ (id) =>
62
+ session.tools.find((tool) => tool.kind === "mcp" && tool.id === id) ?? {
63
+ kind: "mcp" as const,
64
+ id,
65
+ optional: true,
66
+ },
67
+ );
46
68
  }
47
69
  const additions = tools.filter(
48
- (tool) => !existing.some((current) => current.kind === tool.kind && current.id === tool.id),
70
+ (tool) =>
71
+ !session.effectiveToolPolicy?.mandatoryIds.includes(tool.id) &&
72
+ !existing.some((current) => current.kind === tool.kind && current.id === tool.id),
49
73
  );
50
74
  const recommended = Array.isArray(item.metadata?.firstPartyMcpTools)
51
75
  ? (item.metadata.firstPartyMcpTools.filter(
@@ -53,13 +77,102 @@ export async function attachSessionCapability(
53
77
  ) as FirstPartyMcpToolName[])
54
78
  : [];
55
79
  const firstParty = [...new Set([...session.firstPartyMcpTools, ...recommended])];
56
- if (additions.length === 0 && firstParty.length === session.firstPartyMcpTools.length) return;
57
- await client.updateSessionToolPolicy(workspaceId, sessionId, {
80
+ if (additions.length === 0 && firstParty.length === session.firstPartyMcpTools.length)
81
+ return null;
82
+ return {
58
83
  mode: "explicit",
59
84
  tools: [...existing, ...additions],
60
85
  firstPartyMcpTools: firstParty,
61
86
  expectedVersion: session.toolPolicyVersion,
62
- });
87
+ };
88
+ }
89
+
90
+ /** Read-only review. Hosts must show ancestor changes and obtain a separate
91
+ * explicit click before applying. This plan is never authorization. */
92
+ export type SessionCapabilityAccessPlan = {
93
+ workspaceId: string;
94
+ sessionId: string;
95
+ capabilityId: string;
96
+ sessions: Array<{
97
+ id: string;
98
+ title: string | null;
99
+ parentSessionId: string | null;
100
+ toolPolicyVersion: number;
101
+ request: UpdateSessionToolPolicyRequest | null;
102
+ }>;
103
+ };
104
+
105
+ export async function prepareSessionCapabilityAccess(
106
+ client: OpenGeniClient,
107
+ workspaceId: string,
108
+ sessionId: string,
109
+ item: CapabilityCatalogItem,
110
+ stillCurrent: () => boolean = () => true,
111
+ ): Promise<SessionCapabilityAccessPlan> {
112
+ const sessions: SessionCapabilityAccessPlan["sessions"] = [];
113
+ if (item.kind === "skill" || sessionCapabilityTools(item).length === 0)
114
+ return { workspaceId, sessionId, capabilityId: item.id, sessions };
115
+ const visited = new Set<string>();
116
+ let id: string | null = sessionId;
117
+ while (id) {
118
+ if (!stillCurrent()) throw new Error("Connection setup was interrupted.");
119
+ if (visited.has(id)) throw new Error("The parent chat chain could not be verified.");
120
+ visited.add(id);
121
+ const session = await client.getSession(workspaceId, id);
122
+ if (!stillCurrent()) throw new Error("Connection setup was interrupted.");
123
+ const request = capabilityToolPolicyUpdate(session, item);
124
+ sessions.push({
125
+ id,
126
+ title: session.title ?? null,
127
+ parentSessionId: session.parentSessionId ?? null,
128
+ toolPolicyVersion: session.toolPolicyVersion,
129
+ request,
130
+ });
131
+ // An unchanged immediate parent already supplies this capability's ceiling.
132
+ // Do not read or broaden ancestors irrelevant to this addition.
133
+ if (!request) break;
134
+ id = session.parentSessionId ?? null;
135
+ }
136
+ return { workspaceId, sessionId, capabilityId: item.id, sessions: sessions.reverse() };
137
+ }
138
+
139
+ /** Preflight the entire review, then apply ordinary CAS-fenced API writes root
140
+ * first. A partial failure may leave approved ancestor changes committed; never
141
+ * claim success or roll back another person's edits. Prepare a new review. */
142
+ export async function applySessionCapabilityAccess(
143
+ client: OpenGeniClient,
144
+ plan: SessionCapabilityAccessPlan,
145
+ stillCurrent: () => boolean = () => true,
146
+ ): Promise<void> {
147
+ for (const reviewed of plan.sessions) {
148
+ if (!stillCurrent()) throw new Error("Connection setup was interrupted.");
149
+ const current = await client.getSession(plan.workspaceId, reviewed.id);
150
+ if (!stillCurrent()) throw new Error("Connection setup was interrupted.");
151
+ if (
152
+ current.toolPolicyVersion !== reviewed.toolPolicyVersion ||
153
+ (current.parentSessionId ?? null) !== reviewed.parentSessionId
154
+ )
155
+ throw new Error("Chat access changed. Click Add tools to review the current choices again.");
156
+ }
157
+ for (const reviewed of plan.sessions) {
158
+ if (!stillCurrent()) throw new Error("Connection setup was interrupted.");
159
+ if (reviewed.request) {
160
+ const updated = await client.updateSessionToolPolicy(
161
+ plan.workspaceId,
162
+ reviewed.id,
163
+ reviewed.request,
164
+ );
165
+ if (
166
+ reviewed.request.mode === "explicit" &&
167
+ reviewed.request.firstPartyMcpTools.some(
168
+ (tool) => !updated.firstPartyMcpTools.includes(tool),
169
+ )
170
+ )
171
+ throw new Error(
172
+ "This chat's capabilities do not allow these tools. Review its capability settings before adding tools again.",
173
+ );
174
+ }
175
+ }
63
176
  }
64
177
 
65
178
  /** Native OAuth callbacks have already saved the provider connection. Finish
@@ -61,6 +61,16 @@ export type {
61
61
 
62
62
  // renderer registry
63
63
  export { createToolRegistry, rawTypeOf } from "./registry";
64
+ export {
65
+ createSessionRetainedScreenshotLoader,
66
+ createWorkspaceRetainedArtifactLoader,
67
+ createWorkspaceRetainedVideoLoader,
68
+ } from "./retained-loaders";
69
+ export type {
70
+ RetainedArtifactLoaderClient,
71
+ RetainedScreenshotLoaderClient,
72
+ RetainedVideoLoaderClient,
73
+ } from "./retained-loaders";
64
74
  export type {
65
75
  CreateToolRegistryOptions,
66
76
  RetainedArtifactLoader,
@@ -0,0 +1,86 @@
1
+ import type { OpenGeniClient, RetainedArtifactReference } from "@opengeni/sdk";
2
+ import type {
3
+ RetainedArtifactLoader,
4
+ RetainedScreenshotLoader,
5
+ VideoArtifactPlaybackLoader,
6
+ } from "./registry";
7
+
8
+ /** SDK methods the workspace retained-artifact loader needs. */
9
+ export type RetainedArtifactLoaderClient = Pick<
10
+ OpenGeniClient,
11
+ "createRetainedArtifactDownloadUrl" | "downloadRetainedArtifact"
12
+ >;
13
+
14
+ /** SDK method the session retained-screenshot loader needs. */
15
+ export type RetainedScreenshotLoaderClient = Pick<OpenGeniClient, "downloadRetainedScreenshot">;
16
+
17
+ /** SDK method the generated-video playback loader needs. */
18
+ export type RetainedVideoLoaderClient = Pick<OpenGeniClient, "createVideoArtifactPlaybackSource">;
19
+
20
+ /**
21
+ * Bind permanent retained-artifact retrieval (generated images, published
22
+ * files) to one authenticated workspace, for `MessageTimeline`'s
23
+ * `loadRetainedArtifact`. Images get a short-lived URL; files are assembled
24
+ * from verified byte ranges.
25
+ */
26
+ export function createWorkspaceRetainedArtifactLoader(
27
+ client: RetainedArtifactLoaderClient,
28
+ workspaceId: string,
29
+ ): RetainedArtifactLoader {
30
+ return async (artifact: RetainedArtifactReference, signal: AbortSignal) => {
31
+ if (artifact.kind === "file") {
32
+ const download = await client.downloadRetainedArtifact(workspaceId, artifact, { signal });
33
+ return download.bytes;
34
+ }
35
+ const download = await client.createRetainedArtifactDownloadUrl(workspaceId, artifact, {
36
+ signal,
37
+ });
38
+ return { url: download.url };
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Bind the authenticated screenshot downloader to one workspace and session,
44
+ * for `MessageTimeline`'s `loadRetainedScreenshot`. Fetched metadata must match
45
+ * the timeline receipt before bytes are handed to React; errors deliberately
46
+ * contain no storage locator or content.
47
+ */
48
+ export function createSessionRetainedScreenshotLoader(
49
+ client: RetainedScreenshotLoaderClient,
50
+ workspaceId: string,
51
+ sessionId: string,
52
+ ): RetainedScreenshotLoader {
53
+ return async (artifact: RetainedArtifactReference, signal: AbortSignal) => {
54
+ const download = await client.downloadRetainedScreenshot(
55
+ workspaceId,
56
+ sessionId,
57
+ artifact.artifactId,
58
+ { signal },
59
+ );
60
+ if (!download.metadata.available || !download.bytes) return null;
61
+ if (
62
+ download.metadata.artifactId !== artifact.artifactId ||
63
+ download.metadata.kind !== artifact.kind ||
64
+ download.metadata.contentType !== artifact.contentType ||
65
+ download.metadata.originalBytes !== artifact.originalBytes ||
66
+ download.metadata.sha256 !== artifact.sha256 ||
67
+ download.metadata.dimensions?.width !== artifact.dimensions?.width ||
68
+ download.metadata.dimensions?.height !== artifact.dimensions?.height
69
+ ) {
70
+ throw new Error("Retained screenshot receipt verification failed");
71
+ }
72
+ return download.bytes;
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Bind expiring generated-video playback sources to one authenticated
78
+ * workspace, for `MessageTimeline`'s `loadVideoArtifactPlayback`.
79
+ */
80
+ export function createWorkspaceRetainedVideoLoader(
81
+ client: RetainedVideoLoaderClient,
82
+ workspaceId: string,
83
+ ): VideoArtifactPlaybackLoader {
84
+ return async (artifactId: string, signal: AbortSignal) =>
85
+ await client.createVideoArtifactPlaybackSource(workspaceId, artifactId, { signal });
86
+ }
@@ -30,7 +30,11 @@ export function toolMatchesLeaf(wireName: string, leaf: string): boolean {
30
30
  */
31
31
  export function toolDisplayName(name: string, display?: ToolDisplayMetadata): string {
32
32
  if (!display && /^[a-f0-9]{64}$/.test(name)) return "Tool call";
33
- const phrase = (display?.toolName ?? mcpToolLeaf(name)).replace(/[_-]+/g, " ").trim();
33
+ // "batch modify messages" → "Modify messages": batching is transport, not the action.
34
+ const phrase = (display?.toolName ?? mcpToolLeaf(name))
35
+ .replace(/[_-]+/g, " ")
36
+ .trim()
37
+ .replace(/^batch\s+(?=\S)/i, "");
34
38
  const title =
35
39
  display?.title ?? (phrase ? phrase.charAt(0).toUpperCase() + phrase.slice(1) : name);
36
40
  return display?.accountLabel ? `${title} — ${display.accountLabel}` : title;