@assistant-ui/eve 0.0.13 → 0.0.15
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 +45 -5
- package/dist/convertEveMessages.d.ts.map +1 -1
- package/dist/convertEveMessages.js +91 -14
- package/dist/convertEveMessages.js.map +1 -1
- package/dist/eveExtras.d.ts +7 -0
- package/dist/eveExtras.d.ts.map +1 -0
- package/dist/eveExtras.js +7 -0
- package/dist/eveExtras.js.map +1 -0
- package/dist/hooks.d.ts +25 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +32 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/useEveAgentRuntime.d.ts.map +1 -1
- package/dist/useEveAgentRuntime.js +61 -26
- package/dist/useEveAgentRuntime.js.map +1 -1
- package/package.json +7 -6
- package/src/convertEveMessages.test.ts +698 -6
- package/src/convertEveMessages.ts +185 -14
- package/src/eveExtras.ts +10 -0
- package/src/hooks.test.tsx +84 -0
- package/src/hooks.ts +36 -0
- package/src/index.ts +3 -0
- package/src/useEveAgentRuntime.refetch.test.tsx +89 -0
- package/src/useEveAgentRuntime.test.tsx +1037 -167
- package/src/useEveAgentRuntime.ts +148 -42
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
type CompleteAttachment,
|
|
6
6
|
type DataMessagePart,
|
|
7
7
|
type FileMessagePart,
|
|
8
|
+
type MessagePartStreamStatus,
|
|
8
9
|
type MessageStatus,
|
|
10
|
+
type PartProviderMetadata,
|
|
9
11
|
type RespondToToolApprovalOptions,
|
|
10
12
|
type ThreadAssistantMessagePart,
|
|
11
13
|
type ThreadMessage,
|
|
@@ -14,7 +16,10 @@ import {
|
|
|
14
16
|
type ToolApprovalOption,
|
|
15
17
|
type ToolCallMessagePart,
|
|
16
18
|
} from "@assistant-ui/core";
|
|
17
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
httpUrlPattern,
|
|
21
|
+
resolveFilePartSource,
|
|
22
|
+
} from "@assistant-ui/core/internal";
|
|
18
23
|
import type {
|
|
19
24
|
EveAuthorizationOutcome,
|
|
20
25
|
EveAuthorizationPart,
|
|
@@ -24,7 +29,7 @@ import type {
|
|
|
24
29
|
EveMessageInputRequest,
|
|
25
30
|
EveMessagePart,
|
|
26
31
|
} from "eve/react";
|
|
27
|
-
import type { InputResponse
|
|
32
|
+
import type { InputResponse } from "eve/client";
|
|
28
33
|
|
|
29
34
|
const ASSISTANT_COMPLETE_STATUS = {
|
|
30
35
|
type: "complete",
|
|
@@ -148,7 +153,7 @@ const toolApprovalOptionsFromInputRequest = (
|
|
|
148
153
|
kind:
|
|
149
154
|
option.id === "approve"
|
|
150
155
|
? "allow-once"
|
|
151
|
-
: option.id === "
|
|
156
|
+
: option.id === "cancel"
|
|
152
157
|
? "reject-once"
|
|
153
158
|
: `_${option.id}`,
|
|
154
159
|
...(option.label && { label: option.label }),
|
|
@@ -186,10 +191,41 @@ const toApproval = (
|
|
|
186
191
|
};
|
|
187
192
|
};
|
|
188
193
|
|
|
194
|
+
const toJsonSafeInputRequest = (
|
|
195
|
+
inputRequest: EveMessageInputRequest,
|
|
196
|
+
): PartProviderMetadata[string] => ({
|
|
197
|
+
requestId: inputRequest.requestId,
|
|
198
|
+
prompt: inputRequest.prompt,
|
|
199
|
+
kind: inputRequest.kind,
|
|
200
|
+
...(inputRequest.display !== undefined && { display: inputRequest.display }),
|
|
201
|
+
...(inputRequest.allowFreeform !== undefined && {
|
|
202
|
+
allowFreeform: inputRequest.allowFreeform,
|
|
203
|
+
}),
|
|
204
|
+
...(inputRequest.options && {
|
|
205
|
+
options: inputRequest.options.map((option) => ({
|
|
206
|
+
id: option.id,
|
|
207
|
+
label: option.label,
|
|
208
|
+
...(option.description !== undefined && {
|
|
209
|
+
description: option.description,
|
|
210
|
+
}),
|
|
211
|
+
...(option.style !== undefined && { style: option.style }),
|
|
212
|
+
})),
|
|
213
|
+
}),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const toToolProviderMetadata = (
|
|
217
|
+
part: EveDynamicToolPart,
|
|
218
|
+
): PartProviderMetadata | undefined => {
|
|
219
|
+
const inputRequest = part.toolMetadata?.eve?.inputRequest;
|
|
220
|
+
if (!inputRequest) return undefined;
|
|
221
|
+
return { eve: { inputRequest: toJsonSafeInputRequest(inputRequest) } };
|
|
222
|
+
};
|
|
223
|
+
|
|
189
224
|
const convertDynamicToolPart = (
|
|
190
225
|
part: EveDynamicToolPart,
|
|
191
226
|
): ToolCallMessagePart => {
|
|
192
227
|
const approval = toApproval(part);
|
|
228
|
+
const providerMetadata = toToolProviderMetadata(part);
|
|
193
229
|
const toolCall: ToolCallMessagePart = {
|
|
194
230
|
type: "tool-call",
|
|
195
231
|
toolCallId: part.toolCallId,
|
|
@@ -197,6 +233,7 @@ const convertDynamicToolPart = (
|
|
|
197
233
|
args: toJsonObject(part.input),
|
|
198
234
|
argsText: stringifyArgs(part.input),
|
|
199
235
|
...(approval && { approval }),
|
|
236
|
+
...(providerMetadata && { providerMetadata }),
|
|
200
237
|
};
|
|
201
238
|
|
|
202
239
|
switch (part.state) {
|
|
@@ -287,13 +324,31 @@ const convertFilePart = (
|
|
|
287
324
|
};
|
|
288
325
|
};
|
|
289
326
|
|
|
327
|
+
// Eve's `state` is a run marker: the reducer settles a part to "done" only on
|
|
328
|
+
// `reasoning.completed`, `message.completed`, or `turn.cancelled`.
|
|
329
|
+
// `emitStreamContent` does not flush leftover reasoning on a tool-call, so a
|
|
330
|
+
// non-last reasoning part stays `streaming` until the model stream ends and the
|
|
331
|
+
// leftover flush runs. Mapping `streaming` to running would pin that block
|
|
332
|
+
// open. Only `done` is trustworthy; an unsettled part falls back to core's
|
|
333
|
+
// last-part rule.
|
|
334
|
+
const partStateToStatus = (
|
|
335
|
+
state: "done" | "streaming" | undefined,
|
|
336
|
+
): MessagePartStreamStatus | undefined =>
|
|
337
|
+
state === "done" ? { type: "complete" } : undefined;
|
|
338
|
+
|
|
290
339
|
const convertAssistantPart = (
|
|
291
340
|
part: EveMessagePart,
|
|
292
341
|
): ThreadAssistantMessagePart | null => {
|
|
293
342
|
switch (part.type) {
|
|
294
343
|
case "text":
|
|
295
|
-
case "reasoning":
|
|
296
|
-
|
|
344
|
+
case "reasoning": {
|
|
345
|
+
const status = partStateToStatus(part.state);
|
|
346
|
+
return {
|
|
347
|
+
type: part.type,
|
|
348
|
+
text: part.text,
|
|
349
|
+
...(status && { status }),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
297
352
|
|
|
298
353
|
case "step-start":
|
|
299
354
|
return null;
|
|
@@ -418,13 +473,30 @@ export const convertEveMessages = (
|
|
|
418
473
|
convertEveMessage(message, index, messages, options),
|
|
419
474
|
);
|
|
420
475
|
|
|
476
|
+
/**
|
|
477
|
+
* Structural subset of the `string | UserContent` message content Eve's `send`
|
|
478
|
+
* API accepts. The helpers declare the shapes they produce and leave
|
|
479
|
+
* assignability to the send call site, checked against the installed version.
|
|
480
|
+
*/
|
|
481
|
+
export type EveMessageContent =
|
|
482
|
+
| string
|
|
483
|
+
| (
|
|
484
|
+
| { readonly type: "text"; readonly text: string }
|
|
485
|
+
| {
|
|
486
|
+
readonly type: "file";
|
|
487
|
+
readonly data: string;
|
|
488
|
+
readonly mediaType: string;
|
|
489
|
+
readonly filename?: string;
|
|
490
|
+
}
|
|
491
|
+
)[];
|
|
492
|
+
|
|
421
493
|
/**
|
|
422
494
|
* Converts an assistant-ui append message into the message payload accepted by
|
|
423
495
|
* Eve's `send` API.
|
|
424
496
|
*/
|
|
425
497
|
export const getEveMessageContent = (
|
|
426
498
|
message: AppendMessage,
|
|
427
|
-
):
|
|
499
|
+
): EveMessageContent => {
|
|
428
500
|
const content = [
|
|
429
501
|
...message.content,
|
|
430
502
|
...(message.attachments?.flatMap((attachment) => attachment.content) ?? []),
|
|
@@ -457,11 +529,13 @@ export const getEveMessageContent = (
|
|
|
457
529
|
// envelope is rebuilt from the typed format rather than forwarded.
|
|
458
530
|
const mediaType = `audio/${part.audio.format}`;
|
|
459
531
|
const data = part.audio.data;
|
|
532
|
+
const source = resolveFilePartSource({ data, mimeType: mediaType });
|
|
460
533
|
return {
|
|
461
534
|
type: "file" as const,
|
|
462
|
-
data:
|
|
463
|
-
|
|
464
|
-
|
|
535
|
+
data:
|
|
536
|
+
source.kind === "url"
|
|
537
|
+
? source.url
|
|
538
|
+
: `data:${mediaType};base64,${source.data}`,
|
|
465
539
|
mediaType,
|
|
466
540
|
};
|
|
467
541
|
}
|
|
@@ -489,13 +563,110 @@ export const getEveMessageContent = (
|
|
|
489
563
|
return parts;
|
|
490
564
|
};
|
|
491
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Finds the pending input request answered by the given approval id, so the
|
|
568
|
+
* response mapping can honor the request's display mode.
|
|
569
|
+
*/
|
|
570
|
+
export const findEveInputRequest = (
|
|
571
|
+
data: EveMessageData,
|
|
572
|
+
approvalId: string,
|
|
573
|
+
): EveMessageInputRequest | undefined => {
|
|
574
|
+
for (const message of data.messages) {
|
|
575
|
+
for (const part of message.parts) {
|
|
576
|
+
if (part.type === "dynamic-tool" && part.approval?.id === approvalId) {
|
|
577
|
+
return part.toolMetadata?.eve?.inputRequest;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return undefined;
|
|
582
|
+
};
|
|
583
|
+
|
|
492
584
|
/**
|
|
493
585
|
* Converts an assistant-ui tool approval response into an Eve input response.
|
|
586
|
+
*
|
|
587
|
+
* When `inputRequest` is known, every returned response carries either an
|
|
588
|
+
* option the request declares or a free-form answer. A literal option match
|
|
589
|
+
* wins, then the `"approve"` / `"cancel"` option the response's boolean decision
|
|
590
|
+
* names, then the response's `reason` text when the request takes a free-form
|
|
591
|
+
* answer (`display: "text"`, `allowFreeform`, or no options at all, and never
|
|
592
|
+
* `display: "confirmation"`) and the response is not a refusal.
|
|
593
|
+
* A one-argument call, or a part with no request, still forwards a caller
|
|
594
|
+
* `optionId` as it always has, including ids the request never declared.
|
|
595
|
+
*
|
|
596
|
+
* A response the known request cannot record as the answer it asked for throws
|
|
597
|
+
* instead of being submitted, because eve resolves a request the moment any
|
|
598
|
+
* response for it arrives, and an empty one is recorded as an answer with no
|
|
599
|
+
* content. Not sending leaves the request pending, so the caller can retry or
|
|
600
|
+
* the user can answer it as an ordinary message.
|
|
601
|
+
*
|
|
602
|
+
* With no request to map against — a one-argument call, or a part carrying no
|
|
603
|
+
* `toolMetadata.eve.inputRequest` — the response maps as it always has, to the
|
|
604
|
+
* literal `"approve"` / `"cancel"` option every eve approval declares. Guessing
|
|
605
|
+
* a display mode is what this mapper stopped doing; an unknown request is not
|
|
606
|
+
* a guess about one.
|
|
494
607
|
*/
|
|
495
608
|
export const toEveInputResponse = (
|
|
496
609
|
response: RespondToToolApprovalOptions,
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
610
|
+
inputRequest?: EveMessageInputRequest,
|
|
611
|
+
): InputResponse => {
|
|
612
|
+
const requestId = response.approvalId;
|
|
613
|
+
const options = inputRequest?.options;
|
|
614
|
+
const text = response.reason;
|
|
615
|
+
|
|
616
|
+
if (response.optionId !== undefined) {
|
|
617
|
+
if (
|
|
618
|
+
!inputRequest ||
|
|
619
|
+
options?.some((option) => option.id === response.optionId)
|
|
620
|
+
) {
|
|
621
|
+
return {
|
|
622
|
+
requestId,
|
|
623
|
+
optionId: response.optionId,
|
|
624
|
+
...(text && { text }),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
throw new Error(
|
|
628
|
+
`Eve input request "${requestId}" has no option with id "${response.optionId}"; respond with one of: ${
|
|
629
|
+
options?.length
|
|
630
|
+
? options.map((option) => option.id).join(", ")
|
|
631
|
+
: "(the request carries no options)"
|
|
632
|
+
}`,
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const decisionOptionId = response.approved ? "approve" : "cancel";
|
|
637
|
+
// Eve's harness always declares `approve`/`cancel` on a `tool-approval`
|
|
638
|
+
// request, so honoring the kind when the options were dropped keeps a
|
|
639
|
+
// non-spec approval payload answerable rather than stuck. A tool-approval
|
|
640
|
+
// that declares its own options still requires one of them.
|
|
641
|
+
if (
|
|
642
|
+
!inputRequest ||
|
|
643
|
+
(inputRequest.kind === "tool-approval" && !options?.length) ||
|
|
644
|
+
options?.some((option) => option.id === decisionOptionId)
|
|
645
|
+
) {
|
|
646
|
+
return { requestId, optionId: decisionOptionId, ...(text && { text }) };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Eve recognises an approval by its literal two-option `approve`/`cancel`
|
|
650
|
+
// shape, so past this point the request is a question and the boolean
|
|
651
|
+
// carries no decision eve would act on.
|
|
652
|
+
const acceptsText =
|
|
653
|
+
inputRequest.display !== "confirmation" &&
|
|
654
|
+
(inputRequest.display === "text" ||
|
|
655
|
+
inputRequest.allowFreeform === true ||
|
|
656
|
+
!options?.length);
|
|
657
|
+
if (acceptsText && text && response.approved !== false) {
|
|
658
|
+
return { requestId, text };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
throw new Error(
|
|
662
|
+
`Eve input request "${requestId}" (${inputRequest.prompt}) was not answered by this response; ${
|
|
663
|
+
options?.length
|
|
664
|
+
? `respond with one of: ${options.map((option) => option.id).join(", ")}`
|
|
665
|
+
: !acceptsText
|
|
666
|
+
? "the request declares no options to respond with"
|
|
667
|
+
: response.approved === false
|
|
668
|
+
? "a refusal carries no answer for a free-form request; resend with `approved` unset or true and the answer in the response reason"
|
|
669
|
+
: "pass the answer as the response reason, because the request takes a free-form answer"
|
|
670
|
+
}`,
|
|
671
|
+
);
|
|
672
|
+
};
|
package/src/eveExtras.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createRuntimeExtras } from "@assistant-ui/core/react";
|
|
2
|
+
import type { EveMessageData, UseEveAgentHelpers } from "eve/react";
|
|
3
|
+
|
|
4
|
+
export type EveRuntimeExtras = Pick<
|
|
5
|
+
UseEveAgentHelpers<EveMessageData>,
|
|
6
|
+
"error" | "events" | "session" | "reset"
|
|
7
|
+
>;
|
|
8
|
+
|
|
9
|
+
export const eveExtras =
|
|
10
|
+
createRuntimeExtras<EveRuntimeExtras>("useEveAgentRuntime");
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { renderHook } from "@testing-library/react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const { extrasRef } = vi.hoisted(() => ({
|
|
7
|
+
extrasRef: { current: undefined as unknown },
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("@assistant-ui/store", async (importOriginal) => ({
|
|
11
|
+
...(await importOriginal<typeof import("@assistant-ui/store")>()),
|
|
12
|
+
useAui: (() => ({
|
|
13
|
+
thread: { getState: () => ({ extras: extrasRef.current }) },
|
|
14
|
+
})) as unknown as typeof import("@assistant-ui/store").useAui,
|
|
15
|
+
useAuiState: ((selector: (s: unknown) => unknown) =>
|
|
16
|
+
selector({
|
|
17
|
+
thread: { extras: extrasRef.current },
|
|
18
|
+
})) as typeof import("@assistant-ui/store").useAuiState,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { eveExtras, type EveRuntimeExtras } from "./eveExtras";
|
|
22
|
+
import { useEveError, useEveEvents, useEveReset, useEveSession } from "./hooks";
|
|
23
|
+
|
|
24
|
+
const provideExtras = (over: Partial<EveRuntimeExtras>) =>
|
|
25
|
+
eveExtras.provide({
|
|
26
|
+
error: undefined,
|
|
27
|
+
events: [],
|
|
28
|
+
session: {} as EveRuntimeExtras["session"],
|
|
29
|
+
reset: () => {},
|
|
30
|
+
...over,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
extrasRef.current = undefined;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("Eve accessor hooks", () => {
|
|
38
|
+
it("reads the session error from the runtime extras", () => {
|
|
39
|
+
const error = new Error("boom");
|
|
40
|
+
extrasRef.current = provideExtras({ error });
|
|
41
|
+
|
|
42
|
+
expect(renderHook(() => useEveError()).result.current).toBe(error);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reads the session cursor from the runtime extras", () => {
|
|
46
|
+
const session = {
|
|
47
|
+
sessionId: "s1",
|
|
48
|
+
} as unknown as EveRuntimeExtras["session"];
|
|
49
|
+
extrasRef.current = provideExtras({ session });
|
|
50
|
+
|
|
51
|
+
expect(renderHook(() => useEveSession()).result.current).toBe(session);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("reads the server event stream from the runtime extras", () => {
|
|
55
|
+
const events = [
|
|
56
|
+
{ type: "session.started" },
|
|
57
|
+
] as unknown as EveRuntimeExtras["events"];
|
|
58
|
+
extrasRef.current = provideExtras({ events });
|
|
59
|
+
|
|
60
|
+
expect(renderHook(() => useEveEvents()).result.current).toBe(events);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("reads reset from the runtime extras", () => {
|
|
64
|
+
const reset = vi.fn();
|
|
65
|
+
extrasRef.current = provideExtras({ reset });
|
|
66
|
+
|
|
67
|
+
renderHook(() => useEveReset()).result.current();
|
|
68
|
+
|
|
69
|
+
expect(reset).toHaveBeenCalledTimes(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("supplies fallbacks outside an Eve runtime, and defers the reset throw to invocation", () => {
|
|
73
|
+
expect(renderHook(() => useEveError()).result.current).toBeUndefined();
|
|
74
|
+
expect(renderHook(() => useEveSession()).result.current).toBeUndefined();
|
|
75
|
+
|
|
76
|
+
const events = renderHook(() => useEveEvents()).result.current;
|
|
77
|
+
expect(events).toEqual([]);
|
|
78
|
+
expect(renderHook(() => useEveEvents()).result.current).toBe(events);
|
|
79
|
+
expect(Object.isFrozen(events)).toBe(true);
|
|
80
|
+
|
|
81
|
+
const reset = renderHook(() => useEveReset()).result.current;
|
|
82
|
+
expect(() => reset()).toThrow("useEveAgentRuntime");
|
|
83
|
+
});
|
|
84
|
+
});
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useAui } from "@assistant-ui/store";
|
|
4
|
+
import { eveExtras } from "./eveExtras";
|
|
5
|
+
import type { EveRuntimeExtras } from "./eveExtras";
|
|
6
|
+
|
|
7
|
+
const EMPTY_EVENTS: EveRuntimeExtras["events"] = Object.freeze([]);
|
|
8
|
+
|
|
9
|
+
/** Read the last Eve session error from the runtime extras. */
|
|
10
|
+
export const useEveError = () => eveExtras.use((e) => e.error, undefined);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read the current Eve session cursor from the runtime extras. Persist it to
|
|
14
|
+
* resume the session later via `initialSession`. `undefined` when no session
|
|
15
|
+
* exists yet or outside an Eve runtime.
|
|
16
|
+
*/
|
|
17
|
+
export const useEveSession = (): EveRuntimeExtras["session"] | undefined =>
|
|
18
|
+
eveExtras.use((e) => e.session, undefined);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read the authoritative Eve server event stream from the runtime extras.
|
|
22
|
+
* Defaults to an empty array outside an Eve runtime.
|
|
23
|
+
*/
|
|
24
|
+
export const useEveEvents = (): EveRuntimeExtras["events"] =>
|
|
25
|
+
eveExtras.use((e) => e.events, EMPTY_EVENTS);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns a function that resets the Eve session: aborts any in-flight turn,
|
|
29
|
+
* recreates the owned session, and clears events and projected data. Pending
|
|
30
|
+
* staged messages and tool execution state are discarded. Safe to render
|
|
31
|
+
* outside an Eve runtime; invoking the returned function there throws.
|
|
32
|
+
*/
|
|
33
|
+
export const useEveReset = () => {
|
|
34
|
+
const aui = useAui();
|
|
35
|
+
return () => eveExtras.get(aui).reset();
|
|
36
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,9 @@ export {
|
|
|
9
9
|
export type {
|
|
10
10
|
ConvertEveMessagesOptions,
|
|
11
11
|
EveAuthorizationData,
|
|
12
|
+
EveMessageContent,
|
|
12
13
|
} from "./convertEveMessages";
|
|
13
14
|
export { useEveAgentRuntime } from "./useEveAgentRuntime";
|
|
14
15
|
export type { UseEveAgentRuntimeOptions } from "./useEveAgentRuntime";
|
|
16
|
+
export { useEveError, useEveEvents, useEveReset, useEveSession } from "./hooks";
|
|
17
|
+
export type { EveRuntimeExtras } from "./eveExtras";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, renderHook } from "@testing-library/react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { HandleMessageStreamEvent } from "eve/client";
|
|
6
|
+
|
|
7
|
+
import { useEveAgentRuntime } from "./useEveAgentRuntime";
|
|
8
|
+
|
|
9
|
+
const completedTurn = (
|
|
10
|
+
turnId: string,
|
|
11
|
+
text: string,
|
|
12
|
+
reply: string,
|
|
13
|
+
sequence: number,
|
|
14
|
+
) =>
|
|
15
|
+
[
|
|
16
|
+
{
|
|
17
|
+
type: "turn.started",
|
|
18
|
+
data: { sequence, turnId },
|
|
19
|
+
meta: { at: "2026-01-02T10:00:00.000Z", id: `${turnId}-1` },
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
type: "message.received",
|
|
23
|
+
data: { message: text, sequence: sequence + 1, turnId },
|
|
24
|
+
meta: { at: "2026-01-02T10:00:01.000Z", id: `${turnId}-2` },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
type: "step.started",
|
|
28
|
+
data: { modelId: "m", sequence: sequence + 2, stepIndex: 0, turnId },
|
|
29
|
+
meta: { at: "2026-01-02T10:00:02.000Z", id: `${turnId}-3` },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
type: "message.completed",
|
|
33
|
+
data: {
|
|
34
|
+
finishReason: "stop",
|
|
35
|
+
message: reply,
|
|
36
|
+
sequence: sequence + 3,
|
|
37
|
+
stepIndex: 0,
|
|
38
|
+
turnId,
|
|
39
|
+
},
|
|
40
|
+
meta: { at: "2026-01-02T10:00:03.000Z", id: `${turnId}-4` },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: "turn.completed",
|
|
44
|
+
data: { sequence: sequence + 4, turnId },
|
|
45
|
+
meta: { at: "2026-01-02T10:00:04.000Z", id: `${turnId}-5` },
|
|
46
|
+
},
|
|
47
|
+
] as const satisfies readonly HandleMessageStreamEvent[];
|
|
48
|
+
|
|
49
|
+
const knownTurn = completedTurn("t1", "hi", "hello", 1);
|
|
50
|
+
const outOfBandTurn = completedTurn("t2", "answered elsewhere", "done", 6);
|
|
51
|
+
|
|
52
|
+
describe("useEveAgentRuntime against a durable eve session", () => {
|
|
53
|
+
it("surfaces a turn the server appended out of band after reloadMainThread", async () => {
|
|
54
|
+
const stream = vi.fn(async function* () {
|
|
55
|
+
yield* [...knownTurn, ...outOfBandTurn];
|
|
56
|
+
});
|
|
57
|
+
const session = {
|
|
58
|
+
state: { sessionId: "s1" },
|
|
59
|
+
stream,
|
|
60
|
+
send: () => {
|
|
61
|
+
throw new Error("the refetch test must not send");
|
|
62
|
+
},
|
|
63
|
+
} as never;
|
|
64
|
+
|
|
65
|
+
const { result } = renderHook(() =>
|
|
66
|
+
useEveAgentRuntime({ initialEvents: knownTurn as never, session }),
|
|
67
|
+
);
|
|
68
|
+
expect(
|
|
69
|
+
result.current.thread.getState().messages.map((m) => m.role),
|
|
70
|
+
).toEqual(["user", "assistant"]);
|
|
71
|
+
|
|
72
|
+
await act(async () => {
|
|
73
|
+
await result.current.threads.reloadMainThread();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(stream).toHaveBeenCalled();
|
|
77
|
+
const messages = result.current.thread.getState().messages;
|
|
78
|
+
expect(messages.map((m) => m.role)).toEqual([
|
|
79
|
+
"user",
|
|
80
|
+
"assistant",
|
|
81
|
+
"user",
|
|
82
|
+
"assistant",
|
|
83
|
+
]);
|
|
84
|
+
expect(messages[3]?.content).toMatchObject([
|
|
85
|
+
{ type: "text", text: "done" },
|
|
86
|
+
]);
|
|
87
|
+
expect(result.current.thread.getState().isRunning).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|