@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.
- package/LICENSE +1 -1
- package/dist/convertEveMessages.d.ts +31 -33
- package/dist/convertEveMessages.d.ts.map +1 -1
- package/dist/deriveCreatedAt.d.ts +5 -7
- package/dist/deriveCreatedAt.d.ts.map +1 -1
- package/dist/deriveCreatedAt.js +1 -0
- package/dist/deriveCreatedAt.js.map +1 -1
- package/dist/eveCloudSessions.d.ts +9 -0
- package/dist/eveCloudSessions.d.ts.map +1 -0
- package/dist/eveCloudSessions.js +51 -0
- package/dist/eveCloudSessions.js.map +1 -0
- package/dist/eveExtras.d.ts +1 -3
- package/dist/eveExtras.d.ts.map +1 -1
- package/dist/hooks.d.ts +5 -3
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +4 -0
- package/dist/hooks.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -0
- package/dist/sdkIdentity.d.ts +3 -0
- package/dist/sdkIdentity.d.ts.map +1 -0
- package/dist/sdkIdentity.js +9 -0
- package/dist/sdkIdentity.js.map +1 -0
- package/dist/useEveAgentRuntime.d.ts +17 -12
- package/dist/useEveAgentRuntime.d.ts.map +1 -1
- package/dist/useEveAgentRuntime.js +104 -21
- package/dist/useEveAgentRuntime.js.map +1 -1
- package/package.json +9 -5
- package/src/deriveCreatedAt.test.ts +22 -0
- package/src/deriveCreatedAt.ts +5 -0
- package/src/eveCloudSessions.test.ts +78 -0
- package/src/eveCloudSessions.ts +59 -0
- package/src/hooks.ts +4 -0
- package/src/index.ts +1 -1
- package/src/sdkIdentity.ts +9 -0
- package/src/useEveAgentRuntime.cloud.test.tsx +558 -0
- package/src/useEveAgentRuntime.eveResume.test.tsx +102 -2
- package/src/useEveAgentRuntime.fast-refresh.test.tsx +342 -0
- 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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
+
};
|