@assistant-ui/eve 0.0.21 → 0.0.23
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 +17 -0
- package/dist/convertEveMessages.d.ts.map +1 -1
- package/dist/convertEveMessages.js +68 -21
- package/dist/convertEveMessages.js.map +1 -1
- package/dist/sdkIdentity.js +1 -1
- package/dist/useEveAgentRuntime.d.ts.map +1 -1
- package/dist/useEveAgentRuntime.js +13 -18
- package/dist/useEveAgentRuntime.js.map +1 -1
- package/package.json +6 -5
- package/src/convertEveMessages.test.ts +386 -20
- package/src/convertEveMessages.ts +127 -17
- package/src/deriveCreatedAt.test.ts +1 -0
- package/src/testUtils.ts +57 -0
- package/src/useEveAgentRuntime.eveResume.test.tsx +21 -41
- package/src/useEveAgentRuntime.interruptedTurn.test.tsx +80 -0
- package/src/useEveAgentRuntime.refetch.test.tsx +21 -11
- package/src/useEveAgentRuntime.ts +34 -34
|
@@ -19,7 +19,10 @@ import {
|
|
|
19
19
|
} from "@assistant-ui/core";
|
|
20
20
|
import {
|
|
21
21
|
httpUrlPattern,
|
|
22
|
+
resolveFileMediaType,
|
|
22
23
|
resolveFilePartSource,
|
|
24
|
+
resolveImageMediaType,
|
|
25
|
+
toMediaWireUrl,
|
|
23
26
|
} from "@assistant-ui/core/internal";
|
|
24
27
|
import type {
|
|
25
28
|
EveAuthorizationOutcome,
|
|
@@ -31,6 +34,7 @@ import type {
|
|
|
31
34
|
EveMessagePart,
|
|
32
35
|
} from "eve/react";
|
|
33
36
|
import type { InputResponse } from "eve/client";
|
|
37
|
+
import type { MessageStreamEvent } from "eve/client";
|
|
34
38
|
|
|
35
39
|
const ASSISTANT_COMPLETE_STATUS = {
|
|
36
40
|
type: "complete",
|
|
@@ -64,6 +68,12 @@ export type ConvertEveMessagesOptions = {
|
|
|
64
68
|
*/
|
|
65
69
|
readonly error?: unknown;
|
|
66
70
|
readonly getCreatedAt?: ((message: EveMessage) => Date) | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* The session's stream events. A `turn.failed` or `turn.cancelled` event
|
|
73
|
+
* marks its turn's assistant message as incomplete, with a failure's code
|
|
74
|
+
* and message as its error.
|
|
75
|
+
*/
|
|
76
|
+
readonly events?: readonly MessageStreamEvent[] | undefined;
|
|
67
77
|
};
|
|
68
78
|
|
|
69
79
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -88,9 +98,16 @@ const toMessageStatus = (
|
|
|
88
98
|
index: number,
|
|
89
99
|
messages: readonly EveMessage[],
|
|
90
100
|
options: ConvertEveMessagesOptions,
|
|
101
|
+
interruptedTurns: ReadonlyMap<string, MessageStatus>,
|
|
91
102
|
): MessageStatus => {
|
|
92
103
|
if (message.role !== "assistant") return USER_FALLBACK_STATUS;
|
|
93
104
|
|
|
105
|
+
const interrupted =
|
|
106
|
+
message.metadata?.turnId === undefined
|
|
107
|
+
? undefined
|
|
108
|
+
: interruptedTurns.get(message.metadata.turnId);
|
|
109
|
+
if (interrupted) return interrupted;
|
|
110
|
+
|
|
94
111
|
const isLast = index === messages.length - 1;
|
|
95
112
|
const hasPendingApproval = message.parts.some(
|
|
96
113
|
(part) =>
|
|
@@ -123,9 +140,7 @@ const toMessageStatus = (
|
|
|
123
140
|
return ASSISTANT_RUNNING_STATUS;
|
|
124
141
|
}
|
|
125
142
|
|
|
126
|
-
//
|
|
127
|
-
// cancellation or turn/session failure, so liveness comes from isRunning and
|
|
128
|
-
// a leftover marker means the turn was interrupted.
|
|
143
|
+
// Some eve versions leave the streaming marker after cancellation or failure.
|
|
129
144
|
if (message.metadata?.status === "streaming") {
|
|
130
145
|
if (options.isRunning === undefined) {
|
|
131
146
|
return ASSISTANT_RUNNING_STATUS;
|
|
@@ -274,7 +289,7 @@ const convertDynamicToolPart = (
|
|
|
274
289
|
toolCallId: part.toolCallId,
|
|
275
290
|
toolName: part.toolName,
|
|
276
291
|
args: toJsonObject(part.input),
|
|
277
|
-
argsText: stringifyArgs(part.input),
|
|
292
|
+
argsText: part.state === "input-streaming" ? "" : stringifyArgs(part.input),
|
|
278
293
|
...(approval && { approval }),
|
|
279
294
|
...(providerMetadata && { providerMetadata }),
|
|
280
295
|
};
|
|
@@ -361,7 +376,7 @@ const convertFilePart = (
|
|
|
361
376
|
return {
|
|
362
377
|
type: "file",
|
|
363
378
|
data: part.url,
|
|
364
|
-
mimeType: part.mediaType
|
|
379
|
+
mimeType: resolveFileMediaType(part.url, part.mediaType),
|
|
365
380
|
...(part.filename && { filename: part.filename }),
|
|
366
381
|
...(httpUrlPattern.test(part.url) && { sourceType: "url" as const }),
|
|
367
382
|
};
|
|
@@ -469,6 +484,21 @@ export const convertEveMessage = (
|
|
|
469
484
|
index: number,
|
|
470
485
|
messages: readonly EveMessage[],
|
|
471
486
|
options: ConvertEveMessagesOptions = {},
|
|
487
|
+
): ThreadMessage =>
|
|
488
|
+
convertEveMessageWithInterruptions(
|
|
489
|
+
message,
|
|
490
|
+
index,
|
|
491
|
+
messages,
|
|
492
|
+
options,
|
|
493
|
+
getInterruptedTurns(options.events),
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
const convertEveMessageWithInterruptions = (
|
|
497
|
+
message: EveMessage,
|
|
498
|
+
index: number,
|
|
499
|
+
messages: readonly EveMessage[],
|
|
500
|
+
options: ConvertEveMessagesOptions,
|
|
501
|
+
interruptedTurns: ReadonlyMap<string, MessageStatus>,
|
|
472
502
|
): ThreadMessage => {
|
|
473
503
|
const createdAt = options.getCreatedAt?.(message) ?? new Date();
|
|
474
504
|
const metadata = {
|
|
@@ -501,20 +531,81 @@ export const convertEveMessage = (
|
|
|
501
531
|
return fromThreadMessageLike(
|
|
502
532
|
like,
|
|
503
533
|
message.id,
|
|
504
|
-
toMessageStatus(message, index, messages, options),
|
|
534
|
+
toMessageStatus(message, index, messages, options, interruptedTurns),
|
|
505
535
|
);
|
|
506
536
|
};
|
|
507
537
|
|
|
538
|
+
export type InterruptedTurnEventCache = {
|
|
539
|
+
lastEvents: readonly MessageStreamEvent[];
|
|
540
|
+
interruptions: readonly MessageStreamEvent[];
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const isTurnInterruption = (event: MessageStreamEvent) =>
|
|
544
|
+
event.type === "turn.failed" || event.type === "turn.cancelled";
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Collects the `turn.failed` and `turn.cancelled` events of an append-only
|
|
548
|
+
* event log, scanning only the events appended since the cached call. The
|
|
549
|
+
* returned array keeps its identity until a new interruption arrives.
|
|
550
|
+
*/
|
|
551
|
+
export const collectInterruptedTurnEvents = (
|
|
552
|
+
events: readonly MessageStreamEvent[],
|
|
553
|
+
cache: InterruptedTurnEventCache,
|
|
554
|
+
): readonly MessageStreamEvent[] => {
|
|
555
|
+
if (events === cache.lastEvents) return cache.interruptions;
|
|
556
|
+
|
|
557
|
+
const scanned = cache.lastEvents;
|
|
558
|
+
const resumesScan =
|
|
559
|
+
scanned.length === 0 ||
|
|
560
|
+
events[scanned.length - 1] === scanned[scanned.length - 1];
|
|
561
|
+
|
|
562
|
+
let interruptions = resumesScan ? cache.interruptions : [];
|
|
563
|
+
for (let i = resumesScan ? scanned.length : 0; i < events.length; i++) {
|
|
564
|
+
const event = events[i]!;
|
|
565
|
+
if (isTurnInterruption(event)) interruptions = [...interruptions, event];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
cache.lastEvents = events;
|
|
569
|
+
cache.interruptions = interruptions;
|
|
570
|
+
return interruptions;
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const getInterruptedTurns = (
|
|
574
|
+
events: readonly MessageStreamEvent[] | undefined,
|
|
575
|
+
) => {
|
|
576
|
+
const interrupted = new Map<string, MessageStatus>();
|
|
577
|
+
for (const event of events ?? []) {
|
|
578
|
+
if (event.type === "turn.failed") {
|
|
579
|
+
interrupted.set(event.data.turnId, {
|
|
580
|
+
type: "incomplete",
|
|
581
|
+
reason: "error",
|
|
582
|
+
error: { code: event.data.code, message: event.data.message },
|
|
583
|
+
});
|
|
584
|
+
} else if (event.type === "turn.cancelled") {
|
|
585
|
+
interrupted.set(event.data.turnId, ASSISTANT_CANCELLED_STATUS);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return interrupted;
|
|
589
|
+
};
|
|
590
|
+
|
|
508
591
|
/**
|
|
509
592
|
* Converts the full Eve message data object into assistant-ui thread messages.
|
|
510
593
|
*/
|
|
511
594
|
export const convertEveMessages = (
|
|
512
595
|
data: EveMessageData,
|
|
513
596
|
options: ConvertEveMessagesOptions = {},
|
|
514
|
-
): ThreadMessage[] =>
|
|
515
|
-
|
|
516
|
-
|
|
597
|
+
): ThreadMessage[] => {
|
|
598
|
+
const interruptedTurns = getInterruptedTurns(options.events);
|
|
599
|
+
return data.messages.map((message, index, messages) =>
|
|
600
|
+
convertEveMessageWithInterruptions(
|
|
601
|
+
message,
|
|
602
|
+
index,
|
|
603
|
+
messages,
|
|
604
|
+
options,
|
|
605
|
+
interruptedTurns,
|
|
606
|
+
),
|
|
517
607
|
);
|
|
608
|
+
};
|
|
518
609
|
|
|
519
610
|
/**
|
|
520
611
|
* Structural subset of the `string | UserContent` message content Eve's `send`
|
|
@@ -533,6 +624,10 @@ export type EveMessageContent =
|
|
|
533
624
|
}
|
|
534
625
|
)[];
|
|
535
626
|
|
|
627
|
+
type OutboundPart = AppendMessage["content"][number] & {
|
|
628
|
+
readonly contentType?: string | undefined;
|
|
629
|
+
};
|
|
630
|
+
|
|
536
631
|
/**
|
|
537
632
|
* Converts an assistant-ui append message into the message payload accepted by
|
|
538
633
|
* Eve's `send` API.
|
|
@@ -540,9 +635,14 @@ export type EveMessageContent =
|
|
|
540
635
|
export const getEveMessageContent = (
|
|
541
636
|
message: AppendMessage,
|
|
542
637
|
): EveMessageContent => {
|
|
543
|
-
const content = [
|
|
638
|
+
const content: OutboundPart[] = [
|
|
544
639
|
...message.content,
|
|
545
|
-
...(message.attachments?.flatMap((attachment) =>
|
|
640
|
+
...(message.attachments?.flatMap((attachment) =>
|
|
641
|
+
attachment.content.map((part) => ({
|
|
642
|
+
...part,
|
|
643
|
+
contentType: attachment.contentType,
|
|
644
|
+
})),
|
|
645
|
+
) ?? []),
|
|
546
646
|
];
|
|
547
647
|
|
|
548
648
|
const parts = content.flatMap((part) => {
|
|
@@ -551,21 +651,31 @@ export const getEveMessageContent = (
|
|
|
551
651
|
case "text":
|
|
552
652
|
return { type: "text" as const, text: part.text };
|
|
553
653
|
|
|
554
|
-
case "file":
|
|
654
|
+
case "file": {
|
|
655
|
+
const mediaType = resolveFileMediaType(
|
|
656
|
+
part.data,
|
|
657
|
+
part.mimeType || part.contentType,
|
|
658
|
+
);
|
|
555
659
|
return {
|
|
556
660
|
type: "file" as const,
|
|
557
|
-
data:
|
|
558
|
-
|
|
661
|
+
data:
|
|
662
|
+
part.sourceType === "id"
|
|
663
|
+
? part.data
|
|
664
|
+
: toMediaWireUrl(part.data, mediaType),
|
|
665
|
+
mediaType,
|
|
559
666
|
...(part.filename && { filename: part.filename }),
|
|
560
667
|
};
|
|
668
|
+
}
|
|
561
669
|
|
|
562
|
-
case "image":
|
|
670
|
+
case "image": {
|
|
671
|
+
const mediaType = resolveImageMediaType(part.image, part.contentType);
|
|
563
672
|
return {
|
|
564
673
|
type: "file" as const,
|
|
565
|
-
data: part.image,
|
|
566
|
-
mediaType
|
|
674
|
+
data: toMediaWireUrl(part.image, mediaType),
|
|
675
|
+
mediaType,
|
|
567
676
|
...(part.filename && { filename: part.filename }),
|
|
568
677
|
};
|
|
678
|
+
}
|
|
569
679
|
|
|
570
680
|
case "audio": {
|
|
571
681
|
// A data URL's own media type wins over `mediaType` downstream, so the
|
package/src/testUtils.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ClientSession, type MessageStreamEvent } from "eve/client";
|
|
2
|
+
import { vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
export const createEveSessionFixture = ({
|
|
5
|
+
events = [],
|
|
6
|
+
tailIndex = events.length - 1,
|
|
7
|
+
onSend,
|
|
8
|
+
}: {
|
|
9
|
+
events?: readonly MessageStreamEvent[];
|
|
10
|
+
tailIndex?: number;
|
|
11
|
+
onSend?: () => void;
|
|
12
|
+
}) => {
|
|
13
|
+
const encoder = new TextEncoder();
|
|
14
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
15
|
+
const pending = [...events];
|
|
16
|
+
const push = (events: readonly MessageStreamEvent[]) => {
|
|
17
|
+
if (controller === undefined) {
|
|
18
|
+
pending.push(...events);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
for (const event of events) {
|
|
22
|
+
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
26
|
+
if (init?.method === "POST") {
|
|
27
|
+
onSend?.();
|
|
28
|
+
return new Response(
|
|
29
|
+
JSON.stringify({
|
|
30
|
+
sessionId: "session_resumed",
|
|
31
|
+
deliveryId: "delivery_1",
|
|
32
|
+
}),
|
|
33
|
+
{ status: 202, headers: { "content-type": "application/json" } },
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return new Response(
|
|
37
|
+
new ReadableStream<Uint8Array>({
|
|
38
|
+
start(streamController) {
|
|
39
|
+
controller = streamController;
|
|
40
|
+
push(pending.splice(0));
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
{
|
|
44
|
+
headers: {
|
|
45
|
+
"x-eve-stream-version": "26",
|
|
46
|
+
"x-eve-stream-tail-index": String(tailIndex),
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
vi.stubGlobal("fetch", fetch);
|
|
52
|
+
const session = new ClientSession(
|
|
53
|
+
{ host: "http://localhost", resolveHeaders: async () => new Headers() },
|
|
54
|
+
{ sessionId: "session_resumed", streamIndex: 0 },
|
|
55
|
+
);
|
|
56
|
+
return { fetch, push, session };
|
|
57
|
+
};
|
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
|
-
// Deliberately does not mock `eve/react`: the real `useEveAgent` resumes an
|
|
4
|
-
// offline session so the durable `meta.at` values travel through eve's own
|
|
5
|
-
// store and reducer before the adapter reads them. Supplying `session` keeps
|
|
6
|
-
// the store from constructing a network client.
|
|
7
|
-
|
|
8
3
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
9
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
10
5
|
import type { MessageStreamEvent } from "eve/client";
|
|
11
6
|
|
|
7
|
+
import { createEveSessionFixture } from "./testUtils";
|
|
12
8
|
import { useEveAgentRuntime } from "./useEveAgentRuntime";
|
|
13
9
|
|
|
14
10
|
const TURN = "turn_resumed";
|
|
@@ -61,7 +57,11 @@ const nextTurnEvents = [
|
|
|
61
57
|
{
|
|
62
58
|
type: "message.received",
|
|
63
59
|
data: { message: "follow up", sequence: 8, turnId: NEXT_TURN },
|
|
64
|
-
meta: {
|
|
60
|
+
meta: {
|
|
61
|
+
at: "2026-01-02T10:03:01.000Z",
|
|
62
|
+
id: "evt_202",
|
|
63
|
+
deliveryIds: ["delivery_1"],
|
|
64
|
+
},
|
|
65
65
|
},
|
|
66
66
|
{
|
|
67
67
|
type: "step.started",
|
|
@@ -108,23 +108,13 @@ const parkedEvents = [
|
|
|
108
108
|
const copyEvents = (events: readonly MessageStreamEvent[]) =>
|
|
109
109
|
JSON.parse(JSON.stringify(events)) as readonly MessageStreamEvent[];
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
state: { sessionId: "session_resumed", streamIndex: 0 },
|
|
113
|
-
cancel: async () => ({ status: "no_active_turn" }),
|
|
114
|
-
send: () => {
|
|
115
|
-
throw new Error("the resume test must not reach the network");
|
|
116
|
-
},
|
|
117
|
-
stream: async function* () {
|
|
118
|
-
yield* JSON.parse(
|
|
119
|
-
JSON.stringify(resumedEvents),
|
|
120
|
-
) as readonly MessageStreamEvent[];
|
|
121
|
-
},
|
|
122
|
-
} as never;
|
|
111
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
123
112
|
|
|
124
113
|
describe("useEveAgentRuntime against a resumed eve session", () => {
|
|
125
114
|
it("renders resumed history at its durable event times, not the current time", async () => {
|
|
115
|
+
const { session } = createEveSessionFixture({ events: resumedEvents });
|
|
126
116
|
const { result } = renderHook(() =>
|
|
127
|
-
useEveAgentRuntime({ resume: true, session
|
|
117
|
+
useEveAgentRuntime({ resume: true, session }),
|
|
128
118
|
);
|
|
129
119
|
|
|
130
120
|
await waitFor(() =>
|
|
@@ -137,44 +127,34 @@ describe("useEveAgentRuntime against a resumed eve session", () => {
|
|
|
137
127
|
});
|
|
138
128
|
|
|
139
129
|
it("reports the replay as loading and holds a send typed during it until the replay ends", async () => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
130
|
+
const { fetch, push, session } = createEveSessionFixture({
|
|
131
|
+
tailIndex: parkedEvents.length - 1,
|
|
132
|
+
onSend: () => setTimeout(() => push(copyEvents(nextTurnEvents)), 0),
|
|
143
133
|
});
|
|
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
134
|
|
|
158
135
|
const { result } = renderHook(() =>
|
|
159
136
|
useEveAgentRuntime({ resume: true, session }),
|
|
160
137
|
);
|
|
161
138
|
|
|
162
|
-
await waitFor(() => expect(
|
|
139
|
+
await waitFor(() => expect(fetch).toHaveBeenCalledOnce());
|
|
163
140
|
expect(result.current.thread.getState().isLoading).toBe(true);
|
|
164
141
|
|
|
165
142
|
act(() => result.current.thread.append("follow up"));
|
|
166
143
|
await act(async () => {
|
|
167
144
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
168
145
|
});
|
|
169
|
-
expect(
|
|
170
|
-
expect(stream).toHaveBeenCalledOnce();
|
|
146
|
+
expect(fetch).toHaveBeenCalledOnce();
|
|
171
147
|
|
|
172
|
-
await act(async () =>
|
|
148
|
+
await act(async () => {
|
|
149
|
+
push(copyEvents(parkedEvents));
|
|
150
|
+
});
|
|
173
151
|
|
|
174
152
|
await waitFor(() =>
|
|
175
153
|
expect(result.current.thread.getState().messages).toHaveLength(4),
|
|
176
154
|
);
|
|
177
|
-
expect(
|
|
155
|
+
expect(
|
|
156
|
+
fetch.mock.calls.filter(([, init]) => init?.method === "POST"),
|
|
157
|
+
).toHaveLength(1);
|
|
178
158
|
expect(result.current.thread.getState().isLoading).toBe(false);
|
|
179
159
|
});
|
|
180
160
|
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { createEveSessionFixture } from "./testUtils";
|
|
7
|
+
import { useEveAgentRuntime } from "./useEveAgentRuntime";
|
|
8
|
+
|
|
9
|
+
const meta = (second: number) => ({
|
|
10
|
+
at: `2026-01-02T10:00:0${second}.000Z`,
|
|
11
|
+
id: `t1-${second}`,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const interruptedTurn = (ending: Record<string, unknown>) => [
|
|
15
|
+
{ type: "turn.started", data: { sequence: 1, turnId: "t1" }, meta: meta(0) },
|
|
16
|
+
{
|
|
17
|
+
type: "message.received",
|
|
18
|
+
data: { message: "hi", sequence: 2, turnId: "t1" },
|
|
19
|
+
meta: meta(1),
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
type: "step.started",
|
|
23
|
+
data: { modelId: "m", sequence: 3, stepIndex: 0, turnId: "t1" },
|
|
24
|
+
meta: meta(2),
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
type: "message.appended",
|
|
28
|
+
data: {
|
|
29
|
+
messageDelta: "Let me th",
|
|
30
|
+
sequence: 4,
|
|
31
|
+
stepIndex: 0,
|
|
32
|
+
turnId: "t1",
|
|
33
|
+
},
|
|
34
|
+
meta: meta(3),
|
|
35
|
+
},
|
|
36
|
+
{ ...ending, meta: meta(4) },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const renderInterruptedTurn = (ending: Record<string, unknown>) => {
|
|
40
|
+
const { session } = createEveSessionFixture({});
|
|
41
|
+
return renderHook(() =>
|
|
42
|
+
useEveAgentRuntime({
|
|
43
|
+
initialEvents: interruptedTurn(ending) as never,
|
|
44
|
+
session,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
50
|
+
|
|
51
|
+
describe("useEveAgentRuntime with an interrupted turn", () => {
|
|
52
|
+
it("shows a failed turn's assistant message as an error", async () => {
|
|
53
|
+
const { result } = renderInterruptedTurn({
|
|
54
|
+
type: "turn.failed",
|
|
55
|
+
data: { code: "internal", message: "boom", sequence: 5, turnId: "t1" },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await waitFor(() =>
|
|
59
|
+
expect(result.current.thread.getState().messages.at(-1)?.status).toEqual({
|
|
60
|
+
type: "incomplete",
|
|
61
|
+
reason: "error",
|
|
62
|
+
error: { code: "internal", message: "boom" },
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("shows a cancelled turn's assistant message as cancelled", async () => {
|
|
68
|
+
const { result } = renderInterruptedTurn({
|
|
69
|
+
type: "turn.cancelled",
|
|
70
|
+
data: { sequence: 5, turnId: "t1" },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await waitFor(() =>
|
|
74
|
+
expect(result.current.thread.getState().messages.at(-1)?.status).toEqual({
|
|
75
|
+
type: "incomplete",
|
|
76
|
+
reason: "cancelled",
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
3
|
import { act, renderHook } from "@testing-library/react";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { HandleMessageStreamEvent } from "eve/client";
|
|
6
6
|
|
|
7
|
+
import { createEveSessionFixture } from "./testUtils";
|
|
7
8
|
import { useEveAgentRuntime } from "./useEveAgentRuntime";
|
|
8
9
|
|
|
9
10
|
const completedTurn = (
|
|
@@ -49,18 +50,24 @@ const completedTurn = (
|
|
|
49
50
|
const knownTurn = completedTurn("t1", "hi", "hello", 1);
|
|
50
51
|
const outOfBandTurn = completedTurn("t2", "answered elsewhere", "done", 6);
|
|
51
52
|
|
|
53
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
54
|
+
|
|
52
55
|
describe("useEveAgentRuntime against a durable eve session", () => {
|
|
53
56
|
it("surfaces a turn the server appended out of band after reloadMainThread", async () => {
|
|
54
|
-
const
|
|
55
|
-
|
|
57
|
+
const { fetch, session } = createEveSessionFixture({
|
|
58
|
+
events: [
|
|
59
|
+
...knownTurn,
|
|
60
|
+
...outOfBandTurn,
|
|
61
|
+
{
|
|
62
|
+
type: "session.waiting",
|
|
63
|
+
data: {
|
|
64
|
+
continuationToken: "session_resumed",
|
|
65
|
+
wait: "next-user-message",
|
|
66
|
+
},
|
|
67
|
+
meta: { at: "2026-01-02T10:00:05.000Z", id: "waiting" },
|
|
68
|
+
},
|
|
69
|
+
],
|
|
56
70
|
});
|
|
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
71
|
|
|
65
72
|
const { result } = renderHook(() =>
|
|
66
73
|
useEveAgentRuntime({ initialEvents: knownTurn as never, session }),
|
|
@@ -73,7 +80,10 @@ describe("useEveAgentRuntime against a durable eve session", () => {
|
|
|
73
80
|
await result.current.threads.reloadMainThread();
|
|
74
81
|
});
|
|
75
82
|
|
|
76
|
-
expect(
|
|
83
|
+
expect(fetch).toHaveBeenCalled();
|
|
84
|
+
expect(
|
|
85
|
+
fetch.mock.calls.filter(([, init]) => init?.method === "POST"),
|
|
86
|
+
).toHaveLength(0);
|
|
77
87
|
const messages = result.current.thread.getState().messages;
|
|
78
88
|
expect(messages.map((m) => m.role)).toEqual([
|
|
79
89
|
"user",
|