@assistant-ui/eve 0.0.6 → 0.0.8

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,6 +1,10 @@
1
1
  import {
2
2
  fromThreadMessageLike,
3
+ toAssistantError,
3
4
  type AppendMessage,
5
+ type CompleteAttachment,
6
+ type DataMessagePart,
7
+ type FileMessagePart,
4
8
  type MessageStatus,
5
9
  type RespondToToolApprovalOptions,
6
10
  type ThreadAssistantMessagePart,
@@ -10,7 +14,10 @@ import {
10
14
  type ToolApprovalOption,
11
15
  type ToolCallMessagePart,
12
16
  } from "@assistant-ui/core";
17
+ import { httpUrlPattern, parseDataUrl } from "@assistant-ui/core/internal";
13
18
  import type {
19
+ EveAuthorizationOutcome,
20
+ EveAuthorizationPart,
14
21
  EveDynamicToolPart,
15
22
  EveMessage,
16
23
  EveMessageData,
@@ -28,6 +35,11 @@ const ASSISTANT_RUNNING_STATUS = {
28
35
  type: "running",
29
36
  } satisfies MessageStatus;
30
37
 
38
+ const ASSISTANT_CANCELLED_STATUS = {
39
+ type: "incomplete",
40
+ reason: "cancelled",
41
+ } satisfies MessageStatus;
42
+
31
43
  const USER_FALLBACK_STATUS = {
32
44
  type: "complete",
33
45
  reason: "unknown",
@@ -36,9 +48,15 @@ const USER_FALLBACK_STATUS = {
36
48
  export type ConvertEveMessagesOptions = {
37
49
  /**
38
50
  * Marks the last assistant message as running while Eve is submitting or
39
- * streaming.
51
+ * streaming. When omitted, a message carrying Eve's `"streaming"` marker is
52
+ * treated as running; pass `false` to settle interrupted messages to a
53
+ * terminal status.
40
54
  */
41
55
  readonly isRunning?: boolean | undefined;
56
+ /**
57
+ * The Eve session error, mapped onto the assistant message it interrupted.
58
+ */
59
+ readonly error?: unknown;
42
60
  readonly getCreatedAt?: ((message: EveMessage) => Date) | undefined;
43
61
  };
44
62
 
@@ -67,26 +85,55 @@ const toMessageStatus = (
67
85
  ): MessageStatus => {
68
86
  if (message.role !== "assistant") return USER_FALLBACK_STATUS;
69
87
 
88
+ const isLast = index === messages.length - 1;
70
89
  const hasPendingApproval = message.parts.some(
71
90
  (part) =>
72
91
  part.type === "dynamic-tool" && part.state === "approval-requested",
73
92
  );
93
+ // Scoped to the stale-marker case so a terminalized or errored turn keeps its
94
+ // own status; the hold only claims the fall-through that reads as cancelled.
95
+ const hasPendingAuthorization =
96
+ message.metadata?.status === "streaming" &&
97
+ (!isLast || options.error === undefined) &&
98
+ message.parts.some(
99
+ (part) => part.type === "authorization" && part.state === "required",
100
+ );
74
101
 
75
102
  if (hasPendingApproval) {
76
103
  return { type: "requires-action", reason: "tool-calls" };
77
104
  }
78
105
 
106
+ // A connector authorization is answered outside the thread, so the turn is
107
+ // held on external input rather than on a tool call awaiting a result.
108
+ if (hasPendingAuthorization) {
109
+ return { type: "requires-action", reason: "interrupt" };
110
+ }
111
+
79
112
  if (message.metadata?.status === "failed") {
80
113
  return { type: "incomplete", reason: "error" };
81
114
  }
82
115
 
83
- if (
84
- message.metadata?.status === "streaming" ||
85
- (options.isRunning === true && index === messages.length - 1)
86
- ) {
116
+ if (isLast && options.isRunning === true) {
87
117
  return ASSISTANT_RUNNING_STATUS;
88
118
  }
89
119
 
120
+ // Eve's default reducer never terminalizes the "streaming" marker on
121
+ // cancellation or turn/session failure, so liveness comes from isRunning and
122
+ // a leftover marker means the turn was interrupted.
123
+ if (message.metadata?.status === "streaming") {
124
+ if (options.isRunning === undefined) {
125
+ return ASSISTANT_RUNNING_STATUS;
126
+ }
127
+ if (isLast && options.error !== undefined) {
128
+ return {
129
+ type: "incomplete",
130
+ reason: "error",
131
+ error: toAssistantError(options.error),
132
+ };
133
+ }
134
+ return ASSISTANT_CANCELLED_STATUS;
135
+ }
136
+
90
137
  return ASSISTANT_COMPLETE_STATUS;
91
138
  };
92
139
 
@@ -175,6 +222,71 @@ const convertDynamicToolPart = (
175
222
  }
176
223
  };
177
224
 
225
+ /**
226
+ * Payload of the `authorization` data part the Eve runtime emits for a
227
+ * connector authorization challenge. `state` discriminates a pending challenge
228
+ * from a settled one; every other field is present only when Eve projected it.
229
+ * This converter drops a `url` whose scheme is not `http(s)`, so a renderer can
230
+ * link to it without checking the scheme itself; Eve itself passes through
231
+ * whatever the connector supplied. Eve's
232
+ * `turnId` and `stepIndex` part identity is omitted: a renderer receives the
233
+ * one part it renders, in Eve's own step order, so display never has to
234
+ * re-identify a challenge among several.
235
+ */
236
+ export type EveAuthorizationData = {
237
+ readonly state: EveAuthorizationPart["state"];
238
+ readonly name: string;
239
+ readonly displayName?: string;
240
+ readonly description?: string;
241
+ readonly url?: string;
242
+ readonly userCode?: string;
243
+ readonly instructions?: string;
244
+ readonly expiresAt?: string;
245
+ readonly outcome?: EveAuthorizationOutcome;
246
+ readonly reason?: string;
247
+ };
248
+
249
+ const convertAuthorizationPart = (
250
+ part: EveAuthorizationPart,
251
+ ): DataMessagePart<EveAuthorizationData> => ({
252
+ type: "data",
253
+ name: "authorization",
254
+ data: {
255
+ state: part.state,
256
+ name: part.name,
257
+ ...(part.displayName !== undefined && { displayName: part.displayName }),
258
+ ...(part.description !== undefined && { description: part.description }),
259
+ ...(part.authorization?.url !== undefined &&
260
+ httpUrlPattern.test(part.authorization.url) && {
261
+ url: part.authorization.url,
262
+ }),
263
+ ...(part.authorization?.userCode !== undefined && {
264
+ userCode: part.authorization.userCode,
265
+ }),
266
+ ...(part.authorization?.instructions !== undefined && {
267
+ instructions: part.authorization.instructions,
268
+ }),
269
+ ...(part.authorization?.expiresAt !== undefined && {
270
+ expiresAt: part.authorization.expiresAt,
271
+ }),
272
+ ...(part.outcome !== undefined && { outcome: part.outcome }),
273
+ ...(part.reason !== undefined && { reason: part.reason }),
274
+ },
275
+ });
276
+
277
+ const convertFilePart = (
278
+ part: Extract<EveMessagePart, { type: "file" }>,
279
+ ): FileMessagePart | null => {
280
+ if (part.url === undefined) return null;
281
+ return {
282
+ type: "file",
283
+ data: part.url,
284
+ mimeType: part.mediaType ?? "unknown/unknown",
285
+ ...(part.filename && { filename: part.filename }),
286
+ ...(httpUrlPattern.test(part.url) && { sourceType: "url" as const }),
287
+ };
288
+ };
289
+
178
290
  const convertAssistantPart = (
179
291
  part: EveMessagePart,
180
292
  ): ThreadAssistantMessagePart | null => {
@@ -189,6 +301,12 @@ const convertAssistantPart = (
189
301
  case "dynamic-tool":
190
302
  return convertDynamicToolPart(part);
191
303
 
304
+ case "authorization":
305
+ return convertAuthorizationPart(part);
306
+
307
+ case "file":
308
+ return convertFilePart(part);
309
+
192
310
  default:
193
311
  return null;
194
312
  }
@@ -201,6 +319,9 @@ const convertUserPart = (
201
319
  case "text":
202
320
  return { type: "text", text: part.text };
203
321
 
322
+ case "file":
323
+ return convertFilePart(part);
324
+
204
325
  default:
205
326
  return null;
206
327
  }
@@ -213,6 +334,35 @@ const toUserContent = (
213
334
  return content.length > 0 ? content : [{ type: "text", text: "" }];
214
335
  };
215
336
 
337
+ const toUserAttachments = (
338
+ parts: readonly EveMessagePart[],
339
+ ): CompleteAttachment[] => {
340
+ const attachments: CompleteAttachment[] = [];
341
+ for (const part of parts) {
342
+ if (part.type !== "file") continue;
343
+ const file = convertFilePart(part);
344
+ if (file === null) continue;
345
+ const isImage = file.mimeType.startsWith("image/");
346
+ attachments.push({
347
+ id: String(attachments.length),
348
+ type: isImage ? "image" : "file",
349
+ name: part.filename ?? "file",
350
+ content: [
351
+ isImage
352
+ ? {
353
+ type: "image",
354
+ image: file.data,
355
+ ...(part.filename && { filename: part.filename }),
356
+ }
357
+ : file,
358
+ ],
359
+ contentType: file.mimeType,
360
+ status: { type: "complete" },
361
+ });
362
+ }
363
+ return attachments;
364
+ };
365
+
216
366
  /**
217
367
  * Converts a single Eve message into an assistant-ui thread message.
218
368
  */
@@ -237,7 +387,7 @@ export const convertEveMessage = (
237
387
  id: message.id,
238
388
  createdAt,
239
389
  content: toUserContent(message.parts),
240
- attachments: [],
390
+ attachments: toUserAttachments(message.parts),
241
391
  metadata,
242
392
  }
243
393
  : {
@@ -280,7 +430,7 @@ export const getEveMessageContent = (
280
430
  ...(message.attachments?.flatMap((attachment) => attachment.content) ?? []),
281
431
  ];
282
432
 
283
- const parts = content.map((part) => {
433
+ const parts = content.flatMap((part) => {
284
434
  const type = part.type;
285
435
  switch (type) {
286
436
  case "text":
@@ -302,10 +452,25 @@ export const getEveMessageContent = (
302
452
  ...(part.filename && { filename: part.filename }),
303
453
  };
304
454
 
455
+ case "audio": {
456
+ // A data URL's own media type wins over `mediaType` downstream, so the
457
+ // envelope is rebuilt from the typed format rather than forwarded.
458
+ const mediaType = `audio/${part.audio.format}`;
459
+ const data = part.audio.data;
460
+ return {
461
+ type: "file" as const,
462
+ data: httpUrlPattern.test(data)
463
+ ? data
464
+ : `data:${mediaType};base64,${parseDataUrl(data)?.data ?? data}`,
465
+ mediaType,
466
+ };
467
+ }
468
+
469
+ case "data":
470
+ return [];
471
+
305
472
  default: {
306
473
  const _exhaustiveCheck:
307
- | "audio"
308
- | "data"
309
474
  | "generative-ui"
310
475
  | "reasoning"
311
476
  | "source"
package/src/index.ts CHANGED
@@ -4,7 +4,11 @@ export {
4
4
  convertEveMessage,
5
5
  convertEveMessages,
6
6
  getEveMessageContent,
7
+ toEveInputResponse,
8
+ } from "./convertEveMessages";
9
+ export type {
10
+ ConvertEveMessagesOptions,
11
+ EveAuthorizationData,
7
12
  } from "./convertEveMessages";
8
- export type { ConvertEveMessagesOptions } from "./convertEveMessages";
9
13
  export { useEveAgentRuntime } from "./useEveAgentRuntime";
10
14
  export type { UseEveAgentRuntimeOptions } from "./useEveAgentRuntime";
@@ -0,0 +1,408 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+
6
+ const { mockUseEveAgent } = vi.hoisted(() => ({
7
+ mockUseEveAgent: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("eve/react", async (importOriginal) => ({
11
+ ...(await importOriginal<typeof import("eve/react")>()),
12
+ useEveAgent: mockUseEveAgent,
13
+ }));
14
+
15
+ import type { EveMessageData } from "eve/react";
16
+ import { useEveAgentRuntime } from "./useEveAgentRuntime";
17
+
18
+ const stuckStreamingData: EveMessageData = {
19
+ messages: [
20
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
21
+ {
22
+ id: "a1",
23
+ role: "assistant",
24
+ metadata: { status: "streaming" },
25
+ parts: [{ type: "text", text: "Let me th" }],
26
+ },
27
+ ],
28
+ };
29
+
30
+ const createAgent = (overrides: Record<string, unknown>) => ({
31
+ data: stuckStreamingData,
32
+ error: undefined,
33
+ events: [],
34
+ session: undefined,
35
+ status: "ready",
36
+ send: vi.fn(),
37
+ stop: vi.fn(),
38
+ reset: vi.fn(),
39
+ ...overrides,
40
+ });
41
+
42
+ describe("useEveAgentRuntime status forwarding", () => {
43
+ it("maps the session error onto the interrupted assistant message", () => {
44
+ mockUseEveAgent.mockReturnValue(
45
+ createAgent({ status: "error", error: new Error("boom") }) as never,
46
+ );
47
+
48
+ const { result } = renderHook(() => useEveAgentRuntime());
49
+
50
+ expect(result.current.thread.getState().messages.at(-1)?.status).toEqual({
51
+ type: "incomplete",
52
+ reason: "error",
53
+ error: { code: "unknown", message: "boom" },
54
+ });
55
+ });
56
+
57
+ it("settles an aborted turn to cancelled once the agent is idle", () => {
58
+ mockUseEveAgent.mockReturnValue(createAgent({ status: "ready" }) as never);
59
+
60
+ const { result } = renderHook(() => useEveAgentRuntime());
61
+
62
+ expect(result.current.thread.getState().messages.at(-1)?.status).toEqual({
63
+ type: "incomplete",
64
+ reason: "cancelled",
65
+ });
66
+ });
67
+
68
+ it("recomputes statuses when only the session error changes", () => {
69
+ const idle = createAgent({ status: "ready" });
70
+ mockUseEveAgent.mockReturnValue(idle as never);
71
+
72
+ const { result, rerender } = renderHook(() => useEveAgentRuntime());
73
+
74
+ expect(result.current.thread.getState().messages.at(-1)?.status).toEqual({
75
+ type: "incomplete",
76
+ reason: "cancelled",
77
+ });
78
+
79
+ mockUseEveAgent.mockReturnValue({
80
+ ...idle,
81
+ status: "error",
82
+ error: new Error("boom"),
83
+ } as never);
84
+ rerender();
85
+
86
+ expect(result.current.thread.getState().messages.at(-1)?.status).toEqual({
87
+ type: "incomplete",
88
+ reason: "error",
89
+ error: { code: "unknown", message: "boom" },
90
+ });
91
+ });
92
+
93
+ it("keeps the last assistant message running while streaming", () => {
94
+ mockUseEveAgent.mockReturnValue(
95
+ createAgent({ status: "streaming" }) as never,
96
+ );
97
+
98
+ const { result } = renderHook(() => useEveAgentRuntime());
99
+
100
+ expect(result.current.thread.getState().messages.at(-1)?.status).toEqual({
101
+ type: "running",
102
+ });
103
+ });
104
+ });
105
+
106
+ const settledData: EveMessageData = {
107
+ messages: [
108
+ { id: "u1", role: "user", parts: [{ type: "text", text: "earlier" }] },
109
+ {
110
+ id: "a1",
111
+ role: "assistant",
112
+ parts: [{ type: "text", text: "earlier answer" }],
113
+ },
114
+ ],
115
+ };
116
+
117
+ const getText = (runtime: ReturnType<typeof useEveAgentRuntime>) =>
118
+ runtime.thread
119
+ .getState()
120
+ .messages.map((message) =>
121
+ message.content
122
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
123
+ .join(""),
124
+ );
125
+
126
+ const stageMessage = async (
127
+ runtime: ReturnType<typeof useEveAgentRuntime>,
128
+ text: string,
129
+ ) => {
130
+ await act(async () => {
131
+ runtime.thread.append({
132
+ role: "user",
133
+ content: [{ type: "text", text }],
134
+ startRun: false,
135
+ });
136
+ });
137
+ };
138
+
139
+ describe("useEveAgentRuntime staged messages", () => {
140
+ it("stages a user message without submitting when startRun is false", async () => {
141
+ const agent = createAgent({ data: settledData });
142
+ mockUseEveAgent.mockReturnValue(agent as never);
143
+ const { result } = renderHook(() => useEveAgentRuntime());
144
+
145
+ await stageMessage(result.current, "staged draft");
146
+
147
+ await waitFor(() => {
148
+ expect(getText(result.current)).toEqual([
149
+ "earlier",
150
+ "earlier answer",
151
+ "staged draft",
152
+ ]);
153
+ });
154
+ expect(agent.send).not.toHaveBeenCalled();
155
+ });
156
+
157
+ it("renders new turns and refloats staged drafts after a normal send", async () => {
158
+ const agent = createAgent({ data: settledData });
159
+ mockUseEveAgent.mockReturnValue(agent as never);
160
+ const { result, rerender } = renderHook(() => useEveAgentRuntime());
161
+
162
+ await stageMessage(result.current, "staged draft");
163
+
164
+ await act(async () => {
165
+ result.current.thread.append({
166
+ role: "user",
167
+ content: [{ type: "text", text: "hello" }],
168
+ });
169
+ });
170
+ expect(agent.send).toHaveBeenCalledWith({
171
+ message: "hello",
172
+ });
173
+
174
+ mockUseEveAgent.mockReturnValue(
175
+ createAgent({
176
+ data: {
177
+ messages: [
178
+ ...settledData.messages,
179
+ {
180
+ id: "u2",
181
+ role: "user",
182
+ parts: [{ type: "text", text: "hello" }],
183
+ },
184
+ {
185
+ id: "a2",
186
+ role: "assistant",
187
+ parts: [{ type: "text", text: "hi there" }],
188
+ },
189
+ ],
190
+ },
191
+ send: agent.send,
192
+ }) as never,
193
+ );
194
+ rerender();
195
+
196
+ await waitFor(() => {
197
+ expect(getText(result.current)).toEqual([
198
+ "earlier",
199
+ "earlier answer",
200
+ "hello",
201
+ "hi there",
202
+ "staged draft",
203
+ ]);
204
+ });
205
+ });
206
+
207
+ it("keeps both drafts when two appends stage in the same batch", async () => {
208
+ const agent = createAgent({ data: settledData });
209
+ mockUseEveAgent.mockReturnValue(agent as never);
210
+ const { result } = renderHook(() => useEveAgentRuntime());
211
+
212
+ await act(async () => {
213
+ result.current.thread.append({
214
+ role: "user",
215
+ content: [{ type: "text", text: "first staged" }],
216
+ startRun: false,
217
+ });
218
+ result.current.thread.append({
219
+ role: "user",
220
+ content: [{ type: "text", text: "second staged" }],
221
+ startRun: false,
222
+ });
223
+ });
224
+
225
+ await waitFor(() => {
226
+ expect(getText(result.current)).toEqual([
227
+ "earlier",
228
+ "earlier answer",
229
+ "first staged",
230
+ "second staged",
231
+ ]);
232
+ });
233
+ expect(agent.send).not.toHaveBeenCalled();
234
+ });
235
+
236
+ it("restores staged drafts when a promoted send rejects and promotes them all on retry", async () => {
237
+ const send = vi
238
+ .fn()
239
+ .mockRejectedValueOnce(
240
+ new Error("eve session is already processing a turn."),
241
+ )
242
+ .mockResolvedValue(undefined);
243
+ const agent = createAgent({ data: settledData, send });
244
+ mockUseEveAgent.mockReturnValue(agent as never);
245
+ const { result } = renderHook(() => useEveAgentRuntime());
246
+
247
+ await stageMessage(result.current, "first staged");
248
+ await stageMessage(result.current, "second staged");
249
+
250
+ const secondStagedId = result.current.thread.getState().messages[3]!.id;
251
+ await expect(
252
+ Promise.resolve(
253
+ result.current.thread.startRun({
254
+ parentId: secondStagedId,
255
+ sourceId: null,
256
+ runConfig: {},
257
+ }),
258
+ ),
259
+ ).rejects.toThrow("eve session is already processing a turn.");
260
+
261
+ expect(send).toHaveBeenCalledTimes(1);
262
+ await waitFor(() => {
263
+ expect(getText(result.current)).toEqual([
264
+ "earlier",
265
+ "earlier answer",
266
+ "first staged",
267
+ "second staged",
268
+ ]);
269
+ });
270
+
271
+ await act(async () => {
272
+ await result.current.thread.startRun({
273
+ parentId: secondStagedId,
274
+ sourceId: null,
275
+ runConfig: {},
276
+ });
277
+ });
278
+
279
+ expect(send).toHaveBeenCalledTimes(3);
280
+ expect(send).toHaveBeenNthCalledWith(2, { message: "first staged" });
281
+ expect(send).toHaveBeenNthCalledWith(3, { message: "second staged" });
282
+ await waitFor(() => {
283
+ expect(getText(result.current)).toEqual(["earlier", "earlier answer"]);
284
+ });
285
+ });
286
+
287
+ it("keeps later staged messages visible after promoting one staged parent", async () => {
288
+ const agent = createAgent({ data: settledData });
289
+ mockUseEveAgent.mockReturnValue(agent as never);
290
+ const { result } = renderHook(() => useEveAgentRuntime());
291
+
292
+ await stageMessage(result.current, "first staged");
293
+ await stageMessage(result.current, "second staged");
294
+
295
+ const firstStagedId = result.current.thread.getState().messages[2]!.id;
296
+ await act(async () => {
297
+ await result.current.thread.startRun({
298
+ parentId: firstStagedId,
299
+ sourceId: null,
300
+ runConfig: {},
301
+ });
302
+ });
303
+
304
+ expect(agent.send).toHaveBeenCalledTimes(1);
305
+ expect(agent.send).toHaveBeenCalledWith({
306
+ message: "first staged",
307
+ });
308
+ await waitFor(() => {
309
+ expect(getText(result.current)).toEqual([
310
+ "earlier",
311
+ "earlier answer",
312
+ "second staged",
313
+ ]);
314
+ });
315
+ });
316
+
317
+ it("promotes the staged prefix sequentially when reloading the last staged message", async () => {
318
+ let resolveFirstSend!: () => void;
319
+ const send = vi
320
+ .fn()
321
+ .mockImplementationOnce(
322
+ () =>
323
+ new Promise<void>((resolve) => {
324
+ resolveFirstSend = resolve;
325
+ }),
326
+ )
327
+ .mockResolvedValue(undefined);
328
+ const agent = createAgent({ data: settledData, send });
329
+ mockUseEveAgent.mockReturnValue(agent as never);
330
+ const { result } = renderHook(() => useEveAgentRuntime());
331
+
332
+ await stageMessage(result.current, "first staged");
333
+ await stageMessage(result.current, "second staged");
334
+
335
+ const secondStagedId = result.current.thread.getState().messages[3]!.id;
336
+ act(() => {
337
+ void result.current.thread.startRun({
338
+ parentId: secondStagedId,
339
+ sourceId: null,
340
+ runConfig: {},
341
+ });
342
+ });
343
+
344
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
345
+ expect(send).toHaveBeenNthCalledWith(1, {
346
+ message: "first staged",
347
+ });
348
+
349
+ await act(async () => {
350
+ resolveFirstSend();
351
+ });
352
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(2));
353
+ expect(send).toHaveBeenNthCalledWith(2, {
354
+ message: "second staged",
355
+ });
356
+
357
+ await waitFor(() => {
358
+ expect(getText(result.current)).toEqual(["earlier", "earlier answer"]);
359
+ });
360
+
361
+ await expect(
362
+ Promise.resolve(
363
+ result.current.thread.startRun({
364
+ parentId: secondStagedId,
365
+ sourceId: null,
366
+ runConfig: {},
367
+ }),
368
+ ),
369
+ ).rejects.toThrow("Runtime does not support reloading messages.");
370
+ expect(send).toHaveBeenCalledTimes(2);
371
+ });
372
+
373
+ it("keeps staged drafts when the session collapses to empty", async () => {
374
+ const agent = createAgent({ data: settledData });
375
+ mockUseEveAgent.mockReturnValue(agent as never);
376
+ const { result, rerender } = renderHook(() => useEveAgentRuntime());
377
+
378
+ await stageMessage(result.current, "staged draft");
379
+
380
+ mockUseEveAgent.mockReturnValue(
381
+ createAgent({ data: { messages: [] }, send: agent.send }) as never,
382
+ );
383
+ rerender();
384
+
385
+ await waitFor(() => {
386
+ expect(getText(result.current)).toEqual(["staged draft"]);
387
+ });
388
+ });
389
+
390
+ it("still rejects reloading a non-staged message", async () => {
391
+ const agent = createAgent({ data: settledData });
392
+ mockUseEveAgent.mockReturnValue(agent as never);
393
+ const { result } = renderHook(() => useEveAgentRuntime());
394
+
395
+ await stageMessage(result.current, "staged draft");
396
+
397
+ await expect(
398
+ Promise.resolve(
399
+ result.current.thread.startRun({
400
+ parentId: "a1",
401
+ sourceId: null,
402
+ runConfig: {},
403
+ }),
404
+ ),
405
+ ).rejects.toThrow("Runtime does not support reloading messages.");
406
+ expect(agent.send).not.toHaveBeenCalled();
407
+ });
408
+ });