@assistant-ui/eve 0.0.14 → 0.0.15
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 +29 -3
- package/dist/convertEveMessages.d.ts.map +1 -1
- package/dist/convertEveMessages.js +81 -9
- package/dist/convertEveMessages.js.map +1 -1
- package/dist/useEveAgentRuntime.d.ts.map +1 -1
- package/dist/useEveAgentRuntime.js +26 -14
- package/dist/useEveAgentRuntime.js.map +1 -1
- package/package.json +6 -6
- package/src/convertEveMessages.test.ts +400 -3
- package/src/convertEveMessages.ts +144 -9
- package/src/useEveAgentRuntime.refetch.test.tsx +89 -0
- package/src/useEveAgentRuntime.test.tsx +439 -0
- package/src/useEveAgentRuntime.ts +66 -19
|
@@ -1,13 +1,38 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import type { EveMessageData } from "eve/react";
|
|
2
|
+
import type { EveMessageData, EveMessageInputRequest } from "eve/react";
|
|
3
3
|
import { defaultMessageReducer, type EveAgentReducerEvent } from "eve/client";
|
|
4
4
|
import {
|
|
5
5
|
convertEveMessages,
|
|
6
|
+
findEveInputRequest,
|
|
6
7
|
getEveMessageContent,
|
|
7
8
|
toEveInputResponse,
|
|
8
9
|
} from "./convertEveMessages";
|
|
9
10
|
import type { AppendMessage } from "@assistant-ui/core";
|
|
10
11
|
|
|
12
|
+
const withApprovalPart = (eve?: {
|
|
13
|
+
kind: "tool-call";
|
|
14
|
+
name: string;
|
|
15
|
+
inputRequest?: EveMessageInputRequest;
|
|
16
|
+
}): EveMessageData => ({
|
|
17
|
+
messages: [
|
|
18
|
+
{
|
|
19
|
+
id: "a1",
|
|
20
|
+
role: "assistant",
|
|
21
|
+
parts: [
|
|
22
|
+
{
|
|
23
|
+
type: "dynamic-tool",
|
|
24
|
+
state: "approval-requested",
|
|
25
|
+
toolCallId: "call_1",
|
|
26
|
+
toolName: "send_email",
|
|
27
|
+
input: {},
|
|
28
|
+
approval: { id: "req_1" },
|
|
29
|
+
...(eve && { toolMetadata: { eve } }),
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
});
|
|
35
|
+
|
|
11
36
|
const eventMeta = (sequence: number) => ({
|
|
12
37
|
at: "2026-01-01T00:00:00.000Z",
|
|
13
38
|
id: `evt_${sequence}`,
|
|
@@ -182,6 +207,62 @@ describe("convertEveMessages", () => {
|
|
|
182
207
|
});
|
|
183
208
|
});
|
|
184
209
|
|
|
210
|
+
it.each([
|
|
211
|
+
[
|
|
212
|
+
"the full input request",
|
|
213
|
+
{
|
|
214
|
+
requestId: "req_1",
|
|
215
|
+
prompt: "Which environment?",
|
|
216
|
+
kind: "question",
|
|
217
|
+
display: "select",
|
|
218
|
+
allowFreeform: true,
|
|
219
|
+
options: [
|
|
220
|
+
{ id: "staging", label: "Staging", description: "Safe" },
|
|
221
|
+
{ id: "production", label: "Production", style: "danger" },
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
[
|
|
226
|
+
"only the fields the request defines",
|
|
227
|
+
{
|
|
228
|
+
requestId: "req_1",
|
|
229
|
+
prompt: "What should the subject line be?",
|
|
230
|
+
kind: "question",
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
] satisfies [string, EveMessageInputRequest][])(
|
|
234
|
+
"projects %s onto providerMetadata.eve",
|
|
235
|
+
(_label, inputRequest) => {
|
|
236
|
+
const [message] = convertEveMessages(
|
|
237
|
+
withApprovalPart({
|
|
238
|
+
kind: "tool-call",
|
|
239
|
+
name: "send_email",
|
|
240
|
+
inputRequest,
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
const part = message!.content[0];
|
|
244
|
+
|
|
245
|
+
expect(part).toMatchObject({ type: "tool-call" });
|
|
246
|
+
expect((part as { providerMetadata?: unknown }).providerMetadata).toEqual(
|
|
247
|
+
{
|
|
248
|
+
eve: { inputRequest },
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
it.each([
|
|
255
|
+
[
|
|
256
|
+
"eve metadata without an input request",
|
|
257
|
+
{ kind: "tool-call" as const, name: "send_email" },
|
|
258
|
+
],
|
|
259
|
+
["no eve metadata at all", undefined],
|
|
260
|
+
])("omits providerMetadata for a tool part with %s", (_label, eve) => {
|
|
261
|
+
const [message] = convertEveMessages(withApprovalPart(eve));
|
|
262
|
+
|
|
263
|
+
expect(message!.content[0]).not.toHaveProperty("providerMetadata");
|
|
264
|
+
});
|
|
265
|
+
|
|
185
266
|
it("handles denied tool parts without an approval reason", () => {
|
|
186
267
|
const data = {
|
|
187
268
|
messages: [
|
|
@@ -1238,6 +1319,58 @@ describe("convertEveMessages", () => {
|
|
|
1238
1319
|
).toEqual({ type: "requires-action", reason: "interrupt" });
|
|
1239
1320
|
});
|
|
1240
1321
|
|
|
1322
|
+
it("projects the input request onto the approval part it gates", () => {
|
|
1323
|
+
const events: readonly EveAgentReducerEvent[] = [
|
|
1324
|
+
...midStreamEvents,
|
|
1325
|
+
{
|
|
1326
|
+
type: "input.requested",
|
|
1327
|
+
data: {
|
|
1328
|
+
turnId: "turn_1",
|
|
1329
|
+
stepIndex: 0,
|
|
1330
|
+
sequence: 3,
|
|
1331
|
+
requests: [
|
|
1332
|
+
{
|
|
1333
|
+
requestId: "req_1",
|
|
1334
|
+
prompt: "What should the subject line be?",
|
|
1335
|
+
kind: "question",
|
|
1336
|
+
display: "text",
|
|
1337
|
+
action: {
|
|
1338
|
+
kind: "tool-call",
|
|
1339
|
+
callId: "call_1",
|
|
1340
|
+
toolName: "ask_question",
|
|
1341
|
+
input: {},
|
|
1342
|
+
},
|
|
1343
|
+
},
|
|
1344
|
+
],
|
|
1345
|
+
},
|
|
1346
|
+
meta: eventMeta(3),
|
|
1347
|
+
},
|
|
1348
|
+
];
|
|
1349
|
+
|
|
1350
|
+
// Rehydration replays the *stored* event log, which has been through
|
|
1351
|
+
// the wire and back, so the reloaded case is the serialized payload
|
|
1352
|
+
// rather than the same objects a second time.
|
|
1353
|
+
const rehydrated = JSON.parse(
|
|
1354
|
+
JSON.stringify(events),
|
|
1355
|
+
) as readonly EveAgentReducerEvent[];
|
|
1356
|
+
|
|
1357
|
+
for (const state of [replay(events), replay(rehydrated)]) {
|
|
1358
|
+
const part = state.messages
|
|
1359
|
+
.find((message) => message.role === "assistant")
|
|
1360
|
+
?.parts.find((candidate) => candidate.type === "dynamic-tool");
|
|
1361
|
+
|
|
1362
|
+
expect(part).toMatchObject({
|
|
1363
|
+
state: "approval-requested",
|
|
1364
|
+
approval: { id: "req_1" },
|
|
1365
|
+
});
|
|
1366
|
+
expect(findEveInputRequest(state, "req_1")).toMatchObject({
|
|
1367
|
+
requestId: "req_1",
|
|
1368
|
+
prompt: "What should the subject line be?",
|
|
1369
|
+
display: "text",
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1241
1374
|
it("leaves a reasoning part unsettled when a tool call follows it", () => {
|
|
1242
1375
|
const state = replay([
|
|
1243
1376
|
...midStreamEvents.slice(0, 3),
|
|
@@ -1713,18 +1846,282 @@ describe("getEveMessageContent", () => {
|
|
|
1713
1846
|
});
|
|
1714
1847
|
});
|
|
1715
1848
|
|
|
1849
|
+
const approveCancel = [
|
|
1850
|
+
{ id: "approve", label: "Approve" },
|
|
1851
|
+
{ id: "cancel", label: "Cancel" },
|
|
1852
|
+
];
|
|
1853
|
+
const environments = [
|
|
1854
|
+
{ id: "staging", label: "Staging" },
|
|
1855
|
+
{ id: "production", label: "Production" },
|
|
1856
|
+
];
|
|
1857
|
+
|
|
1858
|
+
const withInputRequest = (
|
|
1859
|
+
overrides: Partial<EveMessageInputRequest> = {},
|
|
1860
|
+
): EveMessageInputRequest => ({
|
|
1861
|
+
requestId: "req_1",
|
|
1862
|
+
prompt: "Send the email?",
|
|
1863
|
+
kind: "question",
|
|
1864
|
+
...overrides,
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1716
1867
|
describe("toEveInputResponse", () => {
|
|
1717
1868
|
it("maps assistant-ui approval responses to eve input responses", () => {
|
|
1869
|
+
expect(
|
|
1870
|
+
toEveInputResponse(
|
|
1871
|
+
{
|
|
1872
|
+
approvalId: "req_1",
|
|
1873
|
+
approved: false,
|
|
1874
|
+
reason: "Not yet",
|
|
1875
|
+
},
|
|
1876
|
+
withInputRequest({ display: "confirmation", options: approveCancel }),
|
|
1877
|
+
),
|
|
1878
|
+
).toEqual({
|
|
1879
|
+
requestId: "req_1",
|
|
1880
|
+
optionId: "cancel",
|
|
1881
|
+
text: "Not yet",
|
|
1882
|
+
});
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
it("keeps the shipped one-argument mapping when no request is available", () => {
|
|
1886
|
+
expect(toEveInputResponse({ approvalId: "req_1", approved: true })).toEqual(
|
|
1887
|
+
{
|
|
1888
|
+
requestId: "req_1",
|
|
1889
|
+
optionId: "approve",
|
|
1890
|
+
},
|
|
1891
|
+
);
|
|
1892
|
+
|
|
1718
1893
|
expect(
|
|
1719
1894
|
toEveInputResponse({
|
|
1720
1895
|
approvalId: "req_1",
|
|
1721
1896
|
approved: false,
|
|
1722
1897
|
reason: "Not yet",
|
|
1723
1898
|
}),
|
|
1899
|
+
).toEqual({ requestId: "req_1", optionId: "cancel", text: "Not yet" });
|
|
1900
|
+
|
|
1901
|
+
expect(
|
|
1902
|
+
toEveInputResponse({
|
|
1903
|
+
approvalId: "req_1",
|
|
1904
|
+
approved: true,
|
|
1905
|
+
optionId: "staging",
|
|
1906
|
+
}),
|
|
1907
|
+
).toEqual({ requestId: "req_1", optionId: "staging" });
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
it("keeps the confirmation mapping when the request carries approve/cancel options", () => {
|
|
1911
|
+
expect(
|
|
1912
|
+
toEveInputResponse(
|
|
1913
|
+
{ approvalId: "req_1", approved: true },
|
|
1914
|
+
withInputRequest({ display: "confirmation", options: approveCancel }),
|
|
1915
|
+
),
|
|
1916
|
+
).toEqual({ requestId: "req_1", optionId: "approve" });
|
|
1917
|
+
});
|
|
1918
|
+
|
|
1919
|
+
it("maps a select response to the chosen option id", () => {
|
|
1920
|
+
expect(
|
|
1921
|
+
toEveInputResponse(
|
|
1922
|
+
{ approvalId: "req_1", approved: true, optionId: "staging" },
|
|
1923
|
+
withInputRequest({ display: "select", options: environments }),
|
|
1924
|
+
),
|
|
1925
|
+
).toEqual({ requestId: "req_1", optionId: "staging" });
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
it.each([
|
|
1929
|
+
["a text display", { display: "text", allowFreeform: true }],
|
|
1930
|
+
["allowFreeform and no options", { allowFreeform: true }],
|
|
1931
|
+
["neither a display nor allowFreeform", {}],
|
|
1932
|
+
[
|
|
1933
|
+
"a select display that allows freeform",
|
|
1934
|
+
{ display: "select", allowFreeform: true, options: environments },
|
|
1935
|
+
],
|
|
1936
|
+
] satisfies [string, Partial<EveMessageInputRequest>][])(
|
|
1937
|
+
"answers a request with %s as free-form text, not a fabricated option id",
|
|
1938
|
+
(_label, overrides) => {
|
|
1939
|
+
const response = toEveInputResponse(
|
|
1940
|
+
{ approvalId: "req_1", approved: true, reason: "Quarterly results" },
|
|
1941
|
+
withInputRequest(overrides),
|
|
1942
|
+
);
|
|
1943
|
+
|
|
1944
|
+
expect(response).toEqual({
|
|
1945
|
+
requestId: "req_1",
|
|
1946
|
+
text: "Quarterly results",
|
|
1947
|
+
});
|
|
1948
|
+
expect(response).not.toHaveProperty("optionId");
|
|
1949
|
+
},
|
|
1950
|
+
);
|
|
1951
|
+
|
|
1952
|
+
it("keeps a denial off the free-form path, which carries no answer", () => {
|
|
1953
|
+
expect(() =>
|
|
1954
|
+
toEveInputResponse(
|
|
1955
|
+
{ approvalId: "req_1", approved: false, reason: "not this one" },
|
|
1956
|
+
withInputRequest(),
|
|
1957
|
+
),
|
|
1958
|
+
).toThrow(/a refusal carries no answer for a free-form request/);
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
it("never fabricates approve for a text-display request without an answer", () => {
|
|
1962
|
+
expect(() =>
|
|
1963
|
+
toEveInputResponse(
|
|
1964
|
+
{ approvalId: "req_1", approved: true },
|
|
1965
|
+
withInputRequest({ display: "text" }),
|
|
1966
|
+
),
|
|
1967
|
+
).toThrow(/pass the answer as the response reason/);
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
it("never fabricates approve for a select request without a chosen option", () => {
|
|
1971
|
+
expect(() =>
|
|
1972
|
+
toEveInputResponse(
|
|
1973
|
+
{ approvalId: "req_1", approved: true },
|
|
1974
|
+
withInputRequest({ display: "select", options: environments }),
|
|
1975
|
+
),
|
|
1976
|
+
).toThrow(/respond with one of: staging, production/);
|
|
1977
|
+
});
|
|
1978
|
+
|
|
1979
|
+
it("throws when the response names an option the request does not carry", () => {
|
|
1980
|
+
expect(() =>
|
|
1981
|
+
toEveInputResponse(
|
|
1982
|
+
{ approvalId: "req_1", approved: false, optionId: "sandbox" },
|
|
1983
|
+
withInputRequest({ display: "select", options: environments }),
|
|
1984
|
+
),
|
|
1985
|
+
).toThrow(/no option with id "sandbox".*staging, production/s);
|
|
1986
|
+
|
|
1987
|
+
// A named option the request does not carry outranks the decision, even
|
|
1988
|
+
// when the decision names an option the request does carry: substituting
|
|
1989
|
+
// the literal deny here would discard the choice the caller made.
|
|
1990
|
+
expect(() =>
|
|
1991
|
+
toEveInputResponse(
|
|
1992
|
+
{ approvalId: "req_1", approved: false, optionId: "schedule-later" },
|
|
1993
|
+
withInputRequest({ display: "confirmation", options: approveCancel }),
|
|
1994
|
+
),
|
|
1995
|
+
).toThrow(/no option with id "schedule-later"/);
|
|
1996
|
+
});
|
|
1997
|
+
|
|
1998
|
+
it("prefers a literal approve option over the free-form path on a text-display request", () => {
|
|
1999
|
+
const inputRequest = withInputRequest({
|
|
2000
|
+
display: "text",
|
|
2001
|
+
options: approveCancel,
|
|
2002
|
+
});
|
|
2003
|
+
|
|
2004
|
+
expect(
|
|
2005
|
+
toEveInputResponse({ approvalId: "req_1", approved: true }, inputRequest),
|
|
2006
|
+
).toEqual({ requestId: "req_1", optionId: "approve" });
|
|
2007
|
+
|
|
2008
|
+
expect(
|
|
2009
|
+
toEveInputResponse(
|
|
2010
|
+
{ approvalId: "req_1", approved: true, reason: "Quarterly results" },
|
|
2011
|
+
inputRequest,
|
|
2012
|
+
),
|
|
1724
2013
|
).toEqual({
|
|
1725
2014
|
requestId: "req_1",
|
|
1726
|
-
optionId: "
|
|
1727
|
-
text: "
|
|
2015
|
+
optionId: "approve",
|
|
2016
|
+
text: "Quarterly results",
|
|
2017
|
+
});
|
|
2018
|
+
});
|
|
2019
|
+
|
|
2020
|
+
it("answers a tool-approval by kind even when its options are missing", () => {
|
|
2021
|
+
const inputRequest = withInputRequest({
|
|
2022
|
+
kind: "tool-approval",
|
|
2023
|
+
display: "confirmation",
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
expect(
|
|
2027
|
+
toEveInputResponse({ approvalId: "req_1", approved: true }, inputRequest),
|
|
2028
|
+
).toEqual({ requestId: "req_1", optionId: "approve" });
|
|
2029
|
+
|
|
2030
|
+
expect(
|
|
2031
|
+
toEveInputResponse(
|
|
2032
|
+
{ approvalId: "req_1", approved: false, reason: "not now" },
|
|
2033
|
+
inputRequest,
|
|
2034
|
+
),
|
|
2035
|
+
).toEqual({ requestId: "req_1", optionId: "cancel", text: "not now" });
|
|
2036
|
+
});
|
|
2037
|
+
|
|
2038
|
+
it("still requires a declared option when a tool-approval carries its own", () => {
|
|
2039
|
+
const inputRequest = withInputRequest({
|
|
2040
|
+
kind: "tool-approval",
|
|
2041
|
+
display: "confirmation",
|
|
2042
|
+
options: [{ id: "schedule-later", label: "Schedule later" }],
|
|
2043
|
+
});
|
|
2044
|
+
|
|
2045
|
+
expect(() =>
|
|
2046
|
+
toEveInputResponse({ approvalId: "req_1", approved: true }, inputRequest),
|
|
2047
|
+
).toThrow(/respond with one of: schedule-later/);
|
|
2048
|
+
|
|
2049
|
+
expect(
|
|
2050
|
+
toEveInputResponse(
|
|
2051
|
+
{ approvalId: "req_1", approved: true, optionId: "schedule-later" },
|
|
2052
|
+
inputRequest,
|
|
2053
|
+
),
|
|
2054
|
+
).toEqual({ requestId: "req_1", optionId: "schedule-later" });
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
it("never answers an optionless confirmation as free-form text", () => {
|
|
2058
|
+
const inputRequest = withInputRequest({ display: "confirmation" });
|
|
2059
|
+
|
|
2060
|
+
for (const response of [
|
|
2061
|
+
{ approvalId: "req_1", approved: true },
|
|
2062
|
+
{ approvalId: "req_1", approved: false, reason: "not now" },
|
|
2063
|
+
{ approvalId: "req_1", approved: true, reason: "go ahead" },
|
|
2064
|
+
]) {
|
|
2065
|
+
expect(() => toEveInputResponse(response, inputRequest)).toThrow(
|
|
2066
|
+
/declares no options to respond with/,
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
2069
|
+
});
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
describe("findEveInputRequest", () => {
|
|
2073
|
+
const data = {
|
|
2074
|
+
messages: [
|
|
2075
|
+
{
|
|
2076
|
+
id: "a1",
|
|
2077
|
+
role: "assistant",
|
|
2078
|
+
parts: [
|
|
2079
|
+
{
|
|
2080
|
+
type: "dynamic-tool",
|
|
2081
|
+
state: "approval-requested",
|
|
2082
|
+
toolCallId: "call_1",
|
|
2083
|
+
toolName: "ask_question",
|
|
2084
|
+
input: {},
|
|
2085
|
+
approval: { id: "req_1" },
|
|
2086
|
+
toolMetadata: {
|
|
2087
|
+
eve: {
|
|
2088
|
+
kind: "tool-call",
|
|
2089
|
+
name: "ask_question",
|
|
2090
|
+
inputRequest: {
|
|
2091
|
+
requestId: "req_1",
|
|
2092
|
+
prompt: "What should the subject line be?",
|
|
2093
|
+
kind: "question",
|
|
2094
|
+
display: "text",
|
|
2095
|
+
allowFreeform: true,
|
|
2096
|
+
},
|
|
2097
|
+
},
|
|
2098
|
+
},
|
|
2099
|
+
},
|
|
2100
|
+
],
|
|
2101
|
+
},
|
|
2102
|
+
],
|
|
2103
|
+
} satisfies EveMessageData;
|
|
2104
|
+
|
|
2105
|
+
it("finds the input request by approval id", () => {
|
|
2106
|
+
expect(findEveInputRequest(data, "req_1")).toMatchObject({
|
|
2107
|
+
requestId: "req_1",
|
|
2108
|
+
display: "text",
|
|
1728
2109
|
});
|
|
1729
2110
|
});
|
|
2111
|
+
|
|
2112
|
+
it("returns undefined for unknown approval ids", () => {
|
|
2113
|
+
expect(findEveInputRequest(data, "req_404")).toBeUndefined();
|
|
2114
|
+
});
|
|
2115
|
+
|
|
2116
|
+
it.each([
|
|
2117
|
+
["carries no input request", { eve: { kind: "tool-call", name: "ask" } }],
|
|
2118
|
+
["carries no eve tool metadata at all", undefined],
|
|
2119
|
+
])("returns undefined when the matching part %s", (_label, toolMetadata) => {
|
|
2120
|
+
const part = { ...data.messages[0]!.parts[0]!, toolMetadata };
|
|
2121
|
+
const bare = { messages: [{ ...data.messages[0]!, parts: [part] }] };
|
|
2122
|
+
|
|
2123
|
+
expect(
|
|
2124
|
+
findEveInputRequest(bare as EveMessageData, "req_1"),
|
|
2125
|
+
).toBeUndefined();
|
|
2126
|
+
});
|
|
1730
2127
|
});
|
|
@@ -7,6 +7,7 @@ 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,
|
|
@@ -15,7 +16,10 @@ import {
|
|
|
15
16
|
type ToolApprovalOption,
|
|
16
17
|
type ToolCallMessagePart,
|
|
17
18
|
} from "@assistant-ui/core";
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
httpUrlPattern,
|
|
21
|
+
resolveFilePartSource,
|
|
22
|
+
} from "@assistant-ui/core/internal";
|
|
19
23
|
import type {
|
|
20
24
|
EveAuthorizationOutcome,
|
|
21
25
|
EveAuthorizationPart,
|
|
@@ -187,10 +191,41 @@ const toApproval = (
|
|
|
187
191
|
};
|
|
188
192
|
};
|
|
189
193
|
|
|
194
|
+
const toJsonSafeInputRequest = (
|
|
195
|
+
inputRequest: EveMessageInputRequest,
|
|
196
|
+
): PartProviderMetadata[string] => ({
|
|
197
|
+
requestId: inputRequest.requestId,
|
|
198
|
+
prompt: inputRequest.prompt,
|
|
199
|
+
kind: inputRequest.kind,
|
|
200
|
+
...(inputRequest.display !== undefined && { display: inputRequest.display }),
|
|
201
|
+
...(inputRequest.allowFreeform !== undefined && {
|
|
202
|
+
allowFreeform: inputRequest.allowFreeform,
|
|
203
|
+
}),
|
|
204
|
+
...(inputRequest.options && {
|
|
205
|
+
options: inputRequest.options.map((option) => ({
|
|
206
|
+
id: option.id,
|
|
207
|
+
label: option.label,
|
|
208
|
+
...(option.description !== undefined && {
|
|
209
|
+
description: option.description,
|
|
210
|
+
}),
|
|
211
|
+
...(option.style !== undefined && { style: option.style }),
|
|
212
|
+
})),
|
|
213
|
+
}),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const toToolProviderMetadata = (
|
|
217
|
+
part: EveDynamicToolPart,
|
|
218
|
+
): PartProviderMetadata | undefined => {
|
|
219
|
+
const inputRequest = part.toolMetadata?.eve?.inputRequest;
|
|
220
|
+
if (!inputRequest) return undefined;
|
|
221
|
+
return { eve: { inputRequest: toJsonSafeInputRequest(inputRequest) } };
|
|
222
|
+
};
|
|
223
|
+
|
|
190
224
|
const convertDynamicToolPart = (
|
|
191
225
|
part: EveDynamicToolPart,
|
|
192
226
|
): ToolCallMessagePart => {
|
|
193
227
|
const approval = toApproval(part);
|
|
228
|
+
const providerMetadata = toToolProviderMetadata(part);
|
|
194
229
|
const toolCall: ToolCallMessagePart = {
|
|
195
230
|
type: "tool-call",
|
|
196
231
|
toolCallId: part.toolCallId,
|
|
@@ -198,6 +233,7 @@ const convertDynamicToolPart = (
|
|
|
198
233
|
args: toJsonObject(part.input),
|
|
199
234
|
argsText: stringifyArgs(part.input),
|
|
200
235
|
...(approval && { approval }),
|
|
236
|
+
...(providerMetadata && { providerMetadata }),
|
|
201
237
|
};
|
|
202
238
|
|
|
203
239
|
switch (part.state) {
|
|
@@ -493,11 +529,13 @@ export const getEveMessageContent = (
|
|
|
493
529
|
// envelope is rebuilt from the typed format rather than forwarded.
|
|
494
530
|
const mediaType = `audio/${part.audio.format}`;
|
|
495
531
|
const data = part.audio.data;
|
|
532
|
+
const source = resolveFilePartSource({ data, mimeType: mediaType });
|
|
496
533
|
return {
|
|
497
534
|
type: "file" as const,
|
|
498
|
-
data:
|
|
499
|
-
|
|
500
|
-
|
|
535
|
+
data:
|
|
536
|
+
source.kind === "url"
|
|
537
|
+
? source.url
|
|
538
|
+
: `data:${mediaType};base64,${source.data}`,
|
|
501
539
|
mediaType,
|
|
502
540
|
};
|
|
503
541
|
}
|
|
@@ -525,13 +563,110 @@ export const getEveMessageContent = (
|
|
|
525
563
|
return parts;
|
|
526
564
|
};
|
|
527
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Finds the pending input request answered by the given approval id, so the
|
|
568
|
+
* response mapping can honor the request's display mode.
|
|
569
|
+
*/
|
|
570
|
+
export const findEveInputRequest = (
|
|
571
|
+
data: EveMessageData,
|
|
572
|
+
approvalId: string,
|
|
573
|
+
): EveMessageInputRequest | undefined => {
|
|
574
|
+
for (const message of data.messages) {
|
|
575
|
+
for (const part of message.parts) {
|
|
576
|
+
if (part.type === "dynamic-tool" && part.approval?.id === approvalId) {
|
|
577
|
+
return part.toolMetadata?.eve?.inputRequest;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return undefined;
|
|
582
|
+
};
|
|
583
|
+
|
|
528
584
|
/**
|
|
529
585
|
* Converts an assistant-ui tool approval response into an Eve input response.
|
|
586
|
+
*
|
|
587
|
+
* When `inputRequest` is known, every returned response carries either an
|
|
588
|
+
* option the request declares or a free-form answer. A literal option match
|
|
589
|
+
* wins, then the `"approve"` / `"cancel"` option the response's boolean decision
|
|
590
|
+
* names, then the response's `reason` text when the request takes a free-form
|
|
591
|
+
* answer (`display: "text"`, `allowFreeform`, or no options at all, and never
|
|
592
|
+
* `display: "confirmation"`) and the response is not a refusal.
|
|
593
|
+
* A one-argument call, or a part with no request, still forwards a caller
|
|
594
|
+
* `optionId` as it always has, including ids the request never declared.
|
|
595
|
+
*
|
|
596
|
+
* A response the known request cannot record as the answer it asked for throws
|
|
597
|
+
* instead of being submitted, because eve resolves a request the moment any
|
|
598
|
+
* response for it arrives, and an empty one is recorded as an answer with no
|
|
599
|
+
* content. Not sending leaves the request pending, so the caller can retry or
|
|
600
|
+
* the user can answer it as an ordinary message.
|
|
601
|
+
*
|
|
602
|
+
* With no request to map against — a one-argument call, or a part carrying no
|
|
603
|
+
* `toolMetadata.eve.inputRequest` — the response maps as it always has, to the
|
|
604
|
+
* literal `"approve"` / `"cancel"` option every eve approval declares. Guessing
|
|
605
|
+
* a display mode is what this mapper stopped doing; an unknown request is not
|
|
606
|
+
* a guess about one.
|
|
530
607
|
*/
|
|
531
608
|
export const toEveInputResponse = (
|
|
532
609
|
response: RespondToToolApprovalOptions,
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
610
|
+
inputRequest?: EveMessageInputRequest,
|
|
611
|
+
): InputResponse => {
|
|
612
|
+
const requestId = response.approvalId;
|
|
613
|
+
const options = inputRequest?.options;
|
|
614
|
+
const text = response.reason;
|
|
615
|
+
|
|
616
|
+
if (response.optionId !== undefined) {
|
|
617
|
+
if (
|
|
618
|
+
!inputRequest ||
|
|
619
|
+
options?.some((option) => option.id === response.optionId)
|
|
620
|
+
) {
|
|
621
|
+
return {
|
|
622
|
+
requestId,
|
|
623
|
+
optionId: response.optionId,
|
|
624
|
+
...(text && { text }),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
throw new Error(
|
|
628
|
+
`Eve input request "${requestId}" has no option with id "${response.optionId}"; respond with one of: ${
|
|
629
|
+
options?.length
|
|
630
|
+
? options.map((option) => option.id).join(", ")
|
|
631
|
+
: "(the request carries no options)"
|
|
632
|
+
}`,
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const decisionOptionId = response.approved ? "approve" : "cancel";
|
|
637
|
+
// Eve's harness always declares `approve`/`cancel` on a `tool-approval`
|
|
638
|
+
// request, so honoring the kind when the options were dropped keeps a
|
|
639
|
+
// non-spec approval payload answerable rather than stuck. A tool-approval
|
|
640
|
+
// that declares its own options still requires one of them.
|
|
641
|
+
if (
|
|
642
|
+
!inputRequest ||
|
|
643
|
+
(inputRequest.kind === "tool-approval" && !options?.length) ||
|
|
644
|
+
options?.some((option) => option.id === decisionOptionId)
|
|
645
|
+
) {
|
|
646
|
+
return { requestId, optionId: decisionOptionId, ...(text && { text }) };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Eve recognises an approval by its literal two-option `approve`/`cancel`
|
|
650
|
+
// shape, so past this point the request is a question and the boolean
|
|
651
|
+
// carries no decision eve would act on.
|
|
652
|
+
const acceptsText =
|
|
653
|
+
inputRequest.display !== "confirmation" &&
|
|
654
|
+
(inputRequest.display === "text" ||
|
|
655
|
+
inputRequest.allowFreeform === true ||
|
|
656
|
+
!options?.length);
|
|
657
|
+
if (acceptsText && text && response.approved !== false) {
|
|
658
|
+
return { requestId, text };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
throw new Error(
|
|
662
|
+
`Eve input request "${requestId}" (${inputRequest.prompt}) was not answered by this response; ${
|
|
663
|
+
options?.length
|
|
664
|
+
? `respond with one of: ${options.map((option) => option.id).join(", ")}`
|
|
665
|
+
: !acceptsText
|
|
666
|
+
? "the request declares no options to respond with"
|
|
667
|
+
: response.approved === false
|
|
668
|
+
? "a refusal carries no answer for a free-form request; resend with `approved` unset or true and the answer in the response reason"
|
|
669
|
+
: "pass the answer as the response reason, because the request takes a free-form answer"
|
|
670
|
+
}`,
|
|
671
|
+
);
|
|
672
|
+
};
|