@assistant-ui/eve 0.0.15 → 0.0.17
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 +2 -1
- package/dist/convertEveMessages.d.ts.map +1 -1
- package/dist/convertEveMessages.js +32 -5
- package/dist/convertEveMessages.js.map +1 -1
- package/dist/deriveCreatedAt.d.ts +23 -0
- package/dist/deriveCreatedAt.d.ts.map +1 -0
- package/dist/deriveCreatedAt.js +79 -0
- package/dist/deriveCreatedAt.js.map +1 -0
- package/dist/useEveAgentRuntime.d.ts.map +1 -1
- package/dist/useEveAgentRuntime.js +13 -4
- package/dist/useEveAgentRuntime.js.map +1 -1
- package/package.json +6 -6
- package/src/convertEveMessages.test.ts +177 -2
- package/src/convertEveMessages.ts +52 -12
- package/src/deriveCreatedAt.test.ts +190 -0
- package/src/deriveCreatedAt.ts +96 -0
- package/src/useEveAgentRuntime.eveResume.test.tsx +80 -0
- package/src/useEveAgentRuntime.test.tsx +224 -5
- package/src/useEveAgentRuntime.ts +39 -8
|
@@ -207,6 +207,143 @@ describe("convertEveMessages", () => {
|
|
|
207
207
|
});
|
|
208
208
|
});
|
|
209
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
|
+
|
|
210
347
|
it.each([
|
|
211
348
|
[
|
|
212
349
|
"the full input request",
|
|
@@ -1376,6 +1513,7 @@ describe("convertEveMessages", () => {
|
|
|
1376
1513
|
...midStreamEvents.slice(0, 3),
|
|
1377
1514
|
{
|
|
1378
1515
|
type: "reasoning.appended",
|
|
1516
|
+
meta: eventMeta(2),
|
|
1379
1517
|
data: {
|
|
1380
1518
|
turnId: "turn_1",
|
|
1381
1519
|
stepIndex: 0,
|
|
@@ -1386,6 +1524,7 @@ describe("convertEveMessages", () => {
|
|
|
1386
1524
|
},
|
|
1387
1525
|
{
|
|
1388
1526
|
type: "actions.requested",
|
|
1527
|
+
meta: eventMeta(3),
|
|
1389
1528
|
data: {
|
|
1390
1529
|
turnId: "turn_1",
|
|
1391
1530
|
stepIndex: 0,
|
|
@@ -1425,6 +1564,7 @@ describe("convertEveMessages", () => {
|
|
|
1425
1564
|
...midStreamEvents.slice(0, 3),
|
|
1426
1565
|
{
|
|
1427
1566
|
type: "reasoning.appended",
|
|
1567
|
+
meta: eventMeta(2),
|
|
1428
1568
|
data: {
|
|
1429
1569
|
turnId: "turn_1",
|
|
1430
1570
|
stepIndex: 0,
|
|
@@ -1435,6 +1575,7 @@ describe("convertEveMessages", () => {
|
|
|
1435
1575
|
},
|
|
1436
1576
|
{
|
|
1437
1577
|
type: "actions.requested",
|
|
1578
|
+
meta: eventMeta(3),
|
|
1438
1579
|
data: {
|
|
1439
1580
|
turnId: "turn_1",
|
|
1440
1581
|
stepIndex: 0,
|
|
@@ -1451,6 +1592,7 @@ describe("convertEveMessages", () => {
|
|
|
1451
1592
|
},
|
|
1452
1593
|
{
|
|
1453
1594
|
type: "reasoning.completed",
|
|
1595
|
+
meta: eventMeta(4),
|
|
1454
1596
|
data: {
|
|
1455
1597
|
turnId: "turn_1",
|
|
1456
1598
|
stepIndex: 0,
|
|
@@ -1485,6 +1627,7 @@ describe("convertEveMessages", () => {
|
|
|
1485
1627
|
...midStreamEvents.slice(0, 3),
|
|
1486
1628
|
{
|
|
1487
1629
|
type: "message.appended",
|
|
1630
|
+
meta: eventMeta(2),
|
|
1488
1631
|
data: {
|
|
1489
1632
|
turnId: "turn_1",
|
|
1490
1633
|
stepIndex: 0,
|
|
@@ -1495,6 +1638,7 @@ describe("convertEveMessages", () => {
|
|
|
1495
1638
|
},
|
|
1496
1639
|
{
|
|
1497
1640
|
type: "message.completed",
|
|
1641
|
+
meta: eventMeta(3),
|
|
1498
1642
|
data: {
|
|
1499
1643
|
turnId: "turn_1",
|
|
1500
1644
|
stepIndex: 0,
|
|
@@ -1505,6 +1649,7 @@ describe("convertEveMessages", () => {
|
|
|
1505
1649
|
},
|
|
1506
1650
|
{
|
|
1507
1651
|
type: "step.completed",
|
|
1652
|
+
meta: eventMeta(4),
|
|
1508
1653
|
data: {
|
|
1509
1654
|
turnId: "turn_1",
|
|
1510
1655
|
stepIndex: 0,
|
|
@@ -1514,10 +1659,17 @@ describe("convertEveMessages", () => {
|
|
|
1514
1659
|
},
|
|
1515
1660
|
{
|
|
1516
1661
|
type: "step.started",
|
|
1517
|
-
|
|
1662
|
+
meta: eventMeta(5),
|
|
1663
|
+
data: {
|
|
1664
|
+
turnId: "turn_1",
|
|
1665
|
+
stepIndex: 1,
|
|
1666
|
+
sequence: 5,
|
|
1667
|
+
modelId: "test-model",
|
|
1668
|
+
},
|
|
1518
1669
|
},
|
|
1519
1670
|
{
|
|
1520
1671
|
type: "message.appended",
|
|
1672
|
+
meta: eventMeta(6),
|
|
1521
1673
|
data: {
|
|
1522
1674
|
turnId: "turn_1",
|
|
1523
1675
|
stepIndex: 1,
|
|
@@ -1958,13 +2110,36 @@ describe("toEveInputResponse", () => {
|
|
|
1958
2110
|
).toThrow(/a refusal carries no answer for a free-form request/);
|
|
1959
2111
|
});
|
|
1960
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
|
+
|
|
1961
2136
|
it("never fabricates approve for a text-display request without an answer", () => {
|
|
1962
2137
|
expect(() =>
|
|
1963
2138
|
toEveInputResponse(
|
|
1964
2139
|
{ approvalId: "req_1", approved: true },
|
|
1965
2140
|
withInputRequest({ display: "text" }),
|
|
1966
2141
|
),
|
|
1967
|
-
).toThrow(/pass the answer as the response
|
|
2142
|
+
).toThrow(/pass the answer as the response text/);
|
|
1968
2143
|
});
|
|
1969
2144
|
|
|
1970
2145
|
it("never fabricates approve for a select request without a chosen option", () => {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ThreadMessage,
|
|
14
14
|
type ThreadMessageLike,
|
|
15
15
|
type ThreadUserMessagePart,
|
|
16
|
+
type ToolApprovalDisplay,
|
|
16
17
|
type ToolApprovalOption,
|
|
17
18
|
type ToolCallMessagePart,
|
|
18
19
|
} from "@assistant-ui/core";
|
|
@@ -161,6 +162,44 @@ const toolApprovalOptionsFromInputRequest = (
|
|
|
161
162
|
}));
|
|
162
163
|
};
|
|
163
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Eve resolves a request the moment any response for it arrives, so the answer
|
|
167
|
+
* shapes it accepts decide both what the renderer may offer and what
|
|
168
|
+
* {@link toEveInputResponse} may submit. A request with no options and no
|
|
169
|
+
* `display` takes a typed answer, which is why the absence of both is not the
|
|
170
|
+
* same as a confirmation.
|
|
171
|
+
*/
|
|
172
|
+
const isDroppedOptionApproval = (inputRequest: EveMessageInputRequest) =>
|
|
173
|
+
inputRequest.kind === "tool-approval" && !inputRequest.options?.length;
|
|
174
|
+
|
|
175
|
+
const acceptsFreeformAnswer = (inputRequest: EveMessageInputRequest): boolean =>
|
|
176
|
+
inputRequest.display !== "confirmation" &&
|
|
177
|
+
!isDroppedOptionApproval(inputRequest) &&
|
|
178
|
+
(inputRequest.display === "text" ||
|
|
179
|
+
inputRequest.allowFreeform === true ||
|
|
180
|
+
!inputRequest.options?.length);
|
|
181
|
+
|
|
182
|
+
const toolApprovalDisplay = (
|
|
183
|
+
inputRequest: EveMessageInputRequest,
|
|
184
|
+
): ToolApprovalDisplay => {
|
|
185
|
+
// The mapper answers this shape through its approve/cancel branch whatever
|
|
186
|
+
// the request declares, so the projection has to agree: presenting it as a
|
|
187
|
+
// question would offer an answer the mapper turns into a decision.
|
|
188
|
+
if (isDroppedOptionApproval(inputRequest)) return "decision";
|
|
189
|
+
|
|
190
|
+
switch (inputRequest.display) {
|
|
191
|
+
case "confirmation":
|
|
192
|
+
return "decision";
|
|
193
|
+
case "select":
|
|
194
|
+
return "select";
|
|
195
|
+
case "text":
|
|
196
|
+
return "text";
|
|
197
|
+
default:
|
|
198
|
+
if (inputRequest.kind === "tool-approval") return "decision";
|
|
199
|
+
return inputRequest.options?.length ? "select" : "text";
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
164
203
|
const toApproval = (
|
|
165
204
|
part: EveDynamicToolPart,
|
|
166
205
|
): ToolCallMessagePart["approval"] | undefined => {
|
|
@@ -176,12 +215,16 @@ const toApproval = (
|
|
|
176
215
|
|
|
177
216
|
const approval = part.approval;
|
|
178
217
|
if (!approval) return undefined;
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
);
|
|
218
|
+
const inputRequest = part.toolMetadata?.eve?.inputRequest;
|
|
219
|
+
const options = toolApprovalOptionsFromInputRequest(inputRequest);
|
|
182
220
|
|
|
183
221
|
return {
|
|
184
222
|
id: approval.id,
|
|
223
|
+
...(inputRequest && {
|
|
224
|
+
prompt: inputRequest.prompt,
|
|
225
|
+
display: toolApprovalDisplay(inputRequest),
|
|
226
|
+
...(acceptsFreeformAnswer(inputRequest) && { allowFreeform: true }),
|
|
227
|
+
}),
|
|
185
228
|
...(approval.approved !== undefined && { approved: approval.approved }),
|
|
186
229
|
...(approval.reason && { reason: approval.reason }),
|
|
187
230
|
...(approval.isAutomatic !== undefined && {
|
|
@@ -587,7 +630,8 @@ export const findEveInputRequest = (
|
|
|
587
630
|
* When `inputRequest` is known, every returned response carries either an
|
|
588
631
|
* option the request declares or a free-form answer. A literal option match
|
|
589
632
|
* wins, then the `"approve"` / `"cancel"` option the response's boolean decision
|
|
590
|
-
* names, then the response's `
|
|
633
|
+
* names, then the response's `text` (or its `reason`, for a caller that
|
|
634
|
+
* predates the first-class answer field) when the request takes a free-form
|
|
591
635
|
* answer (`display: "text"`, `allowFreeform`, or no options at all, and never
|
|
592
636
|
* `display: "confirmation"`) and the response is not a refusal.
|
|
593
637
|
* A one-argument call, or a part with no request, still forwards a caller
|
|
@@ -611,7 +655,7 @@ export const toEveInputResponse = (
|
|
|
611
655
|
): InputResponse => {
|
|
612
656
|
const requestId = response.approvalId;
|
|
613
657
|
const options = inputRequest?.options;
|
|
614
|
-
const text = response.reason;
|
|
658
|
+
const text = response.text ?? response.reason;
|
|
615
659
|
|
|
616
660
|
if (response.optionId !== undefined) {
|
|
617
661
|
if (
|
|
@@ -649,11 +693,7 @@ export const toEveInputResponse = (
|
|
|
649
693
|
// Eve recognises an approval by its literal two-option `approve`/`cancel`
|
|
650
694
|
// shape, so past this point the request is a question and the boolean
|
|
651
695
|
// 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);
|
|
696
|
+
const acceptsText = acceptsFreeformAnswer(inputRequest);
|
|
657
697
|
if (acceptsText && text && response.approved !== false) {
|
|
658
698
|
return { requestId, text };
|
|
659
699
|
}
|
|
@@ -665,8 +705,8 @@ export const toEveInputResponse = (
|
|
|
665
705
|
: !acceptsText
|
|
666
706
|
? "the request declares no options to respond with"
|
|
667
707
|
: 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
|
|
669
|
-
: "pass the answer as the response
|
|
708
|
+
? "a refusal carries no answer for a free-form request; resend with `approved` unset or true and the answer in the response text"
|
|
709
|
+
: "pass the answer as the response text, because the request takes a free-form answer"
|
|
670
710
|
}`,
|
|
671
711
|
);
|
|
672
712
|
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { MessageStreamEvent } from "eve/client";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
collectTurnTimestamps,
|
|
6
|
+
createTurnTimestampCache,
|
|
7
|
+
} from "./deriveCreatedAt";
|
|
8
|
+
|
|
9
|
+
const TURN = "turn_1";
|
|
10
|
+
|
|
11
|
+
const turnStarted = (at: string, turnId = TURN) =>
|
|
12
|
+
({
|
|
13
|
+
type: "turn.started",
|
|
14
|
+
data: { sequence: 1, turnId },
|
|
15
|
+
meta: { at, id: `evt_${at}` },
|
|
16
|
+
}) as const satisfies MessageStreamEvent;
|
|
17
|
+
|
|
18
|
+
const messageReceived = (at: string, turnId = TURN) =>
|
|
19
|
+
({
|
|
20
|
+
type: "message.received",
|
|
21
|
+
data: { message: "hi", sequence: 2, turnId },
|
|
22
|
+
meta: { at, id: `evt_${at}` },
|
|
23
|
+
}) as const satisfies MessageStreamEvent;
|
|
24
|
+
|
|
25
|
+
const compactionRequested = (at: string, turnId = TURN) =>
|
|
26
|
+
({
|
|
27
|
+
type: "compaction.requested",
|
|
28
|
+
data: {
|
|
29
|
+
modelId: "m",
|
|
30
|
+
sequence: 3,
|
|
31
|
+
sessionId: "s",
|
|
32
|
+
turnId,
|
|
33
|
+
usageInputTokens: null,
|
|
34
|
+
},
|
|
35
|
+
meta: { at, id: `evt_${at}` },
|
|
36
|
+
}) as const satisfies MessageStreamEvent;
|
|
37
|
+
|
|
38
|
+
const messageAppended = (at: string, turnId = TURN) =>
|
|
39
|
+
({
|
|
40
|
+
type: "message.appended",
|
|
41
|
+
data: {
|
|
42
|
+
messageDelta: "he",
|
|
43
|
+
messageSoFar: "he",
|
|
44
|
+
sequence: 4,
|
|
45
|
+
stepIndex: 0,
|
|
46
|
+
turnId,
|
|
47
|
+
},
|
|
48
|
+
meta: { at, id: `evt_${at}` },
|
|
49
|
+
}) as const satisfies MessageStreamEvent;
|
|
50
|
+
|
|
51
|
+
const stepStarted = (at: string, turnId = TURN) =>
|
|
52
|
+
({
|
|
53
|
+
type: "step.started",
|
|
54
|
+
data: { modelId: "m", sequence: 4, stepIndex: 0, turnId },
|
|
55
|
+
meta: { at, id: `evt_${at}` },
|
|
56
|
+
}) as const satisfies MessageStreamEvent;
|
|
57
|
+
|
|
58
|
+
describe("collectTurnTimestamps", () => {
|
|
59
|
+
it("stamps the user at message.received and the assistant at step.started, skipping the turn's other events", () => {
|
|
60
|
+
const timestamps = collectTurnTimestamps(
|
|
61
|
+
[
|
|
62
|
+
turnStarted("2026-01-02T10:00:00.000Z"),
|
|
63
|
+
messageReceived("2026-01-02T10:00:01.000Z"),
|
|
64
|
+
compactionRequested("2026-01-02T10:00:02.000Z"),
|
|
65
|
+
stepStarted("2026-01-02T10:02:00.000Z"),
|
|
66
|
+
stepStarted("2026-01-02T10:03:00.000Z"),
|
|
67
|
+
],
|
|
68
|
+
createTurnTimestampCache(),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(timestamps.get(TURN)?.user).toEqual(
|
|
72
|
+
new Date("2026-01-02T10:00:01.000Z"),
|
|
73
|
+
);
|
|
74
|
+
expect(timestamps.get(TURN)?.assistant).toEqual(
|
|
75
|
+
new Date("2026-01-02T10:02:00.000Z"),
|
|
76
|
+
);
|
|
77
|
+
expect(timestamps.get("turn_other")?.user).toBe(undefined);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("resumes the scan at appended events and keeps the map identity when they teach nothing", () => {
|
|
81
|
+
const cache = createTurnTimestampCache();
|
|
82
|
+
const events = [
|
|
83
|
+
turnStarted("2026-01-02T10:00:00.000Z"),
|
|
84
|
+
messageReceived("2026-01-02T10:00:01.000Z"),
|
|
85
|
+
];
|
|
86
|
+
const first = collectTurnTimestamps(events, cache);
|
|
87
|
+
expect(first.get(TURN)?.assistant).toBe(undefined);
|
|
88
|
+
|
|
89
|
+
const quiet = [
|
|
90
|
+
...events,
|
|
91
|
+
turnStarted("2026-01-02T10:00:02.000Z", "turn_2"),
|
|
92
|
+
];
|
|
93
|
+
expect(collectTurnTimestamps(quiet, cache)).toBe(first);
|
|
94
|
+
|
|
95
|
+
const timestamps = collectTurnTimestamps(
|
|
96
|
+
[...quiet, stepStarted("2026-01-02T10:02:00.000Z")],
|
|
97
|
+
cache,
|
|
98
|
+
);
|
|
99
|
+
expect(timestamps.get(TURN)?.user).toEqual(
|
|
100
|
+
new Date("2026-01-02T10:00:01.000Z"),
|
|
101
|
+
);
|
|
102
|
+
expect(timestamps.get(TURN)?.assistant).toEqual(
|
|
103
|
+
new Date("2026-01-02T10:02:00.000Z"),
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("stamps an assistant created by a turn ending before its first step", () => {
|
|
108
|
+
const timestamps = collectTurnTimestamps(
|
|
109
|
+
[
|
|
110
|
+
messageReceived("2026-01-02T10:00:01.000Z"),
|
|
111
|
+
{
|
|
112
|
+
type: "turn.cancelled",
|
|
113
|
+
data: { sequence: 3, turnId: TURN },
|
|
114
|
+
meta: { at: "2026-01-02T10:00:05.000Z", id: "evt_cancel" },
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
createTurnTimestampCache(),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(timestamps.get(TURN)?.assistant).toEqual(
|
|
121
|
+
new Date("2026-01-02T10:00:05.000Z"),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("stamps the assistant from the turn's first message-creating event when no step.started precedes it", () => {
|
|
126
|
+
const timestamps = collectTurnTimestamps(
|
|
127
|
+
[
|
|
128
|
+
turnStarted("2026-01-02T10:00:00.000Z"),
|
|
129
|
+
messageReceived("2026-01-02T10:00:01.000Z"),
|
|
130
|
+
compactionRequested("2026-01-02T10:00:02.000Z"),
|
|
131
|
+
messageAppended("2026-01-02T10:02:00.000Z"),
|
|
132
|
+
],
|
|
133
|
+
createTurnTimestampCache(),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
expect(timestamps.get(TURN)?.assistant).toEqual(
|
|
137
|
+
new Date("2026-01-02T10:02:00.000Z"),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("skips malformed events without throwing", () => {
|
|
142
|
+
const events = [
|
|
143
|
+
{ type: "message.received", data: null, meta: { at: "x", id: "e1" } },
|
|
144
|
+
{ type: "step.started", meta: { at: "2026-01-01T00:00:00Z", id: "e2" } },
|
|
145
|
+
{
|
|
146
|
+
type: "message.received",
|
|
147
|
+
data: { turnId: 42 },
|
|
148
|
+
meta: { at: "x", id: "e3" },
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
type: "message.received",
|
|
152
|
+
data: { message: "hi", sequence: 2, turnId: TURN },
|
|
153
|
+
meta: { at: "not a date", id: "e4" },
|
|
154
|
+
},
|
|
155
|
+
{ type: "step.started", data: { stepIndex: 0, turnId: TURN } },
|
|
156
|
+
stepStarted("2026-01-02T10:02:00.000Z"),
|
|
157
|
+
] as never as readonly MessageStreamEvent[];
|
|
158
|
+
|
|
159
|
+
const timestamps = collectTurnTimestamps(
|
|
160
|
+
events,
|
|
161
|
+
createTurnTimestampCache(),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(timestamps.get(TURN)?.user).toBe(undefined);
|
|
165
|
+
expect(timestamps.get(TURN)?.assistant).toEqual(
|
|
166
|
+
new Date("2026-01-02T10:02:00.000Z"),
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("re-derives from scratch when the log is replaced, so a recurring turn id serves the new session's stamps", () => {
|
|
171
|
+
const cache = createTurnTimestampCache();
|
|
172
|
+
collectTurnTimestamps(
|
|
173
|
+
[
|
|
174
|
+
messageReceived("2026-01-02T10:00:01.000Z"),
|
|
175
|
+
stepStarted("2026-01-02T10:02:00.000Z"),
|
|
176
|
+
],
|
|
177
|
+
cache,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const timestamps = collectTurnTimestamps(
|
|
181
|
+
[messageReceived("2026-03-01T09:00:01.000Z")],
|
|
182
|
+
cache,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
expect(timestamps.get(TURN)?.user).toEqual(
|
|
186
|
+
new Date("2026-03-01T09:00:01.000Z"),
|
|
187
|
+
);
|
|
188
|
+
expect(timestamps.get(TURN)?.assistant).toBe(undefined);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { MessageStreamEvent } from "eve/client";
|
|
2
|
+
|
|
3
|
+
export type TurnTimestamps = {
|
|
4
|
+
readonly user?: Date;
|
|
5
|
+
readonly assistant?: Date;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type TurnTimestampCache = {
|
|
9
|
+
lastEvents: readonly MessageStreamEvent[];
|
|
10
|
+
timestamps: ReadonlyMap<string, TurnTimestamps>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The reducer cases eve projects into a message: `message.received` creates
|
|
15
|
+
* `${turnId}:user`, and every assistant entry reaches `updateAssistantMessage`,
|
|
16
|
+
* which creates `${turnId}:assistant` for a turn that has none. It enumerates
|
|
17
|
+
* that reducer across the `>=0.32.0` peer range rather than stating a rule
|
|
18
|
+
* about turn ids, because `turn.started` and the compaction events carry one
|
|
19
|
+
* before the model runs. An event eve adds to the creating set later is absent
|
|
20
|
+
* here and falls back to the wall clock instead of stamping a wrong time.
|
|
21
|
+
*/
|
|
22
|
+
const ROLE_BY_EVENT_TYPE: Partial<
|
|
23
|
+
Record<MessageStreamEvent["type"], keyof TurnTimestamps>
|
|
24
|
+
> = {
|
|
25
|
+
"message.received": "user",
|
|
26
|
+
"step.started": "assistant",
|
|
27
|
+
"reasoning.appended": "assistant",
|
|
28
|
+
"reasoning.completed": "assistant",
|
|
29
|
+
"action.input.appended": "assistant",
|
|
30
|
+
"actions.requested": "assistant",
|
|
31
|
+
"input.requested": "assistant",
|
|
32
|
+
"action.result": "assistant",
|
|
33
|
+
"action.partial": "assistant",
|
|
34
|
+
"authorization.required": "assistant",
|
|
35
|
+
"authorization.completed": "assistant",
|
|
36
|
+
"message.appended": "assistant",
|
|
37
|
+
"message.completed": "assistant",
|
|
38
|
+
"result.completed": "assistant",
|
|
39
|
+
"turn.completed": "assistant",
|
|
40
|
+
"turn.cancelled": "assistant",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const createTurnTimestampCache = (): TurnTimestampCache => ({
|
|
44
|
+
lastEvents: [],
|
|
45
|
+
timestamps: new Map(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Eve appends to its log (`[...events, event]`), so a snapshot that keeps the
|
|
50
|
+
* previous scan's last element by identity shares the whole prefix and the
|
|
51
|
+
* scan resumes there. Any other snapshot is re-derived into a fresh map: turn
|
|
52
|
+
* ids are per-session sequence numbers that recur after `reset()`, so entries
|
|
53
|
+
* must not outlive the log that produced them. Re-derivation is also what
|
|
54
|
+
* makes the render-phase cache writes safe when React discards a render.
|
|
55
|
+
*/
|
|
56
|
+
export const collectTurnTimestamps = (
|
|
57
|
+
events: readonly MessageStreamEvent[],
|
|
58
|
+
cache: TurnTimestampCache,
|
|
59
|
+
): ReadonlyMap<string, TurnTimestamps> => {
|
|
60
|
+
if (events === cache.lastEvents) return cache.timestamps;
|
|
61
|
+
|
|
62
|
+
const scanned = cache.lastEvents;
|
|
63
|
+
const resumesScan =
|
|
64
|
+
scanned.length === 0 ||
|
|
65
|
+
events[scanned.length - 1] === scanned[scanned.length - 1];
|
|
66
|
+
|
|
67
|
+
let timestamps = cache.timestamps;
|
|
68
|
+
let draft: Map<string, TurnTimestamps> | undefined;
|
|
69
|
+
if (!resumesScan) {
|
|
70
|
+
draft = new Map();
|
|
71
|
+
timestamps = draft;
|
|
72
|
+
}
|
|
73
|
+
for (let i = resumesScan ? scanned.length : 0; i < events.length; i++) {
|
|
74
|
+
const event = events[i]!;
|
|
75
|
+
const role = ROLE_BY_EVENT_TYPE[event.type];
|
|
76
|
+
if (role === undefined) continue;
|
|
77
|
+
const data: unknown = (event as { readonly data?: unknown }).data;
|
|
78
|
+
if (typeof data !== "object" || data === null) continue;
|
|
79
|
+
const turnId = (data as { readonly turnId?: unknown }).turnId;
|
|
80
|
+
if (typeof turnId !== "string") continue;
|
|
81
|
+
const known = timestamps.get(turnId);
|
|
82
|
+
if (known?.[role] !== undefined) continue;
|
|
83
|
+
const at: unknown = (event as { readonly meta?: { readonly at?: unknown } })
|
|
84
|
+
.meta?.at;
|
|
85
|
+
if (typeof at !== "string") continue;
|
|
86
|
+
const date = new Date(at);
|
|
87
|
+
if (Number.isNaN(date.getTime())) continue;
|
|
88
|
+
draft ??= new Map(timestamps);
|
|
89
|
+
draft.set(turnId, { ...known, [role]: date });
|
|
90
|
+
timestamps = draft;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
cache.lastEvents = events;
|
|
94
|
+
cache.timestamps = timestamps;
|
|
95
|
+
return timestamps;
|
|
96
|
+
};
|