@assistant-ui/eve 0.0.14 → 0.0.16

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.
@@ -7,15 +7,20 @@ import {
7
7
  type FileMessagePart,
8
8
  type MessagePartStreamStatus,
9
9
  type MessageStatus,
10
+ type PartProviderMetadata,
10
11
  type RespondToToolApprovalOptions,
11
12
  type ThreadAssistantMessagePart,
12
13
  type ThreadMessage,
13
14
  type ThreadMessageLike,
14
15
  type ThreadUserMessagePart,
16
+ type ToolApprovalDisplay,
15
17
  type ToolApprovalOption,
16
18
  type ToolCallMessagePart,
17
19
  } from "@assistant-ui/core";
18
- import { httpUrlPattern, parseDataUrl } from "@assistant-ui/core/internal";
20
+ import {
21
+ httpUrlPattern,
22
+ resolveFilePartSource,
23
+ } from "@assistant-ui/core/internal";
19
24
  import type {
20
25
  EveAuthorizationOutcome,
21
26
  EveAuthorizationPart,
@@ -157,6 +162,44 @@ const toolApprovalOptionsFromInputRequest = (
157
162
  }));
158
163
  };
159
164
 
165
+ /**
166
+ * Eve resolves a request the moment any response for it arrives, so the answer
167
+ * shapes it accepts decide both what the renderer may offer and what
168
+ * {@link toEveInputResponse} may submit. A request with no options and no
169
+ * `display` takes a typed answer, which is why the absence of both is not the
170
+ * same as a confirmation.
171
+ */
172
+ const isDroppedOptionApproval = (inputRequest: EveMessageInputRequest) =>
173
+ inputRequest.kind === "tool-approval" && !inputRequest.options?.length;
174
+
175
+ const acceptsFreeformAnswer = (inputRequest: EveMessageInputRequest): boolean =>
176
+ inputRequest.display !== "confirmation" &&
177
+ !isDroppedOptionApproval(inputRequest) &&
178
+ (inputRequest.display === "text" ||
179
+ inputRequest.allowFreeform === true ||
180
+ !inputRequest.options?.length);
181
+
182
+ const toolApprovalDisplay = (
183
+ inputRequest: EveMessageInputRequest,
184
+ ): ToolApprovalDisplay => {
185
+ // The mapper answers this shape through its approve/cancel branch whatever
186
+ // the request declares, so the projection has to agree: presenting it as a
187
+ // question would offer an answer the mapper turns into a decision.
188
+ if (isDroppedOptionApproval(inputRequest)) return "decision";
189
+
190
+ switch (inputRequest.display) {
191
+ case "confirmation":
192
+ return "decision";
193
+ case "select":
194
+ return "select";
195
+ case "text":
196
+ return "text";
197
+ default:
198
+ if (inputRequest.kind === "tool-approval") return "decision";
199
+ return inputRequest.options?.length ? "select" : "text";
200
+ }
201
+ };
202
+
160
203
  const toApproval = (
161
204
  part: EveDynamicToolPart,
162
205
  ): ToolCallMessagePart["approval"] | undefined => {
@@ -172,12 +215,16 @@ const toApproval = (
172
215
 
173
216
  const approval = part.approval;
174
217
  if (!approval) return undefined;
175
- const options = toolApprovalOptionsFromInputRequest(
176
- part.toolMetadata?.eve?.inputRequest,
177
- );
218
+ const inputRequest = part.toolMetadata?.eve?.inputRequest;
219
+ const options = toolApprovalOptionsFromInputRequest(inputRequest);
178
220
 
179
221
  return {
180
222
  id: approval.id,
223
+ ...(inputRequest && {
224
+ prompt: inputRequest.prompt,
225
+ display: toolApprovalDisplay(inputRequest),
226
+ ...(acceptsFreeformAnswer(inputRequest) && { allowFreeform: true }),
227
+ }),
181
228
  ...(approval.approved !== undefined && { approved: approval.approved }),
182
229
  ...(approval.reason && { reason: approval.reason }),
183
230
  ...(approval.isAutomatic !== undefined && {
@@ -187,10 +234,41 @@ const toApproval = (
187
234
  };
188
235
  };
189
236
 
237
+ const toJsonSafeInputRequest = (
238
+ inputRequest: EveMessageInputRequest,
239
+ ): PartProviderMetadata[string] => ({
240
+ requestId: inputRequest.requestId,
241
+ prompt: inputRequest.prompt,
242
+ kind: inputRequest.kind,
243
+ ...(inputRequest.display !== undefined && { display: inputRequest.display }),
244
+ ...(inputRequest.allowFreeform !== undefined && {
245
+ allowFreeform: inputRequest.allowFreeform,
246
+ }),
247
+ ...(inputRequest.options && {
248
+ options: inputRequest.options.map((option) => ({
249
+ id: option.id,
250
+ label: option.label,
251
+ ...(option.description !== undefined && {
252
+ description: option.description,
253
+ }),
254
+ ...(option.style !== undefined && { style: option.style }),
255
+ })),
256
+ }),
257
+ });
258
+
259
+ const toToolProviderMetadata = (
260
+ part: EveDynamicToolPart,
261
+ ): PartProviderMetadata | undefined => {
262
+ const inputRequest = part.toolMetadata?.eve?.inputRequest;
263
+ if (!inputRequest) return undefined;
264
+ return { eve: { inputRequest: toJsonSafeInputRequest(inputRequest) } };
265
+ };
266
+
190
267
  const convertDynamicToolPart = (
191
268
  part: EveDynamicToolPart,
192
269
  ): ToolCallMessagePart => {
193
270
  const approval = toApproval(part);
271
+ const providerMetadata = toToolProviderMetadata(part);
194
272
  const toolCall: ToolCallMessagePart = {
195
273
  type: "tool-call",
196
274
  toolCallId: part.toolCallId,
@@ -198,6 +276,7 @@ const convertDynamicToolPart = (
198
276
  args: toJsonObject(part.input),
199
277
  argsText: stringifyArgs(part.input),
200
278
  ...(approval && { approval }),
279
+ ...(providerMetadata && { providerMetadata }),
201
280
  };
202
281
 
203
282
  switch (part.state) {
@@ -493,11 +572,13 @@ export const getEveMessageContent = (
493
572
  // envelope is rebuilt from the typed format rather than forwarded.
494
573
  const mediaType = `audio/${part.audio.format}`;
495
574
  const data = part.audio.data;
575
+ const source = resolveFilePartSource({ data, mimeType: mediaType });
496
576
  return {
497
577
  type: "file" as const,
498
- data: httpUrlPattern.test(data)
499
- ? data
500
- : `data:${mediaType};base64,${parseDataUrl(data)?.data ?? data}`,
578
+ data:
579
+ source.kind === "url"
580
+ ? source.url
581
+ : `data:${mediaType};base64,${source.data}`,
501
582
  mediaType,
502
583
  };
503
584
  }
@@ -525,13 +606,107 @@ export const getEveMessageContent = (
525
606
  return parts;
526
607
  };
527
608
 
609
+ /**
610
+ * Finds the pending input request answered by the given approval id, so the
611
+ * response mapping can honor the request's display mode.
612
+ */
613
+ export const findEveInputRequest = (
614
+ data: EveMessageData,
615
+ approvalId: string,
616
+ ): EveMessageInputRequest | undefined => {
617
+ for (const message of data.messages) {
618
+ for (const part of message.parts) {
619
+ if (part.type === "dynamic-tool" && part.approval?.id === approvalId) {
620
+ return part.toolMetadata?.eve?.inputRequest;
621
+ }
622
+ }
623
+ }
624
+ return undefined;
625
+ };
626
+
528
627
  /**
529
628
  * Converts an assistant-ui tool approval response into an Eve input response.
629
+ *
630
+ * When `inputRequest` is known, every returned response carries either an
631
+ * option the request declares or a free-form answer. A literal option match
632
+ * wins, then the `"approve"` / `"cancel"` option the response's boolean decision
633
+ * names, then the response's `text` (or its `reason`, for a caller that
634
+ * predates the first-class answer field) when the request takes a free-form
635
+ * answer (`display: "text"`, `allowFreeform`, or no options at all, and never
636
+ * `display: "confirmation"`) and the response is not a refusal.
637
+ * A one-argument call, or a part with no request, still forwards a caller
638
+ * `optionId` as it always has, including ids the request never declared.
639
+ *
640
+ * A response the known request cannot record as the answer it asked for throws
641
+ * instead of being submitted, because eve resolves a request the moment any
642
+ * response for it arrives, and an empty one is recorded as an answer with no
643
+ * content. Not sending leaves the request pending, so the caller can retry or
644
+ * the user can answer it as an ordinary message.
645
+ *
646
+ * With no request to map against — a one-argument call, or a part carrying no
647
+ * `toolMetadata.eve.inputRequest` — the response maps as it always has, to the
648
+ * literal `"approve"` / `"cancel"` option every eve approval declares. Guessing
649
+ * a display mode is what this mapper stopped doing; an unknown request is not
650
+ * a guess about one.
530
651
  */
531
652
  export const toEveInputResponse = (
532
653
  response: RespondToToolApprovalOptions,
533
- ): InputResponse => ({
534
- requestId: response.approvalId,
535
- optionId: response.optionId ?? (response.approved ? "approve" : "cancel"),
536
- ...(response.reason && { text: response.reason }),
537
- });
654
+ inputRequest?: EveMessageInputRequest,
655
+ ): InputResponse => {
656
+ const requestId = response.approvalId;
657
+ const options = inputRequest?.options;
658
+ const text = response.text ?? response.reason;
659
+
660
+ if (response.optionId !== undefined) {
661
+ if (
662
+ !inputRequest ||
663
+ options?.some((option) => option.id === response.optionId)
664
+ ) {
665
+ return {
666
+ requestId,
667
+ optionId: response.optionId,
668
+ ...(text && { text }),
669
+ };
670
+ }
671
+ throw new Error(
672
+ `Eve input request "${requestId}" has no option with id "${response.optionId}"; respond with one of: ${
673
+ options?.length
674
+ ? options.map((option) => option.id).join(", ")
675
+ : "(the request carries no options)"
676
+ }`,
677
+ );
678
+ }
679
+
680
+ const decisionOptionId = response.approved ? "approve" : "cancel";
681
+ // Eve's harness always declares `approve`/`cancel` on a `tool-approval`
682
+ // request, so honoring the kind when the options were dropped keeps a
683
+ // non-spec approval payload answerable rather than stuck. A tool-approval
684
+ // that declares its own options still requires one of them.
685
+ if (
686
+ !inputRequest ||
687
+ (inputRequest.kind === "tool-approval" && !options?.length) ||
688
+ options?.some((option) => option.id === decisionOptionId)
689
+ ) {
690
+ return { requestId, optionId: decisionOptionId, ...(text && { text }) };
691
+ }
692
+
693
+ // Eve recognises an approval by its literal two-option `approve`/`cancel`
694
+ // shape, so past this point the request is a question and the boolean
695
+ // carries no decision eve would act on.
696
+ const acceptsText = acceptsFreeformAnswer(inputRequest);
697
+ if (acceptsText && text && response.approved !== false) {
698
+ return { requestId, text };
699
+ }
700
+
701
+ throw new Error(
702
+ `Eve input request "${requestId}" (${inputRequest.prompt}) was not answered by this response; ${
703
+ options?.length
704
+ ? `respond with one of: ${options.map((option) => option.id).join(", ")}`
705
+ : !acceptsText
706
+ ? "the request declares no options to respond with"
707
+ : response.approved === false
708
+ ? "a refusal carries no answer for a free-form request; resend with `approved` unset or true and the answer in the response text"
709
+ : "pass the answer as the response text, because the request takes a free-form answer"
710
+ }`,
711
+ );
712
+ };
@@ -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
+ });