@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.
@@ -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
- // Eve's default reducer never terminalizes the "streaming" marker on
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 ?? "unknown/unknown",
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
- data.messages.map((message, index, messages) =>
516
- convertEveMessage(message, index, messages, options),
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) => attachment.content) ?? []),
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: part.data,
558
- mediaType: part.mimeType,
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: "image/*",
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
@@ -29,6 +29,7 @@ const compactionRequested = (at: string, turnId = TURN) =>
29
29
  modelId: "m",
30
30
  sequence: 3,
31
31
  sessionId: "s",
32
+ stepIndex: 0,
32
33
  turnId,
33
34
  usageInputTokens: null,
34
35
  },
@@ -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: { at: "2026-01-02T10:03:01.000Z", id: "evt_202" },
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
- const offlineSession = {
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: offlineSession }),
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
- let openReplay!: () => void;
141
- const replayOpened = new Promise<void>((resolve) => {
142
- openReplay = resolve;
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(stream).toHaveBeenCalledOnce());
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(send).not.toHaveBeenCalled();
170
- expect(stream).toHaveBeenCalledOnce();
146
+ expect(fetch).toHaveBeenCalledOnce();
171
147
 
172
- await act(async () => openReplay());
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(send).toHaveBeenCalledOnce();
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 stream = vi.fn(async function* () {
55
- yield* [...knownTurn, ...outOfBandTurn];
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(stream).toHaveBeenCalled();
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",