@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.
- package/dist/convertEveMessages.d.ts +30 -3
- package/dist/convertEveMessages.d.ts.map +1 -1
- package/dist/convertEveMessages.js +109 -10
- package/dist/convertEveMessages.js.map +1 -1
- package/dist/useEveAgentRuntime.d.ts.map +1 -1
- package/dist/useEveAgentRuntime.js +30 -16
- package/dist/useEveAgentRuntime.js.map +1 -1
- package/package.json +7 -7
- package/src/convertEveMessages.test.ts +576 -4
- package/src/convertEveMessages.ts +187 -12
- package/src/useEveAgentRuntime.refetch.test.tsx +89 -0
- package/src/useEveAgentRuntime.test.tsx +574 -2
- package/src/useEveAgentRuntime.ts +80 -21
|
@@ -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 {
|
|
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
|
|
176
|
-
|
|
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:
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
+
});
|