@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.
- package/dist/{chunk-6BXBGJ3B.js → chunk-6XUS5VFM.js} +2 -2
- package/dist/{chunk-LAZ2A743.js → chunk-CATATY77.js} +3 -3
- package/dist/{chunk-HHFA4AFX.js → chunk-I3BJZIG5.js} +15 -1
- package/dist/chunk-I3BJZIG5.js.map +1 -0
- package/dist/{chunk-6UT5OC3P.js → chunk-UAHPKLB6.js} +113 -9
- package/dist/chunk-UAHPKLB6.js.map +1 -0
- package/dist/{chunk-MRSECXRP.js → chunk-UQS7OQDE.js} +82 -54
- package/dist/chunk-UQS7OQDE.js.map +1 -0
- package/dist/{chunk-EBTT3MYN.js → chunk-YDNWMKXO.js} +11 -1
- package/dist/chunk-YDNWMKXO.js.map +1 -0
- package/dist/{composer-DvUaa5ki.d.ts → composer-CVm5uoUI.d.ts} +2 -2
- package/dist/composer.d.ts +3 -3
- package/dist/composer.js +3 -3
- package/dist/index.d.ts +8 -8
- package/dist/index.js +140 -26
- package/dist/index.js.map +1 -1
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +2 -2
- package/dist/{queue-surface-implementation-NQMV2ML3.js → queue-surface-implementation-3UW3MIIR.js} +9 -9
- package/dist/queue-surface-implementation-3UW3MIIR.js.map +1 -0
- package/dist/{session-Gd4iVPYw.d.ts → session-Db00TRwl.d.ts} +32 -11
- package/dist/{session-context-DIFFlXKc.d.ts → session-context-rgrfElMl.d.ts} +7 -1
- package/dist/session.d.ts +3 -3
- package/dist/session.js +7 -3
- package/dist/{use-composer-BCfm4mzX.d.ts → use-composer-By1wn3MM.d.ts} +5 -18
- package/package.json +2 -2
- package/src/client.ts +4 -0
- package/src/components/message-timeline.tsx +195 -23
- package/src/components/queue-draft-policy.ts +18 -0
- package/src/components/queue-surface-implementation.tsx +6 -10
- package/src/components/queue-surface.tsx +1 -0
- package/src/hooks/use-composer.ts +128 -78
- package/src/hooks/use-session-mcp-approval-policy.ts +161 -0
- package/src/index.ts +9 -0
- package/src/session-context.ts +25 -0
- package/src/session.ts +10 -0
- package/src/timeline/types.ts +8 -15
- package/dist/chunk-6UT5OC3P.js.map +0 -1
- package/dist/chunk-EBTT3MYN.js.map +0 -1
- package/dist/chunk-HHFA4AFX.js.map +0 -1
- package/dist/chunk-MRSECXRP.js.map +0 -1
- package/dist/queue-surface-implementation-NQMV2ML3.js.map +0 -1
- /package/dist/{chunk-6BXBGJ3B.js.map → chunk-6XUS5VFM.js.map} +0 -0
- /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
|
|
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
|
-
|
|
119
|
-
|
|
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.
|
|
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
|
-
|
|
121
|
+
valueRef.current = "";
|
|
136
122
|
draftRef.current = null;
|
|
137
|
-
|
|
123
|
+
restoredResourcesRef.current = [];
|
|
138
124
|
lastSavedSignature.current = null;
|
|
139
|
-
//
|
|
140
|
-
//
|
|
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
|
|
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 (
|
|
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 (
|
|
209
|
+
if (fetched.revision >= currentRevision) {
|
|
201
210
|
draftRef.current = fetched;
|
|
202
211
|
setDraft(fetched);
|
|
203
212
|
setDraftConflict(null);
|
|
204
|
-
if (
|
|
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
|
-
|
|
229
|
+
readTicket === draftReadGeneration.current
|
|
227
230
|
) {
|
|
228
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
398
|
-
// byte-for-byte
|
|
399
|
-
//
|
|
400
|
-
//
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
package/src/session-context.ts
CHANGED
|
@@ -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";
|