@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.
- package/dist/convertEveMessages.d.ts +32 -3
- package/dist/convertEveMessages.d.ts.map +1 -1
- package/dist/convertEveMessages.js +87 -5
- package/dist/convertEveMessages.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/useEveAgentRuntime.d.ts.map +1 -1
- package/dist/useEveAgentRuntime.js +46 -14
- package/dist/useEveAgentRuntime.js.map +1 -1
- package/package.json +7 -3
- package/src/convertEveMessages.test.ts +1134 -12
- package/src/convertEveMessages.ts +174 -9
- package/src/index.ts +5 -1
- package/src/useEveAgentRuntime.test.tsx +408 -0
- package/src/useEveAgentRuntime.ts +55 -23
|
@@ -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.
|
|
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
|
+
});
|