@assistant-ui/eve 0.0.19 → 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.
@@ -0,0 +1,558 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, render, renderHook, waitFor } from "@testing-library/react";
4
+ import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
5
+ import type { AssistantRuntime } from "@assistant-ui/core";
6
+ import type { AssistantCloud } from "assistant-cloud";
7
+ import type { ClientSessionState } from "eve/client";
8
+ import type { EveMessageData, UseEveAgentOptions } from "eve/react";
9
+ import { Component, StrictMode, useState, type ReactNode } from "react";
10
+ import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
11
+
12
+ const { mockUseEveAgent } = vi.hoisted(() => ({
13
+ mockUseEveAgent: vi.fn(),
14
+ }));
15
+
16
+ vi.mock("eve/react", async (importOriginal) => ({
17
+ ...(await importOriginal<typeof import("eve/react")>()),
18
+ useEveAgent: mockUseEveAgent,
19
+ }));
20
+
21
+ import { useEveReset } from "./hooks";
22
+ import {
23
+ useEveAgentRuntime,
24
+ type UseEveAgentRuntimeOptions,
25
+ } from "./useEveAgentRuntime";
26
+
27
+ type AgentOptions = UseEveAgentOptions<EveMessageData>;
28
+
29
+ type FakeSnapshot = { data: EveMessageData; status: string };
30
+
31
+ type FakeAgent = {
32
+ readonly initialOptions: AgentOptions;
33
+ options: AgentOptions;
34
+ update: (next: Partial<FakeSnapshot>) => void;
35
+ readonly send: Mock<() => Promise<void>>;
36
+ readonly reset: Mock<() => void>;
37
+ readonly resume: Mock<() => Promise<void>>;
38
+ };
39
+
40
+ const agents: FakeAgent[] = [];
41
+ let withResume = true;
42
+
43
+ const createdSession = (sessionId: string): ClientSessionState => ({
44
+ sessionId,
45
+ streamIndex: 5,
46
+ });
47
+
48
+ mockUseEveAgent.mockImplementation(function useFakeEveAgent(
49
+ options: AgentOptions,
50
+ ) {
51
+ const [agent] = useState(() => {
52
+ const created: FakeAgent = {
53
+ initialOptions: options,
54
+ options,
55
+ update: () => {},
56
+ send: vi.fn(async () => {}),
57
+ reset: vi.fn(),
58
+ resume: vi.fn(async () => {}),
59
+ };
60
+ agents.push(created);
61
+ return created;
62
+ });
63
+ const [snapshot, setSnapshot] = useState<FakeSnapshot>({
64
+ data: { messages: [] },
65
+ status: "ready",
66
+ });
67
+ agent.options = options;
68
+ agent.update = (next) => setSnapshot((current) => ({ ...current, ...next }));
69
+ return {
70
+ data: snapshot.data,
71
+ error: undefined,
72
+ events: [],
73
+ session: agent.initialOptions.initialSession,
74
+ status: snapshot.status,
75
+ send: agent.send,
76
+ respond: vi.fn(async () => {}),
77
+ cancel: vi.fn(async () => ({ status: "no_active_turn" })),
78
+ reset: agent.reset,
79
+ ...(withResume ? { resume: agent.resume } : {}),
80
+ };
81
+ });
82
+
83
+ type CloudThreadRow = { id: string; external_id: string | null };
84
+
85
+ const makeCloud = (
86
+ rows: CloudThreadRow[] = [],
87
+ telemetry: { enabled: boolean } = { enabled: false },
88
+ ) => {
89
+ let created = 0;
90
+ return {
91
+ threads: {
92
+ list: vi.fn(async ({ is_archived }: { is_archived?: boolean } = {}) => ({
93
+ threads: is_archived
94
+ ? []
95
+ : rows.map((row) => ({
96
+ ...row,
97
+ title: row.id,
98
+ is_archived: false,
99
+ last_message_at: new Date(0),
100
+ metadata: null,
101
+ })),
102
+ })),
103
+ create: vi.fn(async () => ({ thread_id: `cloud-${++created}` })),
104
+ update: vi.fn(async () => {}),
105
+ delete: vi.fn(async () => {}),
106
+ get: vi.fn(),
107
+ messages: {
108
+ list: vi.fn(async () => ({ messages: [] })),
109
+ create: vi.fn(async () => ({ message_id: "cloud-message" })),
110
+ update: vi.fn(async () => {}),
111
+ },
112
+ },
113
+ events: { track: vi.fn() },
114
+ telemetry,
115
+ runs: {
116
+ report: vi.fn(async () => {}),
117
+ stream: vi.fn(
118
+ async () =>
119
+ new ReadableStream({
120
+ start(controller) {
121
+ controller.close();
122
+ },
123
+ }),
124
+ ),
125
+ },
126
+ registerSdk: vi.fn(),
127
+ } as unknown as AssistantCloud & {
128
+ threads: { create: Mock; list: Mock; messages: { create: Mock } };
129
+ registerSdk: Mock;
130
+ };
131
+ };
132
+
133
+ const settle = () =>
134
+ act(async () => {
135
+ await new Promise((resolve) => setTimeout(resolve, 0));
136
+ });
137
+
138
+ // switchToThread resolves once the thread's runtime mounts, which React 18's act holds back until its callback settles, so the switch starts inside act and settles outside it.
139
+ const settled = () => {};
140
+
141
+ const switchTo = async (runtime: AssistantRuntime, threadId: string) => {
142
+ let switched!: Promise<void>;
143
+ await act(async () => {
144
+ switched = runtime.threads.switchToThread(threadId);
145
+ switched.catch(() => {});
146
+ });
147
+ await waitFor(() => switched.then(settled, settled));
148
+ await act(async () => {});
149
+ await switched;
150
+ };
151
+
152
+ const setup = (
153
+ options: UseEveAgentRuntimeOptions,
154
+ { strict = false }: { strict?: boolean } = {},
155
+ ) => {
156
+ let runtime!: AssistantRuntime;
157
+ let reset!: () => void;
158
+ const ResetProbe = () => {
159
+ reset = useEveReset();
160
+ return null;
161
+ };
162
+ const App = () => {
163
+ runtime = useEveAgentRuntime(options);
164
+ return (
165
+ <AssistantRuntimeProvider runtime={runtime}>
166
+ <ResetProbe />
167
+ </AssistantRuntimeProvider>
168
+ );
169
+ };
170
+ render(
171
+ strict ? (
172
+ <StrictMode>
173
+ <App />
174
+ </StrictMode>
175
+ ) : (
176
+ <App />
177
+ ),
178
+ );
179
+ return { runtime: () => runtime, reset: () => reset() };
180
+ };
181
+
182
+ const lastAgent = () => {
183
+ const agent = agents.at(-1);
184
+ if (!agent) throw new Error("no eve agent mounted");
185
+ return agent;
186
+ };
187
+
188
+ afterEach(() => {
189
+ agents.length = 0;
190
+ withResume = true;
191
+ });
192
+
193
+ describe("useEveAgentRuntime with cloud", () => {
194
+ it("saves a new thread once its first turn creates a session, with that session as its external id", async () => {
195
+ const cloud = makeCloud();
196
+ const app = setup({
197
+ cloud,
198
+ initialSession: { sessionId: "ignored", streamIndex: 1 },
199
+ resume: true,
200
+ });
201
+ await settle();
202
+
203
+ const agent = lastAgent();
204
+ expect(agent.initialOptions.initialSession).toBeUndefined();
205
+ expect(agent.initialOptions.resume).toBeUndefined();
206
+ expect(cloud.registerSdk).toHaveBeenCalledWith({
207
+ name: "@assistant-ui/eve",
208
+ version: expect.any(String),
209
+ });
210
+
211
+ let acceptTurn!: () => void;
212
+ agent.send.mockImplementation(
213
+ () =>
214
+ new Promise<void>((resolve) => {
215
+ acceptTurn = () => {
216
+ agent.options.onSessionChange?.(createdSession("session-1"));
217
+ resolve();
218
+ };
219
+ }),
220
+ );
221
+ act(() => app.runtime().thread.append("hi"));
222
+ await settle();
223
+
224
+ expect(agent.send).toHaveBeenCalledOnce();
225
+ expect(cloud.threads.create).not.toHaveBeenCalled();
226
+
227
+ await act(async () => acceptTurn());
228
+ await waitFor(() =>
229
+ expect(cloud.threads.create).toHaveBeenCalledExactlyOnceWith({
230
+ last_message_at: expect.any(Date),
231
+ external_id: "session-1",
232
+ upsert: true,
233
+ }),
234
+ );
235
+ await waitFor(() =>
236
+ expect(app.runtime().threads.mainItem.getState()).toMatchObject({
237
+ status: "regular",
238
+ remoteId: "cloud-1",
239
+ externalId: "session-1",
240
+ }),
241
+ );
242
+ });
243
+
244
+ it("leaves a thread unsaved when its first turn ends without a session, then saves it with the next turn's", async () => {
245
+ const cloud = makeCloud();
246
+ const app = setup({ cloud });
247
+ await settle();
248
+ const agent = lastAgent();
249
+
250
+ agent.send.mockImplementationOnce(async () => {
251
+ agent.options.onSessionChange?.(undefined);
252
+ });
253
+ act(() => app.runtime().thread.append("hi"));
254
+ await settle();
255
+
256
+ expect(cloud.threads.create).not.toHaveBeenCalled();
257
+ expect(app.runtime().threads.mainItem.getState().status).toBe("new");
258
+
259
+ agent.send.mockImplementationOnce(async () => {
260
+ agent.options.onSessionChange?.(createdSession("session-2"));
261
+ });
262
+ act(() => app.runtime().thread.append("again"));
263
+
264
+ await waitFor(() =>
265
+ expect(cloud.threads.create).toHaveBeenCalledExactlyOnceWith({
266
+ last_message_at: expect.any(Date),
267
+ external_id: "session-2",
268
+ upsert: true,
269
+ }),
270
+ );
271
+ });
272
+
273
+ it("releases a new thread whose first message is staged, and saves it once a run creates the session", async () => {
274
+ const cloud = makeCloud();
275
+ const app = setup({ cloud });
276
+ await settle();
277
+ const agent = lastAgent();
278
+
279
+ act(() =>
280
+ app.runtime().thread.append({
281
+ role: "user",
282
+ content: [{ type: "text", text: "draft" }],
283
+ startRun: false,
284
+ }),
285
+ );
286
+ await settle();
287
+
288
+ expect(agent.send).not.toHaveBeenCalled();
289
+ expect(cloud.threads.create).not.toHaveBeenCalled();
290
+ expect(app.runtime().threads.mainItem.getState().status).toBe("new");
291
+ const switched = await act(() =>
292
+ Promise.race([
293
+ app
294
+ .runtime()
295
+ .threads.switchToNewThread()
296
+ .then(() => "switched"),
297
+ new Promise((resolve) => setTimeout(resolve, 1000, "stuck")),
298
+ ]),
299
+ );
300
+ expect(switched).toBe("switched");
301
+
302
+ const staged = app.runtime().thread.getState().messages.at(-1);
303
+ if (!staged) throw new Error("the staged message is missing");
304
+ agent.send.mockImplementationOnce(async () => {
305
+ agent.options.onSessionChange?.(createdSession("session-3"));
306
+ });
307
+ await act(() => app.runtime().thread.startRun({ parentId: staged.id }));
308
+
309
+ await waitFor(() =>
310
+ expect(cloud.threads.create).toHaveBeenCalledExactlyOnceWith({
311
+ last_message_at: expect.any(Date),
312
+ external_id: "session-3",
313
+ upsert: true,
314
+ }),
315
+ );
316
+ });
317
+
318
+ it("retries a lost save with the same session and upsert, so the cloud answers the thread it already stored", async () => {
319
+ const cloud = makeCloud();
320
+ cloud.threads.create.mockRejectedValueOnce(new Error("response lost"));
321
+ const app = setup({ cloud });
322
+ await settle();
323
+ const agent = lastAgent();
324
+ agent.send.mockImplementation(async () => {
325
+ agent.options.onSessionChange?.(createdSession("session-1"));
326
+ });
327
+
328
+ act(() => app.runtime().thread.append("hi"));
329
+ await waitFor(() => expect(cloud.threads.create).toHaveBeenCalledOnce());
330
+ await settle();
331
+ expect(app.runtime().threads.mainItem.getState().status).toBe("new");
332
+
333
+ act(() => app.runtime().thread.append("again"));
334
+ await waitFor(() => expect(cloud.threads.create).toHaveBeenCalledTimes(2));
335
+ for (const [body] of cloud.threads.create.mock.calls) {
336
+ expect(body).toEqual({
337
+ last_message_at: expect.any(Date),
338
+ external_id: "session-1",
339
+ upsert: true,
340
+ });
341
+ }
342
+ });
343
+
344
+ it("opens a listed thread on the session its external id names and replays it", async () => {
345
+ const cloud = makeCloud([{ id: "cloud-9", external_id: "session-9" }]);
346
+ const app = setup({ cloud });
347
+ await waitFor(() =>
348
+ expect(app.runtime().threads.getState().threadIds).toContain("cloud-9"),
349
+ );
350
+
351
+ await switchTo(app.runtime(), "cloud-9");
352
+ await settle();
353
+
354
+ const agent = lastAgent();
355
+ expect(agent.initialOptions).toMatchObject({
356
+ initialSession: { sessionId: "session-9", streamIndex: 0 },
357
+ resume: true,
358
+ });
359
+ expect(agent.resume).toHaveBeenCalledOnce();
360
+ });
361
+
362
+ it("replays a listed thread once under StrictMode", async () => {
363
+ const cloud = makeCloud([{ id: "cloud-9", external_id: "session-9" }]);
364
+ const app = setup({ cloud }, { strict: true });
365
+ await waitFor(() =>
366
+ expect(app.runtime().threads.getState().threadIds).toContain("cloud-9"),
367
+ );
368
+
369
+ await switchTo(app.runtime(), "cloud-9");
370
+ await settle();
371
+
372
+ const replays = agents
373
+ .filter((agent) => agent.initialOptions.initialSession !== undefined)
374
+ .reduce((count, agent) => count + agent.resume.mock.calls.length, 0);
375
+ expect(replays).toBe(1);
376
+ });
377
+
378
+ it("refuses to send in a listed thread that has no eve session and hands the draft back", async () => {
379
+ const cloud = makeCloud([{ id: "cloud-7", external_id: null }]);
380
+ const app = setup({ cloud });
381
+ await waitFor(() =>
382
+ expect(app.runtime().threads.getState().threadIds).toContain("cloud-7"),
383
+ );
384
+ await switchTo(app.runtime(), "cloud-7");
385
+ await settle();
386
+
387
+ const composer = app.runtime().thread.composer;
388
+ act(() => {
389
+ composer.setText("hello");
390
+ composer.send();
391
+ });
392
+ await settle();
393
+
394
+ expect(lastAgent().send).not.toHaveBeenCalled();
395
+ expect(composer.getState().text).toBe("hello");
396
+ });
397
+
398
+ it("starts a new thread on reset and keeps the current one with its session", async () => {
399
+ const cloud = makeCloud();
400
+ const app = setup({ cloud });
401
+ await settle();
402
+ const agent = lastAgent();
403
+ agent.send.mockImplementationOnce(async () => {
404
+ agent.options.onSessionChange?.(createdSession("session-1"));
405
+ });
406
+ act(() => app.runtime().thread.append("hi"));
407
+ await waitFor(() =>
408
+ expect(app.runtime().threads.mainItem.getState().remoteId).toBe(
409
+ "cloud-1",
410
+ ),
411
+ );
412
+ const savedId = app.runtime().threads.mainItem.getState().id;
413
+
414
+ act(() => app.reset());
415
+ await settle();
416
+
417
+ expect(agent.reset).not.toHaveBeenCalled();
418
+ expect(app.runtime().threads.mainItem.getState()).toMatchObject({
419
+ status: "new",
420
+ });
421
+ expect(app.runtime().threads.mainItem.getState().id).not.toBe(savedId);
422
+ expect(app.runtime().threads.getItemById(savedId).getState()).toMatchObject(
423
+ { remoteId: "cloud-1", externalId: "session-1" },
424
+ );
425
+ });
426
+
427
+ it("copies each settled turn into the cloud thread under eve's message ids", async () => {
428
+ const cloud = makeCloud([], { enabled: true });
429
+ const app = setup({ cloud });
430
+ await settle();
431
+ const agent = lastAgent();
432
+ agent.send.mockImplementationOnce(async () => {
433
+ agent.options.onSessionChange?.(createdSession("session-1"));
434
+ });
435
+ act(() => app.runtime().thread.append("hi"));
436
+ await waitFor(() => expect(cloud.threads.create).toHaveBeenCalledOnce());
437
+
438
+ act(() => agent.update({ status: "streaming" }));
439
+ act(() =>
440
+ agent.update({
441
+ status: "ready",
442
+ data: {
443
+ messages: [
444
+ {
445
+ id: "turn_1:user",
446
+ role: "user",
447
+ parts: [{ type: "text", text: "hi" }],
448
+ metadata: { turnId: "turn_1" },
449
+ },
450
+ {
451
+ id: "turn_1:assistant",
452
+ role: "assistant",
453
+ parts: [{ type: "text", text: "hello" }],
454
+ metadata: { turnId: "turn_1" },
455
+ },
456
+ ],
457
+ } as never,
458
+ }),
459
+ );
460
+ await settle();
461
+
462
+ await waitFor(() =>
463
+ expect(cloud.threads.messages.create).toHaveBeenCalledTimes(2),
464
+ );
465
+ expect(cloud.threads.messages.create.mock.calls).toMatchObject([
466
+ ["cloud-1", { format: "aui/v0", external_id: "turn_1:user" }],
467
+ [
468
+ "cloud-1",
469
+ {
470
+ format: "aui/v0",
471
+ external_id: "turn_1:assistant",
472
+ parent_external_id: "turn_1:user",
473
+ },
474
+ ],
475
+ ]);
476
+ });
477
+
478
+ it("requires an eve that can resume a session", async () => {
479
+ withResume = false;
480
+ const errors: unknown[] = [];
481
+ class Boundary extends Component<{ children: ReactNode }> {
482
+ override state = { failed: false };
483
+ static getDerivedStateFromError() {
484
+ return { failed: true };
485
+ }
486
+ override componentDidCatch(error: unknown) {
487
+ errors.push(error);
488
+ }
489
+ override render() {
490
+ return this.state.failed ? null : this.props.children;
491
+ }
492
+ }
493
+ const cloud = makeCloud();
494
+ const App = () => {
495
+ const runtime = useEveAgentRuntime({ cloud });
496
+ return (
497
+ <AssistantRuntimeProvider runtime={runtime}>
498
+ {null}
499
+ </AssistantRuntimeProvider>
500
+ );
501
+ };
502
+ const consoleError = vi
503
+ .spyOn(console, "error")
504
+ .mockImplementation(() => {});
505
+
506
+ render(
507
+ <Boundary>
508
+ <App />
509
+ </Boundary>,
510
+ );
511
+ await settle();
512
+
513
+ expect(errors).toContainEqual(
514
+ expect.objectContaining({
515
+ message: expect.stringContaining("eve 0.44.1 or later"),
516
+ }),
517
+ );
518
+ consoleError.mockRestore();
519
+ });
520
+ });
521
+
522
+ describe("useEveAgentRuntime switching cloud after mount", () => {
523
+ it.each([
524
+ ["adding", {}, { cloud: makeCloud() }],
525
+ ["removing", { cloud: makeCloud() }, {}],
526
+ ] as const)(
527
+ "names the remount when %s cloud",
528
+ (
529
+ _,
530
+ initialProps: UseEveAgentRuntimeOptions,
531
+ nextProps: UseEveAgentRuntimeOptions,
532
+ ) => {
533
+ const consoleError = vi
534
+ .spyOn(console, "error")
535
+ .mockImplementation(() => {});
536
+ const { rerender } = renderHook(
537
+ (props: UseEveAgentRuntimeOptions) => useEveAgentRuntime(props),
538
+ { initialProps },
539
+ );
540
+
541
+ expect(() => rerender(nextProps)).toThrow(
542
+ "useEveAgentRuntime cannot add or remove `cloud` after it mounts",
543
+ );
544
+ consoleError.mockRestore();
545
+ },
546
+ );
547
+ });
548
+
549
+ describe("useEveAgentRuntime without cloud", () => {
550
+ it("keeps the caller's session options and replays only when asked to", async () => {
551
+ const initialSession = { sessionId: "session-1", streamIndex: 4 };
552
+ setup({ initialSession });
553
+ await settle();
554
+
555
+ expect(lastAgent().initialOptions).toMatchObject({ initialSession });
556
+ expect(lastAgent().resume).not.toHaveBeenCalled();
557
+ });
558
+ });
@@ -5,8 +5,8 @@
5
5
  // store and reducer before the adapter reads them. Supplying `session` keeps
6
6
  // the store from constructing a network client.
7
7
 
8
- import { renderHook, waitFor } from "@testing-library/react";
9
- import { describe, expect, it } from "vitest";
8
+ import { act, renderHook, waitFor } from "@testing-library/react";
9
+ import { describe, expect, it, vi } from "vitest";
10
10
  import type { MessageStreamEvent } from "eve/client";
11
11
 
12
12
  import { useEveAgentRuntime } from "./useEveAgentRuntime";
@@ -50,6 +50,64 @@ const resumedEvents = [
50
50
  },
51
51
  ] as const satisfies readonly MessageStreamEvent[];
52
52
 
53
+ const NEXT_TURN = "turn_next";
54
+
55
+ const nextTurnEvents = [
56
+ {
57
+ type: "turn.started",
58
+ data: { sequence: 7, turnId: NEXT_TURN },
59
+ meta: { at: "2026-01-02T10:03:00.000Z", id: "evt_201" },
60
+ },
61
+ {
62
+ type: "message.received",
63
+ data: { message: "follow up", sequence: 8, turnId: NEXT_TURN },
64
+ meta: { at: "2026-01-02T10:03:01.000Z", id: "evt_202" },
65
+ },
66
+ {
67
+ type: "step.started",
68
+ data: {
69
+ modelId: "test-model",
70
+ sequence: 9,
71
+ stepIndex: 0,
72
+ turnId: NEXT_TURN,
73
+ },
74
+ meta: { at: "2026-01-02T10:03:02.000Z", id: "evt_203" },
75
+ },
76
+ {
77
+ type: "message.completed",
78
+ data: {
79
+ finishReason: "stop",
80
+ message: "noted",
81
+ sequence: 10,
82
+ stepIndex: 0,
83
+ turnId: NEXT_TURN,
84
+ },
85
+ meta: { at: "2026-01-02T10:03:03.000Z", id: "evt_204" },
86
+ },
87
+ {
88
+ type: "turn.completed",
89
+ data: { sequence: 11, turnId: NEXT_TURN },
90
+ meta: { at: "2026-01-02T10:03:04.000Z", id: "evt_205" },
91
+ },
92
+ {
93
+ type: "session.waiting",
94
+ data: { continuationToken: "session_resumed", wait: "next-user-message" },
95
+ meta: { at: "2026-01-02T10:03:05.000Z", id: "evt_206" },
96
+ },
97
+ ] as const satisfies readonly MessageStreamEvent[];
98
+
99
+ const parkedEvents = [
100
+ ...resumedEvents,
101
+ {
102
+ type: "session.waiting",
103
+ data: { continuationToken: "session_resumed", wait: "next-user-message" },
104
+ meta: { at: "2026-01-02T10:02:08.000Z", id: "evt_005" },
105
+ },
106
+ ] as const satisfies readonly MessageStreamEvent[];
107
+
108
+ const copyEvents = (events: readonly MessageStreamEvent[]) =>
109
+ JSON.parse(JSON.stringify(events)) as readonly MessageStreamEvent[];
110
+
53
111
  const offlineSession = {
54
112
  state: { sessionId: "session_resumed", streamIndex: 0 },
55
113
  cancel: async () => ({ status: "no_active_turn" }),
@@ -77,4 +135,46 @@ describe("useEveAgentRuntime against a resumed eve session", () => {
77
135
  expect(messages[0]?.createdAt).toEqual(new Date(USER_AT));
78
136
  expect(messages[1]?.createdAt).toEqual(new Date(ASSISTANT_AT));
79
137
  });
138
+
139
+ it("reports the replay as loading and holds a send typed during it until the replay ends", async () => {
140
+ let openReplay!: () => void;
141
+ const replayOpened = new Promise<void>((resolve) => {
142
+ openReplay = resolve;
143
+ });
144
+ const stream = vi.fn(async function* () {
145
+ await replayOpened;
146
+ yield* copyEvents(parkedEvents);
147
+ });
148
+ const send = vi.fn(async function* () {
149
+ yield* copyEvents(nextTurnEvents);
150
+ });
151
+ const session = {
152
+ state: { sessionId: "session_resumed", streamIndex: 0 },
153
+ cancel: async () => ({ status: "no_active_turn" }),
154
+ send,
155
+ stream,
156
+ } as never;
157
+
158
+ const { result } = renderHook(() =>
159
+ useEveAgentRuntime({ resume: true, session }),
160
+ );
161
+
162
+ await waitFor(() => expect(stream).toHaveBeenCalledOnce());
163
+ expect(result.current.thread.getState().isLoading).toBe(true);
164
+
165
+ act(() => result.current.thread.append("follow up"));
166
+ await act(async () => {
167
+ await new Promise((resolve) => setTimeout(resolve, 0));
168
+ });
169
+ expect(send).not.toHaveBeenCalled();
170
+ expect(stream).toHaveBeenCalledOnce();
171
+
172
+ await act(async () => openReplay());
173
+
174
+ await waitFor(() =>
175
+ expect(result.current.thread.getState().messages).toHaveLength(4),
176
+ );
177
+ expect(send).toHaveBeenCalledOnce();
178
+ expect(result.current.thread.getState().isLoading).toBe(false);
179
+ });
80
180
  });