@assistant-ui/eve 0.0.18 → 0.0.20

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 (39) hide show
  1. package/LICENSE +1 -1
  2. package/dist/convertEveMessages.d.ts +31 -33
  3. package/dist/convertEveMessages.d.ts.map +1 -1
  4. package/dist/deriveCreatedAt.d.ts +5 -7
  5. package/dist/deriveCreatedAt.d.ts.map +1 -1
  6. package/dist/deriveCreatedAt.js +1 -0
  7. package/dist/deriveCreatedAt.js.map +1 -1
  8. package/dist/eveCloudSessions.d.ts +9 -0
  9. package/dist/eveCloudSessions.d.ts.map +1 -0
  10. package/dist/eveCloudSessions.js +51 -0
  11. package/dist/eveCloudSessions.js.map +1 -0
  12. package/dist/eveExtras.d.ts +1 -3
  13. package/dist/eveExtras.d.ts.map +1 -1
  14. package/dist/hooks.d.ts +5 -3
  15. package/dist/hooks.d.ts.map +1 -1
  16. package/dist/hooks.js +4 -0
  17. package/dist/hooks.js.map +1 -1
  18. package/dist/index.d.ts +8 -6
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/sdkIdentity.d.ts +3 -0
  21. package/dist/sdkIdentity.d.ts.map +1 -0
  22. package/dist/sdkIdentity.js +9 -0
  23. package/dist/sdkIdentity.js.map +1 -0
  24. package/dist/useEveAgentRuntime.d.ts +17 -12
  25. package/dist/useEveAgentRuntime.d.ts.map +1 -1
  26. package/dist/useEveAgentRuntime.js +104 -21
  27. package/dist/useEveAgentRuntime.js.map +1 -1
  28. package/package.json +9 -5
  29. package/src/deriveCreatedAt.test.ts +22 -0
  30. package/src/deriveCreatedAt.ts +5 -0
  31. package/src/eveCloudSessions.test.ts +78 -0
  32. package/src/eveCloudSessions.ts +59 -0
  33. package/src/hooks.ts +4 -0
  34. package/src/index.ts +1 -1
  35. package/src/sdkIdentity.ts +9 -0
  36. package/src/useEveAgentRuntime.cloud.test.tsx +558 -0
  37. package/src/useEveAgentRuntime.eveResume.test.tsx +102 -2
  38. package/src/useEveAgentRuntime.fast-refresh.test.tsx +342 -0
  39. package/src/useEveAgentRuntime.ts +203 -45
@@ -0,0 +1,342 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, type ReactNode } from "react";
4
+ import type { AssistantCloud } from "assistant-cloud";
5
+ import { afterAll, afterEach, expect, it, vi } from "vitest";
6
+ import type { EveCloudSessions } from "./eveCloudSessions";
7
+
8
+ const { mockUseEveAgent, directRuntimeHook, sessionInstances } = vi.hoisted(
9
+ () => ({
10
+ mockUseEveAgent: vi.fn(),
11
+ directRuntimeHook: { current: false },
12
+ sessionInstances: [] as unknown[],
13
+ }),
14
+ );
15
+
16
+ vi.mock("eve/react", async (importOriginal) => ({
17
+ ...(await importOriginal<typeof import("eve/react")>()),
18
+ useEveAgent: mockUseEveAgent,
19
+ }));
20
+
21
+ vi.mock("@assistant-ui/store", async (importOriginal) => {
22
+ const original = await importOriginal<typeof import("@assistant-ui/store")>();
23
+ return {
24
+ ...original,
25
+ useAui: () => {
26
+ const aui = original.useAui();
27
+ if (!directRuntimeHook.current) return aui;
28
+ return new Proxy(aui, {
29
+ get(target, key) {
30
+ if (key !== "threadListItem") return Reflect.get(target, key);
31
+ return new Proxy(target.threadListItem, {
32
+ get(item, itemKey) {
33
+ if (itemKey !== "getState") return Reflect.get(item, itemKey);
34
+ return () => ({ ...item.getState(), status: "new" });
35
+ },
36
+ });
37
+ },
38
+ });
39
+ },
40
+ };
41
+ });
42
+
43
+ vi.mock("@assistant-ui/core/react", async (importOriginal) => {
44
+ const original =
45
+ await importOriginal<typeof import("@assistant-ui/core/react")>();
46
+ return {
47
+ ...original,
48
+ useRemoteThreadListRuntime: (
49
+ options: Parameters<typeof original.useRemoteThreadListRuntime>[0],
50
+ ) =>
51
+ directRuntimeHook.current
52
+ ? options.runtimeHook()
53
+ : original.useRemoteThreadListRuntime(options),
54
+ };
55
+ });
56
+
57
+ vi.mock("./eveCloudSessions", async (importOriginal) => {
58
+ const original = await importOriginal<typeof import("./eveCloudSessions")>();
59
+ return {
60
+ ...original,
61
+ createEveCloudSessions: () => {
62
+ const sessions = original.createEveCloudSessions();
63
+ sessionInstances.push(sessions);
64
+ return sessions;
65
+ },
66
+ };
67
+ });
68
+
69
+ type Family = { current: unknown };
70
+ type RendererInternals = {
71
+ setRefreshHandler: (resolve: (type: unknown) => Family | undefined) => void;
72
+ scheduleRefresh: (
73
+ root: unknown,
74
+ update: { staleFamilies: Set<Family>; updatedFamilies: Set<Family> },
75
+ ) => void;
76
+ };
77
+
78
+ let renderer: RendererInternals | undefined;
79
+ const fiberRoots = new Set<unknown>();
80
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
81
+ vi.stubGlobal("__REACT_DEVTOOLS_GLOBAL_HOOK__", {
82
+ supportsFiber: true,
83
+ inject: (internals: RendererInternals) => {
84
+ renderer = internals;
85
+ return 1;
86
+ },
87
+ onScheduleFiberRoot: () => {},
88
+ onCommitFiberRoot: (_id: number, root: unknown) => fiberRoots.add(root),
89
+ onCommitFiberUnmount: () => {},
90
+ });
91
+ const { cleanup, render, waitFor } = await import("@testing-library/react");
92
+ const { AssistantRuntimeProvider } = await import("@assistant-ui/core/react");
93
+ const { useEveAgentRuntime } = await import("./useEveAgentRuntime");
94
+ const { useEveReset } = await import("./hooks");
95
+ const { useAui } = await import("@assistant-ui/store");
96
+
97
+ afterEach(() => {
98
+ cleanup();
99
+ mockUseEveAgent.mockReset();
100
+ directRuntimeHook.current = false;
101
+ sessionInstances.length = 0;
102
+ renderer!.setRefreshHandler(() => undefined);
103
+ fiberRoots.clear();
104
+ });
105
+ afterAll(() => vi.unstubAllGlobals());
106
+
107
+ const refresh = async (Before: unknown, After: unknown) => {
108
+ const family: Family = { current: After };
109
+ renderer!.setRefreshHandler((type) =>
110
+ type === Before || type === After ? family : undefined,
111
+ );
112
+ await act(async () => {
113
+ for (const root of fiberRoots) {
114
+ renderer!.scheduleRefresh(root, {
115
+ staleFamilies: new Set(),
116
+ updatedFamilies: new Set([family]),
117
+ });
118
+ }
119
+ });
120
+ await act(async () => {});
121
+ };
122
+
123
+ const makeAgent = (send: (content: string) => Promise<void>) => ({
124
+ data: {
125
+ messages: [
126
+ { id: "u1", role: "user", parts: [{ type: "text", text: "earlier" }] },
127
+ {
128
+ id: "a1",
129
+ role: "assistant",
130
+ parts: [{ type: "text", text: "earlier answer" }],
131
+ },
132
+ ],
133
+ },
134
+ error: undefined,
135
+ events: [],
136
+ session: undefined,
137
+ status: "ready",
138
+ send: vi.fn(send),
139
+ respond: vi.fn(async () => {}),
140
+ cancel: vi.fn(async () => ({ status: "no_active_turn" })),
141
+ reset: vi.fn(),
142
+ resume: vi.fn(async () => {}),
143
+ });
144
+
145
+ it("dispatches an Eve send queued behind a turn after Fast Refresh", async () => {
146
+ let releaseFirst!: () => void;
147
+ const agent = makeAgent(
148
+ vi
149
+ .fn()
150
+ .mockImplementationOnce(
151
+ () =>
152
+ new Promise<void>((resolve) => {
153
+ releaseFirst = resolve;
154
+ }),
155
+ )
156
+ .mockResolvedValue(undefined),
157
+ );
158
+ mockUseEveAgent.mockReturnValue(agent);
159
+ let runtime!: ReturnType<typeof useEveAgentRuntime>;
160
+ let rendered = "";
161
+ const Before = () => {
162
+ rendered = "before";
163
+ runtime = useEveAgentRuntime();
164
+ return null;
165
+ };
166
+ const After = () => {
167
+ rendered = "after";
168
+ runtime = useEveAgentRuntime();
169
+ return null;
170
+ };
171
+ const view = render(<Before />);
172
+
173
+ await act(async () => runtime.thread.append("first"));
174
+ await waitFor(() => expect(agent.send).toHaveBeenCalledTimes(1));
175
+ await act(async () => runtime.thread.append("queued"));
176
+ expect(agent.send).toHaveBeenCalledTimes(1);
177
+
178
+ await refresh(Before, After);
179
+ expect(rendered).toBe("after");
180
+ await act(async () => releaseFirst());
181
+ await waitFor(() => expect(agent.send).toHaveBeenCalledTimes(2));
182
+ expect(agent.send).toHaveBeenNthCalledWith(2, "queued", undefined);
183
+ view.unmount();
184
+ });
185
+
186
+ it("abandons an Eve send still queued when the runtime unmounts", async () => {
187
+ let releaseFirst!: () => void;
188
+ const agent = makeAgent(
189
+ vi
190
+ .fn()
191
+ .mockImplementationOnce(
192
+ () =>
193
+ new Promise<void>((resolve) => {
194
+ releaseFirst = resolve;
195
+ }),
196
+ )
197
+ .mockResolvedValue(undefined),
198
+ );
199
+ mockUseEveAgent.mockReturnValue(agent);
200
+ let runtime!: ReturnType<typeof useEveAgentRuntime>;
201
+ const Host = () => {
202
+ runtime = useEveAgentRuntime();
203
+ return null;
204
+ };
205
+ const view = render(<Host />);
206
+ await act(async () => runtime.thread.append("first"));
207
+ await waitFor(() => expect(agent.send).toHaveBeenCalledTimes(1));
208
+ await act(async () => runtime.thread.append("queued"));
209
+
210
+ view.unmount();
211
+ await act(async () => {});
212
+ await act(async () => releaseFirst());
213
+ expect(agent.send).toHaveBeenCalledTimes(1);
214
+ });
215
+
216
+ const makeCloud = () => {
217
+ const create = vi.fn(async () => ({ thread_id: "cloud-1" }));
218
+ const cloud = {
219
+ threads: {
220
+ list: vi.fn(async () => ({ threads: [] })),
221
+ create,
222
+ update: vi.fn(async () => {}),
223
+ delete: vi.fn(async () => {}),
224
+ get: vi.fn(),
225
+ messages: {
226
+ list: vi.fn(async () => ({ messages: [] })),
227
+ create: vi.fn(async () => ({ message_id: "cloud-message" })),
228
+ update: vi.fn(async () => {}),
229
+ },
230
+ },
231
+ events: { track: vi.fn() },
232
+ telemetry: { enabled: false },
233
+ runs: { report: vi.fn(async () => {}) },
234
+ registerSdk: vi.fn(),
235
+ } as unknown as AssistantCloud;
236
+ return { cloud, create };
237
+ };
238
+
239
+ it("resolves a pending first Eve session after Fast Refresh", async () => {
240
+ const { cloud, create } = makeCloud();
241
+ let onSessionChange:
242
+ | ((session: { sessionId: string; streamIndex: number }) => void)
243
+ | undefined;
244
+ const agent = makeAgent(async () => {});
245
+ agent.data.messages = [];
246
+ mockUseEveAgent.mockImplementation((options) => {
247
+ onSessionChange = options.onSessionChange;
248
+ return agent;
249
+ });
250
+ let runtime!: ReturnType<typeof useEveAgentRuntime>;
251
+ let rendered = "";
252
+ const Probe = () => {
253
+ useEveReset();
254
+ return null;
255
+ };
256
+ const Before = () => {
257
+ rendered = "before";
258
+ runtime = useEveAgentRuntime({ cloud });
259
+ return (
260
+ <AssistantRuntimeProvider runtime={runtime}>
261
+ <Probe />
262
+ </AssistantRuntimeProvider>
263
+ );
264
+ };
265
+ const After = () => {
266
+ rendered = "after";
267
+ runtime = useEveAgentRuntime({ cloud });
268
+ return (
269
+ <AssistantRuntimeProvider runtime={runtime}>
270
+ <Probe />
271
+ </AssistantRuntimeProvider>
272
+ );
273
+ };
274
+ const view = render(<Before />);
275
+ await act(async () => {
276
+ await new Promise((resolve) => setTimeout(resolve, 0));
277
+ });
278
+ await act(async () => runtime.thread.append("first"));
279
+ await waitFor(() => expect(agent.send).toHaveBeenCalledTimes(1));
280
+ expect(create).not.toHaveBeenCalled();
281
+
282
+ await refresh(Before, After);
283
+ expect(rendered).toBe("after");
284
+ expect(create).not.toHaveBeenCalled();
285
+ act(() => onSessionChange?.({ sessionId: "session-1", streamIndex: 0 }));
286
+ await waitFor(() =>
287
+ expect(create).toHaveBeenCalledWith({
288
+ last_message_at: expect.any(Date),
289
+ external_id: "session-1",
290
+ upsert: true,
291
+ }),
292
+ );
293
+ view.unmount();
294
+ });
295
+
296
+ it("keeps a first Eve session wait alive when a nested runtimeHook refreshes", async () => {
297
+ directRuntimeHook.current = true;
298
+ const { cloud } = makeCloud();
299
+ const agent = makeAgent(async () => {});
300
+ let onSessionChange:
301
+ | ((session: { sessionId: string; streamIndex: number }) => void)
302
+ | undefined;
303
+ mockUseEveAgent.mockImplementation((options) => {
304
+ onSessionChange = options.onSessionChange;
305
+ return agent;
306
+ });
307
+ let threadId = "";
308
+ let rendered = "";
309
+ const Before = () => {
310
+ rendered = "before";
311
+ threadId = useAui().threadListItem.getState().id;
312
+ useEveAgentRuntime({ cloud });
313
+ return null;
314
+ };
315
+ const After = () => {
316
+ rendered = "after";
317
+ threadId = useAui().threadListItem.getState().id;
318
+ useEveAgentRuntime({ cloud });
319
+ return null;
320
+ };
321
+ const Host = ({ children }: { children: ReactNode }) => {
322
+ const runtime = useEveAgentRuntime();
323
+ return (
324
+ <AssistantRuntimeProvider runtime={runtime}>
325
+ {children}
326
+ </AssistantRuntimeProvider>
327
+ );
328
+ };
329
+ const view = render(
330
+ <Host>
331
+ <Before />
332
+ </Host>,
333
+ );
334
+ const sessions = sessionInstances[0] as EveCloudSessions;
335
+ const waiting = sessions.wait(threadId);
336
+
337
+ await refresh(Before, After);
338
+ expect(rendered).toBe("after");
339
+ act(() => onSessionChange?.({ sessionId: "session-1", streamIndex: 0 }));
340
+ await expect(waiting).resolves.toBe("session-1");
341
+ view.unmount();
342
+ });
@@ -24,9 +24,14 @@ import {
24
24
  type ToolExecutionStatus,
25
25
  } from "@assistant-ui/core";
26
26
  import {
27
+ useCloudThreadListAdapter,
27
28
  useExternalStoreRuntime,
29
+ useRemoteThreadListRuntime,
28
30
  useRuntimeAdapters,
29
31
  } from "@assistant-ui/core/react";
32
+ import { useReplaySafeEffect } from "@assistant-ui/store/internal";
33
+ import { useAui } from "@assistant-ui/store";
34
+ import type { AssistantCloud } from "assistant-cloud";
30
35
  import {
31
36
  useEveAgent,
32
37
  type EveMessageData,
@@ -43,7 +48,12 @@ import {
43
48
  collectTurnTimestamps,
44
49
  createTurnTimestampCache,
45
50
  } from "./deriveCreatedAt";
51
+ import {
52
+ createEveCloudSessions,
53
+ type EveCloudSessions,
54
+ } from "./eveCloudSessions";
46
55
  import { eveExtras } from "./eveExtras";
56
+ import { EVE_SDK } from "./sdkIdentity";
47
57
 
48
58
  const USER_STAGED_STATUS = {
49
59
  type: "complete",
@@ -125,10 +135,9 @@ const toEveSendOptions = (
125
135
  ? { clientContext: runConfig.custom as EveClientContext }
126
136
  : undefined;
127
137
 
128
- export type UseEveAgentRuntimeOptions = Omit<
129
- UseEveAgentOptions<EveMessageData>,
130
- "reducer"
131
- > &
138
+ type EveAgentOptions = Omit<UseEveAgentOptions<EveMessageData>, "reducer">;
139
+
140
+ export type UseEveAgentRuntimeOptions = EveAgentOptions &
132
141
  ExternalStoreSharedOptions & {
133
142
  readonly adapters?:
134
143
  | {
@@ -139,18 +148,44 @@ export type UseEveAgentRuntimeOptions = Omit<
139
148
  readonly feedback?: FeedbackAdapter | undefined;
140
149
  }
141
150
  | undefined;
151
+ /**
152
+ * Backs the thread list with Assistant Cloud. Each cloud thread keeps one Eve session as its external id, saved once the thread's first turn creates the session, and opening a thread resumes it, so `session`, `initialSession`, `initialEvents`, and `resume` are not used. Pass it from the first render: adding or removing it later throws, so remount the runtime, for example with a `key`, to switch.
153
+ */
154
+ readonly cloud?: AssistantCloud | undefined;
142
155
  };
143
156
 
144
- /**
145
- * Connects Eve's `useEveAgent` hook to assistant-ui's runtime contract.
146
- *
147
- * The runtime renders Eve messages, forwards new user messages to the Eve
148
- * session, supports cancellation, and maps Eve input requests to assistant-ui
149
- * tool approval UI.
150
- */
151
- export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
157
+ type EveCloudThread = {
158
+ readonly sessions: EveCloudSessions;
159
+ readonly id: string;
160
+ readonly isNew: boolean;
161
+ readonly sessionId: string | undefined;
162
+ };
163
+
164
+ const withCloudThreadSession = (
165
+ {
166
+ initialEvents: _initialEvents,
167
+ initialSession: _initialSession,
168
+ resume: _resume,
169
+ session: _session,
170
+ ...options
171
+ }: EveAgentOptions,
172
+ sessionId: string | undefined,
173
+ ): EveAgentOptions =>
174
+ sessionId === undefined
175
+ ? options
176
+ : {
177
+ ...options,
178
+ initialSession: { sessionId, streamIndex: 0 },
179
+ resume: true,
180
+ };
181
+
182
+ const useEveThreadRuntime = (
183
+ options: UseEveAgentRuntimeOptions,
184
+ cloudSessions: EveCloudSessions | undefined,
185
+ ) => {
152
186
  const {
153
187
  adapters,
188
+ cloud: _cloud,
154
189
  isDisabled: _isDisabled,
155
190
  isSendDisabled: _isSendDisabled,
156
191
  suggestions: _suggestions,
@@ -162,10 +197,33 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
162
197
  ? true
163
198
  : never;
164
199
 
200
+ const aui = useAui();
201
+ const [cloudThread] = useState((): EveCloudThread | undefined => {
202
+ if (!cloudSessions) return undefined;
203
+ const { id, status, externalId } = aui.threadListItem.getState();
204
+ return {
205
+ sessions: cloudSessions,
206
+ id,
207
+ isNew: status === "new",
208
+ sessionId: externalId,
209
+ };
210
+ });
211
+ const [resumesOnMount] = useState(() =>
212
+ cloudThread
213
+ ? cloudThread.sessionId !== undefined
214
+ : agentOptions.resume === true,
215
+ );
216
+ const isSessionless =
217
+ cloudThread !== undefined &&
218
+ !cloudThread.isNew &&
219
+ cloudThread.sessionId === undefined;
220
+
165
221
  const { onError, onEvent, onFinish, onSessionChange } = agentOptions;
166
222
  const lastFinishStatusRef = useRef<UseEveAgentStatus | null>(null);
167
223
  const agent = useEveAgent({
168
- ...agentOptions,
224
+ ...(cloudThread
225
+ ? withCloudThreadSession(agentOptions, cloudThread.sessionId)
226
+ : agentOptions),
169
227
  ...(onError
170
228
  ? {
171
229
  onError: (error) =>
@@ -182,17 +240,34 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
182
240
  lastFinishStatusRef.current = snapshot.status;
183
241
  invokeEveLifecycleCallback("onFinish", onFinish, snapshot);
184
242
  },
185
- ...(onSessionChange
243
+ ...(onSessionChange || cloudThread?.isNew
186
244
  ? {
187
- onSessionChange: (session) =>
245
+ onSessionChange: (session) => {
246
+ if (cloudThread?.isNew && session === undefined) {
247
+ cloudThread.sessions.reject(
248
+ cloudThread.id,
249
+ new Error("The eve turn ended before it created a session."),
250
+ );
251
+ } else if (cloudThread?.isNew && session !== undefined) {
252
+ cloudThread.sessions.resolve(cloudThread.id, session.sessionId);
253
+ // A first message staged without a run leaves the thread unsaved, so the run that creates the session saves it.
254
+ if (aui.threadListItem.getState().status === "new")
255
+ aui.threadListItem.initialize().catch(() => {});
256
+ }
188
257
  invokeEveLifecycleCallback(
189
258
  "onSessionChange",
190
259
  onSessionChange,
191
260
  session,
192
- ),
261
+ );
262
+ },
193
263
  }
194
264
  : {}),
195
265
  });
266
+ if (cloudThread && !("resume" in agent)) {
267
+ throw new Error(
268
+ "useEveAgentRuntime needs eve 0.44.1 or later for `cloud`, because opening a cloud thread resumes its session.",
269
+ );
270
+ }
196
271
  const runtimeAdapters = useRuntimeAdapters();
197
272
  const [toolStatuses, setToolStatuses] = useState<
198
273
  Record<string, ToolExecutionStatus>
@@ -279,19 +354,22 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
279
354
  null,
280
355
  );
281
356
 
282
- const enqueueSend = (
283
- dispatch: () => Promise<void>,
284
- epochRef: { current: number } = sendEpochRef,
285
- ) => {
286
- const epoch = epochRef.current;
287
- const next = sendChainRef.current.then(() => {
288
- if (!isMountedRef.current) throw sendAbandonedError;
289
- if (epoch !== epochRef.current) throw sendCancelledError;
290
- return dispatch();
291
- });
292
- sendChainRef.current = next.catch(() => {});
293
- return next;
294
- };
357
+ const enqueueSend = useCallback(
358
+ (
359
+ dispatch: () => Promise<void>,
360
+ epochRef: { current: number } = sendEpochRef,
361
+ ) => {
362
+ const epoch = epochRef.current;
363
+ const next = sendChainRef.current.then(() => {
364
+ if (!isMountedRef.current) throw sendAbandonedError;
365
+ if (epoch !== epochRef.current) throw sendCancelledError;
366
+ return dispatch();
367
+ });
368
+ sendChainRef.current = next.catch(() => {});
369
+ return next;
370
+ },
371
+ [],
372
+ );
295
373
 
296
374
  // The store outlives the component (useEveAgent holds it in a ref with no
297
375
  // cleanup), so queued sends must not fire server turns after unmount. The
@@ -301,10 +379,45 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
301
379
  isMountedRef.current = true;
302
380
  return () => {
303
381
  isMountedRef.current = false;
304
- sendEpochRef.current += 1;
305
382
  };
306
383
  }, []);
307
384
 
385
+ // A replay (Fast Refresh, StrictMode) must not cancel the sends queued behind the active turn.
386
+ useReplaySafeEffect(
387
+ () => () => {
388
+ sendEpochRef.current += 1;
389
+ },
390
+ [],
391
+ );
392
+
393
+ // A replay rides the send chain because `resume()` rejects during a turn and upstream refuses sends while it runs; the reset counter keeps a cancel from dropping a refetch, and the hook's own replay on mount joins this one since upstream shares concurrent `resume()` calls.
394
+ const enqueueResume = useCallback(
395
+ () =>
396
+ enqueueSend(async () => {
397
+ const live = agentRef.current;
398
+ if (live.session === undefined || !("resume" in live)) return;
399
+ await live.resume();
400
+ }, resetEpochRef),
401
+ [enqueueSend],
402
+ );
403
+
404
+ useEffect(() => {
405
+ if (!resumesOnMount) return;
406
+ // StrictMode's second setup queues the replay again, so only the setup that survives queues it.
407
+ let active = true;
408
+ queueMicrotask(() => {
409
+ if (active) enqueueResume().catch(() => {});
410
+ });
411
+ return () => {
412
+ active = false;
413
+ };
414
+ }, [enqueueResume, resumesOnMount]);
415
+
416
+ useReplaySafeEffect(() => {
417
+ if (!cloudThread?.isNew) return;
418
+ return () => cloudThread.sessions.release(cloudThread.id);
419
+ }, [cloudThread]);
420
+
308
421
  useEffect(() => {
309
422
  if (stagedInputsRef.current.size === 0) return;
310
423
  const baseIds = new Set(convertedMessages.map((message) => message.id));
@@ -347,6 +460,11 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
347
460
  };
348
461
 
349
462
  const reset = useCallback(() => {
463
+ // A cloud thread keeps the session its external id names, so a fresh session starts a new thread and leaves this one in the list.
464
+ if (cloudThread) {
465
+ aui.threads.switchToNewThread();
466
+ return;
467
+ }
350
468
  runtimeRef.current?.thread.unstable_notifySessionReset();
351
469
  // Sends parked behind an active turn captured the pre-reset epoch, so the
352
470
  // epoch has to advance before the session is torn down or they dispatch
@@ -359,7 +477,7 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
359
477
  stagedInputsRef.current.clear();
360
478
  setToolStatuses({});
361
479
  agent.reset();
362
- }, [agent]);
480
+ }, [agent, aui, cloudThread]);
363
481
 
364
482
  const extras = useMemo(
365
483
  () =>
@@ -376,6 +494,7 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
376
494
  ...pickExternalStoreSharedOptions(options),
377
495
  messages,
378
496
  isRunning: providerIsRunning,
497
+ isLoading: agent.status === "resuming",
379
498
  extras,
380
499
  unstable_enableToolInvocations: true,
381
500
  setToolStatuses,
@@ -387,7 +506,17 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
387
506
  feedback: adapters?.feedback,
388
507
  },
389
508
  onNew: async (message) => {
509
+ if (isSessionless)
510
+ throw new MessageNotSentError(
511
+ "This thread has no eve session to send to.",
512
+ );
390
513
  if (!(message.startRun ?? message.role === "user")) {
514
+ // Saving a new cloud thread waits for its first session, which a staged message never creates.
515
+ if (cloudThread?.isNew)
516
+ cloudThread.sessions.reject(
517
+ cloudThread.id,
518
+ new Error("The thread's first message was staged without a run."),
519
+ );
391
520
  stageUserMessage(message);
392
521
  return;
393
522
  }
@@ -474,20 +603,7 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
474
603
  ...("resume" in agent
475
604
  ? {
476
605
  onRefetchThread: () =>
477
- // `resume()` rejects during a turn, so the replay rides the send
478
- // chain like every other dispatch and runs once the turn parks; a
479
- // cancel of that turn does not drop it, or the caller would be told
480
- // the thread was refetched when it was not. Upstream shares one
481
- // replay across concurrent `resume()` calls, so a refetch
482
- // dispatched while `resume: true` is replaying on mount joins that
483
- // read instead of taking a fresh one. The session is read at
484
- // dispatch time because nothing durable exists before the first
485
- // send lands.
486
- enqueueSend(async () => {
487
- const live = agentRef.current;
488
- if (live.session === undefined) return;
489
- await live.resume();
490
- }, resetEpochRef).catch((error) => {
606
+ enqueueResume().catch((error) => {
491
607
  if (isDroppedSend(error)) return;
492
608
  throw error;
493
609
  }),
@@ -514,3 +630,45 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
514
630
 
515
631
  return runtime;
516
632
  };
633
+
634
+ const useEveCloudRuntime = (
635
+ cloud: AssistantCloud,
636
+ options: UseEveAgentRuntimeOptions,
637
+ ) => {
638
+ const [sessions] = useState(createEveCloudSessions);
639
+ const adapter = useCloudThreadListAdapter({
640
+ cloud,
641
+ sdk: EVE_SDK,
642
+ upsert: true,
643
+ create: async (threadId) => ({ externalId: await sessions.wait(threadId) }),
644
+ });
645
+ return useRemoteThreadListRuntime({
646
+ adapter,
647
+ allowNesting: true,
648
+ runtimeHook: function RuntimeHook() {
649
+ return useEveThreadRuntime(options, sessions);
650
+ },
651
+ });
652
+ };
653
+
654
+ /**
655
+ * Connects Eve's `useEveAgent` hook to assistant-ui's runtime contract.
656
+ *
657
+ * The runtime renders Eve messages, forwards new user messages to the Eve
658
+ * session, supports cancellation, and maps Eve input requests to assistant-ui
659
+ * tool approval UI.
660
+ */
661
+ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
662
+ const [hasCloud] = useState(options.cloud !== undefined);
663
+ if ((options.cloud !== undefined) !== hasCloud) {
664
+ throw new Error(
665
+ "useEveAgentRuntime cannot add or remove `cloud` after it mounts; remount it, for example with a `key`, to switch.",
666
+ );
667
+ }
668
+ if (!options.cloud) {
669
+ // oxlint-disable-next-line react-hooks/rules-of-hooks -- `cloud` stays set or unset for the runtime's lifetime, so every render takes the same branch
670
+ return useEveThreadRuntime(options, undefined);
671
+ }
672
+ // oxlint-disable-next-line react-hooks/rules-of-hooks -- `cloud` stays set or unset for the runtime's lifetime, so every render takes the same branch
673
+ return useEveCloudRuntime(options.cloud, options);
674
+ };