@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
|
@@ -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,199 @@ describe("convertEveMessages", () => {
|
|
|
182
207
|
});
|
|
183
208
|
});
|
|
184
209
|
|
|
210
|
+
it.each([
|
|
211
|
+
[
|
|
212
|
+
"a confirmation request",
|
|
213
|
+
{
|
|
214
|
+
requestId: "req_1",
|
|
215
|
+
kind: "tool-approval",
|
|
216
|
+
prompt: "Send the email?",
|
|
217
|
+
display: "confirmation",
|
|
218
|
+
options: [
|
|
219
|
+
{ id: "approve", label: "Approve" },
|
|
220
|
+
{ id: "cancel", label: "Cancel" },
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
{ prompt: "Send the email?", display: "decision" },
|
|
224
|
+
],
|
|
225
|
+
[
|
|
226
|
+
"a select question that also takes a typed answer",
|
|
227
|
+
{
|
|
228
|
+
requestId: "req_1",
|
|
229
|
+
kind: "question",
|
|
230
|
+
prompt: "Which environment?",
|
|
231
|
+
display: "select",
|
|
232
|
+
allowFreeform: true,
|
|
233
|
+
options: [{ id: "staging", label: "Staging" }],
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
prompt: "Which environment?",
|
|
237
|
+
display: "select",
|
|
238
|
+
allowFreeform: true,
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
[
|
|
242
|
+
"a text question",
|
|
243
|
+
{
|
|
244
|
+
requestId: "req_1",
|
|
245
|
+
kind: "question",
|
|
246
|
+
prompt: "What should the subject line be?",
|
|
247
|
+
display: "text",
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
prompt: "What should the subject line be?",
|
|
251
|
+
display: "text",
|
|
252
|
+
allowFreeform: true,
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
[
|
|
256
|
+
"a question with neither a display nor options",
|
|
257
|
+
{ requestId: "req_1", kind: "question", prompt: "Anything else?" },
|
|
258
|
+
{ prompt: "Anything else?", display: "text", allowFreeform: true },
|
|
259
|
+
],
|
|
260
|
+
] satisfies [string, EveMessageInputRequest, object][])(
|
|
261
|
+
"describes %s on the approval itself",
|
|
262
|
+
(_label, inputRequest, expected) => {
|
|
263
|
+
const [message] = convertEveMessages(
|
|
264
|
+
withApprovalPart({
|
|
265
|
+
kind: "tool-call",
|
|
266
|
+
name: "send_email",
|
|
267
|
+
inputRequest,
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(message!.content[0]).toMatchObject({ approval: expected });
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
it.each([
|
|
276
|
+
["no display", undefined],
|
|
277
|
+
["a text display", "text" as const],
|
|
278
|
+
["a select display", "select" as const],
|
|
279
|
+
])(
|
|
280
|
+
"projects a tool approval whose options were dropped as a decision, with %s",
|
|
281
|
+
(_label, display) => {
|
|
282
|
+
const [message] = convertEveMessages(
|
|
283
|
+
withApprovalPart({
|
|
284
|
+
kind: "tool-call",
|
|
285
|
+
name: "send_email",
|
|
286
|
+
inputRequest: {
|
|
287
|
+
requestId: "req_1",
|
|
288
|
+
kind: "tool-approval",
|
|
289
|
+
prompt: "Send the email?",
|
|
290
|
+
...(display && { display }),
|
|
291
|
+
},
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
expect(message!.content[0]).toMatchObject({
|
|
296
|
+
approval: { display: "decision" },
|
|
297
|
+
});
|
|
298
|
+
expect(
|
|
299
|
+
(message!.content[0] as { approval?: object }).approval,
|
|
300
|
+
).not.toHaveProperty("allowFreeform");
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
it("does not offer a typed answer on a tool approval whose options were dropped", () => {
|
|
305
|
+
const [message] = convertEveMessages(
|
|
306
|
+
withApprovalPart({
|
|
307
|
+
kind: "tool-call",
|
|
308
|
+
name: "send_email",
|
|
309
|
+
inputRequest: {
|
|
310
|
+
requestId: "req_1",
|
|
311
|
+
kind: "tool-approval",
|
|
312
|
+
prompt: "Send the email?",
|
|
313
|
+
},
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// The mapper answers this shape with its approve/cancel branch before it
|
|
318
|
+
// ever reads a text answer, so the renderer must not offer one.
|
|
319
|
+
expect(
|
|
320
|
+
(message!.content[0] as { approval?: object }).approval,
|
|
321
|
+
).not.toHaveProperty("allowFreeform");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("does not offer a typed answer on a confirmation request", () => {
|
|
325
|
+
const [message] = convertEveMessages(
|
|
326
|
+
withApprovalPart({
|
|
327
|
+
kind: "tool-call",
|
|
328
|
+
name: "send_email",
|
|
329
|
+
inputRequest: {
|
|
330
|
+
requestId: "req_1",
|
|
331
|
+
kind: "tool-approval",
|
|
332
|
+
prompt: "Send the email?",
|
|
333
|
+
display: "confirmation",
|
|
334
|
+
options: [
|
|
335
|
+
{ id: "approve", label: "Approve" },
|
|
336
|
+
{ id: "cancel", label: "Cancel" },
|
|
337
|
+
],
|
|
338
|
+
},
|
|
339
|
+
}),
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
expect(
|
|
343
|
+
(message!.content[0] as { approval?: object }).approval,
|
|
344
|
+
).not.toHaveProperty("allowFreeform");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it.each([
|
|
348
|
+
[
|
|
349
|
+
"the full input request",
|
|
350
|
+
{
|
|
351
|
+
requestId: "req_1",
|
|
352
|
+
prompt: "Which environment?",
|
|
353
|
+
kind: "question",
|
|
354
|
+
display: "select",
|
|
355
|
+
allowFreeform: true,
|
|
356
|
+
options: [
|
|
357
|
+
{ id: "staging", label: "Staging", description: "Safe" },
|
|
358
|
+
{ id: "production", label: "Production", style: "danger" },
|
|
359
|
+
],
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
[
|
|
363
|
+
"only the fields the request defines",
|
|
364
|
+
{
|
|
365
|
+
requestId: "req_1",
|
|
366
|
+
prompt: "What should the subject line be?",
|
|
367
|
+
kind: "question",
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
] satisfies [string, EveMessageInputRequest][])(
|
|
371
|
+
"projects %s onto providerMetadata.eve",
|
|
372
|
+
(_label, inputRequest) => {
|
|
373
|
+
const [message] = convertEveMessages(
|
|
374
|
+
withApprovalPart({
|
|
375
|
+
kind: "tool-call",
|
|
376
|
+
name: "send_email",
|
|
377
|
+
inputRequest,
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
const part = message!.content[0];
|
|
381
|
+
|
|
382
|
+
expect(part).toMatchObject({ type: "tool-call" });
|
|
383
|
+
expect((part as { providerMetadata?: unknown }).providerMetadata).toEqual(
|
|
384
|
+
{
|
|
385
|
+
eve: { inputRequest },
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
it.each([
|
|
392
|
+
[
|
|
393
|
+
"eve metadata without an input request",
|
|
394
|
+
{ kind: "tool-call" as const, name: "send_email" },
|
|
395
|
+
],
|
|
396
|
+
["no eve metadata at all", undefined],
|
|
397
|
+
])("omits providerMetadata for a tool part with %s", (_label, eve) => {
|
|
398
|
+
const [message] = convertEveMessages(withApprovalPart(eve));
|
|
399
|
+
|
|
400
|
+
expect(message!.content[0]).not.toHaveProperty("providerMetadata");
|
|
401
|
+
});
|
|
402
|
+
|
|
185
403
|
it("handles denied tool parts without an approval reason", () => {
|
|
186
404
|
const data = {
|
|
187
405
|
messages: [
|
|
@@ -1238,11 +1456,64 @@ describe("convertEveMessages", () => {
|
|
|
1238
1456
|
).toEqual({ type: "requires-action", reason: "interrupt" });
|
|
1239
1457
|
});
|
|
1240
1458
|
|
|
1459
|
+
it("projects the input request onto the approval part it gates", () => {
|
|
1460
|
+
const events: readonly EveAgentReducerEvent[] = [
|
|
1461
|
+
...midStreamEvents,
|
|
1462
|
+
{
|
|
1463
|
+
type: "input.requested",
|
|
1464
|
+
data: {
|
|
1465
|
+
turnId: "turn_1",
|
|
1466
|
+
stepIndex: 0,
|
|
1467
|
+
sequence: 3,
|
|
1468
|
+
requests: [
|
|
1469
|
+
{
|
|
1470
|
+
requestId: "req_1",
|
|
1471
|
+
prompt: "What should the subject line be?",
|
|
1472
|
+
kind: "question",
|
|
1473
|
+
display: "text",
|
|
1474
|
+
action: {
|
|
1475
|
+
kind: "tool-call",
|
|
1476
|
+
callId: "call_1",
|
|
1477
|
+
toolName: "ask_question",
|
|
1478
|
+
input: {},
|
|
1479
|
+
},
|
|
1480
|
+
},
|
|
1481
|
+
],
|
|
1482
|
+
},
|
|
1483
|
+
meta: eventMeta(3),
|
|
1484
|
+
},
|
|
1485
|
+
];
|
|
1486
|
+
|
|
1487
|
+
// Rehydration replays the *stored* event log, which has been through
|
|
1488
|
+
// the wire and back, so the reloaded case is the serialized payload
|
|
1489
|
+
// rather than the same objects a second time.
|
|
1490
|
+
const rehydrated = JSON.parse(
|
|
1491
|
+
JSON.stringify(events),
|
|
1492
|
+
) as readonly EveAgentReducerEvent[];
|
|
1493
|
+
|
|
1494
|
+
for (const state of [replay(events), replay(rehydrated)]) {
|
|
1495
|
+
const part = state.messages
|
|
1496
|
+
.find((message) => message.role === "assistant")
|
|
1497
|
+
?.parts.find((candidate) => candidate.type === "dynamic-tool");
|
|
1498
|
+
|
|
1499
|
+
expect(part).toMatchObject({
|
|
1500
|
+
state: "approval-requested",
|
|
1501
|
+
approval: { id: "req_1" },
|
|
1502
|
+
});
|
|
1503
|
+
expect(findEveInputRequest(state, "req_1")).toMatchObject({
|
|
1504
|
+
requestId: "req_1",
|
|
1505
|
+
prompt: "What should the subject line be?",
|
|
1506
|
+
display: "text",
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1241
1511
|
it("leaves a reasoning part unsettled when a tool call follows it", () => {
|
|
1242
1512
|
const state = replay([
|
|
1243
1513
|
...midStreamEvents.slice(0, 3),
|
|
1244
1514
|
{
|
|
1245
1515
|
type: "reasoning.appended",
|
|
1516
|
+
meta: eventMeta(2),
|
|
1246
1517
|
data: {
|
|
1247
1518
|
turnId: "turn_1",
|
|
1248
1519
|
stepIndex: 0,
|
|
@@ -1253,6 +1524,7 @@ describe("convertEveMessages", () => {
|
|
|
1253
1524
|
},
|
|
1254
1525
|
{
|
|
1255
1526
|
type: "actions.requested",
|
|
1527
|
+
meta: eventMeta(3),
|
|
1256
1528
|
data: {
|
|
1257
1529
|
turnId: "turn_1",
|
|
1258
1530
|
stepIndex: 0,
|
|
@@ -1292,6 +1564,7 @@ describe("convertEveMessages", () => {
|
|
|
1292
1564
|
...midStreamEvents.slice(0, 3),
|
|
1293
1565
|
{
|
|
1294
1566
|
type: "reasoning.appended",
|
|
1567
|
+
meta: eventMeta(2),
|
|
1295
1568
|
data: {
|
|
1296
1569
|
turnId: "turn_1",
|
|
1297
1570
|
stepIndex: 0,
|
|
@@ -1302,6 +1575,7 @@ describe("convertEveMessages", () => {
|
|
|
1302
1575
|
},
|
|
1303
1576
|
{
|
|
1304
1577
|
type: "actions.requested",
|
|
1578
|
+
meta: eventMeta(3),
|
|
1305
1579
|
data: {
|
|
1306
1580
|
turnId: "turn_1",
|
|
1307
1581
|
stepIndex: 0,
|
|
@@ -1318,6 +1592,7 @@ describe("convertEveMessages", () => {
|
|
|
1318
1592
|
},
|
|
1319
1593
|
{
|
|
1320
1594
|
type: "reasoning.completed",
|
|
1595
|
+
meta: eventMeta(4),
|
|
1321
1596
|
data: {
|
|
1322
1597
|
turnId: "turn_1",
|
|
1323
1598
|
stepIndex: 0,
|
|
@@ -1352,6 +1627,7 @@ describe("convertEveMessages", () => {
|
|
|
1352
1627
|
...midStreamEvents.slice(0, 3),
|
|
1353
1628
|
{
|
|
1354
1629
|
type: "message.appended",
|
|
1630
|
+
meta: eventMeta(2),
|
|
1355
1631
|
data: {
|
|
1356
1632
|
turnId: "turn_1",
|
|
1357
1633
|
stepIndex: 0,
|
|
@@ -1362,6 +1638,7 @@ describe("convertEveMessages", () => {
|
|
|
1362
1638
|
},
|
|
1363
1639
|
{
|
|
1364
1640
|
type: "message.completed",
|
|
1641
|
+
meta: eventMeta(3),
|
|
1365
1642
|
data: {
|
|
1366
1643
|
turnId: "turn_1",
|
|
1367
1644
|
stepIndex: 0,
|
|
@@ -1372,6 +1649,7 @@ describe("convertEveMessages", () => {
|
|
|
1372
1649
|
},
|
|
1373
1650
|
{
|
|
1374
1651
|
type: "step.completed",
|
|
1652
|
+
meta: eventMeta(4),
|
|
1375
1653
|
data: {
|
|
1376
1654
|
turnId: "turn_1",
|
|
1377
1655
|
stepIndex: 0,
|
|
@@ -1381,10 +1659,17 @@ describe("convertEveMessages", () => {
|
|
|
1381
1659
|
},
|
|
1382
1660
|
{
|
|
1383
1661
|
type: "step.started",
|
|
1384
|
-
|
|
1662
|
+
meta: eventMeta(5),
|
|
1663
|
+
data: {
|
|
1664
|
+
turnId: "turn_1",
|
|
1665
|
+
stepIndex: 1,
|
|
1666
|
+
sequence: 5,
|
|
1667
|
+
modelId: "test-model",
|
|
1668
|
+
},
|
|
1385
1669
|
},
|
|
1386
1670
|
{
|
|
1387
1671
|
type: "message.appended",
|
|
1672
|
+
meta: eventMeta(6),
|
|
1388
1673
|
data: {
|
|
1389
1674
|
turnId: "turn_1",
|
|
1390
1675
|
stepIndex: 1,
|
|
@@ -1713,18 +1998,305 @@ describe("getEveMessageContent", () => {
|
|
|
1713
1998
|
});
|
|
1714
1999
|
});
|
|
1715
2000
|
|
|
2001
|
+
const approveCancel = [
|
|
2002
|
+
{ id: "approve", label: "Approve" },
|
|
2003
|
+
{ id: "cancel", label: "Cancel" },
|
|
2004
|
+
];
|
|
2005
|
+
const environments = [
|
|
2006
|
+
{ id: "staging", label: "Staging" },
|
|
2007
|
+
{ id: "production", label: "Production" },
|
|
2008
|
+
];
|
|
2009
|
+
|
|
2010
|
+
const withInputRequest = (
|
|
2011
|
+
overrides: Partial<EveMessageInputRequest> = {},
|
|
2012
|
+
): EveMessageInputRequest => ({
|
|
2013
|
+
requestId: "req_1",
|
|
2014
|
+
prompt: "Send the email?",
|
|
2015
|
+
kind: "question",
|
|
2016
|
+
...overrides,
|
|
2017
|
+
});
|
|
2018
|
+
|
|
1716
2019
|
describe("toEveInputResponse", () => {
|
|
1717
2020
|
it("maps assistant-ui approval responses to eve input responses", () => {
|
|
2021
|
+
expect(
|
|
2022
|
+
toEveInputResponse(
|
|
2023
|
+
{
|
|
2024
|
+
approvalId: "req_1",
|
|
2025
|
+
approved: false,
|
|
2026
|
+
reason: "Not yet",
|
|
2027
|
+
},
|
|
2028
|
+
withInputRequest({ display: "confirmation", options: approveCancel }),
|
|
2029
|
+
),
|
|
2030
|
+
).toEqual({
|
|
2031
|
+
requestId: "req_1",
|
|
2032
|
+
optionId: "cancel",
|
|
2033
|
+
text: "Not yet",
|
|
2034
|
+
});
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
it("keeps the shipped one-argument mapping when no request is available", () => {
|
|
2038
|
+
expect(toEveInputResponse({ approvalId: "req_1", approved: true })).toEqual(
|
|
2039
|
+
{
|
|
2040
|
+
requestId: "req_1",
|
|
2041
|
+
optionId: "approve",
|
|
2042
|
+
},
|
|
2043
|
+
);
|
|
2044
|
+
|
|
1718
2045
|
expect(
|
|
1719
2046
|
toEveInputResponse({
|
|
1720
2047
|
approvalId: "req_1",
|
|
1721
2048
|
approved: false,
|
|
1722
2049
|
reason: "Not yet",
|
|
1723
2050
|
}),
|
|
2051
|
+
).toEqual({ requestId: "req_1", optionId: "cancel", text: "Not yet" });
|
|
2052
|
+
|
|
2053
|
+
expect(
|
|
2054
|
+
toEveInputResponse({
|
|
2055
|
+
approvalId: "req_1",
|
|
2056
|
+
approved: true,
|
|
2057
|
+
optionId: "staging",
|
|
2058
|
+
}),
|
|
2059
|
+
).toEqual({ requestId: "req_1", optionId: "staging" });
|
|
2060
|
+
});
|
|
2061
|
+
|
|
2062
|
+
it("keeps the confirmation mapping when the request carries approve/cancel options", () => {
|
|
2063
|
+
expect(
|
|
2064
|
+
toEveInputResponse(
|
|
2065
|
+
{ approvalId: "req_1", approved: true },
|
|
2066
|
+
withInputRequest({ display: "confirmation", options: approveCancel }),
|
|
2067
|
+
),
|
|
2068
|
+
).toEqual({ requestId: "req_1", optionId: "approve" });
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
it("maps a select response to the chosen option id", () => {
|
|
2072
|
+
expect(
|
|
2073
|
+
toEveInputResponse(
|
|
2074
|
+
{ approvalId: "req_1", approved: true, optionId: "staging" },
|
|
2075
|
+
withInputRequest({ display: "select", options: environments }),
|
|
2076
|
+
),
|
|
2077
|
+
).toEqual({ requestId: "req_1", optionId: "staging" });
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
it.each([
|
|
2081
|
+
["a text display", { display: "text", allowFreeform: true }],
|
|
2082
|
+
["allowFreeform and no options", { allowFreeform: true }],
|
|
2083
|
+
["neither a display nor allowFreeform", {}],
|
|
2084
|
+
[
|
|
2085
|
+
"a select display that allows freeform",
|
|
2086
|
+
{ display: "select", allowFreeform: true, options: environments },
|
|
2087
|
+
],
|
|
2088
|
+
] satisfies [string, Partial<EveMessageInputRequest>][])(
|
|
2089
|
+
"answers a request with %s as free-form text, not a fabricated option id",
|
|
2090
|
+
(_label, overrides) => {
|
|
2091
|
+
const response = toEveInputResponse(
|
|
2092
|
+
{ approvalId: "req_1", approved: true, reason: "Quarterly results" },
|
|
2093
|
+
withInputRequest(overrides),
|
|
2094
|
+
);
|
|
2095
|
+
|
|
2096
|
+
expect(response).toEqual({
|
|
2097
|
+
requestId: "req_1",
|
|
2098
|
+
text: "Quarterly results",
|
|
2099
|
+
});
|
|
2100
|
+
expect(response).not.toHaveProperty("optionId");
|
|
2101
|
+
},
|
|
2102
|
+
);
|
|
2103
|
+
|
|
2104
|
+
it("keeps a denial off the free-form path, which carries no answer", () => {
|
|
2105
|
+
expect(() =>
|
|
2106
|
+
toEveInputResponse(
|
|
2107
|
+
{ approvalId: "req_1", approved: false, reason: "not this one" },
|
|
2108
|
+
withInputRequest(),
|
|
2109
|
+
),
|
|
2110
|
+
).toThrow(/a refusal carries no answer for a free-form request/);
|
|
2111
|
+
});
|
|
2112
|
+
|
|
2113
|
+
it("submits the response text as the free-form answer", () => {
|
|
2114
|
+
expect(
|
|
2115
|
+
toEveInputResponse(
|
|
2116
|
+
{ approvalId: "req_1", approved: true, text: "staging" },
|
|
2117
|
+
withInputRequest({ display: "text" }),
|
|
2118
|
+
),
|
|
2119
|
+
).toEqual({ requestId: "req_1", text: "staging" });
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
it("prefers the response text over the reason it used to be smuggled in", () => {
|
|
2123
|
+
expect(
|
|
2124
|
+
toEveInputResponse(
|
|
2125
|
+
{
|
|
2126
|
+
approvalId: "req_1",
|
|
2127
|
+
approved: true,
|
|
2128
|
+
text: "staging",
|
|
2129
|
+
reason: "picked the safe one",
|
|
2130
|
+
},
|
|
2131
|
+
withInputRequest({ display: "text" }),
|
|
2132
|
+
),
|
|
2133
|
+
).toEqual({ requestId: "req_1", text: "staging" });
|
|
2134
|
+
});
|
|
2135
|
+
|
|
2136
|
+
it("never fabricates approve for a text-display request without an answer", () => {
|
|
2137
|
+
expect(() =>
|
|
2138
|
+
toEveInputResponse(
|
|
2139
|
+
{ approvalId: "req_1", approved: true },
|
|
2140
|
+
withInputRequest({ display: "text" }),
|
|
2141
|
+
),
|
|
2142
|
+
).toThrow(/pass the answer as the response text/);
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
it("never fabricates approve for a select request without a chosen option", () => {
|
|
2146
|
+
expect(() =>
|
|
2147
|
+
toEveInputResponse(
|
|
2148
|
+
{ approvalId: "req_1", approved: true },
|
|
2149
|
+
withInputRequest({ display: "select", options: environments }),
|
|
2150
|
+
),
|
|
2151
|
+
).toThrow(/respond with one of: staging, production/);
|
|
2152
|
+
});
|
|
2153
|
+
|
|
2154
|
+
it("throws when the response names an option the request does not carry", () => {
|
|
2155
|
+
expect(() =>
|
|
2156
|
+
toEveInputResponse(
|
|
2157
|
+
{ approvalId: "req_1", approved: false, optionId: "sandbox" },
|
|
2158
|
+
withInputRequest({ display: "select", options: environments }),
|
|
2159
|
+
),
|
|
2160
|
+
).toThrow(/no option with id "sandbox".*staging, production/s);
|
|
2161
|
+
|
|
2162
|
+
// A named option the request does not carry outranks the decision, even
|
|
2163
|
+
// when the decision names an option the request does carry: substituting
|
|
2164
|
+
// the literal deny here would discard the choice the caller made.
|
|
2165
|
+
expect(() =>
|
|
2166
|
+
toEveInputResponse(
|
|
2167
|
+
{ approvalId: "req_1", approved: false, optionId: "schedule-later" },
|
|
2168
|
+
withInputRequest({ display: "confirmation", options: approveCancel }),
|
|
2169
|
+
),
|
|
2170
|
+
).toThrow(/no option with id "schedule-later"/);
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
it("prefers a literal approve option over the free-form path on a text-display request", () => {
|
|
2174
|
+
const inputRequest = withInputRequest({
|
|
2175
|
+
display: "text",
|
|
2176
|
+
options: approveCancel,
|
|
2177
|
+
});
|
|
2178
|
+
|
|
2179
|
+
expect(
|
|
2180
|
+
toEveInputResponse({ approvalId: "req_1", approved: true }, inputRequest),
|
|
2181
|
+
).toEqual({ requestId: "req_1", optionId: "approve" });
|
|
2182
|
+
|
|
2183
|
+
expect(
|
|
2184
|
+
toEveInputResponse(
|
|
2185
|
+
{ approvalId: "req_1", approved: true, reason: "Quarterly results" },
|
|
2186
|
+
inputRequest,
|
|
2187
|
+
),
|
|
1724
2188
|
).toEqual({
|
|
1725
2189
|
requestId: "req_1",
|
|
1726
|
-
optionId: "
|
|
1727
|
-
text: "
|
|
2190
|
+
optionId: "approve",
|
|
2191
|
+
text: "Quarterly results",
|
|
2192
|
+
});
|
|
2193
|
+
});
|
|
2194
|
+
|
|
2195
|
+
it("answers a tool-approval by kind even when its options are missing", () => {
|
|
2196
|
+
const inputRequest = withInputRequest({
|
|
2197
|
+
kind: "tool-approval",
|
|
2198
|
+
display: "confirmation",
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
expect(
|
|
2202
|
+
toEveInputResponse({ approvalId: "req_1", approved: true }, inputRequest),
|
|
2203
|
+
).toEqual({ requestId: "req_1", optionId: "approve" });
|
|
2204
|
+
|
|
2205
|
+
expect(
|
|
2206
|
+
toEveInputResponse(
|
|
2207
|
+
{ approvalId: "req_1", approved: false, reason: "not now" },
|
|
2208
|
+
inputRequest,
|
|
2209
|
+
),
|
|
2210
|
+
).toEqual({ requestId: "req_1", optionId: "cancel", text: "not now" });
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
it("still requires a declared option when a tool-approval carries its own", () => {
|
|
2214
|
+
const inputRequest = withInputRequest({
|
|
2215
|
+
kind: "tool-approval",
|
|
2216
|
+
display: "confirmation",
|
|
2217
|
+
options: [{ id: "schedule-later", label: "Schedule later" }],
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
expect(() =>
|
|
2221
|
+
toEveInputResponse({ approvalId: "req_1", approved: true }, inputRequest),
|
|
2222
|
+
).toThrow(/respond with one of: schedule-later/);
|
|
2223
|
+
|
|
2224
|
+
expect(
|
|
2225
|
+
toEveInputResponse(
|
|
2226
|
+
{ approvalId: "req_1", approved: true, optionId: "schedule-later" },
|
|
2227
|
+
inputRequest,
|
|
2228
|
+
),
|
|
2229
|
+
).toEqual({ requestId: "req_1", optionId: "schedule-later" });
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
it("never answers an optionless confirmation as free-form text", () => {
|
|
2233
|
+
const inputRequest = withInputRequest({ display: "confirmation" });
|
|
2234
|
+
|
|
2235
|
+
for (const response of [
|
|
2236
|
+
{ approvalId: "req_1", approved: true },
|
|
2237
|
+
{ approvalId: "req_1", approved: false, reason: "not now" },
|
|
2238
|
+
{ approvalId: "req_1", approved: true, reason: "go ahead" },
|
|
2239
|
+
]) {
|
|
2240
|
+
expect(() => toEveInputResponse(response, inputRequest)).toThrow(
|
|
2241
|
+
/declares no options to respond with/,
|
|
2242
|
+
);
|
|
2243
|
+
}
|
|
2244
|
+
});
|
|
2245
|
+
});
|
|
2246
|
+
|
|
2247
|
+
describe("findEveInputRequest", () => {
|
|
2248
|
+
const data = {
|
|
2249
|
+
messages: [
|
|
2250
|
+
{
|
|
2251
|
+
id: "a1",
|
|
2252
|
+
role: "assistant",
|
|
2253
|
+
parts: [
|
|
2254
|
+
{
|
|
2255
|
+
type: "dynamic-tool",
|
|
2256
|
+
state: "approval-requested",
|
|
2257
|
+
toolCallId: "call_1",
|
|
2258
|
+
toolName: "ask_question",
|
|
2259
|
+
input: {},
|
|
2260
|
+
approval: { id: "req_1" },
|
|
2261
|
+
toolMetadata: {
|
|
2262
|
+
eve: {
|
|
2263
|
+
kind: "tool-call",
|
|
2264
|
+
name: "ask_question",
|
|
2265
|
+
inputRequest: {
|
|
2266
|
+
requestId: "req_1",
|
|
2267
|
+
prompt: "What should the subject line be?",
|
|
2268
|
+
kind: "question",
|
|
2269
|
+
display: "text",
|
|
2270
|
+
allowFreeform: true,
|
|
2271
|
+
},
|
|
2272
|
+
},
|
|
2273
|
+
},
|
|
2274
|
+
},
|
|
2275
|
+
],
|
|
2276
|
+
},
|
|
2277
|
+
],
|
|
2278
|
+
} satisfies EveMessageData;
|
|
2279
|
+
|
|
2280
|
+
it("finds the input request by approval id", () => {
|
|
2281
|
+
expect(findEveInputRequest(data, "req_1")).toMatchObject({
|
|
2282
|
+
requestId: "req_1",
|
|
2283
|
+
display: "text",
|
|
1728
2284
|
});
|
|
1729
2285
|
});
|
|
2286
|
+
|
|
2287
|
+
it("returns undefined for unknown approval ids", () => {
|
|
2288
|
+
expect(findEveInputRequest(data, "req_404")).toBeUndefined();
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
it.each([
|
|
2292
|
+
["carries no input request", { eve: { kind: "tool-call", name: "ask" } }],
|
|
2293
|
+
["carries no eve tool metadata at all", undefined],
|
|
2294
|
+
])("returns undefined when the matching part %s", (_label, toolMetadata) => {
|
|
2295
|
+
const part = { ...data.messages[0]!.parts[0]!, toolMetadata };
|
|
2296
|
+
const bare = { messages: [{ ...data.messages[0]!, parts: [part] }] };
|
|
2297
|
+
|
|
2298
|
+
expect(
|
|
2299
|
+
findEveInputRequest(bare as EveMessageData, "req_1"),
|
|
2300
|
+
).toBeUndefined();
|
|
2301
|
+
});
|
|
1730
2302
|
});
|