@assistant-ui/eve 0.0.13 → 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 +45 -5
- package/dist/convertEveMessages.d.ts.map +1 -1
- package/dist/convertEveMessages.js +91 -14
- package/dist/convertEveMessages.js.map +1 -1
- package/dist/eveExtras.d.ts +7 -0
- package/dist/eveExtras.d.ts.map +1 -0
- package/dist/eveExtras.js +7 -0
- package/dist/eveExtras.js.map +1 -0
- package/dist/hooks.d.ts +25 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +32 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/useEveAgentRuntime.d.ts.map +1 -1
- package/dist/useEveAgentRuntime.js +61 -26
- package/dist/useEveAgentRuntime.js.map +1 -1
- package/package.json +7 -6
- package/src/convertEveMessages.test.ts +698 -6
- package/src/convertEveMessages.ts +185 -14
- package/src/eveExtras.ts +10 -0
- package/src/hooks.test.tsx +84 -0
- package/src/hooks.ts +36 -0
- package/src/index.ts +3 -0
- package/src/useEveAgentRuntime.refetch.test.tsx +89 -0
- package/src/useEveAgentRuntime.test.tsx +1037 -167
- package/src/useEveAgentRuntime.ts +148 -42
|
@@ -1,13 +1,43 @@
|
|
|
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
|
+
|
|
36
|
+
const eventMeta = (sequence: number) => ({
|
|
37
|
+
at: "2026-01-01T00:00:00.000Z",
|
|
38
|
+
id: `evt_${sequence}`,
|
|
39
|
+
});
|
|
40
|
+
|
|
11
41
|
describe("convertEveMessages", () => {
|
|
12
42
|
it("converts text and reasoning parts", () => {
|
|
13
43
|
const data = {
|
|
@@ -48,6 +78,75 @@ describe("convertEveMessages", () => {
|
|
|
48
78
|
});
|
|
49
79
|
});
|
|
50
80
|
|
|
81
|
+
it("omits the part status when the part state is still streaming", () => {
|
|
82
|
+
const data = {
|
|
83
|
+
messages: [
|
|
84
|
+
{
|
|
85
|
+
id: "a1",
|
|
86
|
+
role: "assistant",
|
|
87
|
+
metadata: { status: "streaming" },
|
|
88
|
+
parts: [
|
|
89
|
+
{ type: "reasoning", text: "Thinking", state: "streaming" },
|
|
90
|
+
{ type: "text", text: "Hi", state: "streaming" },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
} satisfies EveMessageData;
|
|
95
|
+
|
|
96
|
+
const messages = convertEveMessages(data, { isRunning: true });
|
|
97
|
+
|
|
98
|
+
for (const part of messages[0]!.content) {
|
|
99
|
+
expect(part).not.toHaveProperty("status");
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("maps a done part state to a complete part status", () => {
|
|
104
|
+
const data = {
|
|
105
|
+
messages: [
|
|
106
|
+
{
|
|
107
|
+
id: "a1",
|
|
108
|
+
role: "assistant",
|
|
109
|
+
metadata: { status: "complete" },
|
|
110
|
+
parts: [
|
|
111
|
+
{ type: "reasoning", text: "Thinking", state: "done" },
|
|
112
|
+
{ type: "text", text: "Hi", state: "done" },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
} satisfies EveMessageData;
|
|
117
|
+
|
|
118
|
+
const messages = convertEveMessages(data);
|
|
119
|
+
|
|
120
|
+
expect(messages[0]!.content).toEqual([
|
|
121
|
+
expect.objectContaining({
|
|
122
|
+
type: "reasoning",
|
|
123
|
+
status: { type: "complete" },
|
|
124
|
+
}),
|
|
125
|
+
expect.objectContaining({ type: "text", status: { type: "complete" } }),
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("omits the part status when the part state is absent", () => {
|
|
130
|
+
const data = {
|
|
131
|
+
messages: [
|
|
132
|
+
{
|
|
133
|
+
id: "a1",
|
|
134
|
+
role: "assistant",
|
|
135
|
+
parts: [
|
|
136
|
+
{ type: "reasoning", text: "Thinking" },
|
|
137
|
+
{ type: "text", text: "Hi" },
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
} satisfies EveMessageData;
|
|
142
|
+
|
|
143
|
+
const messages = convertEveMessages(data);
|
|
144
|
+
|
|
145
|
+
for (const part of messages[0]!.content) {
|
|
146
|
+
expect(part).not.toHaveProperty("status");
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
51
150
|
it("converts dynamic tool parts with approval options", () => {
|
|
52
151
|
const data = {
|
|
53
152
|
messages: [
|
|
@@ -68,11 +167,12 @@ describe("convertEveMessages", () => {
|
|
|
68
167
|
name: "send_email",
|
|
69
168
|
inputRequest: {
|
|
70
169
|
requestId: "req_1",
|
|
170
|
+
kind: "tool-approval",
|
|
71
171
|
prompt: "Send the email?",
|
|
72
172
|
display: "confirmation",
|
|
73
173
|
options: [
|
|
74
174
|
{ id: "approve", label: "Approve" },
|
|
75
|
-
{ id: "
|
|
175
|
+
{ id: "cancel", label: "Cancel", style: "danger" },
|
|
76
176
|
{ id: "escalate", label: "Escalate" },
|
|
77
177
|
],
|
|
78
178
|
},
|
|
@@ -98,7 +198,7 @@ describe("convertEveMessages", () => {
|
|
|
98
198
|
id: "req_1",
|
|
99
199
|
options: [
|
|
100
200
|
{ id: "approve", kind: "allow-once", label: "Approve" },
|
|
101
|
-
{ id: "
|
|
201
|
+
{ id: "cancel", kind: "reject-once", label: "Cancel" },
|
|
102
202
|
{ id: "escalate", kind: "_escalate", label: "Escalate" },
|
|
103
203
|
],
|
|
104
204
|
},
|
|
@@ -107,6 +207,62 @@ describe("convertEveMessages", () => {
|
|
|
107
207
|
});
|
|
108
208
|
});
|
|
109
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
|
+
|
|
110
266
|
it("handles denied tool parts without an approval reason", () => {
|
|
111
267
|
const data = {
|
|
112
268
|
messages: [
|
|
@@ -846,6 +1002,7 @@ describe("convertEveMessages", () => {
|
|
|
846
1002
|
const events: readonly EveAgentReducerEvent[] = [
|
|
847
1003
|
{
|
|
848
1004
|
type: "authorization.required",
|
|
1005
|
+
meta: eventMeta(0),
|
|
849
1006
|
data: {
|
|
850
1007
|
turnId: "turn_1",
|
|
851
1008
|
stepIndex: 0,
|
|
@@ -856,6 +1013,7 @@ describe("convertEveMessages", () => {
|
|
|
856
1013
|
},
|
|
857
1014
|
{
|
|
858
1015
|
type: "turn.cancelled",
|
|
1016
|
+
meta: eventMeta(1),
|
|
859
1017
|
data: { turnId: "turn_1", sequence: 1 },
|
|
860
1018
|
},
|
|
861
1019
|
];
|
|
@@ -1010,14 +1168,22 @@ describe("convertEveMessages", () => {
|
|
|
1010
1168
|
},
|
|
1011
1169
|
{
|
|
1012
1170
|
type: "turn.started",
|
|
1171
|
+
meta: eventMeta(0),
|
|
1013
1172
|
data: { turnId: "turn_1", sequence: 0 },
|
|
1014
1173
|
},
|
|
1015
1174
|
{
|
|
1016
1175
|
type: "step.started",
|
|
1017
|
-
|
|
1176
|
+
meta: eventMeta(1),
|
|
1177
|
+
data: {
|
|
1178
|
+
turnId: "turn_1",
|
|
1179
|
+
stepIndex: 0,
|
|
1180
|
+
sequence: 1,
|
|
1181
|
+
modelId: "test-model",
|
|
1182
|
+
},
|
|
1018
1183
|
},
|
|
1019
1184
|
{
|
|
1020
1185
|
type: "message.appended",
|
|
1186
|
+
meta: eventMeta(2),
|
|
1021
1187
|
data: {
|
|
1022
1188
|
turnId: "turn_1",
|
|
1023
1189
|
stepIndex: 0,
|
|
@@ -1033,6 +1199,7 @@ describe("convertEveMessages", () => {
|
|
|
1033
1199
|
...midStreamEvents,
|
|
1034
1200
|
{
|
|
1035
1201
|
type: "authorization.required",
|
|
1202
|
+
meta: eventMeta(3),
|
|
1036
1203
|
data: {
|
|
1037
1204
|
turnId: "turn_1",
|
|
1038
1205
|
stepIndex: 0,
|
|
@@ -1073,6 +1240,7 @@ describe("convertEveMessages", () => {
|
|
|
1073
1240
|
...midStreamEvents,
|
|
1074
1241
|
{
|
|
1075
1242
|
type: "authorization.required",
|
|
1243
|
+
meta: eventMeta(3),
|
|
1076
1244
|
data: {
|
|
1077
1245
|
turnId: "turn_1",
|
|
1078
1246
|
stepIndex: 0,
|
|
@@ -1083,6 +1251,7 @@ describe("convertEveMessages", () => {
|
|
|
1083
1251
|
},
|
|
1084
1252
|
{
|
|
1085
1253
|
type: "authorization.completed",
|
|
1254
|
+
meta: eventMeta(4),
|
|
1086
1255
|
data: {
|
|
1087
1256
|
turnId: "turn_1",
|
|
1088
1257
|
stepIndex: 0,
|
|
@@ -1112,6 +1281,7 @@ describe("convertEveMessages", () => {
|
|
|
1112
1281
|
...midStreamEvents,
|
|
1113
1282
|
{
|
|
1114
1283
|
type: "authorization.required",
|
|
1284
|
+
meta: eventMeta(3),
|
|
1115
1285
|
data: {
|
|
1116
1286
|
turnId: "turn_1",
|
|
1117
1287
|
stepIndex: 0,
|
|
@@ -1122,6 +1292,7 @@ describe("convertEveMessages", () => {
|
|
|
1122
1292
|
},
|
|
1123
1293
|
{
|
|
1124
1294
|
type: "authorization.required",
|
|
1295
|
+
meta: eventMeta(4),
|
|
1125
1296
|
data: {
|
|
1126
1297
|
turnId: "turn_1",
|
|
1127
1298
|
stepIndex: 0,
|
|
@@ -1132,6 +1303,7 @@ describe("convertEveMessages", () => {
|
|
|
1132
1303
|
},
|
|
1133
1304
|
{
|
|
1134
1305
|
type: "authorization.completed",
|
|
1306
|
+
meta: eventMeta(5),
|
|
1135
1307
|
data: {
|
|
1136
1308
|
turnId: "turn_1",
|
|
1137
1309
|
stepIndex: 0,
|
|
@@ -1147,6 +1319,245 @@ describe("convertEveMessages", () => {
|
|
|
1147
1319
|
).toEqual({ type: "requires-action", reason: "interrupt" });
|
|
1148
1320
|
});
|
|
1149
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
|
+
|
|
1374
|
+
it("leaves a reasoning part unsettled when a tool call follows it", () => {
|
|
1375
|
+
const state = replay([
|
|
1376
|
+
...midStreamEvents.slice(0, 3),
|
|
1377
|
+
{
|
|
1378
|
+
type: "reasoning.appended",
|
|
1379
|
+
data: {
|
|
1380
|
+
turnId: "turn_1",
|
|
1381
|
+
stepIndex: 0,
|
|
1382
|
+
sequence: 2,
|
|
1383
|
+
reasoningDelta: "Think",
|
|
1384
|
+
reasoningSoFar: "Think",
|
|
1385
|
+
},
|
|
1386
|
+
},
|
|
1387
|
+
{
|
|
1388
|
+
type: "actions.requested",
|
|
1389
|
+
data: {
|
|
1390
|
+
turnId: "turn_1",
|
|
1391
|
+
stepIndex: 0,
|
|
1392
|
+
sequence: 3,
|
|
1393
|
+
actions: [
|
|
1394
|
+
{
|
|
1395
|
+
kind: "tool-call",
|
|
1396
|
+
callId: "call_1",
|
|
1397
|
+
toolName: "search",
|
|
1398
|
+
input: {},
|
|
1399
|
+
},
|
|
1400
|
+
],
|
|
1401
|
+
},
|
|
1402
|
+
},
|
|
1403
|
+
]);
|
|
1404
|
+
|
|
1405
|
+
const message = state.messages.find((m) => m.role === "assistant");
|
|
1406
|
+
expect(message?.parts).toEqual(
|
|
1407
|
+
expect.arrayContaining([
|
|
1408
|
+
expect.objectContaining({ type: "reasoning", state: "streaming" }),
|
|
1409
|
+
expect.objectContaining({ type: "dynamic-tool" }),
|
|
1410
|
+
]),
|
|
1411
|
+
);
|
|
1412
|
+
|
|
1413
|
+
const content = convertEveMessages(state, { isRunning: true }).at(
|
|
1414
|
+
-1,
|
|
1415
|
+
)?.content;
|
|
1416
|
+
expect(content).toEqual([
|
|
1417
|
+
expect.objectContaining({ type: "reasoning" }),
|
|
1418
|
+
expect.objectContaining({ type: "tool-call" }),
|
|
1419
|
+
]);
|
|
1420
|
+
expect(content?.[0]).not.toHaveProperty("status");
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
it("settles leftover reasoning after the model stream completes a tool call", () => {
|
|
1424
|
+
const state = replay([
|
|
1425
|
+
...midStreamEvents.slice(0, 3),
|
|
1426
|
+
{
|
|
1427
|
+
type: "reasoning.appended",
|
|
1428
|
+
data: {
|
|
1429
|
+
turnId: "turn_1",
|
|
1430
|
+
stepIndex: 0,
|
|
1431
|
+
sequence: 2,
|
|
1432
|
+
reasoningDelta: "Think",
|
|
1433
|
+
reasoningSoFar: "Think",
|
|
1434
|
+
},
|
|
1435
|
+
},
|
|
1436
|
+
{
|
|
1437
|
+
type: "actions.requested",
|
|
1438
|
+
data: {
|
|
1439
|
+
turnId: "turn_1",
|
|
1440
|
+
stepIndex: 0,
|
|
1441
|
+
sequence: 3,
|
|
1442
|
+
actions: [
|
|
1443
|
+
{
|
|
1444
|
+
kind: "tool-call",
|
|
1445
|
+
callId: "call_1",
|
|
1446
|
+
toolName: "search",
|
|
1447
|
+
input: {},
|
|
1448
|
+
},
|
|
1449
|
+
],
|
|
1450
|
+
},
|
|
1451
|
+
},
|
|
1452
|
+
{
|
|
1453
|
+
type: "reasoning.completed",
|
|
1454
|
+
data: {
|
|
1455
|
+
turnId: "turn_1",
|
|
1456
|
+
stepIndex: 0,
|
|
1457
|
+
sequence: 4,
|
|
1458
|
+
reasoning: "Think",
|
|
1459
|
+
},
|
|
1460
|
+
},
|
|
1461
|
+
]);
|
|
1462
|
+
|
|
1463
|
+
const message = state.messages.find((m) => m.role === "assistant");
|
|
1464
|
+
expect(message?.parts).toEqual(
|
|
1465
|
+
expect.arrayContaining([
|
|
1466
|
+
expect.objectContaining({ type: "reasoning", state: "done" }),
|
|
1467
|
+
expect.objectContaining({ type: "dynamic-tool" }),
|
|
1468
|
+
]),
|
|
1469
|
+
);
|
|
1470
|
+
|
|
1471
|
+
const content = convertEveMessages(state, { isRunning: true }).at(
|
|
1472
|
+
-1,
|
|
1473
|
+
)?.content;
|
|
1474
|
+
expect(content).toEqual([
|
|
1475
|
+
expect.objectContaining({
|
|
1476
|
+
type: "reasoning",
|
|
1477
|
+
status: { type: "complete" },
|
|
1478
|
+
}),
|
|
1479
|
+
expect.objectContaining({ type: "tool-call" }),
|
|
1480
|
+
]);
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
it("keeps a later step's live text part unsettled after an earlier step completed", () => {
|
|
1484
|
+
const state = replay([
|
|
1485
|
+
...midStreamEvents.slice(0, 3),
|
|
1486
|
+
{
|
|
1487
|
+
type: "message.appended",
|
|
1488
|
+
data: {
|
|
1489
|
+
turnId: "turn_1",
|
|
1490
|
+
stepIndex: 0,
|
|
1491
|
+
sequence: 2,
|
|
1492
|
+
messageDelta: "First",
|
|
1493
|
+
messageSoFar: "First",
|
|
1494
|
+
},
|
|
1495
|
+
},
|
|
1496
|
+
{
|
|
1497
|
+
type: "message.completed",
|
|
1498
|
+
data: {
|
|
1499
|
+
turnId: "turn_1",
|
|
1500
|
+
stepIndex: 0,
|
|
1501
|
+
sequence: 3,
|
|
1502
|
+
finishReason: "tool-calls",
|
|
1503
|
+
message: "First",
|
|
1504
|
+
},
|
|
1505
|
+
},
|
|
1506
|
+
{
|
|
1507
|
+
type: "step.completed",
|
|
1508
|
+
data: {
|
|
1509
|
+
turnId: "turn_1",
|
|
1510
|
+
stepIndex: 0,
|
|
1511
|
+
sequence: 4,
|
|
1512
|
+
finishReason: "tool-calls",
|
|
1513
|
+
},
|
|
1514
|
+
},
|
|
1515
|
+
{
|
|
1516
|
+
type: "step.started",
|
|
1517
|
+
data: { turnId: "turn_1", stepIndex: 1, sequence: 5 },
|
|
1518
|
+
},
|
|
1519
|
+
{
|
|
1520
|
+
type: "message.appended",
|
|
1521
|
+
data: {
|
|
1522
|
+
turnId: "turn_1",
|
|
1523
|
+
stepIndex: 1,
|
|
1524
|
+
sequence: 6,
|
|
1525
|
+
messageDelta: "Sec",
|
|
1526
|
+
messageSoFar: "Sec",
|
|
1527
|
+
},
|
|
1528
|
+
},
|
|
1529
|
+
]);
|
|
1530
|
+
|
|
1531
|
+
const assistant = state.messages.find((m) => m.role === "assistant");
|
|
1532
|
+
expect(assistant?.parts).toEqual([
|
|
1533
|
+
expect.objectContaining({ type: "step-start" }),
|
|
1534
|
+
expect.objectContaining({
|
|
1535
|
+
type: "text",
|
|
1536
|
+
text: "First",
|
|
1537
|
+
state: "done",
|
|
1538
|
+
}),
|
|
1539
|
+
expect.objectContaining({ type: "step-start" }),
|
|
1540
|
+
expect.objectContaining({
|
|
1541
|
+
type: "text",
|
|
1542
|
+
text: "Sec",
|
|
1543
|
+
state: "streaming",
|
|
1544
|
+
}),
|
|
1545
|
+
]);
|
|
1546
|
+
|
|
1547
|
+
const content = convertEveMessages(state, { isRunning: true }).at(
|
|
1548
|
+
-1,
|
|
1549
|
+
)?.content;
|
|
1550
|
+
expect(content).toEqual([
|
|
1551
|
+
expect.objectContaining({
|
|
1552
|
+
type: "text",
|
|
1553
|
+
text: "First",
|
|
1554
|
+
status: { type: "complete" },
|
|
1555
|
+
}),
|
|
1556
|
+
expect.objectContaining({ type: "text", text: "Sec" }),
|
|
1557
|
+
]);
|
|
1558
|
+
expect(content?.[1]).not.toHaveProperty("status");
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1150
1561
|
it("a locally aborted turn keeps its streaming marker and converts to cancelled", () => {
|
|
1151
1562
|
const state = replay(midStreamEvents);
|
|
1152
1563
|
|
|
@@ -1165,6 +1576,7 @@ describe("convertEveMessages", () => {
|
|
|
1165
1576
|
...midStreamEvents,
|
|
1166
1577
|
{
|
|
1167
1578
|
type: "session.failed",
|
|
1579
|
+
meta: eventMeta(3),
|
|
1168
1580
|
data: { sessionId: "session_1", code: "internal", message: "boom" },
|
|
1169
1581
|
},
|
|
1170
1582
|
]);
|
|
@@ -1188,6 +1600,7 @@ describe("convertEveMessages", () => {
|
|
|
1188
1600
|
...midStreamEvents,
|
|
1189
1601
|
{
|
|
1190
1602
|
type: "turn.failed",
|
|
1603
|
+
meta: eventMeta(3),
|
|
1191
1604
|
data: {
|
|
1192
1605
|
turnId: "turn_1",
|
|
1193
1606
|
sequence: 3,
|
|
@@ -1209,6 +1622,7 @@ describe("convertEveMessages", () => {
|
|
|
1209
1622
|
...midStreamEvents,
|
|
1210
1623
|
{
|
|
1211
1624
|
type: "message.completed",
|
|
1625
|
+
meta: eventMeta(3),
|
|
1212
1626
|
data: {
|
|
1213
1627
|
turnId: "turn_1",
|
|
1214
1628
|
stepIndex: 0,
|
|
@@ -1219,18 +1633,32 @@ describe("convertEveMessages", () => {
|
|
|
1219
1633
|
},
|
|
1220
1634
|
{
|
|
1221
1635
|
type: "turn.completed",
|
|
1636
|
+
meta: eventMeta(4),
|
|
1222
1637
|
data: { turnId: "turn_1", sequence: 4 },
|
|
1223
1638
|
},
|
|
1224
1639
|
]);
|
|
1225
1640
|
|
|
1226
1641
|
const assistant = state.messages.find((m) => m.role === "assistant");
|
|
1227
1642
|
expect(assistant?.metadata?.status).toBe("complete");
|
|
1643
|
+
expect(assistant?.parts).toEqual(
|
|
1644
|
+
expect.arrayContaining([
|
|
1645
|
+
expect.objectContaining({ type: "text", state: "done" }),
|
|
1646
|
+
]),
|
|
1647
|
+
);
|
|
1228
1648
|
|
|
1229
1649
|
const converted = convertEveMessages(state, { isRunning: false });
|
|
1230
1650
|
expect(converted.at(-1)?.status).toEqual({
|
|
1231
1651
|
type: "complete",
|
|
1232
1652
|
reason: "stop",
|
|
1233
1653
|
});
|
|
1654
|
+
expect(converted.at(-1)?.content).toEqual(
|
|
1655
|
+
expect.arrayContaining([
|
|
1656
|
+
expect.objectContaining({
|
|
1657
|
+
type: "text",
|
|
1658
|
+
status: { type: "complete" },
|
|
1659
|
+
}),
|
|
1660
|
+
]),
|
|
1661
|
+
);
|
|
1234
1662
|
});
|
|
1235
1663
|
});
|
|
1236
1664
|
});
|
|
@@ -1418,18 +1846,282 @@ describe("getEveMessageContent", () => {
|
|
|
1418
1846
|
});
|
|
1419
1847
|
});
|
|
1420
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
|
+
|
|
1421
1867
|
describe("toEveInputResponse", () => {
|
|
1422
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
|
+
|
|
1423
1893
|
expect(
|
|
1424
1894
|
toEveInputResponse({
|
|
1425
1895
|
approvalId: "req_1",
|
|
1426
1896
|
approved: false,
|
|
1427
1897
|
reason: "Not yet",
|
|
1428
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
|
+
),
|
|
1429
2013
|
).toEqual({
|
|
1430
2014
|
requestId: "req_1",
|
|
1431
|
-
optionId: "
|
|
1432
|
-
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",
|
|
1433
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",
|
|
2109
|
+
});
|
|
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();
|
|
1434
2126
|
});
|
|
1435
2127
|
});
|