@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.
@@ -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: "deny", label: "Deny", style: "danger" },
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: "deny", kind: "reject-once", label: "Deny" },
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
- data: { turnId: "turn_1", stepIndex: 0, sequence: 1 },
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: "deny",
1432
- text: "Not yet",
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
  });