@assistant-ui/eve 0.0.12 → 0.0.14

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.
@@ -1,9 +1,10 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import {
5
5
  fromThreadMessageLike,
6
6
  generateId,
7
+ MessageNotSentError,
7
8
  pickExternalStoreSharedOptions,
8
9
  type AppendMessage,
9
10
  type AttachmentAdapter,
@@ -25,22 +26,29 @@ import {
25
26
  type UseEveAgentOptions,
26
27
  type UseEveAgentStatus,
27
28
  } from "eve/react";
28
- import type { SendTurnPayload } from "eve/client";
29
29
  import {
30
30
  convertEveMessages,
31
31
  getEveMessageContent,
32
32
  toEveInputResponse,
33
33
  } from "./convertEveMessages";
34
+ import { eveExtras } from "./eveExtras";
34
35
 
35
36
  const USER_STAGED_STATUS = {
36
37
  type: "complete",
37
38
  reason: "unknown",
38
39
  } as const;
39
40
 
40
- const sendCancelledError = new Error(
41
+ const sendCancelledError = new MessageNotSentError(
41
42
  "eve send was dropped because the run was cancelled.",
42
43
  );
43
44
 
45
+ const sendAbandonedError = new Error(
46
+ "eve send was dropped because the runtime unmounted.",
47
+ );
48
+
49
+ const isDroppedSend = (error: unknown) =>
50
+ error === sendCancelledError || error === sendAbandonedError;
51
+
44
52
  type EveLifecycleCallbackName =
45
53
  | "onError"
46
54
  | "onEvent"
@@ -75,22 +83,35 @@ const hasRunConfig = (
75
83
  ): runConfig is NonNullable<AppendMessage["runConfig"]> =>
76
84
  runConfig?.custom !== undefined && Object.keys(runConfig.custom).length > 0;
77
85
 
86
+ type EveJsonValue =
87
+ | string
88
+ | number
89
+ | boolean
90
+ | null
91
+ | readonly EveJsonValue[]
92
+ | { readonly [key: string]: EveJsonValue };
93
+
94
+ /**
95
+ * Structural equivalent of eve's `JsonObject`. Eve demoted its public send
96
+ * payload type once already (`SendTurnPayload`, public through 0.30, internal
97
+ * from 0.31), so the helpers declare the shapes they forward instead of
98
+ * re-exporting eve's option types, and assignability is checked at the send
99
+ * call sites against the installed version.
100
+ */
101
+ type EveClientContext = { readonly [key: string]: EveJsonValue };
102
+
78
103
  /**
79
104
  * Only the `custom` bag crosses the wire. Eve reads `clientContext` as its own
80
105
  * namespace and serializes it into a model-visible context message, so sending
81
106
  * the assistant-ui envelope would surface a literal `"custom"` key in the
82
107
  * prompt and to every eve-side handler.
83
108
  */
84
- const toEveClientContext = (
109
+ const toEveSendOptions = (
85
110
  runConfig: AppendMessage["runConfig"],
86
- ): Pick<SendTurnPayload, "clientContext"> =>
111
+ ): { readonly clientContext: EveClientContext } | undefined =>
87
112
  hasRunConfig(runConfig)
88
- ? {
89
- clientContext: runConfig.custom as NonNullable<
90
- SendTurnPayload["clientContext"]
91
- >,
92
- }
93
- : {};
113
+ ? { clientContext: runConfig.custom as EveClientContext }
114
+ : undefined;
94
115
 
95
116
  export type UseEveAgentRuntimeOptions = Omit<
96
117
  UseEveAgentOptions<EveMessageData>,
@@ -210,27 +231,40 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
210
231
  const messagesRef = useRef(messages);
211
232
  messagesRef.current = messages;
212
233
 
213
- // Upstream `EveAgentStore.send` rejects while a turn is in flight and only
214
- // resolves once the turn's stream parks, so a pending chain link is exactly
215
- // an active turn; chaining every send serializes them without watching
216
- // status.
234
+ // Upstream `EveAgentStore` `send` and `respond` reject while a turn is in
235
+ // flight and only resolve once the turn's stream parks, so a pending chain
236
+ // link is exactly an active turn; chaining every dispatch serializes them
237
+ // without watching status.
217
238
  const sendChainRef = useRef<Promise<void>>(Promise.resolve());
218
239
  const sendEpochRef = useRef(0);
240
+ // A cancel drops the queued send but keeps its draft for a later promotion;
241
+ // only a reset discards the draft with the session, so the two need separate
242
+ // counters.
243
+ const resetEpochRef = useRef(0);
244
+ const isMountedRef = useRef(true);
245
+ const runtimeRef = useRef<ReturnType<typeof useExternalStoreRuntime> | null>(
246
+ null,
247
+ );
219
248
 
220
- const enqueueSend = (payload: Parameters<typeof agent.send>[0]) => {
249
+ const enqueueSend = (dispatch: () => Promise<void>) => {
221
250
  const epoch = sendEpochRef.current;
222
251
  const next = sendChainRef.current.then(() => {
223
- if (epoch !== sendEpochRef.current) throw sendCancelledError;
224
- return agent.send(payload);
252
+ if (epoch !== sendEpochRef.current)
253
+ throw isMountedRef.current ? sendCancelledError : sendAbandonedError;
254
+ return dispatch();
225
255
  });
226
256
  sendChainRef.current = next.catch(() => {});
227
257
  return next;
228
258
  };
229
259
 
230
260
  // The store outlives the component (useEveAgent holds it in a ref with no
231
- // cleanup), so queued sends must not fire server turns after unmount.
261
+ // cleanup), so queued sends must not fire server turns after unmount. The
262
+ // flag separates that teardown from a user cancel, and is re-armed in setup
263
+ // for a remounted tree.
232
264
  useEffect(() => {
265
+ isMountedRef.current = true;
233
266
  return () => {
267
+ isMountedRef.current = false;
234
268
  sendEpochRef.current += 1;
235
269
  };
236
270
  }, []);
@@ -276,10 +310,37 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
276
310
  setStagedMessages(nextMessages);
277
311
  };
278
312
 
279
- return useExternalStoreRuntime({
313
+ const reset = useCallback(() => {
314
+ runtimeRef.current?.thread.unstable_notifySessionReset();
315
+ // Sends parked behind an active turn captured the pre-reset epoch, so the
316
+ // epoch has to advance before the session is torn down or they dispatch
317
+ // into the new one; `lastFinishStatusRef` is run-scoped for the same
318
+ // reason.
319
+ sendEpochRef.current += 1;
320
+ resetEpochRef.current += 1;
321
+ lastFinishStatusRef.current = null;
322
+ setStagedMessages(null);
323
+ stagedInputsRef.current.clear();
324
+ setToolStatuses({});
325
+ agent.reset();
326
+ }, [agent]);
327
+
328
+ const extras = useMemo(
329
+ () =>
330
+ eveExtras.provide({
331
+ error: agent.error,
332
+ events: agent.events,
333
+ session: agent.session,
334
+ reset,
335
+ }),
336
+ [agent.error, agent.events, agent.session, reset],
337
+ );
338
+
339
+ const runtime = useExternalStoreRuntime({
280
340
  ...pickExternalStoreSharedOptions(options),
281
341
  messages,
282
342
  isRunning,
343
+ extras,
283
344
  unstable_enableToolInvocations: true,
284
345
  setToolStatuses,
285
346
  adapters: {
@@ -295,12 +356,18 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
295
356
  return;
296
357
  }
297
358
  try {
298
- await enqueueSend({
299
- message: getEveMessageContent(message),
300
- ...toEveClientContext(message.runConfig),
301
- });
359
+ await enqueueSend(() =>
360
+ agent.send(
361
+ getEveMessageContent(message),
362
+ toEveSendOptions(message.runConfig),
363
+ ),
364
+ );
302
365
  } catch (error) {
303
- if (error !== sendCancelledError) throw error;
366
+ // A cancelled send never reached the session, so it rethrows for the
367
+ // composer to take the draft back; an unmounted one has no composer
368
+ // left to restore.
369
+ if (error === sendAbandonedError) return;
370
+ throw error;
304
371
  }
305
372
  },
306
373
  ...(stagedMessages
@@ -310,6 +377,7 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
310
377
  if (!stagedRun)
311
378
  throw new Error("Runtime does not support reloading messages.");
312
379
  const epoch = sendEpochRef.current;
380
+ const resetEpoch = resetEpochRef.current;
313
381
  for (const { message: stagedMessage, input } of stagedRun) {
314
382
  if (epoch !== sendEpochRef.current) return;
315
383
  const previousMessages = messagesRef.current;
@@ -329,15 +397,21 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
329
397
  ? config.runConfig
330
398
  : input.runConfig;
331
399
  try {
332
- await enqueueSend({
333
- message: getEveMessageContent(input.message),
334
- ...toEveClientContext(runConfig),
335
- });
400
+ await enqueueSend(() =>
401
+ agent.send(
402
+ getEveMessageContent(input.message),
403
+ toEveSendOptions(runConfig),
404
+ ),
405
+ );
336
406
  } catch (error) {
407
+ // A reset discarded the draft along with the session, so
408
+ // restoring it here would refloat a message the thread no
409
+ // longer has. A cancel still restores.
410
+ if (resetEpoch !== resetEpochRef.current) return;
337
411
  stagedInputsRef.current.set(stagedMessage.id, input);
338
412
  messagesRef.current = previousMessages;
339
413
  setStagedMessages(previousMessages);
340
- if (error === sendCancelledError) return;
414
+ if (isDroppedSend(error)) return;
341
415
  throw error;
342
416
  }
343
417
  if (lastFinishStatusRef.current === "error") return;
@@ -352,10 +426,13 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
352
426
  },
353
427
  onRespondToToolApproval: async (response) => {
354
428
  try {
355
- await enqueueSend({ inputResponses: [toEveInputResponse(response)] });
429
+ await enqueueSend(() => agent.respond([toEveInputResponse(response)]));
356
430
  } catch (error) {
357
- if (error !== sendCancelledError) throw error;
431
+ if (!isDroppedSend(error)) throw error;
358
432
  }
359
433
  },
360
434
  });
435
+ runtimeRef.current = runtime;
436
+
437
+ return runtime;
361
438
  };