@assistant-ui/eve 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,10 @@
1
1
  import {
2
2
  fromThreadMessageLike,
3
+ toAssistantError,
3
4
  type AppendMessage,
5
+ type CompleteAttachment,
6
+ type DataMessagePart,
7
+ type FileMessagePart,
4
8
  type MessageStatus,
5
9
  type RespondToToolApprovalOptions,
6
10
  type ThreadAssistantMessagePart,
@@ -12,6 +16,8 @@ import {
12
16
  } from "@assistant-ui/core";
13
17
  import { httpUrlPattern, parseDataUrl } from "@assistant-ui/core/internal";
14
18
  import type {
19
+ EveAuthorizationOutcome,
20
+ EveAuthorizationPart,
15
21
  EveDynamicToolPart,
16
22
  EveMessage,
17
23
  EveMessageData,
@@ -29,6 +35,11 @@ const ASSISTANT_RUNNING_STATUS = {
29
35
  type: "running",
30
36
  } satisfies MessageStatus;
31
37
 
38
+ const ASSISTANT_CANCELLED_STATUS = {
39
+ type: "incomplete",
40
+ reason: "cancelled",
41
+ } satisfies MessageStatus;
42
+
32
43
  const USER_FALLBACK_STATUS = {
33
44
  type: "complete",
34
45
  reason: "unknown",
@@ -37,9 +48,15 @@ const USER_FALLBACK_STATUS = {
37
48
  export type ConvertEveMessagesOptions = {
38
49
  /**
39
50
  * Marks the last assistant message as running while Eve is submitting or
40
- * 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.
41
54
  */
42
55
  readonly isRunning?: boolean | undefined;
56
+ /**
57
+ * The Eve session error, mapped onto the assistant message it interrupted.
58
+ */
59
+ readonly error?: unknown;
43
60
  readonly getCreatedAt?: ((message: EveMessage) => Date) | undefined;
44
61
  };
45
62
 
@@ -68,26 +85,55 @@ const toMessageStatus = (
68
85
  ): MessageStatus => {
69
86
  if (message.role !== "assistant") return USER_FALLBACK_STATUS;
70
87
 
88
+ const isLast = index === messages.length - 1;
71
89
  const hasPendingApproval = message.parts.some(
72
90
  (part) =>
73
91
  part.type === "dynamic-tool" && part.state === "approval-requested",
74
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
+ );
75
101
 
76
102
  if (hasPendingApproval) {
77
103
  return { type: "requires-action", reason: "tool-calls" };
78
104
  }
79
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
+
80
112
  if (message.metadata?.status === "failed") {
81
113
  return { type: "incomplete", reason: "error" };
82
114
  }
83
115
 
84
- if (
85
- message.metadata?.status === "streaming" ||
86
- (options.isRunning === true && index === messages.length - 1)
87
- ) {
116
+ if (isLast && options.isRunning === true) {
88
117
  return ASSISTANT_RUNNING_STATUS;
89
118
  }
90
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
+
91
137
  return ASSISTANT_COMPLETE_STATUS;
92
138
  };
93
139
 
@@ -176,6 +222,71 @@ const convertDynamicToolPart = (
176
222
  }
177
223
  };
178
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
+
179
290
  const convertAssistantPart = (
180
291
  part: EveMessagePart,
181
292
  ): ThreadAssistantMessagePart | null => {
@@ -190,6 +301,12 @@ const convertAssistantPart = (
190
301
  case "dynamic-tool":
191
302
  return convertDynamicToolPart(part);
192
303
 
304
+ case "authorization":
305
+ return convertAuthorizationPart(part);
306
+
307
+ case "file":
308
+ return convertFilePart(part);
309
+
193
310
  default:
194
311
  return null;
195
312
  }
@@ -202,6 +319,9 @@ const convertUserPart = (
202
319
  case "text":
203
320
  return { type: "text", text: part.text };
204
321
 
322
+ case "file":
323
+ return convertFilePart(part);
324
+
205
325
  default:
206
326
  return null;
207
327
  }
@@ -214,6 +334,35 @@ const toUserContent = (
214
334
  return content.length > 0 ? content : [{ type: "text", text: "" }];
215
335
  };
216
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
+
217
366
  /**
218
367
  * Converts a single Eve message into an assistant-ui thread message.
219
368
  */
@@ -238,7 +387,7 @@ export const convertEveMessage = (
238
387
  id: message.id,
239
388
  createdAt,
240
389
  content: toUserContent(message.parts),
241
- attachments: [],
390
+ attachments: toUserAttachments(message.parts),
242
391
  metadata,
243
392
  }
244
393
  : {
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
+ });