@opengeni/react 0.20.0 → 0.23.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 (44) hide show
  1. package/dist/{chunk-6BXBGJ3B.js → chunk-6XUS5VFM.js} +2 -2
  2. package/dist/{chunk-LAZ2A743.js → chunk-CATATY77.js} +3 -3
  3. package/dist/{chunk-HHFA4AFX.js → chunk-I3BJZIG5.js} +15 -1
  4. package/dist/chunk-I3BJZIG5.js.map +1 -0
  5. package/dist/{chunk-6UT5OC3P.js → chunk-UAHPKLB6.js} +113 -9
  6. package/dist/chunk-UAHPKLB6.js.map +1 -0
  7. package/dist/{chunk-MRSECXRP.js → chunk-UQS7OQDE.js} +82 -54
  8. package/dist/chunk-UQS7OQDE.js.map +1 -0
  9. package/dist/{chunk-EBTT3MYN.js → chunk-YDNWMKXO.js} +11 -1
  10. package/dist/chunk-YDNWMKXO.js.map +1 -0
  11. package/dist/{composer-DvUaa5ki.d.ts → composer-CVm5uoUI.d.ts} +2 -2
  12. package/dist/composer.d.ts +3 -3
  13. package/dist/composer.js +3 -3
  14. package/dist/index.d.ts +8 -8
  15. package/dist/index.js +140 -26
  16. package/dist/index.js.map +1 -1
  17. package/dist/machines.d.ts +1 -1
  18. package/dist/machines.js +2 -2
  19. package/dist/{queue-surface-implementation-NQMV2ML3.js → queue-surface-implementation-3UW3MIIR.js} +9 -9
  20. package/dist/queue-surface-implementation-3UW3MIIR.js.map +1 -0
  21. package/dist/{session-Gd4iVPYw.d.ts → session-Db00TRwl.d.ts} +32 -11
  22. package/dist/{session-context-DIFFlXKc.d.ts → session-context-rgrfElMl.d.ts} +7 -1
  23. package/dist/session.d.ts +3 -3
  24. package/dist/session.js +7 -3
  25. package/dist/{use-composer-BCfm4mzX.d.ts → use-composer-By1wn3MM.d.ts} +5 -18
  26. package/package.json +2 -2
  27. package/src/client.ts +4 -0
  28. package/src/components/message-timeline.tsx +195 -23
  29. package/src/components/queue-draft-policy.ts +18 -0
  30. package/src/components/queue-surface-implementation.tsx +6 -10
  31. package/src/components/queue-surface.tsx +1 -0
  32. package/src/hooks/use-composer.ts +128 -78
  33. package/src/hooks/use-session-mcp-approval-policy.ts +161 -0
  34. package/src/index.ts +9 -0
  35. package/src/session-context.ts +25 -0
  36. package/src/session.ts +10 -0
  37. package/src/timeline/types.ts +8 -15
  38. package/dist/chunk-6UT5OC3P.js.map +0 -1
  39. package/dist/chunk-EBTT3MYN.js.map +0 -1
  40. package/dist/chunk-HHFA4AFX.js.map +0 -1
  41. package/dist/chunk-MRSECXRP.js.map +0 -1
  42. package/dist/queue-surface-implementation-NQMV2ML3.js.map +0 -1
  43. /package/dist/{chunk-6BXBGJ3B.js.map → chunk-6XUS5VFM.js.map} +0 -0
  44. /package/dist/{chunk-LAZ2A743.js.map → chunk-CATATY77.js.map} +0 -0
@@ -27,23 +27,15 @@ export type UseComposerOptions = EmbeddedSessionClientOverride &
27
27
  effectiveControl?: EffectiveSessionControl | null | undefined;
28
28
  /** Apply durable model/tool/reasoning settings in the host's controlled UI. */
29
29
  onDraftApplied?: ((draft: ComposerDraft) => void) | undefined;
30
- /**
31
- * Opt out of OpenGeni's remote composer-draft reads and writes. Embedded
32
- * hosts can use this when their human lane permits messages and controls but
33
- * deliberately withholds draft mutation because model/tool policy is
34
- * server-owned. The unsent value remains local React state and send/steer
35
- * continue to work without a draft revision fence. Queue checkout is hidden
36
- * because the current queue-edit contract necessarily creates a durable
37
- * composer draft.
38
- *
39
- * @default "durable"
40
- */
30
+ /** Disable remote composer-draft reads and writes for embedded hosts. */
41
31
  draftPersistence?: "durable" | "disabled" | undefined;
42
32
  };
43
33
 
44
34
  export type ComposerState = {
45
35
  value: string;
46
36
  setValue: (value: string) => void;
37
+ /** Read the current draft synchronously before a destructive replacement. */
38
+ hasDraftContent: () => boolean;
47
39
  /** Append the draft behind prompts already visible in the queue. */
48
40
  send: (text?: string) => Promise<boolean>;
49
41
  /** Supersede current direction with the draft. */
@@ -61,12 +53,7 @@ export type ComposerState = {
61
53
  draftLoading: boolean;
62
54
  draftSaving: boolean;
63
55
  draftConflict: Error | null;
64
- /**
65
- * Whether this controller owns a durable server-side draft. Custom
66
- * controllers that omit the field retain the historical durable behavior.
67
- * Queue checkout is unavailable when persistence is disabled because the
68
- * current queue-edit contract atomically moves the turn into that draft.
69
- */
56
+ /** Whether this controller owns a durable server-side draft. */
70
57
  draftPersistence?: "durable" | "disabled" | undefined;
71
58
  /** Apply an atomic queue Edit checkout without a second read. */
72
59
  applyDraft: (draft: ComposerDraft) => void;
@@ -92,6 +79,8 @@ export function useComposer(
92
79
  const durableDrafts = options.draftPersistence !== "disabled";
93
80
  const targetKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${durableDrafts ? "durable" : "disabled"}`;
94
81
  const [value, setValue] = useState("");
82
+ // Keep rendered state behind the committed target identity for one frame:
83
+ // a parent may switch sessionId without remounting this public hook.
95
84
  const [stateTargetKey, setStateTargetKey] = useState(targetKey);
96
85
  const [sending, setSending] = useState(false);
97
86
  const [pausing, setPausing] = useState(false);
@@ -103,11 +92,12 @@ export function useComposer(
103
92
  const [draftConflict, setDraftConflict] = useState<Error | null>(null);
104
93
  const [restoredResources, setRestoredResources] = useState<ResourceRef[]>([]);
105
94
  const pendingClientEventId = useRef<string | null>(null);
95
+ const valueRef = useRef("");
106
96
  const draftRef = useRef<ComposerDraft | null>(null);
107
- const draftLoadErrorRef = useRef<Error | null>(null);
108
- const draftReadGeneration = useRef(0);
97
+ const restoredResourcesRef = useRef<ResourceRef[]>([]);
109
98
  const localEditRevision = useRef(0);
110
99
  const targetGeneration = useRef(0);
100
+ const draftReadGeneration = useRef(0);
111
101
  const lastSavedSignature = useRef<string | null>(null);
112
102
  const saveChain = useRef<Promise<void>>(Promise.resolve());
113
103
  const onSent = options.onSent;
@@ -115,29 +105,25 @@ export function useComposer(
115
105
  // Read through a ref so a new extras closure (created every render by
116
106
  // callers passing inline functions) does not invalidate `send`.
117
107
  const sendExtrasRef = useRef(options.sendExtras);
118
- useLayoutEffect(() => {
119
- sendExtrasRef.current = options.sendExtras;
120
- }, [options.sendExtras]);
121
- const liveExtras = resolveSendExtras(options.sendExtras);
122
- const liveExtrasVersion = JSON.stringify(liveExtras);
108
+ sendExtrasRef.current = options.sendExtras;
109
+ const liveExtrasVersion = JSON.stringify(resolveSendExtras(options.sendExtras));
123
110
 
124
111
  // A composer is bound to one session: switching targets must not leak the
125
- // previous session's draft, error, or retry idempotency key. Revoke the old
126
- // target only when the new render commits: a suspended transition must leave
127
- // the still-visible composer fully interactive.
112
+ // previous session's draft, error, or retry idempotency key.
128
113
  const targetKeyRef = useRef(targetKey);
129
114
  useLayoutEffect(() => {
130
115
  if (targetKeyRef.current === targetKey) return;
131
116
  targetKeyRef.current = targetKey;
132
117
  targetGeneration.current += 1;
118
+ draftReadGeneration.current += 1;
133
119
  pendingClientEventId.current = null;
134
120
  localEditRevision.current = 0;
135
- draftReadGeneration.current += 1;
121
+ valueRef.current = "";
136
122
  draftRef.current = null;
137
- draftLoadErrorRef.current = null;
123
+ restoredResourcesRef.current = [];
138
124
  lastSavedSignature.current = null;
139
- // An unresolved save for the old target must neither block nor join the
140
- // new target's autosave chain. Its own generation fence drops settlement.
125
+ // Old saves may still be awaiting the network. Their generation fence
126
+ // prevents settlement, and a fresh chain avoids blocking this target.
141
127
  saveChain.current = Promise.resolve();
142
128
  setStateTargetKey(targetKey);
143
129
  setValue("");
@@ -158,6 +144,8 @@ export function useComposer(
158
144
  if (!durableDrafts) {
159
145
  pendingClientEventId.current = null;
160
146
  localEditRevision.current += 1;
147
+ valueRef.current = next.text;
148
+ restoredResourcesRef.current = next.resources;
161
149
  draftRef.current = null;
162
150
  lastSavedSignature.current = null;
163
151
  setDraft(null);
@@ -166,7 +154,9 @@ export function useComposer(
166
154
  setDraftConflict(null);
167
155
  return;
168
156
  }
157
+ valueRef.current = next.text;
169
158
  draftRef.current = next;
159
+ restoredResourcesRef.current = next.resources;
170
160
  lastSavedSignature.current = draftSignature(draftPayload(next));
171
161
  localEditRevision.current += 1;
172
162
  pendingClientEventId.current = null;
@@ -180,7 +170,7 @@ export function useComposer(
180
170
  );
181
171
 
182
172
  const loadDraft = useCallback(
183
- async (replaceLocal: boolean, rejectOnFailure = false): Promise<void> => {
173
+ async (replaceLocal: boolean): Promise<void> => {
184
174
  if (targetKeyRef.current !== targetKey) return;
185
175
  if (!sessionId || !durableDrafts) {
186
176
  setDraftLoading(false);
@@ -189,47 +179,57 @@ export function useComposer(
189
179
  const generation = targetGeneration.current;
190
180
  const readTicket = ++draftReadGeneration.current;
191
181
  const localAtStart = localEditRevision.current;
182
+ const baseAtStart = draftRef.current;
183
+ const extrasAtStart = resolveSendExtras(sendExtrasRef.current);
184
+ const localSignatureAtStart = baseAtStart
185
+ ? draftSignature(
186
+ composerDraftPayload(
187
+ baseAtStart,
188
+ valueRef.current,
189
+ restoredResourcesRef.current,
190
+ extrasAtStart,
191
+ ),
192
+ )
193
+ : null;
194
+ const localWasDirtyAtStart =
195
+ localSignatureAtStart === null
196
+ ? localAtStart !== 0
197
+ : localSignatureAtStart !== lastSavedSignature.current;
192
198
  setDraftLoading(true);
193
199
  try {
194
200
  const fetched = await client.getComposerDraft(workspaceId, sessionId);
195
- if (generation !== targetGeneration.current || targetKeyRef.current !== targetKey) {
201
+ if (
202
+ generation !== targetGeneration.current ||
203
+ targetKeyRef.current !== targetKey ||
204
+ readTicket !== draftReadGeneration.current
205
+ ) {
196
206
  return;
197
207
  }
198
- const ownsLatestRead = readTicket === draftReadGeneration.current;
199
208
  const currentRevision = draftRef.current?.revision ?? -1;
200
- if ((ownsLatestRead || rejectOnFailure) && fetched.revision >= currentRevision) {
209
+ if (fetched.revision >= currentRevision) {
201
210
  draftRef.current = fetched;
202
211
  setDraft(fetched);
203
212
  setDraftConflict(null);
204
- if (replaceLocal || localAtStart === localEditRevision.current) {
213
+ if (
214
+ replaceLocal ||
215
+ (!localWasDirtyAtStart && localAtStart === localEditRevision.current)
216
+ ) {
217
+ valueRef.current = fetched.text;
218
+ restoredResourcesRef.current = fetched.resources;
205
219
  lastSavedSignature.current = draftSignature(draftPayload(fetched));
206
220
  setValue(fetched.text);
207
221
  setRestoredResources(fetched.resources);
208
222
  onDraftApplied?.(fetched);
209
223
  }
210
224
  }
211
- const authoritativeRevision = draftRef.current?.revision ?? -1;
212
- if (rejectOnFailure && authoritativeRevision < fetched.revision) {
213
- throw new TypeError("Composer draft reconciliation did not commit authoritative state");
214
- }
215
- if (ownsLatestRead || rejectOnFailure) {
216
- const recoveredError = draftLoadErrorRef.current;
217
- draftLoadErrorRef.current = null;
218
- if (recoveredError) {
219
- setError((current) => (current === recoveredError ? null : current));
220
- }
221
- }
222
225
  } catch (cause) {
223
226
  if (
224
227
  generation === targetGeneration.current &&
225
228
  targetKeyRef.current === targetKey &&
226
- (readTicket === draftReadGeneration.current || rejectOnFailure)
229
+ readTicket === draftReadGeneration.current
227
230
  ) {
228
- const failure = asError(cause);
229
- draftLoadErrorRef.current = failure;
230
- setError(failure);
231
+ setError(asError(cause));
231
232
  }
232
- if (rejectOnFailure) throw cause;
233
233
  } finally {
234
234
  if (
235
235
  generation === targetGeneration.current &&
@@ -264,29 +264,23 @@ export function useComposer(
264
264
  enabled: Boolean(sessionId) && durableDrafts,
265
265
  ...(options.events !== undefined ? { events: options.events } : {}),
266
266
  },
267
- async () => await loadDraft(false, true),
268
267
  );
269
268
 
270
269
  const currentDraftPayload = useCallback((): SaveComposerDraftRequest | null => {
271
- if (targetKeyRef.current !== targetKey) return null;
270
+ if (!durableDrafts || targetKeyRef.current !== targetKey) return null;
272
271
  const base = draftRef.current;
273
272
  if (!base) return null;
274
273
  const extras = resolveSendExtras(sendExtrasRef.current);
275
- return {
276
- expectedRevision: base.revision,
277
- text: value,
278
- resources: mergeResources(restoredResources, extras.resources ?? []),
279
- tools: extras.tools ?? base.tools,
280
- model: extras.model ?? base.model,
281
- reasoningEffort: extras.reasoningEffort ?? base.reasoningEffort,
282
- };
283
- }, [restoredResources, targetKey, value]);
274
+ return composerDraftPayload(base, value, restoredResources, extras);
275
+ }, [durableDrafts, restoredResources, targetKey, value]);
284
276
 
285
277
  const persistPayload = useCallback(
286
278
  async (payload: SaveComposerDraftRequest): Promise<boolean> => {
287
279
  const ownedTargetKey = targetKey;
288
280
  const ownedGeneration = targetGeneration.current;
289
- if (!sessionId || targetKeyRef.current !== ownedTargetKey) return false;
281
+ if (!sessionId || !durableDrafts || targetKeyRef.current !== ownedTargetKey) {
282
+ return false;
283
+ }
290
284
  let success = false;
291
285
  const run = async () => {
292
286
  if (
@@ -339,7 +333,7 @@ export function useComposer(
339
333
  await saveChain.current;
340
334
  return success;
341
335
  },
342
- [client, sessionId, targetKey, workspaceId],
336
+ [client, durableDrafts, sessionId, targetKey, workspaceId],
343
337
  );
344
338
 
345
339
  // Private durable autosave. A newer local edit is never replaced by an older
@@ -394,11 +388,10 @@ export function useComposer(
394
388
  setSending(true);
395
389
  setError(null);
396
390
  try {
397
- // Trimming is only an emptiness check. A non-blank prompt must be sent
398
- // byte-for-byte as the user wrote it, because expectedDraftRevision
399
- // binds the submission to that exact durable draft. File-only messages
400
- // use the same non-blank placeholder in both the saved draft and the
401
- // submitted prompt so the content fence cannot reject its own client.
391
+ // Trimming is only an emptiness check. A non-blank prompt is persisted
392
+ // and submitted byte-for-byte, while file-only sends use the same
393
+ // placeholder for both operations so the server content fence cannot
394
+ // reject its own client.
402
395
  const sendText = hasText ? rawText : FILE_ONLY_MESSAGE_TEXT;
403
396
  const currentPayload = currentDraftPayload();
404
397
  const payload = currentPayload ? { ...currentPayload, text: sendText } : null;
@@ -409,11 +402,22 @@ export function useComposer(
409
402
  ) {
410
403
  return false;
411
404
  }
412
- const input = composeSendInput(sendText, pendingClientEventId.current, extras, {
405
+ // The wire contract requires non-empty text (z.string().min(1)) and the
406
+ // worker rejects whitespace-only text; a file-only message therefore
407
+ // carries a minimal default so the attachments still get delivered.
408
+ const acknowledgedDraft = draftRef.current;
409
+ const sendExtras =
410
+ acknowledgedDraft?.toolsProvided === true &&
411
+ !Object.prototype.hasOwnProperty.call(extras, "tools")
412
+ ? { ...extras, tools: acknowledgedDraft.tools }
413
+ : extras;
414
+ const input = composeSendInput(sendText, pendingClientEventId.current, sendExtras, {
413
415
  ...(options.effectiveControl?.controlEtag
414
416
  ? { controlEtag: options.effectiveControl.controlEtag }
415
417
  : {}),
416
- ...(draftRef.current ? { expectedDraftRevision: draftRef.current.revision } : {}),
418
+ ...(durableDrafts && draftRef.current
419
+ ? { expectedDraftRevision: draftRef.current.revision }
420
+ : {}),
417
421
  resources: mergeResources(restoredResources, extras.resources ?? []),
418
422
  });
419
423
  if (delivery === "steer") {
@@ -440,6 +444,7 @@ export function useComposer(
440
444
  updatedAt: null,
441
445
  };
442
446
  draftRef.current = cleared;
447
+ restoredResourcesRef.current = [];
443
448
  setDraft(cleared);
444
449
  setRestoredResources([]);
445
450
  lastSavedSignature.current = draftSignature(draftPayload(cleared));
@@ -447,7 +452,10 @@ export function useComposer(
447
452
  if (explicit === undefined) {
448
453
  // Clear only the draft that was sent: edits made while the request
449
454
  // was in flight were never delivered and must survive.
450
- setValue((current) => (current === draftAtSend ? "" : current));
455
+ if (valueRef.current === draftAtSend) {
456
+ valueRef.current = "";
457
+ setValue("");
458
+ }
451
459
  }
452
460
  onSent?.(sendText);
453
461
  return true;
@@ -471,6 +479,7 @@ export function useComposer(
471
479
  [
472
480
  client,
473
481
  currentDraftPayload,
482
+ durableDrafts,
474
483
  onSent,
475
484
  options.effectiveControl?.controlEtag,
476
485
  persistPayload,
@@ -491,7 +500,9 @@ export function useComposer(
491
500
  // — keeping useComposer attachment-agnostic while still lighting up the send
492
501
  // affordance the moment a file is ready. ChatComposer additionally gates this
493
502
  // on its `attachments.uploading` flag so a message never departs mid-upload.
494
- const hasReadyResources = restoredResources.length > 0 || (liveExtras.resources?.length ?? 0) > 0;
503
+ const hasReadyResources =
504
+ restoredResources.length > 0 ||
505
+ (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
495
506
 
496
507
  const pause = useCallback(
497
508
  async (reason?: string): Promise<void> => {
@@ -570,12 +581,14 @@ export function useComposer(
570
581
  setError(null);
571
582
  try {
572
583
  if (option.scope === "workspace") {
573
- if (!client.setWorkspaceInferenceState) {
574
- throw new Error("Workspace-level resume is not available through this client");
575
- }
576
584
  const workspaceBlocker = options.effectiveControl?.blockers.find(
577
585
  (blocker) => blocker.kind === "workspace",
578
586
  );
587
+ if (!client.setWorkspaceInferenceState) {
588
+ throw new Error(
589
+ "@opengeni/react: workspace-scoped resume requires setWorkspaceInferenceState.",
590
+ );
591
+ }
579
592
  await client.setWorkspaceInferenceState(workspaceId, {
580
593
  action: "resume",
581
594
  clientEventId: generateClientEventId(),
@@ -623,6 +636,7 @@ export function useComposer(
623
636
  if (targetKeyRef.current !== targetKey) return;
624
637
  pendingClientEventId.current = null;
625
638
  localEditRevision.current += 1;
639
+ valueRef.current = next;
626
640
  setValue(next);
627
641
  },
628
642
  [targetKey],
@@ -632,11 +646,27 @@ export function useComposer(
632
646
  (index: number) => {
633
647
  if (targetKeyRef.current !== targetKey) return;
634
648
  localEditRevision.current += 1;
635
- setRestoredResources((current) => current.filter((_, candidate) => candidate !== index));
649
+ const next = restoredResourcesRef.current.filter((_, candidate) => candidate !== index);
650
+ restoredResourcesRef.current = next;
651
+ setRestoredResources(next);
636
652
  },
637
653
  [targetKey],
638
654
  );
639
655
 
656
+ const hasDraftContent = useCallback((): boolean => {
657
+ const current = draftRef.current;
658
+ const extras = resolveSendExtras(sendExtrasRef.current);
659
+ const toolsProvidedByHost = Object.prototype.hasOwnProperty.call(extras, "tools");
660
+ const tools = toolsProvidedByHost ? (extras.tools ?? []) : (current?.tools ?? []);
661
+ return (
662
+ valueRef.current.length > 0 ||
663
+ restoredResourcesRef.current.length > 0 ||
664
+ (extras.resources?.length ?? 0) > 0 ||
665
+ tools.length > 0 ||
666
+ (current?.sourceTurnId !== null && current?.sourceTurnId !== undefined)
667
+ );
668
+ }, []);
669
+
640
670
  const resolveDraftConflict = useCallback(
641
671
  async (choice: "keep_mine" | "use_remote"): Promise<void> => {
642
672
  const ownedTargetKey = targetKey;
@@ -679,6 +709,7 @@ export function useComposer(
679
709
  return {
680
710
  value: identityMatches ? value : "",
681
711
  setValue: updateValue,
712
+ hasDraftContent,
682
713
  send,
683
714
  steer,
684
715
  sending: identityMatches ? sending : false,
@@ -774,11 +805,30 @@ function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest {
774
805
  text: draft.text,
775
806
  resources: draft.resources,
776
807
  tools: draft.tools,
808
+ toolsProvided: draft.toolsProvided,
777
809
  model: draft.model,
778
810
  reasoningEffort: draft.reasoningEffort,
779
811
  };
780
812
  }
781
813
 
814
+ function composerDraftPayload(
815
+ base: ComposerDraft,
816
+ text: string,
817
+ restoredResources: ResourceRef[],
818
+ extras: ComposerSendExtras,
819
+ ): SaveComposerDraftRequest {
820
+ const toolsProvidedByHost = Object.prototype.hasOwnProperty.call(extras, "tools");
821
+ return {
822
+ expectedRevision: base.revision,
823
+ text,
824
+ resources: mergeResources(restoredResources, extras.resources ?? []),
825
+ tools: toolsProvidedByHost ? (extras.tools ?? []) : base.tools,
826
+ toolsProvided: toolsProvidedByHost ? true : base.toolsProvided,
827
+ model: extras.model ?? base.model,
828
+ reasoningEffort: extras.reasoningEffort ?? base.reasoningEffort,
829
+ };
830
+ }
831
+
782
832
  function draftSignature(payload: SaveComposerDraftRequest): string {
783
833
  const { expectedRevision: _revision, ...content } = payload;
784
834
  return JSON.stringify(content);
@@ -0,0 +1,161 @@
1
+ import type {
2
+ SessionEvent,
3
+ SessionMcpApprovalPolicy,
4
+ SessionMcpServerMetadata,
5
+ UpdateSessionMcpApprovalPolicyResponse,
6
+ } from "@opengeni/sdk";
7
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
8
+ import {
9
+ useEmbeddedSessionMcpApprovalPolicy,
10
+ type EmbeddedSessionMcpApprovalPolicyClientOverride,
11
+ } from "../session-context";
12
+ import {
13
+ useDebouncedCallback,
14
+ useMutationRunner,
15
+ usePolledValue,
16
+ useSessionEventTrigger,
17
+ type SessionEventFeedOptions,
18
+ } from "./internal";
19
+
20
+ export function isSessionMcpApprovalPolicyEvent(
21
+ event: Pick<SessionEvent, "type" | "payload">,
22
+ serverId?: string,
23
+ ): boolean {
24
+ if (event.type !== "session.mcp.approval_policy.updated") {
25
+ return false;
26
+ }
27
+ if (serverId === undefined) {
28
+ return true;
29
+ }
30
+ const payload = event.payload as { serverId?: unknown } | null;
31
+ return payload?.serverId === serverId;
32
+ }
33
+
34
+ export type UseSessionMcpApprovalPolicyOptions = EmbeddedSessionMcpApprovalPolicyClientOverride &
35
+ SessionEventFeedOptions & {
36
+ /** Optional safety-net polling (ms). Off by default; policy events drive refreshes. */
37
+ pollIntervalMs?: number | undefined;
38
+ };
39
+
40
+ export type UseSessionMcpApprovalPolicyResult = {
41
+ server: SessionMcpServerMetadata | null;
42
+ policy: SessionMcpApprovalPolicy | null;
43
+ loading: boolean;
44
+ error: Error | null;
45
+ refresh: () => Promise<void>;
46
+ update: (
47
+ policy: SessionMcpApprovalPolicy,
48
+ ) => Promise<UpdateSessionMcpApprovalPolicyResponse | null>;
49
+ updating: boolean;
50
+ clearError: () => void;
51
+ };
52
+
53
+ /**
54
+ * Read and update one existing session MCP server's approval policy. Updates
55
+ * become effective for the next claimed attempt; current work is unchanged.
56
+ */
57
+ export function useSessionMcpApprovalPolicy(
58
+ sessionId: string | null | undefined,
59
+ serverId: string | null | undefined,
60
+ options: UseSessionMcpApprovalPolicyOptions = {},
61
+ ): UseSessionMcpApprovalPolicyResult {
62
+ const { client, workspaceId } = useEmbeddedSessionMcpApprovalPolicy(options);
63
+ const enabled = (options.enabled ?? true) && Boolean(sessionId && serverId);
64
+ const [override, setOverride] = useState<SessionMcpServerMetadata | null>(null);
65
+ const targetKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${serverId ?? ""}`;
66
+ const authoritativeGeneration = useRef(0);
67
+ const mutationIdentity = useMemo(() => ({ client, targetKey }), [client, targetKey]);
68
+ const { run, mutating, mutationError, clearMutationError } = useMutationRunner(mutationIdentity);
69
+
70
+ useLayoutEffect(() => {
71
+ authoritativeGeneration.current += 1;
72
+ setOverride(null);
73
+ }, [client, targetKey]);
74
+
75
+ type PolicyRead = {
76
+ targetKey: string;
77
+ generation: number;
78
+ server: SessionMcpServerMetadata | null;
79
+ };
80
+ const load = useCallback(async (): Promise<PolicyRead> => {
81
+ const generation = authoritativeGeneration.current;
82
+ if (!sessionId || !serverId) {
83
+ return { targetKey, generation, server: null };
84
+ }
85
+ const session = await client.getSession(workspaceId, sessionId);
86
+ return {
87
+ targetKey,
88
+ generation,
89
+ server: session.mcpServers.find((server) => server.id === serverId) ?? null,
90
+ };
91
+ }, [client, serverId, sessionId, targetKey, workspaceId]);
92
+ const {
93
+ data,
94
+ loading,
95
+ error: loadError,
96
+ refresh,
97
+ } = usePolledValue(load, {
98
+ enabled,
99
+ pollIntervalMs: options.pollIntervalMs,
100
+ });
101
+ useEffect(() => {
102
+ if (data?.targetKey === targetKey && data.generation === authoritativeGeneration.current) {
103
+ setOverride(null);
104
+ }
105
+ }, [data, targetKey]);
106
+
107
+ const scheduleRefresh = useDebouncedCallback(() => void refresh());
108
+ const matchesPolicyEvent = useCallback(
109
+ (event: Pick<SessionEvent, "type" | "payload">) =>
110
+ isSessionMcpApprovalPolicyEvent(event, serverId ?? undefined),
111
+ [serverId],
112
+ );
113
+ useSessionEventTrigger(
114
+ client,
115
+ workspaceId,
116
+ sessionId,
117
+ matchesPolicyEvent,
118
+ scheduleRefresh,
119
+ {
120
+ enabled,
121
+ ...(options.events !== undefined ? { events: options.events } : {}),
122
+ },
123
+ async () => await refresh(),
124
+ );
125
+
126
+ const update = useCallback(
127
+ async (
128
+ policy: SessionMcpApprovalPolicy,
129
+ ): Promise<UpdateSessionMcpApprovalPolicyResponse | null> => {
130
+ if (!sessionId || !serverId) {
131
+ return null;
132
+ }
133
+ const response = await run(() =>
134
+ client.updateSessionMcpApprovalPolicy(workspaceId, sessionId, serverId, {
135
+ requireApproval: policy,
136
+ }),
137
+ );
138
+ if (response) {
139
+ authoritativeGeneration.current += 1;
140
+ setOverride(response.server);
141
+ void refresh();
142
+ }
143
+ return response;
144
+ },
145
+ [client, refresh, run, serverId, sessionId, workspaceId],
146
+ );
147
+
148
+ const server = override ?? data?.server ?? null;
149
+ return {
150
+ server,
151
+ policy: server?.requireApproval ?? null,
152
+ loading,
153
+ error: mutationError ?? loadError,
154
+ refresh: async () => {
155
+ await refresh();
156
+ },
157
+ update,
158
+ updating: mutating,
159
+ clearError: clearMutationError,
160
+ };
161
+ }
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  export type {
11
11
  EmbeddedHumanInputSessionClientLike,
12
+ EmbeddedSessionMcpApprovalPolicyClientLike,
12
13
  EmbeddedSessionClientLike,
13
14
  SessionClientLike,
14
15
  } from "./client";
@@ -78,6 +79,14 @@ export type {
78
79
  UseSessionControlOptions,
79
80
  UseSessionControlResult,
80
81
  } from "./hooks/use-session-control";
82
+ export {
83
+ isSessionMcpApprovalPolicyEvent,
84
+ useSessionMcpApprovalPolicy,
85
+ } from "./hooks/use-session-mcp-approval-policy";
86
+ export type {
87
+ UseSessionMcpApprovalPolicyOptions,
88
+ UseSessionMcpApprovalPolicyResult,
89
+ } from "./hooks/use-session-mcp-approval-policy";
81
90
  export { useScheduledTasks } from "./hooks/use-scheduled-tasks";
82
91
  export type {
83
92
  UseScheduledTasksOptions,
@@ -2,6 +2,7 @@ import type { StreamConnectionState, WorkspaceControlEvent } from "@opengeni/sdk
2
2
  import { createContext, useContext } from "react";
3
3
  import type {
4
4
  EmbeddedHumanInputSessionClientLike,
5
+ EmbeddedSessionMcpApprovalPolicyClientLike,
5
6
  EmbeddedSessionClientLike,
6
7
  SessionClientLike,
7
8
  } from "./client";
@@ -40,6 +41,11 @@ export type EmbeddedHumanInputClientOverride = {
40
41
  workspaceId?: string | undefined;
41
42
  };
42
43
 
44
+ export type EmbeddedSessionMcpApprovalPolicyClientOverride = {
45
+ client?: EmbeddedSessionMcpApprovalPolicyClientLike | undefined;
46
+ workspaceId?: string | undefined;
47
+ };
48
+
43
49
  export type EmbeddedSessionContextValue = Omit<OpenGeniContextValue, "client"> & {
44
50
  client: EmbeddedSessionClientLike;
45
51
  };
@@ -117,6 +123,25 @@ export function useEmbeddedHumanInputSession(override: EmbeddedHumanInputClientO
117
123
  };
118
124
  }
119
125
 
126
+ /** Resolve the approval-policy refinement without widening session-only hosts. */
127
+ export function useEmbeddedSessionMcpApprovalPolicy(
128
+ override: EmbeddedSessionMcpApprovalPolicyClientOverride = {},
129
+ ): Omit<EmbeddedSessionContextValue, "client"> & {
130
+ client: EmbeddedSessionMcpApprovalPolicyClientLike;
131
+ } {
132
+ const embedded = useEmbeddedSession(override);
133
+ const client = embedded.client as Partial<EmbeddedSessionMcpApprovalPolicyClientLike>;
134
+ if (typeof client.updateSessionMcpApprovalPolicy !== "function") {
135
+ throw new Error(
136
+ "@opengeni/react: useSessionMcpApprovalPolicy requires updateSessionMcpApprovalPolicy.",
137
+ );
138
+ }
139
+ return {
140
+ ...embedded,
141
+ client: client as EmbeddedSessionMcpApprovalPolicyClientLike,
142
+ };
143
+ }
144
+
120
145
  /**
121
146
  * Resolve the client only — for hooks that are not workspace-scoped
122
147
  * (`useWorkspaces`, `useBillingUsage`).
package/src/session.ts CHANGED
@@ -6,10 +6,12 @@
6
6
 
7
7
  export type {
8
8
  EmbeddedHumanInputSessionClientLike as HumanInputSessionClientLike,
9
+ EmbeddedSessionMcpApprovalPolicyClientLike as SessionMcpApprovalPolicyClientLike,
9
10
  EmbeddedSessionClientLike as SessionClientLike,
10
11
  } from "./client";
11
12
  export type {
12
13
  EmbeddedHumanInputClientOverride as HumanInputClientOverride,
14
+ EmbeddedSessionMcpApprovalPolicyClientOverride as SessionMcpApprovalPolicyClientOverride,
13
15
  EmbeddedSessionClientOverride as ClientOverride,
14
16
  } from "./session-context";
15
17
 
@@ -38,6 +40,14 @@ export type {
38
40
  UseSessionControlOptions,
39
41
  UseSessionControlResult,
40
42
  } from "./hooks/use-session-control";
43
+ export {
44
+ isSessionMcpApprovalPolicyEvent,
45
+ useSessionMcpApprovalPolicy,
46
+ } from "./hooks/use-session-mcp-approval-policy";
47
+ export type {
48
+ UseSessionMcpApprovalPolicyOptions,
49
+ UseSessionMcpApprovalPolicyResult,
50
+ } from "./hooks/use-session-mcp-approval-policy";
41
51
 
42
52
  export { approvalsFromRequiresAction, projectPendingApprovals } from "./approvals";
43
53
  export type { PendingApproval } from "./approvals";