@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.
@@ -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
- data: { turnId: "turn_1", stepIndex: 1, sequence: 5 },
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 reason/);
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 options = toolApprovalOptionsFromInputRequest(
180
- part.toolMetadata?.eve?.inputRequest,
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 `reason` text when the request takes a free-form
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 reason"
669
- : "pass the answer as the response reason, because the request takes a free-form answer"
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
+ };