@assistant-ui/eve 0.0.24 → 0.0.26

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.
Files changed (37) hide show
  1. package/dist/convertEveMessages.d.ts +40 -51
  2. package/dist/convertEveMessages.d.ts.map +1 -1
  3. package/dist/convertEveMessages.js +146 -117
  4. package/dist/convertEveMessages.js.map +1 -1
  5. package/dist/deriveCreatedAt.d.ts +9 -9
  6. package/dist/deriveCreatedAt.d.ts.map +1 -1
  7. package/dist/deriveCreatedAt.js +25 -34
  8. package/dist/deriveCreatedAt.js.map +1 -1
  9. package/dist/eveCloudSessions.js +1 -2
  10. package/dist/eveExtras.d.ts +4 -2
  11. package/dist/eveExtras.d.ts.map +1 -1
  12. package/dist/eveExtras.js +1 -2
  13. package/dist/eveExtras.js.map +1 -1
  14. package/dist/hooks.d.ts +4 -3
  15. package/dist/hooks.d.ts.map +1 -1
  16. package/dist/hooks.js +5 -5
  17. package/dist/hooks.js.map +1 -1
  18. package/dist/sdkIdentity.js +2 -3
  19. package/dist/useEveAgentRuntime.d.ts +3 -5
  20. package/dist/useEveAgentRuntime.d.ts.map +1 -1
  21. package/dist/useEveAgentRuntime.js +34 -23
  22. package/dist/useEveAgentRuntime.js.map +1 -1
  23. package/package.json +10 -10
  24. package/src/convertEveMessages.test.ts +925 -729
  25. package/src/convertEveMessages.ts +282 -227
  26. package/src/deriveCreatedAt.test.ts +90 -88
  27. package/src/deriveCreatedAt.ts +32 -36
  28. package/src/eveExtras.ts +4 -4
  29. package/src/hooks.ts +4 -3
  30. package/src/testUtils.ts +189 -8
  31. package/src/useEveAgentRuntime.cloud.test.tsx +105 -136
  32. package/src/useEveAgentRuntime.eveResume.test.tsx +22 -104
  33. package/src/useEveAgentRuntime.fast-refresh.test.tsx +195 -37
  34. package/src/useEveAgentRuntime.interruptedTurn.test.tsx +86 -40
  35. package/src/useEveAgentRuntime.refetch.test.tsx +19 -58
  36. package/src/useEveAgentRuntime.test.tsx +372 -291
  37. package/src/useEveAgentRuntime.ts +100 -113
@@ -1,12 +1,14 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import type { EveMessageData, EveMessageInputRequest } from "eve/react";
2
+ import type { EveInteractionPart, EveToolPart, Interaction } from "eve/react";
3
3
  import {
4
- defaultMessageReducer,
5
- type EveAgentReducerEvent,
6
- type MessageStreamEvent,
4
+ conversationOf,
5
+ type ConversationState,
6
+ type SessionEvent,
7
+ type SessionStreamEvent,
7
8
  } from "eve/client";
8
9
  import {
9
10
  collectInterruptedTurnEvents,
11
+ convertEveMessage,
10
12
  convertEveMessages,
11
13
  type InterruptedTurnEventCache,
12
14
  findEveInputRequest,
@@ -15,36 +17,137 @@ import {
15
17
  } from "./convertEveMessages";
16
18
  import type { AppendMessage } from "@assistant-ui/core";
17
19
 
18
- const withApprovalPart = (eve?: {
19
- kind: "tool-call";
20
- name: string;
21
- inputRequest?: EveMessageInputRequest;
22
- }): EveMessageData => ({
20
+ type MessageData = Pick<ConversationState, "messages">;
21
+ type InputRequest = Interaction["request"];
22
+
23
+ const approvalInteraction = (
24
+ request: InputRequest = { kind: "approval", prompt: "Send the email?" },
25
+ ): Interaction => ({
26
+ interactionId: "req_1",
27
+ turnId: "turn_1",
28
+ stepIndex: 0,
29
+ status: "open",
30
+ request,
31
+ call: { callId: "call_1", name: "send_email", input: {} },
32
+ });
33
+
34
+ const toolPart = (
35
+ overrides: {
36
+ state?: "requested" | "approval" | "rejected";
37
+ input?: unknown;
38
+ interaction?: Interaction;
39
+ name?: string;
40
+ label?: string;
41
+ } = {},
42
+ ): Exclude<EveToolPart, { state: "streaming" }> => ({
43
+ type: "tool",
44
+ callId: "call_1",
45
+ stepIndex: 0,
46
+ kind: "tool",
47
+ name: "send_email",
48
+ label: "send_email",
49
+ state: overrides.interaction ? "approval" : "requested",
50
+ input: {},
51
+ ...overrides,
52
+ });
53
+
54
+ const signInPart = (
55
+ request: InputRequest,
56
+ overrides: Partial<Interaction> = {},
57
+ ): EveInteractionPart => ({
58
+ type: "interaction",
59
+ interactionId: "auth_1",
60
+ turnId: "turn_1",
61
+ stepIndex: 0,
62
+ status: "open",
63
+ request,
64
+ ...overrides,
65
+ });
66
+
67
+ const withApprovalPart = (request?: InputRequest): MessageData => ({
23
68
  messages: [
24
69
  {
25
70
  id: "a1",
26
71
  role: "assistant",
27
- parts: [
28
- {
29
- type: "dynamic-tool",
30
- state: "approval-requested",
31
- toolCallId: "call_1",
32
- toolName: "send_email",
33
- input: {},
34
- approval: { id: "req_1" },
35
- ...(eve && { toolMetadata: { eve } }),
36
- },
37
- ],
72
+ parts: [toolPart({ interaction: approvalInteraction(request) })],
38
73
  },
39
74
  ],
40
75
  });
41
76
 
42
77
  const eventMeta = (sequence: number) => ({
43
78
  at: "2026-01-01T00:00:00.000Z",
44
- id: `evt_${sequence}`,
79
+ position: { line: sequence, index: 0 },
80
+ });
81
+
82
+ const replay = (events: readonly SessionEvent[]) =>
83
+ conversationOf(
84
+ events.map((event, index) => ({ ...event, meta: eventMeta(index) })),
85
+ );
86
+
87
+ const initialTurnEvents = [
88
+ { type: "session.started", data: {} },
89
+ { type: "delivery.admitted", data: { deliveryId: "delivery_1" } },
90
+ {
91
+ type: "turn.started",
92
+ data: {
93
+ turnId: "turn_1",
94
+ cause: { deliveryId: "delivery_1" },
95
+ follows: null,
96
+ },
97
+ },
98
+ {
99
+ type: "delivery.consumed",
100
+ data: {
101
+ deliveryId: "delivery_1",
102
+ turnId: "turn_1",
103
+ parts: [{ kind: "text", text: "hi" }],
104
+ },
105
+ },
106
+ {
107
+ type: "model.requested",
108
+ scope: { turnId: "turn_1" },
109
+ data: { runId: "run_1", owner: { turnId: "turn_1" } },
110
+ },
111
+ {
112
+ type: "model.started",
113
+ scope: { turnId: "turn_1", runId: "run_1" },
114
+ data: { runId: "run_1", modelId: "test-model", attempt: 0 },
115
+ },
116
+ ] as const satisfies readonly SessionEvent[];
117
+
118
+ const signInOpened = (
119
+ name: string,
120
+ signIn: Partial<NonNullable<InputRequest["signIn"]>> = {},
121
+ ): SessionEvent => ({
122
+ type: "interaction.opened",
123
+ scope: { turnId: "turn_1" },
124
+ data: {
125
+ interactionId: `auth_${name}`,
126
+ subject: { turnId: "turn_1" },
127
+ request: {
128
+ kind: "sign-in",
129
+ prompt: `Authorization required for ${signIn.displayName ?? name}`,
130
+ signIn: { name, ...signIn },
131
+ },
132
+ },
45
133
  });
46
134
 
47
135
  describe("convertEveMessages", () => {
136
+ it("converts a single completed message with default options", () => {
137
+ const message = {
138
+ id: "a1",
139
+ role: "assistant",
140
+ parts: [{ type: "text", text: "Hello" }],
141
+ } as const satisfies MessageData["messages"][number];
142
+
143
+ expect(convertEveMessage(message, 0, [message])).toMatchObject({
144
+ id: "a1",
145
+ role: "assistant",
146
+ content: [{ type: "text", text: "Hello" }],
147
+ status: { type: "complete", reason: "stop" },
148
+ });
149
+ });
150
+
48
151
  it("converts text and reasoning parts", () => {
49
152
  const data = {
50
153
  messages: [
@@ -63,7 +166,7 @@ describe("convertEveMessages", () => {
63
166
  ],
64
167
  },
65
168
  ],
66
- } satisfies EveMessageData;
169
+ } satisfies MessageData;
67
170
 
68
171
  const messages = convertEveMessages(data, { isRunning: true });
69
172
 
@@ -97,7 +200,7 @@ describe("convertEveMessages", () => {
97
200
  ],
98
201
  },
99
202
  ],
100
- } satisfies EveMessageData;
203
+ } satisfies MessageData;
101
204
 
102
205
  const messages = convertEveMessages(data, { isRunning: true });
103
206
 
@@ -119,7 +222,7 @@ describe("convertEveMessages", () => {
119
222
  ],
120
223
  },
121
224
  ],
122
- } satisfies EveMessageData;
225
+ } satisfies MessageData;
123
226
 
124
227
  const messages = convertEveMessages(data);
125
228
 
@@ -144,7 +247,7 @@ describe("convertEveMessages", () => {
144
247
  ],
145
248
  },
146
249
  ],
147
- } satisfies EveMessageData;
250
+ } satisfies MessageData;
148
251
 
149
252
  const messages = convertEveMessages(data);
150
253
 
@@ -153,42 +256,30 @@ describe("convertEveMessages", () => {
153
256
  }
154
257
  });
155
258
 
156
- it("converts dynamic tool parts with approval options", () => {
259
+ it("converts tool parts with approval options", () => {
157
260
  const data = {
158
261
  messages: [
159
262
  {
160
263
  id: "a1",
161
264
  role: "assistant",
162
265
  parts: [
163
- {
164
- type: "dynamic-tool",
165
- state: "approval-requested",
166
- toolCallId: "call_1",
167
- toolName: "send_email",
266
+ toolPart({
168
267
  input: { to: "dev@example.com" },
169
- approval: { id: "req_1" },
170
- toolMetadata: {
171
- eve: {
172
- kind: "tool-call",
173
- name: "send_email",
174
- inputRequest: {
175
- requestId: "req_1",
176
- kind: "tool-approval",
177
- prompt: "Send the email?",
178
- display: "confirmation",
179
- options: [
180
- { id: "approve", label: "Approve" },
181
- { id: "cancel", label: "Cancel", style: "danger" },
182
- { id: "escalate", label: "Escalate" },
183
- ],
184
- },
185
- },
186
- },
187
- },
268
+ interaction: approvalInteraction({
269
+ kind: "approval",
270
+ prompt: "Send the email?",
271
+ display: "confirmation",
272
+ options: [
273
+ { id: "approve", label: "Approve" },
274
+ { id: "cancel", label: "Cancel", style: "danger" },
275
+ { id: "escalate", label: "Escalate" },
276
+ ],
277
+ }),
278
+ }),
188
279
  ],
189
280
  },
190
281
  ],
191
- } satisfies EveMessageData;
282
+ } satisfies MessageData;
192
283
 
193
284
  const [message] = convertEveMessages(data);
194
285
 
@@ -204,7 +295,7 @@ describe("convertEveMessages", () => {
204
295
  id: "req_1",
205
296
  options: [
206
297
  { id: "approve", kind: "allow-once", label: "Approve" },
207
- { id: "cancel", kind: "reject-once", label: "Cancel" },
298
+ { id: "cancel", kind: "_cancel", label: "Cancel" },
208
299
  { id: "escalate", kind: "_escalate", label: "Escalate" },
209
300
  ],
210
301
  },
@@ -213,7 +304,7 @@ describe("convertEveMessages", () => {
213
304
  });
214
305
  });
215
306
 
216
- it("keeps streaming dynamic tool args incomplete until input is available", () => {
307
+ it("keeps streaming tool args incomplete until input is available", () => {
217
308
  const streamingData = {
218
309
  messages: [
219
310
  {
@@ -221,17 +312,19 @@ describe("convertEveMessages", () => {
221
312
  role: "assistant",
222
313
  parts: [
223
314
  {
224
- type: "dynamic-tool",
225
- state: "input-streaming",
226
- toolCallId: "call_1",
227
- toolName: "search",
228
- input: undefined,
315
+ type: "tool",
316
+ state: "streaming",
317
+ callId: "call_1",
318
+ stepIndex: 0,
319
+ kind: "tool",
320
+ name: "search",
321
+ label: "search",
229
322
  inputText: "",
230
323
  },
231
324
  ],
232
325
  },
233
326
  ],
234
- } satisfies EveMessageData;
327
+ } satisfies MessageData;
235
328
 
236
329
  const [streamingMessage] = convertEveMessages(streamingData);
237
330
 
@@ -247,17 +340,15 @@ describe("convertEveMessages", () => {
247
340
  id: "a1",
248
341
  role: "assistant",
249
342
  parts: [
250
- {
251
- type: "dynamic-tool",
252
- state: "input-available",
253
- toolCallId: "call_1",
254
- toolName: "search",
343
+ toolPart({
344
+ name: "search",
345
+ label: "search",
255
346
  input: { query: "badge" },
256
- },
347
+ }),
257
348
  ],
258
349
  },
259
350
  ],
260
- } satisfies EveMessageData;
351
+ } satisfies MessageData;
261
352
 
262
353
  const [availableMessage] = convertEveMessages(availableData);
263
354
 
@@ -272,8 +363,7 @@ describe("convertEveMessages", () => {
272
363
  [
273
364
  "a confirmation request",
274
365
  {
275
- requestId: "req_1",
276
- kind: "tool-approval",
366
+ kind: "approval",
277
367
  prompt: "Send the email?",
278
368
  display: "confirmation",
279
369
  options: [
@@ -286,7 +376,6 @@ describe("convertEveMessages", () => {
286
376
  [
287
377
  "a select question that also takes a typed answer",
288
378
  {
289
- requestId: "req_1",
290
379
  kind: "question",
291
380
  prompt: "Which environment?",
292
381
  display: "select",
@@ -302,7 +391,6 @@ describe("convertEveMessages", () => {
302
391
  [
303
392
  "a text question",
304
393
  {
305
- requestId: "req_1",
306
394
  kind: "question",
307
395
  prompt: "What should the subject line be?",
308
396
  display: "text",
@@ -315,19 +403,13 @@ describe("convertEveMessages", () => {
315
403
  ],
316
404
  [
317
405
  "a question with neither a display nor options",
318
- { requestId: "req_1", kind: "question", prompt: "Anything else?" },
406
+ { kind: "question", prompt: "Anything else?" },
319
407
  { prompt: "Anything else?", display: "text", allowFreeform: true },
320
408
  ],
321
- ] satisfies [string, EveMessageInputRequest, object][])(
409
+ ] satisfies [string, InputRequest, object][])(
322
410
  "describes %s on the approval itself",
323
411
  (_label, inputRequest, expected) => {
324
- const [message] = convertEveMessages(
325
- withApprovalPart({
326
- kind: "tool-call",
327
- name: "send_email",
328
- inputRequest,
329
- }),
330
- );
412
+ const [message] = convertEveMessages(withApprovalPart(inputRequest));
331
413
 
332
414
  expect(message!.content[0]).toMatchObject({ approval: expected });
333
415
  },
@@ -342,14 +424,9 @@ describe("convertEveMessages", () => {
342
424
  (_label, display) => {
343
425
  const [message] = convertEveMessages(
344
426
  withApprovalPart({
345
- kind: "tool-call",
346
- name: "send_email",
347
- inputRequest: {
348
- requestId: "req_1",
349
- kind: "tool-approval",
350
- prompt: "Send the email?",
351
- ...(display && { display }),
352
- },
427
+ kind: "approval",
428
+ prompt: "Send the email?",
429
+ ...(display && { display }),
353
430
  }),
354
431
  );
355
432
 
@@ -365,13 +442,8 @@ describe("convertEveMessages", () => {
365
442
  it("does not offer a typed answer on a tool approval whose options were dropped", () => {
366
443
  const [message] = convertEveMessages(
367
444
  withApprovalPart({
368
- kind: "tool-call",
369
- name: "send_email",
370
- inputRequest: {
371
- requestId: "req_1",
372
- kind: "tool-approval",
373
- prompt: "Send the email?",
374
- },
445
+ kind: "approval",
446
+ prompt: "Send the email?",
375
447
  }),
376
448
  );
377
449
 
@@ -385,18 +457,13 @@ describe("convertEveMessages", () => {
385
457
  it("does not offer a typed answer on a confirmation request", () => {
386
458
  const [message] = convertEveMessages(
387
459
  withApprovalPart({
388
- kind: "tool-call",
389
- name: "send_email",
390
- inputRequest: {
391
- requestId: "req_1",
392
- kind: "tool-approval",
393
- prompt: "Send the email?",
394
- display: "confirmation",
395
- options: [
396
- { id: "approve", label: "Approve" },
397
- { id: "cancel", label: "Cancel" },
398
- ],
399
- },
460
+ kind: "approval",
461
+ prompt: "Send the email?",
462
+ display: "confirmation",
463
+ options: [
464
+ { id: "approve", label: "Approve" },
465
+ { id: "cancel", label: "Cancel" },
466
+ ],
400
467
  }),
401
468
  );
402
469
 
@@ -409,7 +476,6 @@ describe("convertEveMessages", () => {
409
476
  [
410
477
  "the full input request",
411
478
  {
412
- requestId: "req_1",
413
479
  prompt: "Which environment?",
414
480
  kind: "question",
415
481
  display: "select",
@@ -423,40 +489,29 @@ describe("convertEveMessages", () => {
423
489
  [
424
490
  "only the fields the request defines",
425
491
  {
426
- requestId: "req_1",
427
492
  prompt: "What should the subject line be?",
428
493
  kind: "question",
429
494
  },
430
495
  ],
431
- ] satisfies [string, EveMessageInputRequest][])(
496
+ ] satisfies [string, InputRequest][])(
432
497
  "projects %s onto providerMetadata.eve",
433
498
  (_label, inputRequest) => {
434
- const [message] = convertEveMessages(
435
- withApprovalPart({
436
- kind: "tool-call",
437
- name: "send_email",
438
- inputRequest,
439
- }),
440
- );
499
+ const [message] = convertEveMessages(withApprovalPart(inputRequest));
441
500
  const part = message!.content[0];
442
501
 
443
502
  expect(part).toMatchObject({ type: "tool-call" });
444
503
  expect((part as { providerMetadata?: unknown }).providerMetadata).toEqual(
445
504
  {
446
- eve: { inputRequest },
505
+ eve: { interaction: { interactionId: "req_1", ...inputRequest } },
447
506
  },
448
507
  );
449
508
  },
450
509
  );
451
510
 
452
- it.each([
453
- [
454
- "eve metadata without an input request",
455
- { kind: "tool-call" as const, name: "send_email" },
456
- ],
457
- ["no eve metadata at all", undefined],
458
- ])("omits providerMetadata for a tool part with %s", (_label, eve) => {
459
- const [message] = convertEveMessages(withApprovalPart(eve));
511
+ it("omits providerMetadata for a tool part without an interaction", () => {
512
+ const [message] = convertEveMessages({
513
+ messages: [{ id: "a1", role: "assistant", parts: [toolPart()] }],
514
+ });
460
515
 
461
516
  expect(message!.content[0]).not.toHaveProperty("providerMetadata");
462
517
  });
@@ -469,17 +524,20 @@ describe("convertEveMessages", () => {
469
524
  role: "assistant",
470
525
  parts: [
471
526
  {
472
- type: "dynamic-tool",
473
- state: "output-denied",
474
- toolCallId: "call_1",
475
- toolName: "send_email",
476
- input: { to: "dev@example.com" },
477
- approval: { id: "req_1", approved: false },
527
+ ...toolPart({
528
+ state: "rejected",
529
+ input: { to: "dev@example.com" },
530
+ interaction: {
531
+ ...approvalInteraction(),
532
+ status: "settled",
533
+ outcome: "declined",
534
+ },
535
+ }),
478
536
  },
479
537
  ],
480
538
  },
481
539
  ],
482
- } satisfies EveMessageData;
540
+ } satisfies MessageData;
483
541
 
484
542
  const [message] = convertEveMessages(data);
485
543
 
@@ -496,6 +554,161 @@ describe("convertEveMessages", () => {
496
554
  });
497
555
  });
498
556
 
557
+ it.each([
558
+ ["completed", { answer: "sent" }, { answer: "sent" }, false],
559
+ ["failed", undefined, { error: "Tool call failed" }, true],
560
+ ["cancelled", undefined, { error: "Tool call cancelled" }, true],
561
+ ] as const)(
562
+ "maps a %s tool call to its result and error state",
563
+ (state, output, result, isError) => {
564
+ const [message] = convertEveMessages({
565
+ messages: [
566
+ {
567
+ id: "a1",
568
+ role: "assistant",
569
+ parts: [{ ...toolPart(), state, output }],
570
+ },
571
+ ],
572
+ });
573
+
574
+ expect(message?.content[0]).toMatchObject({ type: "tool-call", result });
575
+ expect(Object.hasOwn(message?.content[0] ?? {}, "isError")).toBe(isError);
576
+ expect(message?.content[0]).toMatchObject(isError ? { isError } : {});
577
+ },
578
+ );
579
+
580
+ it("uses a failed tool's error before its interaction reason", () => {
581
+ const [message] = convertEveMessages({
582
+ messages: [
583
+ {
584
+ id: "a1",
585
+ role: "assistant",
586
+ parts: [
587
+ {
588
+ ...toolPart({
589
+ interaction: { ...approvalInteraction(), reason: "Denied" },
590
+ }),
591
+ state: "failed",
592
+ error: { code: "internal", message: "Service unavailable" },
593
+ },
594
+ ],
595
+ },
596
+ ],
597
+ });
598
+
599
+ expect(message?.content[0]).toMatchObject({
600
+ result: { error: "Service unavailable" },
601
+ isError: true,
602
+ });
603
+ });
604
+
605
+ it("exposes preliminary tool output before the call settles", () => {
606
+ const [message] = convertEveMessages({
607
+ messages: [
608
+ {
609
+ id: "a1",
610
+ role: "assistant",
611
+ parts: [{ ...toolPart({ input: ["first"] }), output: "partial" }],
612
+ },
613
+ ],
614
+ });
615
+
616
+ expect(message?.content[0]).toMatchObject({
617
+ args: { value: ["first"] },
618
+ argsText: '["first"]',
619
+ result: "partial",
620
+ isPreliminary: true,
621
+ });
622
+ });
623
+
624
+ it.each([
625
+ [
626
+ "accepted",
627
+ { approved: true, optionId: "approve", text: "Proceed" },
628
+ false,
629
+ ],
630
+ ["declined", { approved: false, optionId: "deny", text: "Later" }, true],
631
+ ["expired", { resolution: "expired" }, true],
632
+ ["withdrawn", { resolution: "cancelled" }, true],
633
+ ] as const)(
634
+ "projects a settled %s interaction with its answer or resolution",
635
+ (outcome, approval, isError) => {
636
+ const interaction = signInPart(
637
+ { kind: "question", prompt: "Proceed?", options: approveCancel },
638
+ {
639
+ status: "settled",
640
+ outcome,
641
+ reason: "Session finished",
642
+ ...(outcome === "accepted" || outcome === "declined"
643
+ ? {
644
+ answer: {
645
+ interactionId: "auth_1",
646
+ optionId: outcome === "accepted" ? "approve" : "deny",
647
+ text: outcome === "accepted" ? "Proceed" : "Later",
648
+ },
649
+ }
650
+ : {}),
651
+ },
652
+ );
653
+ const [message] = convertEveMessages({
654
+ messages: [{ id: "a1", role: "assistant", parts: [interaction] }],
655
+ });
656
+
657
+ expect(message?.content[0]).toMatchObject({
658
+ type: "tool-call",
659
+ toolName: "question",
660
+ approval: { ...approval, reason: "Session finished" },
661
+ });
662
+ expect(Object.hasOwn(message?.content[0] ?? {}, "isError")).toBe(isError);
663
+ expect(message?.content[0]).toMatchObject(isError ? { isError } : {});
664
+ expect(message?.content[0]).toHaveProperty(
665
+ "result",
666
+ outcome === "accepted" || outcome === "declined"
667
+ ? {
668
+ interactionId: "auth_1",
669
+ optionId: outcome === "accepted" ? "approve" : "deny",
670
+ text: outcome === "accepted" ? "Proceed" : "Later",
671
+ }
672
+ : { outcome, reason: "Session finished" },
673
+ );
674
+ },
675
+ );
676
+
677
+ it.each([
678
+ ["declined", "declined"],
679
+ ["invalid", "declined"],
680
+ ["expired", "timed-out"],
681
+ ["failed", "failed"],
682
+ ["withdrawn", "cancelled"],
683
+ ] as const)("maps a %s sign-in to %s", (outcome, mapped) => {
684
+ const [message] = convertEveMessages({
685
+ messages: [
686
+ {
687
+ id: "a1",
688
+ role: "assistant",
689
+ parts: [
690
+ signInPart(
691
+ { kind: "sign-in", prompt: "Connect", title: "Account" },
692
+ { status: "settled", outcome, reason: "Session finished" },
693
+ ),
694
+ ],
695
+ },
696
+ ],
697
+ });
698
+
699
+ expect(message?.content[0]).toEqual({
700
+ type: "data",
701
+ name: "authorization",
702
+ data: {
703
+ state: "completed",
704
+ name: "Account",
705
+ description: "Connect",
706
+ outcome: mapped,
707
+ reason: "Session finished",
708
+ },
709
+ });
710
+ });
711
+
499
712
  it("drops empty and whitespace-only assistant text and reasoning parts", () => {
500
713
  const data = {
501
714
  messages: [
@@ -509,7 +722,7 @@ describe("convertEveMessages", () => {
509
722
  ],
510
723
  },
511
724
  ],
512
- } satisfies EveMessageData;
725
+ } satisfies MessageData;
513
726
 
514
727
  const [message] = convertEveMessages(data);
515
728
 
@@ -526,7 +739,7 @@ describe("convertEveMessages", () => {
526
739
  parts: [{ type: "text", text: "Hello" }],
527
740
  },
528
741
  ],
529
- } satisfies EveMessageData;
742
+ } satisfies MessageData;
530
743
 
531
744
  const [message] = convertEveMessages(data);
532
745
 
@@ -543,7 +756,7 @@ describe("convertEveMessages", () => {
543
756
  parts: [{ type: "text", text: "Hello" }],
544
757
  },
545
758
  ],
546
- } satisfies EveMessageData;
759
+ } satisfies MessageData;
547
760
 
548
761
  const [message] = convertEveMessages(data);
549
762
 
@@ -559,7 +772,7 @@ describe("convertEveMessages", () => {
559
772
  parts: [{ type: "file", mediaType: "image/png" }],
560
773
  },
561
774
  ],
562
- } satisfies EveMessageData;
775
+ } satisfies MessageData;
563
776
 
564
777
  const [message] = convertEveMessages(data);
565
778
 
@@ -579,7 +792,7 @@ describe("convertEveMessages", () => {
579
792
  ],
580
793
  },
581
794
  ],
582
- } satisfies EveMessageData;
795
+ } satisfies MessageData;
583
796
 
584
797
  const [message] = convertEveMessages(data);
585
798
 
@@ -604,7 +817,7 @@ describe("convertEveMessages", () => {
604
817
  ],
605
818
  },
606
819
  ],
607
- } satisfies EveMessageData;
820
+ } satisfies MessageData;
608
821
 
609
822
  const [message] = convertEveMessages(data);
610
823
 
@@ -647,7 +860,7 @@ describe("convertEveMessages", () => {
647
860
  ],
648
861
  },
649
862
  ],
650
- } satisfies EveMessageData;
863
+ } satisfies MessageData;
651
864
 
652
865
  const [message] = convertEveMessages(data);
653
866
 
@@ -685,7 +898,7 @@ describe("convertEveMessages", () => {
685
898
  ],
686
899
  },
687
900
  ],
688
- } satisfies EveMessageData;
901
+ } satisfies MessageData;
689
902
 
690
903
  const [message] = convertEveMessages(data);
691
904
 
@@ -729,7 +942,7 @@ describe("convertEveMessages", () => {
729
942
  ],
730
943
  },
731
944
  ],
732
- } satisfies EveMessageData;
945
+ } satisfies MessageData;
733
946
 
734
947
  const [message] = convertEveMessages(data);
735
948
 
@@ -739,58 +952,6 @@ describe("convertEveMessages", () => {
739
952
  ]);
740
953
  });
741
954
 
742
- it("defaults a file part with a missing mediaType to application/octet-stream", () => {
743
- const data = {
744
- messages: [
745
- {
746
- id: "u1",
747
- role: "user",
748
- parts: [{ type: "file", url: "https://example.com/blob" }],
749
- },
750
- ],
751
- } as unknown as EveMessageData;
752
-
753
- const [message] = convertEveMessages(data);
754
-
755
- expect(message?.content).toEqual([]);
756
- expect(message?.attachments).toEqual([
757
- {
758
- id: "0",
759
- type: "file",
760
- name: "file",
761
- content: [
762
- {
763
- type: "file",
764
- data: "https://example.com/blob",
765
- mimeType: "application/octet-stream",
766
- sourceType: "url",
767
- },
768
- ],
769
- contentType: "application/octet-stream",
770
- status: { type: "complete" },
771
- },
772
- ]);
773
- });
774
-
775
- it("reads the media type from a data URL when eve omits mediaType", () => {
776
- const data = {
777
- messages: [
778
- {
779
- id: "u1",
780
- role: "user",
781
- parts: [{ type: "file", url: "data:image/png;base64,iVBORw0KGgo=" }],
782
- },
783
- ],
784
- } as unknown as EveMessageData;
785
-
786
- const [message] = convertEveMessages(data);
787
-
788
- expect(message?.content).toEqual([]);
789
- expect(message?.attachments?.map((a) => [a.type, a.contentType])).toEqual([
790
- ["image", "image/png"],
791
- ]);
792
- });
793
-
794
955
  it("prefers an explicit mediaType over the data URL declaration", () => {
795
956
  const data = {
796
957
  messages: [
@@ -806,7 +967,7 @@ describe("convertEveMessages", () => {
806
967
  ],
807
968
  },
808
969
  ],
809
- } satisfies EveMessageData;
970
+ } satisfies MessageData;
810
971
 
811
972
  const [message] = convertEveMessages(data);
812
973
 
@@ -833,7 +994,7 @@ describe("convertEveMessages", () => {
833
994
  ],
834
995
  },
835
996
  ],
836
- } satisfies EveMessageData;
997
+ } satisfies MessageData;
837
998
 
838
999
  const [message] = convertEveMessages(data);
839
1000
 
@@ -849,6 +1010,25 @@ describe("convertEveMessages", () => {
849
1010
  ]);
850
1011
  });
851
1012
 
1013
+ it("drops a URL-less assistant file part while retaining text", () => {
1014
+ const data = {
1015
+ messages: [
1016
+ {
1017
+ id: "a1",
1018
+ role: "assistant",
1019
+ parts: [
1020
+ { type: "file", mediaType: "application/pdf" },
1021
+ { type: "text", text: "Done" },
1022
+ ],
1023
+ },
1024
+ ],
1025
+ } satisfies MessageData;
1026
+
1027
+ const [message] = convertEveMessages(data);
1028
+
1029
+ expect(message?.content).toEqual([{ type: "text", text: "Done" }]);
1030
+ });
1031
+
852
1032
  it("drops non-convertible part types instead of throwing", () => {
853
1033
  const data = {
854
1034
  messages: [
@@ -856,14 +1036,12 @@ describe("convertEveMessages", () => {
856
1036
  id: "a1",
857
1037
  role: "assistant",
858
1038
  parts: [
859
- { type: "step-start" },
860
1039
  { type: "future-part", payload: {} },
861
- { type: "file", mediaType: "application/pdf" },
862
1040
  { type: "text", text: "Done" },
863
1041
  ],
864
1042
  },
865
1043
  ],
866
- } as unknown as EveMessageData;
1044
+ } as unknown as MessageData;
867
1045
 
868
1046
  const [message] = convertEveMessages(data);
869
1047
 
@@ -877,26 +1055,22 @@ describe("convertEveMessages", () => {
877
1055
  id: "a1",
878
1056
  role: "assistant",
879
1057
  parts: [
880
- {
881
- type: "authorization",
882
- state: "required",
883
- name: "github",
884
- description: "Sign in to GitHub",
885
- displayName: "GitHub",
886
- stepIndex: 0,
887
- turnId: "turn_1",
888
- authorization: {
1058
+ signInPart({
1059
+ kind: "sign-in",
1060
+ prompt: "Sign in to GitHub",
1061
+ signIn: {
1062
+ name: "github",
889
1063
  displayName: "GitHub",
890
1064
  instructions: "Enter the code on the device page",
891
1065
  url: "https://github.com/login/device",
892
1066
  userCode: "ABCD-1234",
893
1067
  expiresAt: "2026-01-01T00:00:00Z",
894
1068
  },
895
- },
1069
+ }),
896
1070
  ],
897
1071
  },
898
1072
  ],
899
- } satisfies EveMessageData;
1073
+ } satisfies MessageData;
900
1074
 
901
1075
  const [message] = convertEveMessages(data);
902
1076
 
@@ -925,15 +1099,15 @@ describe("convertEveMessages", () => {
925
1099
  id: "a1",
926
1100
  role: "assistant",
927
1101
  parts: [
928
- {
929
- type: "authorization",
930
- state: "required",
931
- name: "github",
932
- },
1102
+ signInPart({
1103
+ kind: "sign-in",
1104
+ prompt: "",
1105
+ signIn: { name: "github" },
1106
+ }),
933
1107
  ],
934
1108
  },
935
1109
  ],
936
- } as unknown as EveMessageData;
1110
+ } satisfies MessageData;
937
1111
 
938
1112
  const [message] = convertEveMessages(data);
939
1113
 
@@ -941,7 +1115,7 @@ describe("convertEveMessages", () => {
941
1115
  {
942
1116
  type: "data",
943
1117
  name: "authorization",
944
- data: { state: "required", name: "github" },
1118
+ data: { state: "required", name: "github", description: "" },
945
1119
  },
946
1120
  ]);
947
1121
  });
@@ -953,19 +1127,19 @@ describe("convertEveMessages", () => {
953
1127
  id: "a1",
954
1128
  role: "assistant",
955
1129
  parts: [
956
- {
957
- type: "authorization",
958
- state: "required",
959
- name: "github",
960
- authorization: {
1130
+ signInPart({
1131
+ kind: "sign-in",
1132
+ prompt: "",
1133
+ signIn: {
1134
+ name: "github",
961
1135
  url: "javascript:alert(1)",
962
1136
  userCode: "ABCD-1234",
963
1137
  },
964
- },
1138
+ }),
965
1139
  ],
966
1140
  },
967
1141
  ],
968
- } as unknown as EveMessageData;
1142
+ } satisfies MessageData;
969
1143
 
970
1144
  const [message] = convertEveMessages(data);
971
1145
 
@@ -973,7 +1147,12 @@ describe("convertEveMessages", () => {
973
1147
  {
974
1148
  type: "data",
975
1149
  name: "authorization",
976
- data: { state: "required", name: "github", userCode: "ABCD-1234" },
1150
+ data: {
1151
+ state: "required",
1152
+ name: "github",
1153
+ description: "",
1154
+ userCode: "ABCD-1234",
1155
+ },
977
1156
  },
978
1157
  ]);
979
1158
  });
@@ -985,20 +1164,18 @@ describe("convertEveMessages", () => {
985
1164
  id: "a1",
986
1165
  role: "assistant",
987
1166
  parts: [
988
- {
989
- type: "authorization",
990
- state: "completed",
991
- name: "github",
992
- description: "Sign in to GitHub",
993
- displayName: "GitHub",
994
- stepIndex: 0,
995
- turnId: "turn_1",
996
- outcome: "authorized",
997
- },
1167
+ signInPart(
1168
+ {
1169
+ kind: "sign-in",
1170
+ prompt: "Sign in to GitHub",
1171
+ signIn: { name: "github", displayName: "GitHub" },
1172
+ },
1173
+ { status: "settled", outcome: "accepted" },
1174
+ ),
998
1175
  ],
999
1176
  },
1000
1177
  ],
1001
- } satisfies EveMessageData;
1178
+ } satisfies MessageData;
1002
1179
 
1003
1180
  const [message] = convertEveMessages(data);
1004
1181
 
@@ -1020,7 +1197,7 @@ describe("convertEveMessages", () => {
1020
1197
  describe("assistant message status mapping", () => {
1021
1198
  const withStatus = (
1022
1199
  status: "streaming" | "complete" | "failed",
1023
- ): EveMessageData => ({
1200
+ ): MessageData => ({
1024
1201
  messages: [
1025
1202
  {
1026
1203
  id: "a1",
@@ -1084,7 +1261,7 @@ describe("convertEveMessages", () => {
1084
1261
  parts: [{ type: "text", text: "Try again" }],
1085
1262
  },
1086
1263
  ],
1087
- } satisfies EveMessageData;
1264
+ } satisfies MessageData;
1088
1265
 
1089
1266
  const [assistant] = convertEveMessages(data, {
1090
1267
  isRunning: true,
@@ -1114,6 +1291,101 @@ describe("convertEveMessages", () => {
1114
1291
  expect(message?.status).toEqual({ type: "incomplete", reason: "error" });
1115
1292
  });
1116
1293
 
1294
+ it("keeps a failed turn incomplete when the event has no error detail", () => {
1295
+ const failure = {
1296
+ type: "turn.settled",
1297
+ data: { turnId: "turn_1", outcome: "failed" },
1298
+ meta: eventMeta(1),
1299
+ } as const satisfies SessionStreamEvent;
1300
+ const [message] = convertEveMessages(
1301
+ {
1302
+ messages: [
1303
+ {
1304
+ id: "a1",
1305
+ role: "assistant",
1306
+ metadata: { turnId: "turn_1", status: "complete" },
1307
+ parts: [{ type: "text", text: "Partial response" }],
1308
+ },
1309
+ ],
1310
+ },
1311
+ { events: [failure] },
1312
+ );
1313
+
1314
+ expect(message?.status).toEqual({ type: "incomplete", reason: "error" });
1315
+ });
1316
+
1317
+ it("scopes session failures to their turn and gives them precedence over turn cancellation", () => {
1318
+ const events = [
1319
+ {
1320
+ type: "turn.settled",
1321
+ data: { turnId: "turn_1", outcome: "cancelled" },
1322
+ meta: eventMeta(1),
1323
+ },
1324
+ {
1325
+ type: "session.ended",
1326
+ data: { outcome: "failed", cause: { turnId: "turn_1" } },
1327
+ meta: eventMeta(2),
1328
+ },
1329
+ ] as const satisfies readonly SessionStreamEvent[];
1330
+ const messages = convertEveMessages(
1331
+ {
1332
+ messages: [
1333
+ {
1334
+ id: "a1",
1335
+ role: "assistant",
1336
+ metadata: { turnId: "turn_1" },
1337
+ parts: [{ type: "text", text: "First" }],
1338
+ },
1339
+ {
1340
+ id: "a2",
1341
+ role: "assistant",
1342
+ metadata: { turnId: "turn_2" },
1343
+ parts: [{ type: "text", text: "Second" }],
1344
+ },
1345
+ ],
1346
+ },
1347
+ { events, isRunning: false },
1348
+ );
1349
+
1350
+ expect(messages.map((message) => message.status)).toEqual([
1351
+ { type: "incomplete", reason: "error" },
1352
+ { type: "complete", reason: "stop" },
1353
+ ]);
1354
+ });
1355
+
1356
+ it.each([
1357
+ ["completed", { outcome: "completed" }],
1358
+ ["failed without a cause", { outcome: "failed" }],
1359
+ [
1360
+ "failed outside a turn",
1361
+ { outcome: "failed", cause: { deliveryId: "delivery_1" } },
1362
+ ],
1363
+ ] as const)(
1364
+ "does not assign a %s session ending to a turn",
1365
+ (_label, data) => {
1366
+ const event = {
1367
+ type: "session.ended",
1368
+ data,
1369
+ meta: eventMeta(1),
1370
+ } as const satisfies SessionStreamEvent;
1371
+ const [message] = convertEveMessages(
1372
+ {
1373
+ messages: [
1374
+ {
1375
+ id: "a1",
1376
+ role: "assistant",
1377
+ metadata: { turnId: "turn_1" },
1378
+ parts: [{ type: "text", text: "Done" }],
1379
+ },
1380
+ ],
1381
+ },
1382
+ { events: [event], isRunning: false },
1383
+ );
1384
+
1385
+ expect(message?.status).toEqual({ type: "complete", reason: "stop" });
1386
+ },
1387
+ );
1388
+
1117
1389
  it("keeps requires-action for pending approvals when not running", () => {
1118
1390
  const data = {
1119
1391
  messages: [
@@ -1121,21 +1393,12 @@ describe("convertEveMessages", () => {
1121
1393
  id: "a1",
1122
1394
  role: "assistant",
1123
1395
  metadata: { status: "streaming" },
1124
- parts: [
1125
- {
1126
- type: "dynamic-tool",
1127
- state: "approval-requested",
1128
- toolCallId: "call_1",
1129
- toolName: "send_email",
1130
- input: {},
1131
- approval: { id: "req_1" },
1132
- },
1133
- ],
1396
+ parts: [toolPart({ interaction: approvalInteraction() })],
1134
1397
  },
1135
1398
  ],
1136
- } satisfies EveMessageData;
1399
+ } satisfies MessageData;
1137
1400
 
1138
- const [message] = convertEveMessages(data, { isRunning: false });
1401
+ const message = convertEveMessages(data, { isRunning: false }).at(-1);
1139
1402
 
1140
1403
  expect(message?.status).toEqual({
1141
1404
  type: "requires-action",
@@ -1151,19 +1414,15 @@ describe("convertEveMessages", () => {
1151
1414
  role: "assistant",
1152
1415
  metadata: { status: "streaming" },
1153
1416
  parts: [
1154
- {
1155
- type: "authorization",
1156
- state: "required",
1157
- name: "github",
1158
- description: "Sign in to GitHub",
1159
- displayName: "GitHub",
1160
- stepIndex: 0,
1161
- turnId: "turn_1",
1162
- },
1417
+ signInPart({
1418
+ kind: "sign-in",
1419
+ prompt: "Sign in to GitHub",
1420
+ signIn: { name: "github", displayName: "GitHub" },
1421
+ }),
1163
1422
  ],
1164
1423
  },
1165
1424
  ],
1166
- } satisfies EveMessageData;
1425
+ } satisfies MessageData;
1167
1426
 
1168
1427
  const [message] = convertEveMessages(data, { isRunning: false });
1169
1428
 
@@ -1181,27 +1440,16 @@ describe("convertEveMessages", () => {
1181
1440
  role: "assistant",
1182
1441
  metadata: { status: "streaming" },
1183
1442
  parts: [
1184
- {
1185
- type: "dynamic-tool",
1186
- state: "approval-requested",
1187
- toolCallId: "call_1",
1188
- toolName: "send_email",
1189
- input: {},
1190
- approval: { id: "req_1" },
1191
- },
1192
- {
1193
- type: "authorization",
1194
- state: "required",
1195
- name: "github",
1196
- description: "Sign in to GitHub",
1197
- displayName: "GitHub",
1198
- stepIndex: 0,
1199
- turnId: "turn_1",
1200
- },
1443
+ toolPart({ interaction: approvalInteraction() }),
1444
+ signInPart({
1445
+ kind: "sign-in",
1446
+ prompt: "Sign in to GitHub",
1447
+ signIn: { name: "github", displayName: "GitHub" },
1448
+ }),
1201
1449
  ],
1202
1450
  },
1203
1451
  ],
1204
- } satisfies EveMessageData;
1452
+ } satisfies MessageData;
1205
1453
 
1206
1454
  const [message] = convertEveMessages(data, { isRunning: false });
1207
1455
 
@@ -1210,54 +1458,28 @@ describe("convertEveMessages", () => {
1210
1458
  reason: "tool-calls",
1211
1459
  });
1212
1460
  });
1213
-
1214
- it("settles a cancelled turn with a pending authorization", () => {
1215
- const reducer = defaultMessageReducer();
1216
- const events: readonly EveAgentReducerEvent[] = [
1217
- {
1218
- type: "authorization.required",
1219
- meta: eventMeta(0),
1220
- data: {
1221
- turnId: "turn_1",
1222
- stepIndex: 0,
1223
- sequence: 0,
1224
- name: "github",
1225
- description: "Sign in to GitHub",
1226
- },
1227
- },
1228
- {
1229
- type: "turn.cancelled",
1230
- meta: eventMeta(1),
1231
- data: { turnId: "turn_1", sequence: 1 },
1232
- },
1233
- ];
1234
- const pendingData = events
1235
- .slice(0, 1)
1236
- .reduce(
1237
- (state, event) => reducer.reduce(state, event),
1238
- reducer.initial(),
1239
- );
1461
+
1462
+ it("settles a cancelled turn with a pending authorization", () => {
1463
+ const events = [...initialTurnEvents, signInOpened("github")];
1464
+ const pendingData = replay(events);
1240
1465
  const pendingAssistant = pendingData.messages.find(
1241
1466
  (message) => message.role === "assistant",
1242
1467
  );
1243
1468
  expect(pendingAssistant?.metadata?.status).toBe("streaming");
1244
1469
  expect(
1245
1470
  convertEveMessages(pendingData, { isRunning: false }).at(-1)?.status,
1246
- ).toEqual({
1247
- type: "requires-action",
1248
- reason: "interrupt",
1249
- });
1250
-
1251
- const data = events
1252
- .slice(1)
1253
- .reduce((state, event) => reducer.reduce(state, event), pendingData);
1471
+ ).toEqual({ type: "requires-action", reason: "interrupt" });
1254
1472
 
1255
- const [message] = convertEveMessages(data, { isRunning: false });
1473
+ const data = replay([
1474
+ ...events,
1475
+ {
1476
+ type: "turn.settled",
1477
+ data: { turnId: "turn_1", outcome: "cancelled" },
1478
+ },
1479
+ ]);
1480
+ const message = convertEveMessages(data, { isRunning: false }).at(-1);
1256
1481
 
1257
- expect(message?.status).toEqual({
1258
- type: "complete",
1259
- reason: "stop",
1260
- });
1482
+ expect(message?.status).toEqual({ type: "complete", reason: "stop" });
1261
1483
  });
1262
1484
 
1263
1485
  it("reports a failed turn with a pending authorization", () => {
@@ -1268,19 +1490,15 @@ describe("convertEveMessages", () => {
1268
1490
  role: "assistant",
1269
1491
  metadata: { status: "streaming" },
1270
1492
  parts: [
1271
- {
1272
- type: "authorization",
1273
- state: "required",
1274
- name: "github",
1275
- description: "Sign in to GitHub",
1276
- displayName: "GitHub",
1277
- stepIndex: 0,
1278
- turnId: "turn_1",
1279
- },
1493
+ signInPart({
1494
+ kind: "sign-in",
1495
+ prompt: "Sign in to GitHub",
1496
+ signIn: { name: "github", displayName: "GitHub" },
1497
+ }),
1280
1498
  ],
1281
1499
  },
1282
1500
  ],
1283
- } satisfies EveMessageData;
1501
+ } satisfies MessageData;
1284
1502
 
1285
1503
  const error = new Error("authorization failed");
1286
1504
  const [message] = convertEveMessages(data, {
@@ -1306,15 +1524,11 @@ describe("convertEveMessages", () => {
1306
1524
  role: "assistant",
1307
1525
  metadata: { status: "streaming" },
1308
1526
  parts: [
1309
- {
1310
- type: "authorization",
1311
- state: "required",
1312
- name: "github",
1313
- description: "Sign in to GitHub",
1314
- displayName: "GitHub",
1315
- stepIndex: 0,
1316
- turnId: "turn_1",
1317
- },
1527
+ signInPart({
1528
+ kind: "sign-in",
1529
+ prompt: "Sign in to GitHub",
1530
+ signIn: { name: "github", displayName: "GitHub" },
1531
+ }),
1318
1532
  ],
1319
1533
  },
1320
1534
  {
@@ -1324,7 +1538,7 @@ describe("convertEveMessages", () => {
1324
1538
  parts: [{ type: "text", text: "Later turn" }],
1325
1539
  },
1326
1540
  ],
1327
- } satisfies EveMessageData;
1541
+ } satisfies MessageData;
1328
1542
 
1329
1543
  const [authorization] = convertEveMessages(data, {
1330
1544
  isRunning: false,
@@ -1345,93 +1559,70 @@ describe("convertEveMessages", () => {
1345
1559
  role: "assistant",
1346
1560
  metadata: { status: "complete" },
1347
1561
  parts: [
1348
- {
1349
- type: "authorization",
1350
- state: "completed",
1351
- name: "github",
1352
- description: "Sign in to GitHub",
1353
- displayName: "GitHub",
1354
- stepIndex: 0,
1355
- turnId: "turn_1",
1356
- outcome: "authorized",
1357
- },
1562
+ signInPart(
1563
+ {
1564
+ kind: "sign-in",
1565
+ prompt: "Sign in to GitHub",
1566
+ signIn: { name: "github", displayName: "GitHub" },
1567
+ },
1568
+ { status: "settled", outcome: "accepted" },
1569
+ ),
1358
1570
  { type: "text", text: "Done" },
1359
1571
  ],
1360
1572
  },
1361
1573
  ],
1362
- } satisfies EveMessageData;
1574
+ } satisfies MessageData;
1363
1575
 
1364
1576
  const [message] = convertEveMessages(data, { isRunning: false });
1365
1577
 
1366
1578
  expect(message?.status).toEqual({ type: "complete", reason: "stop" });
1367
1579
  });
1368
1580
 
1369
- describe("contract with eve's default reducer", () => {
1370
- const replay = (events: readonly EveAgentReducerEvent[]) => {
1371
- const reducer = defaultMessageReducer();
1372
- return events.reduce(
1373
- (state, event) => reducer.reduce(state, event),
1374
- reducer.initial(),
1375
- );
1376
- };
1377
-
1378
- const midStreamEvents: readonly EveAgentReducerEvent[] = [
1379
- {
1380
- type: "client.message.submitted",
1381
- data: { submissionId: "sub_1", message: "hi", createdAt: 0 },
1382
- },
1581
+ describe("contract with eve's conversation projection", () => {
1582
+ const midStreamEvents: readonly SessionEvent[] = [
1583
+ ...initialTurnEvents,
1383
1584
  {
1384
- type: "turn.started",
1385
- meta: eventMeta(0),
1386
- data: { turnId: "turn_1", sequence: 0 },
1387
- },
1388
- {
1389
- type: "step.started",
1390
- meta: eventMeta(1),
1391
- data: {
1392
- turnId: "turn_1",
1393
- stepIndex: 0,
1394
- sequence: 1,
1395
- modelId: "test-model",
1396
- },
1397
- },
1398
- {
1399
- type: "message.appended",
1400
- meta: eventMeta(2),
1401
- data: {
1402
- turnId: "turn_1",
1403
- stepIndex: 0,
1404
- sequence: 2,
1405
- messageDelta: "Let me th",
1406
- },
1585
+ type: "content.delta",
1586
+ scope: { turnId: "turn_1", runId: "run_1" },
1587
+ data: { partId: "part_1", kind: "text", delta: "Let me th" },
1407
1588
  },
1408
1589
  ];
1409
1590
 
1591
+ const completedContent = (
1592
+ kind: "text" | "reasoning",
1593
+ partId: string,
1594
+ value: string,
1595
+ runId = "run_1",
1596
+ ): SessionEvent => ({
1597
+ type: "content.completed",
1598
+ scope: { turnId: "turn_1", runId },
1599
+ data: { partId, runId, kind, value, phase: "narration" },
1600
+ });
1601
+
1602
+ const callRequested = {
1603
+ type: "call.requested",
1604
+ scope: { turnId: "turn_1", runId: "run_1" },
1605
+ data: {
1606
+ callId: "call_1",
1607
+ owner: { runId: "run_1" },
1608
+ capability: { kind: "tool", name: "search" },
1609
+ input: {},
1610
+ },
1611
+ } as const satisfies SessionEvent;
1612
+
1410
1613
  it("carries the challenge Eve projects onto an authorization part", () => {
1411
1614
  const state = replay([
1412
1615
  ...midStreamEvents,
1413
- {
1414
- type: "authorization.required",
1415
- meta: eventMeta(3),
1416
- data: {
1417
- turnId: "turn_1",
1418
- stepIndex: 0,
1419
- sequence: 3,
1420
- name: "github",
1421
- description: "Authorization required for github",
1422
- authorization: {
1423
- displayName: "GitHub",
1424
- instructions: "Enter the code on the device page",
1425
- url: "https://github.com/login/device",
1426
- userCode: "ABCD-1234",
1427
- expiresAt: "2026-01-01T00:00:00Z",
1428
- },
1429
- },
1430
- },
1616
+ signInOpened("github", {
1617
+ displayName: "GitHub",
1618
+ instructions: "Enter the code on the device page",
1619
+ url: "https://github.com/login/device",
1620
+ userCode: "ABCD-1234",
1621
+ expiresAt: "2026-01-01T00:00:00Z",
1622
+ }),
1431
1623
  ]);
1432
1624
 
1433
1625
  const converted = convertEveMessages(state, { isRunning: false });
1434
-
1435
1626
  expect(converted.at(-1)?.content).toContainEqual({
1436
1627
  type: "data",
1437
1628
  name: "authorization",
@@ -1451,39 +1642,19 @@ describe("convertEveMessages", () => {
1451
1642
  it("releases the hold when Eve settles the authorization part in place", () => {
1452
1643
  const state = replay([
1453
1644
  ...midStreamEvents,
1645
+ signInOpened("github"),
1454
1646
  {
1455
- type: "authorization.required",
1456
- meta: eventMeta(3),
1457
- data: {
1458
- turnId: "turn_1",
1459
- stepIndex: 0,
1460
- sequence: 3,
1461
- name: "github",
1462
- description: "Authorization required for github",
1463
- },
1464
- },
1465
- {
1466
- type: "authorization.completed",
1467
- meta: eventMeta(4),
1468
- data: {
1469
- turnId: "turn_1",
1470
- stepIndex: 0,
1471
- sequence: 4,
1472
- name: "github",
1473
- outcome: "authorized",
1474
- },
1647
+ type: "interaction.settled",
1648
+ data: { interactionId: "auth_github", outcome: "accepted" },
1475
1649
  },
1476
1650
  ]);
1477
1651
 
1478
1652
  const assistant = state.messages.find((m) => m.role === "assistant");
1479
1653
  const authorizationParts = assistant?.parts.filter(
1480
- (part) => part.type === "authorization",
1654
+ (part) => part.type === "interaction",
1481
1655
  );
1482
1656
  expect(authorizationParts).toHaveLength(1);
1483
- expect(authorizationParts?.[0]).toMatchObject({ state: "completed" });
1484
-
1485
- // Eve leaves the streaming marker set, so the released turn falls back
1486
- // to the stale-marker mapping rather than to a terminal status.
1657
+ expect(authorizationParts?.[0]).toMatchObject({ status: "settled" });
1487
1658
  expect(
1488
1659
  convertEveMessages(state, { isRunning: false }).at(-1)?.status,
1489
1660
  ).toEqual({ type: "incomplete", reason: "cancelled" });
@@ -1492,38 +1663,11 @@ describe("convertEveMessages", () => {
1492
1663
  it("keeps the hold while another connector is still required", () => {
1493
1664
  const state = replay([
1494
1665
  ...midStreamEvents,
1666
+ signInOpened("github"),
1667
+ signInOpened("slack"),
1495
1668
  {
1496
- type: "authorization.required",
1497
- meta: eventMeta(3),
1498
- data: {
1499
- turnId: "turn_1",
1500
- stepIndex: 0,
1501
- sequence: 3,
1502
- name: "github",
1503
- description: "Authorization required for github",
1504
- },
1505
- },
1506
- {
1507
- type: "authorization.required",
1508
- meta: eventMeta(4),
1509
- data: {
1510
- turnId: "turn_1",
1511
- stepIndex: 0,
1512
- sequence: 4,
1513
- name: "slack",
1514
- description: "Authorization required for slack",
1515
- },
1516
- },
1517
- {
1518
- type: "authorization.completed",
1519
- meta: eventMeta(5),
1520
- data: {
1521
- turnId: "turn_1",
1522
- stepIndex: 0,
1523
- sequence: 5,
1524
- name: "github",
1525
- outcome: "authorized",
1526
- },
1669
+ type: "interaction.settled",
1670
+ data: { interactionId: "auth_github", outcome: "accepted" },
1527
1671
  },
1528
1672
  ]);
1529
1673
 
@@ -1533,51 +1677,41 @@ describe("convertEveMessages", () => {
1533
1677
  });
1534
1678
 
1535
1679
  it("projects the input request onto the approval part it gates", () => {
1536
- const events: readonly EveAgentReducerEvent[] = [
1680
+ const events: readonly SessionEvent[] = [
1537
1681
  ...midStreamEvents,
1538
1682
  {
1539
- type: "input.requested",
1683
+ ...callRequested,
1540
1684
  data: {
1541
- turnId: "turn_1",
1542
- stepIndex: 0,
1543
- sequence: 3,
1544
- requests: [
1545
- {
1546
- requestId: "req_1",
1547
- prompt: "What should the subject line be?",
1548
- kind: "question",
1549
- display: "text",
1550
- action: {
1551
- kind: "tool-call",
1552
- callId: "call_1",
1553
- toolName: "ask_question",
1554
- input: {},
1555
- },
1556
- },
1557
- ],
1685
+ ...callRequested.data,
1686
+ capability: { kind: "tool", name: "ask_question" },
1687
+ },
1688
+ },
1689
+ {
1690
+ type: "interaction.opened",
1691
+ scope: { turnId: "turn_1" },
1692
+ data: {
1693
+ interactionId: "req_1",
1694
+ subject: { callId: "call_1" },
1695
+ request: {
1696
+ kind: "question",
1697
+ prompt: "What should the subject line be?",
1698
+ display: "text",
1699
+ },
1558
1700
  },
1559
- meta: eventMeta(3),
1560
1701
  },
1561
1702
  ];
1562
-
1563
- // Rehydration replays the *stored* event log, which has been through
1564
- // the wire and back, so the reloaded case is the serialized payload
1565
- // rather than the same objects a second time.
1566
- const rehydrated = JSON.parse(
1567
- JSON.stringify(events),
1568
- ) as readonly EveAgentReducerEvent[];
1703
+ const rehydrated = JSON.parse(JSON.stringify(events)) as SessionEvent[];
1569
1704
 
1570
1705
  for (const state of [replay(events), replay(rehydrated)]) {
1571
1706
  const part = state.messages
1572
1707
  .find((message) => message.role === "assistant")
1573
- ?.parts.find((candidate) => candidate.type === "dynamic-tool");
1708
+ ?.parts.find((candidate) => candidate.type === "tool");
1574
1709
 
1575
1710
  expect(part).toMatchObject({
1576
- state: "approval-requested",
1577
- approval: { id: "req_1" },
1711
+ type: "tool",
1712
+ interaction: { interactionId: "req_1", status: "open" },
1578
1713
  });
1579
1714
  expect(findEveInputRequest(state, "req_1")).toMatchObject({
1580
- requestId: "req_1",
1581
1715
  prompt: "What should the subject line be?",
1582
1716
  display: "text",
1583
1717
  });
@@ -1586,188 +1720,110 @@ describe("convertEveMessages", () => {
1586
1720
 
1587
1721
  it("leaves a reasoning part unsettled when a tool call follows it", () => {
1588
1722
  const state = replay([
1589
- ...midStreamEvents.slice(0, 3),
1590
- {
1591
- type: "reasoning.appended",
1592
- meta: eventMeta(2),
1593
- data: {
1594
- turnId: "turn_1",
1595
- stepIndex: 0,
1596
- sequence: 2,
1597
- reasoningDelta: "Think",
1598
- },
1599
- },
1723
+ ...initialTurnEvents,
1600
1724
  {
1601
- type: "actions.requested",
1602
- meta: eventMeta(3),
1603
- data: {
1604
- turnId: "turn_1",
1605
- stepIndex: 0,
1606
- sequence: 3,
1607
- actions: [
1608
- {
1609
- kind: "tool-call",
1610
- callId: "call_1",
1611
- toolName: "search",
1612
- input: {},
1613
- },
1614
- ],
1615
- },
1725
+ type: "content.delta",
1726
+ scope: { turnId: "turn_1", runId: "run_1" },
1727
+ data: { partId: "reason_1", kind: "reasoning", delta: "Think" },
1616
1728
  },
1729
+ callRequested,
1617
1730
  ]);
1618
1731
 
1619
1732
  const message = state.messages.find((m) => m.role === "assistant");
1620
1733
  expect(message?.parts).toEqual(
1621
1734
  expect.arrayContaining([
1622
1735
  expect.objectContaining({ type: "reasoning", state: "streaming" }),
1623
- expect.objectContaining({ type: "dynamic-tool" }),
1736
+ expect.objectContaining({ type: "tool" }),
1624
1737
  ]),
1625
1738
  );
1626
-
1627
1739
  const content = convertEveMessages(state, { isRunning: true }).at(
1628
1740
  -1,
1629
1741
  )?.content;
1630
1742
  expect(content).toEqual([
1631
- expect.objectContaining({ type: "reasoning" }),
1632
1743
  expect.objectContaining({ type: "tool-call" }),
1744
+ expect.objectContaining({ type: "reasoning" }),
1633
1745
  ]);
1634
- expect(content?.[0]).not.toHaveProperty("status");
1746
+ expect(content?.[1]).not.toHaveProperty("status");
1635
1747
  });
1636
1748
 
1637
1749
  it("settles leftover reasoning after the model stream completes a tool call", () => {
1638
1750
  const state = replay([
1639
- ...midStreamEvents.slice(0, 3),
1640
- {
1641
- type: "reasoning.appended",
1642
- meta: eventMeta(2),
1643
- data: {
1644
- turnId: "turn_1",
1645
- stepIndex: 0,
1646
- sequence: 2,
1647
- reasoningDelta: "Think",
1648
- },
1649
- },
1650
- {
1651
- type: "actions.requested",
1652
- meta: eventMeta(3),
1653
- data: {
1654
- turnId: "turn_1",
1655
- stepIndex: 0,
1656
- sequence: 3,
1657
- actions: [
1658
- {
1659
- kind: "tool-call",
1660
- callId: "call_1",
1661
- toolName: "search",
1662
- input: {},
1663
- },
1664
- ],
1665
- },
1666
- },
1751
+ ...initialTurnEvents,
1667
1752
  {
1668
- type: "reasoning.completed",
1669
- meta: eventMeta(4),
1670
- data: {
1671
- turnId: "turn_1",
1672
- stepIndex: 0,
1673
- sequence: 4,
1674
- reasoning: "Think",
1675
- },
1753
+ type: "content.delta",
1754
+ scope: { turnId: "turn_1", runId: "run_1" },
1755
+ data: { partId: "reason_1", kind: "reasoning", delta: "Think" },
1676
1756
  },
1757
+ callRequested,
1758
+ completedContent("reasoning", "reason_1", "Think"),
1677
1759
  ]);
1678
1760
 
1679
1761
  const message = state.messages.find((m) => m.role === "assistant");
1680
1762
  expect(message?.parts).toEqual(
1681
1763
  expect.arrayContaining([
1682
1764
  expect.objectContaining({ type: "reasoning", state: "done" }),
1683
- expect.objectContaining({ type: "dynamic-tool" }),
1765
+ expect.objectContaining({ type: "tool" }),
1684
1766
  ]),
1685
1767
  );
1686
-
1687
1768
  const content = convertEveMessages(state, { isRunning: true }).at(
1688
1769
  -1,
1689
1770
  )?.content;
1690
1771
  expect(content).toEqual([
1772
+ expect.objectContaining({ type: "tool-call" }),
1691
1773
  expect.objectContaining({
1692
1774
  type: "reasoning",
1693
1775
  status: { type: "complete" },
1694
1776
  }),
1695
- expect.objectContaining({ type: "tool-call" }),
1696
1777
  ]);
1697
1778
  });
1698
1779
 
1699
1780
  it("keeps a later step's live text part unsettled after an earlier step completed", () => {
1700
1781
  const state = replay([
1701
- ...midStreamEvents.slice(0, 3),
1782
+ ...initialTurnEvents,
1702
1783
  {
1703
- type: "message.appended",
1704
- meta: eventMeta(2),
1705
- data: {
1706
- turnId: "turn_1",
1707
- stepIndex: 0,
1708
- sequence: 2,
1709
- messageDelta: "First",
1710
- },
1784
+ type: "content.delta",
1785
+ scope: { turnId: "turn_1", runId: "run_1" },
1786
+ data: { partId: "part_1", kind: "text", delta: "First" },
1711
1787
  },
1788
+ completedContent("text", "part_1", "First"),
1712
1789
  {
1713
- type: "message.completed",
1714
- meta: eventMeta(3),
1790
+ type: "model.settled",
1715
1791
  data: {
1716
- turnId: "turn_1",
1717
- stepIndex: 0,
1718
- sequence: 3,
1792
+ runId: "run_1",
1793
+ outcome: "completed",
1719
1794
  finishReason: "tool-calls",
1720
- message: "First",
1721
1795
  },
1722
1796
  },
1723
1797
  {
1724
- type: "step.completed",
1725
- meta: eventMeta(4),
1726
- data: {
1727
- turnId: "turn_1",
1728
- stepIndex: 0,
1729
- sequence: 4,
1730
- finishReason: "tool-calls",
1731
- },
1798
+ type: "model.requested",
1799
+ scope: { turnId: "turn_1" },
1800
+ data: { runId: "run_2", owner: { turnId: "turn_1" } },
1732
1801
  },
1733
1802
  {
1734
- type: "step.started",
1735
- meta: eventMeta(5),
1736
- data: {
1737
- turnId: "turn_1",
1738
- stepIndex: 1,
1739
- sequence: 5,
1740
- modelId: "test-model",
1741
- },
1803
+ type: "model.started",
1804
+ scope: { turnId: "turn_1", runId: "run_2" },
1805
+ data: { runId: "run_2", modelId: "test-model", attempt: 0 },
1742
1806
  },
1743
1807
  {
1744
- type: "message.appended",
1745
- meta: eventMeta(6),
1746
- data: {
1747
- turnId: "turn_1",
1748
- stepIndex: 1,
1749
- sequence: 6,
1750
- messageDelta: "Sec",
1751
- },
1808
+ type: "content.delta",
1809
+ scope: { turnId: "turn_1", runId: "run_2" },
1810
+ data: { partId: "part_2", kind: "text", delta: "Sec" },
1752
1811
  },
1753
1812
  ]);
1754
1813
 
1755
1814
  const assistant = state.messages.find((m) => m.role === "assistant");
1756
1815
  expect(assistant?.parts).toEqual([
1757
- expect.objectContaining({ type: "step-start" }),
1758
1816
  expect.objectContaining({
1759
1817
  type: "text",
1760
1818
  text: "First",
1761
1819
  state: "done",
1762
1820
  }),
1763
- expect.objectContaining({ type: "step-start" }),
1764
1821
  expect.objectContaining({
1765
1822
  type: "text",
1766
1823
  text: "Sec",
1767
1824
  state: "streaming",
1768
1825
  }),
1769
1826
  ]);
1770
-
1771
1827
  const content = convertEveMessages(state, { isRunning: true }).at(
1772
1828
  -1,
1773
1829
  )?.content;
@@ -1784,59 +1840,68 @@ describe("convertEveMessages", () => {
1784
1840
 
1785
1841
  it("a locally aborted turn keeps its streaming marker and converts to cancelled", () => {
1786
1842
  const state = replay(midStreamEvents);
1787
-
1788
1843
  const assistant = state.messages.find((m) => m.role === "assistant");
1789
1844
  expect(assistant?.metadata?.status).toBe("streaming");
1790
-
1791
- const converted = convertEveMessages(state, { isRunning: false });
1792
- expect(converted.at(-1)?.status).toEqual({
1845
+ expect(
1846
+ convertEveMessages(state, { isRunning: false }).at(-1)?.status,
1847
+ ).toEqual({
1793
1848
  type: "incomplete",
1794
1849
  reason: "cancelled",
1795
1850
  });
1796
1851
  });
1797
1852
 
1798
1853
  it("a failed session keeps its streaming marker and converts to error", () => {
1799
- const state = replay([
1800
- ...midStreamEvents,
1801
- {
1802
- type: "session.failed",
1803
- meta: eventMeta(3),
1804
- data: { sessionId: "session_1", code: "internal", message: "boom" },
1854
+ const sessionFailure = {
1855
+ type: "session.ended",
1856
+ meta: eventMeta(midStreamEvents.length),
1857
+ data: {
1858
+ outcome: "failed",
1859
+ cause: { turnId: "turn_1" },
1860
+ error: { code: "internal", message: "boom" },
1805
1861
  },
1806
- ]);
1807
-
1862
+ } as const satisfies SessionStreamEvent;
1863
+ const state = replay([...midStreamEvents, sessionFailure]);
1808
1864
  const assistant = state.messages.find((m) => m.role === "assistant");
1809
1865
  expect(assistant?.metadata?.status).toBe("streaming");
1810
-
1811
- const converted = convertEveMessages(state, {
1812
- isRunning: false,
1813
- error: new Error("boom"),
1814
- });
1815
- expect(converted.at(-1)?.status).toEqual({
1866
+ expect(
1867
+ convertEveMessages(state, {
1868
+ isRunning: false,
1869
+ error: new Error("boom"),
1870
+ }).at(-1)?.status,
1871
+ ).toEqual({
1816
1872
  type: "incomplete",
1817
1873
  reason: "error",
1818
1874
  error: { code: "unknown", message: "boom" },
1819
1875
  });
1876
+ expect(
1877
+ convertEveMessages(state, {
1878
+ isRunning: false,
1879
+ events: [sessionFailure],
1880
+ }).at(-1)?.status,
1881
+ ).toEqual({
1882
+ type: "incomplete",
1883
+ reason: "error",
1884
+ error: { code: "internal", message: "boom" },
1885
+ });
1820
1886
  });
1821
1887
 
1822
1888
  it("a failed turn stays incomplete after Eve settles its message", () => {
1823
1889
  const failureEvent = {
1824
- type: "turn.failed",
1825
- meta: eventMeta(3),
1890
+ type: "turn.settled",
1891
+ meta: eventMeta(8),
1826
1892
  data: {
1827
1893
  turnId: "turn_1",
1828
- sequence: 3,
1829
- code: "internal",
1830
- message: "boom",
1894
+ outcome: "failed",
1895
+ error: { code: "internal", message: "boom" },
1831
1896
  },
1832
- } as const satisfies MessageStreamEvent;
1897
+ } as const satisfies SessionStreamEvent;
1833
1898
  const state = replay([...midStreamEvents, failureEvent]);
1834
-
1835
- const converted = convertEveMessages(state, {
1836
- isRunning: false,
1837
- events: [failureEvent],
1838
- });
1839
- expect(converted.at(-1)?.status).toEqual({
1899
+ expect(
1900
+ convertEveMessages(state, {
1901
+ isRunning: false,
1902
+ events: [failureEvent],
1903
+ }).at(-1)?.status,
1904
+ ).toEqual({
1840
1905
  type: "incomplete",
1841
1906
  reason: "error",
1842
1907
  error: { code: "internal", message: "boom" },
@@ -1845,44 +1910,45 @@ describe("convertEveMessages", () => {
1845
1910
 
1846
1911
  it("a cancelled turn stays cancelled after Eve settles its message", () => {
1847
1912
  const cancelEvent = {
1848
- type: "turn.cancelled",
1849
- meta: eventMeta(3),
1850
- data: { turnId: "turn_1", sequence: 3 },
1851
- } as const satisfies MessageStreamEvent;
1913
+ type: "turn.settled",
1914
+ meta: eventMeta(8),
1915
+ data: { turnId: "turn_1", outcome: "cancelled" },
1916
+ } as const satisfies SessionStreamEvent;
1852
1917
  const state = replay([...midStreamEvents, cancelEvent]);
1853
-
1854
1918
  expect(
1855
1919
  convertEveMessages(state, { isRunning: false }).at(-1)?.status,
1856
- ).toEqual({ type: "complete", reason: "stop" });
1920
+ ).toEqual({
1921
+ type: "complete",
1922
+ reason: "stop",
1923
+ });
1857
1924
  expect(
1858
1925
  convertEveMessages(state, {
1859
1926
  isRunning: false,
1860
1927
  events: [cancelEvent],
1861
1928
  }).at(-1)?.status,
1862
- ).toEqual({ type: "incomplete", reason: "cancelled" });
1929
+ ).toEqual({
1930
+ type: "incomplete",
1931
+ reason: "cancelled",
1932
+ });
1863
1933
  });
1864
1934
 
1865
1935
  it("a completed turn terminalizes the streaming marker and converts to complete", () => {
1866
1936
  const state = replay([
1867
1937
  ...midStreamEvents,
1938
+ completedContent("text", "part_1", "Let me think"),
1868
1939
  {
1869
- type: "message.completed",
1870
- meta: eventMeta(3),
1940
+ type: "model.settled",
1871
1941
  data: {
1872
- turnId: "turn_1",
1873
- stepIndex: 0,
1874
- sequence: 3,
1942
+ runId: "run_1",
1943
+ outcome: "completed",
1875
1944
  finishReason: "stop",
1876
- message: "Let me think",
1877
1945
  },
1878
1946
  },
1879
1947
  {
1880
- type: "turn.completed",
1881
- meta: eventMeta(4),
1882
- data: { turnId: "turn_1", sequence: 4 },
1948
+ type: "turn.settled",
1949
+ data: { turnId: "turn_1", outcome: "completed", reply: ["part_1"] },
1883
1950
  },
1884
1951
  ]);
1885
-
1886
1952
  const assistant = state.messages.find((m) => m.role === "assistant");
1887
1953
  expect(assistant?.metadata?.status).toBe("complete");
1888
1954
  expect(assistant?.parts).toEqual(
@@ -1890,7 +1956,6 @@ describe("convertEveMessages", () => {
1890
1956
  expect.objectContaining({ type: "text", state: "done" }),
1891
1957
  ]),
1892
1958
  );
1893
-
1894
1959
  const converted = convertEveMessages(state, { isRunning: false });
1895
1960
  expect(converted.at(-1)?.status).toEqual({
1896
1961
  type: "complete",
@@ -1918,7 +1983,7 @@ describe("convertEveMessages", () => {
1918
1983
  parts: [{ type: "text", text: "Hello" }],
1919
1984
  },
1920
1985
  ],
1921
- } satisfies EveMessageData;
1986
+ } satisfies MessageData;
1922
1987
 
1923
1988
  const [message] = convertEveMessages(data, {
1924
1989
  getCreatedAt: () => createdAt,
@@ -1948,11 +2013,42 @@ describe("getEveMessageContent", () => {
1948
2013
  expect(getEveMessageContent(message)).toBe("Hello");
1949
2014
  });
1950
2015
 
2016
+ it("preserves multiple text parts without attachments", () => {
2017
+ const message = {
2018
+ role: "user",
2019
+ createdAt: new Date(),
2020
+ parentId: null,
2021
+ sourceId: null,
2022
+ runConfig: undefined,
2023
+ metadata: { custom: {} },
2024
+ content: [
2025
+ { type: "text", text: "First" },
2026
+ { type: "text", text: "Second" },
2027
+ ],
2028
+ } satisfies AppendMessage;
2029
+
2030
+ expect(getEveMessageContent(message)).toEqual([
2031
+ { type: "text", text: "First" },
2032
+ { type: "text", text: "Second" },
2033
+ ]);
2034
+ });
2035
+
2036
+ it("rejects an unsupported append part instead of sending it to Eve", () => {
2037
+ const message = {
2038
+ ...baseAppendMessage,
2039
+ content: [{ type: "reasoning", text: "Private reasoning" }],
2040
+ } as AppendMessage;
2041
+
2042
+ expect(() => getEveMessageContent(message)).toThrow(
2043
+ "Unsupported eve append message part type: reasoning",
2044
+ );
2045
+ });
2046
+
1951
2047
  it("declares the data URL subtype of an image part", () => {
1952
2048
  const message = {
1953
2049
  ...baseAppendMessage,
1954
2050
  content: [{ type: "image", image: "data:image/jpeg;base64,/9j/4AAQ" }],
1955
- } as unknown as AppendMessage;
2051
+ } as AppendMessage;
1956
2052
 
1957
2053
  expect(getEveMessageContent(message)).toEqual([
1958
2054
  {
@@ -1973,7 +2069,7 @@ describe("getEveMessageContent", () => {
1973
2069
  "data:application/octet-stream;base64,iVBORw0KGgoAAAANSUhEUgAAAAE=",
1974
2070
  },
1975
2071
  ],
1976
- } as unknown as AppendMessage;
2072
+ } as AppendMessage;
1977
2073
 
1978
2074
  expect(getEveMessageContent(message)).toEqual([
1979
2075
  {
@@ -1994,7 +2090,7 @@ describe("getEveMessageContent", () => {
1994
2090
  filename: "photo",
1995
2091
  },
1996
2092
  ],
1997
- } as unknown as AppendMessage;
2093
+ } as AppendMessage;
1998
2094
 
1999
2095
  expect(getEveMessageContent(message)).toEqual([
2000
2096
  {
@@ -2020,7 +2116,7 @@ describe("getEveMessageContent", () => {
2020
2116
  status: { type: "complete" },
2021
2117
  },
2022
2118
  ],
2023
- } as unknown as AppendMessage;
2119
+ } as AppendMessage;
2024
2120
 
2025
2121
  expect(getEveMessageContent(message)).toEqual([
2026
2122
  {
@@ -2120,7 +2216,7 @@ describe("getEveMessageContent", () => {
2120
2216
  const message = {
2121
2217
  ...baseAppendMessage,
2122
2218
  content: [{ type: "audio", audio: { data: "QUJD", format: "mp3" } }],
2123
- } as unknown as AppendMessage;
2219
+ } as AppendMessage;
2124
2220
 
2125
2221
  expect(getEveMessageContent(message)).toEqual([
2126
2222
  {
@@ -2135,7 +2231,7 @@ describe("getEveMessageContent", () => {
2135
2231
  const message = {
2136
2232
  ...baseAppendMessage,
2137
2233
  content: [{ type: "audio", audio: { data: "QUJD", format: "wav" } }],
2138
- } as unknown as AppendMessage;
2234
+ } as AppendMessage;
2139
2235
 
2140
2236
  expect(getEveMessageContent(message)).toEqual([
2141
2237
  {
@@ -2155,7 +2251,7 @@ describe("getEveMessageContent", () => {
2155
2251
  audio: { data: "data:audio/mpeg;base64,QUJD", format: "mp3" },
2156
2252
  },
2157
2253
  ],
2158
- } as unknown as AppendMessage;
2254
+ } as AppendMessage;
2159
2255
 
2160
2256
  expect(getEveMessageContent(message)).toEqual([
2161
2257
  {
@@ -2175,7 +2271,7 @@ describe("getEveMessageContent", () => {
2175
2271
  audio: { data: "data:;base64,QUJD", format: "mp3" },
2176
2272
  },
2177
2273
  ],
2178
- } as unknown as AppendMessage;
2274
+ } as AppendMessage;
2179
2275
 
2180
2276
  expect(getEveMessageContent(message)).toEqual([
2181
2277
  {
@@ -2195,7 +2291,7 @@ describe("getEveMessageContent", () => {
2195
2291
  audio: { data: "https://cdn.example.com/memo.mp3", format: "mp3" },
2196
2292
  },
2197
2293
  ],
2198
- } as unknown as AppendMessage;
2294
+ } as AppendMessage;
2199
2295
 
2200
2296
  expect(getEveMessageContent(message)).toEqual([
2201
2297
  {
@@ -2226,7 +2322,7 @@ describe("getEveMessageContent", () => {
2226
2322
  status: { type: "complete" },
2227
2323
  },
2228
2324
  ],
2229
- } as unknown as AppendMessage;
2325
+ } as AppendMessage;
2230
2326
 
2231
2327
  expect(getEveMessageContent(message)).toEqual([
2232
2328
  {
@@ -2253,7 +2349,7 @@ describe("getEveMessageContent", () => {
2253
2349
  ],
2254
2350
  },
2255
2351
  ],
2256
- } satisfies EveMessageData);
2352
+ } satisfies MessageData);
2257
2353
 
2258
2354
  expect(echoed?.content).toEqual([]);
2259
2355
  expect(echoed?.attachments?.map((a) => a.content)).toEqual([
@@ -2276,7 +2372,7 @@ describe("getEveMessageContent", () => {
2276
2372
  { type: "text", text: "hi" },
2277
2373
  { type: "data", name: "chart", data: { x: 1 } },
2278
2374
  ],
2279
- } as unknown as AppendMessage;
2375
+ } as AppendMessage;
2280
2376
 
2281
2377
  expect(getEveMessageContent(message)).toBe("hi");
2282
2378
  });
@@ -2284,7 +2380,7 @@ describe("getEveMessageContent", () => {
2284
2380
 
2285
2381
  const approveCancel = [
2286
2382
  { id: "approve", label: "Approve" },
2287
- { id: "cancel", label: "Cancel" },
2383
+ { id: "deny", label: "Cancel" },
2288
2384
  ];
2289
2385
  const environments = [
2290
2386
  { id: "staging", label: "Staging" },
@@ -2292,9 +2388,8 @@ const environments = [
2292
2388
  ];
2293
2389
 
2294
2390
  const withInputRequest = (
2295
- overrides: Partial<EveMessageInputRequest> = {},
2296
- ): EveMessageInputRequest => ({
2297
- requestId: "req_1",
2391
+ overrides: Partial<InputRequest> = {},
2392
+ ): InputRequest => ({
2298
2393
  prompt: "Send the email?",
2299
2394
  kind: "question",
2300
2395
  ...overrides,
@@ -2312,8 +2407,8 @@ describe("toEveInputResponse", () => {
2312
2407
  withInputRequest({ display: "confirmation", options: approveCancel }),
2313
2408
  ),
2314
2409
  ).toEqual({
2315
- requestId: "req_1",
2316
- optionId: "cancel",
2410
+ interactionId: "req_1",
2411
+ optionId: "deny",
2317
2412
  text: "Not yet",
2318
2413
  });
2319
2414
  });
@@ -2321,7 +2416,7 @@ describe("toEveInputResponse", () => {
2321
2416
  it("keeps the shipped one-argument mapping when no request is available", () => {
2322
2417
  expect(toEveInputResponse({ approvalId: "req_1", approved: true })).toEqual(
2323
2418
  {
2324
- requestId: "req_1",
2419
+ interactionId: "req_1",
2325
2420
  optionId: "approve",
2326
2421
  },
2327
2422
  );
@@ -2332,7 +2427,7 @@ describe("toEveInputResponse", () => {
2332
2427
  approved: false,
2333
2428
  reason: "Not yet",
2334
2429
  }),
2335
- ).toEqual({ requestId: "req_1", optionId: "cancel", text: "Not yet" });
2430
+ ).toEqual({ interactionId: "req_1", optionId: "deny", text: "Not yet" });
2336
2431
 
2337
2432
  expect(
2338
2433
  toEveInputResponse({
@@ -2340,7 +2435,7 @@ describe("toEveInputResponse", () => {
2340
2435
  approved: true,
2341
2436
  optionId: "staging",
2342
2437
  }),
2343
- ).toEqual({ requestId: "req_1", optionId: "staging" });
2438
+ ).toEqual({ interactionId: "req_1", optionId: "staging" });
2344
2439
  });
2345
2440
 
2346
2441
  it("keeps the confirmation mapping when the request carries approve/cancel options", () => {
@@ -2349,7 +2444,7 @@ describe("toEveInputResponse", () => {
2349
2444
  { approvalId: "req_1", approved: true },
2350
2445
  withInputRequest({ display: "confirmation", options: approveCancel }),
2351
2446
  ),
2352
- ).toEqual({ requestId: "req_1", optionId: "approve" });
2447
+ ).toEqual({ interactionId: "req_1", optionId: "approve" });
2353
2448
  });
2354
2449
 
2355
2450
  it("maps a select response to the chosen option id", () => {
@@ -2358,7 +2453,36 @@ describe("toEveInputResponse", () => {
2358
2453
  { approvalId: "req_1", approved: true, optionId: "staging" },
2359
2454
  withInputRequest({ display: "select", options: environments }),
2360
2455
  ),
2361
- ).toEqual({ requestId: "req_1", optionId: "staging" });
2456
+ ).toEqual({ interactionId: "req_1", optionId: "staging" });
2457
+ });
2458
+
2459
+ it("preserves explanatory text with an explicitly selected option", () => {
2460
+ expect(
2461
+ toEveInputResponse(
2462
+ {
2463
+ approvalId: "req_1",
2464
+ approved: true,
2465
+ optionId: "staging",
2466
+ text: "Use the safe environment",
2467
+ },
2468
+ withInputRequest({ display: "select", options: environments }),
2469
+ ),
2470
+ ).toEqual({
2471
+ interactionId: "req_1",
2472
+ optionId: "staging",
2473
+ text: "Use the safe environment",
2474
+ });
2475
+ });
2476
+
2477
+ it("explains that an optionless request cannot accept an explicit option", () => {
2478
+ expect(() =>
2479
+ toEveInputResponse(
2480
+ { approvalId: "req_1", approved: true, optionId: "staging" },
2481
+ withInputRequest({ display: "text" }),
2482
+ ),
2483
+ ).toThrow(
2484
+ 'Eve interaction "req_1" has no option with id "staging"; respond with one of: (the request carries no options)',
2485
+ );
2362
2486
  });
2363
2487
 
2364
2488
  it.each([
@@ -2369,7 +2493,7 @@ describe("toEveInputResponse", () => {
2369
2493
  "a select display that allows freeform",
2370
2494
  { display: "select", allowFreeform: true, options: environments },
2371
2495
  ],
2372
- ] satisfies [string, Partial<EveMessageInputRequest>][])(
2496
+ ] satisfies [string, Partial<InputRequest>][])(
2373
2497
  "answers a request with %s as free-form text, not a fabricated option id",
2374
2498
  (_label, overrides) => {
2375
2499
  const response = toEveInputResponse(
@@ -2378,7 +2502,7 @@ describe("toEveInputResponse", () => {
2378
2502
  );
2379
2503
 
2380
2504
  expect(response).toEqual({
2381
- requestId: "req_1",
2505
+ interactionId: "req_1",
2382
2506
  text: "Quarterly results",
2383
2507
  });
2384
2508
  expect(response).not.toHaveProperty("optionId");
@@ -2400,7 +2524,7 @@ describe("toEveInputResponse", () => {
2400
2524
  { approvalId: "req_1", approved: true, text: "staging" },
2401
2525
  withInputRequest({ display: "text" }),
2402
2526
  ),
2403
- ).toEqual({ requestId: "req_1", text: "staging" });
2527
+ ).toEqual({ interactionId: "req_1", text: "staging" });
2404
2528
  });
2405
2529
 
2406
2530
  it("prefers the response text over the reason it used to be smuggled in", () => {
@@ -2414,7 +2538,7 @@ describe("toEveInputResponse", () => {
2414
2538
  },
2415
2539
  withInputRequest({ display: "text" }),
2416
2540
  ),
2417
- ).toEqual({ requestId: "req_1", text: "staging" });
2541
+ ).toEqual({ interactionId: "req_1", text: "staging" });
2418
2542
  });
2419
2543
 
2420
2544
  it("never fabricates approve for a text-display request without an answer", () => {
@@ -2443,9 +2567,6 @@ describe("toEveInputResponse", () => {
2443
2567
  ),
2444
2568
  ).toThrow(/no option with id "sandbox".*staging, production/s);
2445
2569
 
2446
- // A named option the request does not carry outranks the decision, even
2447
- // when the decision names an option the request does carry: substituting
2448
- // the literal deny here would discard the choice the caller made.
2449
2570
  expect(() =>
2450
2571
  toEveInputResponse(
2451
2572
  { approvalId: "req_1", approved: false, optionId: "schedule-later" },
@@ -2462,7 +2583,7 @@ describe("toEveInputResponse", () => {
2462
2583
 
2463
2584
  expect(
2464
2585
  toEveInputResponse({ approvalId: "req_1", approved: true }, inputRequest),
2465
- ).toEqual({ requestId: "req_1", optionId: "approve" });
2586
+ ).toEqual({ interactionId: "req_1", optionId: "approve" });
2466
2587
 
2467
2588
  expect(
2468
2589
  toEveInputResponse(
@@ -2470,7 +2591,7 @@ describe("toEveInputResponse", () => {
2470
2591
  inputRequest,
2471
2592
  ),
2472
2593
  ).toEqual({
2473
- requestId: "req_1",
2594
+ interactionId: "req_1",
2474
2595
  optionId: "approve",
2475
2596
  text: "Quarterly results",
2476
2597
  });
@@ -2478,25 +2599,25 @@ describe("toEveInputResponse", () => {
2478
2599
 
2479
2600
  it("answers a tool-approval by kind even when its options are missing", () => {
2480
2601
  const inputRequest = withInputRequest({
2481
- kind: "tool-approval",
2602
+ kind: "approval",
2482
2603
  display: "confirmation",
2483
2604
  });
2484
2605
 
2485
2606
  expect(
2486
2607
  toEveInputResponse({ approvalId: "req_1", approved: true }, inputRequest),
2487
- ).toEqual({ requestId: "req_1", optionId: "approve" });
2608
+ ).toEqual({ interactionId: "req_1", optionId: "approve" });
2488
2609
 
2489
2610
  expect(
2490
2611
  toEveInputResponse(
2491
2612
  { approvalId: "req_1", approved: false, reason: "not now" },
2492
2613
  inputRequest,
2493
2614
  ),
2494
- ).toEqual({ requestId: "req_1", optionId: "cancel", text: "not now" });
2615
+ ).toEqual({ interactionId: "req_1", optionId: "deny", text: "not now" });
2495
2616
  });
2496
2617
 
2497
2618
  it("still requires a declared option when a tool-approval carries its own", () => {
2498
2619
  const inputRequest = withInputRequest({
2499
- kind: "tool-approval",
2620
+ kind: "approval",
2500
2621
  display: "confirmation",
2501
2622
  options: [{ id: "schedule-later", label: "Schedule later" }],
2502
2623
  });
@@ -2510,7 +2631,7 @@ describe("toEveInputResponse", () => {
2510
2631
  { approvalId: "req_1", approved: true, optionId: "schedule-later" },
2511
2632
  inputRequest,
2512
2633
  ),
2513
- ).toEqual({ requestId: "req_1", optionId: "schedule-later" });
2634
+ ).toEqual({ interactionId: "req_1", optionId: "schedule-later" });
2514
2635
  });
2515
2636
 
2516
2637
  it("never answers an optionless confirmation as free-form text", () => {
@@ -2529,41 +2650,25 @@ describe("toEveInputResponse", () => {
2529
2650
  });
2530
2651
 
2531
2652
  describe("findEveInputRequest", () => {
2653
+ const request = {
2654
+ kind: "question",
2655
+ prompt: "What should the subject line be?",
2656
+ display: "text",
2657
+ allowFreeform: true,
2658
+ } as const satisfies InputRequest;
2532
2659
  const data = {
2533
2660
  messages: [
2534
2661
  {
2535
2662
  id: "a1",
2536
2663
  role: "assistant",
2537
- parts: [
2538
- {
2539
- type: "dynamic-tool",
2540
- state: "approval-requested",
2541
- toolCallId: "call_1",
2542
- toolName: "ask_question",
2543
- input: {},
2544
- approval: { id: "req_1" },
2545
- toolMetadata: {
2546
- eve: {
2547
- kind: "tool-call",
2548
- name: "ask_question",
2549
- inputRequest: {
2550
- requestId: "req_1",
2551
- prompt: "What should the subject line be?",
2552
- kind: "question",
2553
- display: "text",
2554
- allowFreeform: true,
2555
- },
2556
- },
2557
- },
2558
- },
2559
- ],
2664
+ parts: [toolPart({ interaction: approvalInteraction(request) })],
2560
2665
  },
2561
2666
  ],
2562
- } satisfies EveMessageData;
2667
+ } satisfies MessageData;
2563
2668
 
2564
2669
  it("finds the input request by approval id", () => {
2565
2670
  expect(findEveInputRequest(data, "req_1")).toMatchObject({
2566
- requestId: "req_1",
2671
+ prompt: "What should the subject line be?",
2567
2672
  display: "text",
2568
2673
  });
2569
2674
  });
@@ -2572,16 +2677,24 @@ describe("findEveInputRequest", () => {
2572
2677
  expect(findEveInputRequest(data, "req_404")).toBeUndefined();
2573
2678
  });
2574
2679
 
2575
- it.each([
2576
- ["carries no input request", { eve: { kind: "tool-call", name: "ask" } }],
2577
- ["carries no eve tool metadata at all", undefined],
2578
- ])("returns undefined when the matching part %s", (_label, toolMetadata) => {
2579
- const part = { ...data.messages[0]!.parts[0]!, toolMetadata };
2580
- const bare = { messages: [{ ...data.messages[0]!, parts: [part] }] };
2680
+ it("returns undefined when the matching tool has no interaction", () => {
2681
+ const bare = {
2682
+ messages: [{ id: "a1", role: "assistant", parts: [toolPart()] }],
2683
+ } satisfies MessageData;
2684
+ expect(findEveInputRequest(bare, "req_1")).toBeUndefined();
2685
+ });
2581
2686
 
2687
+ it("finds a standalone interaction by its approval id", () => {
2582
2688
  expect(
2583
- findEveInputRequest(bare as EveMessageData, "req_1"),
2584
- ).toBeUndefined();
2689
+ findEveInputRequest(
2690
+ {
2691
+ messages: [
2692
+ { id: "a1", role: "assistant", parts: [signInPart(request)] },
2693
+ ],
2694
+ },
2695
+ "auth_1",
2696
+ ),
2697
+ ).toEqual(request);
2585
2698
  });
2586
2699
  });
2587
2700
 
@@ -2590,20 +2703,78 @@ describe("collectInterruptedTurnEvents", () => {
2590
2703
  ({
2591
2704
  type: "turn.started",
2592
2705
  meta: eventMeta(sequence),
2593
- data: { turnId, sequence },
2594
- }) as const satisfies MessageStreamEvent;
2706
+ data: {
2707
+ turnId,
2708
+ cause: { deliveryId: `delivery_${turnId}` },
2709
+ follows: null,
2710
+ },
2711
+ }) as const satisfies SessionStreamEvent;
2595
2712
  const failed = (turnId: string, sequence: number) =>
2596
2713
  ({
2597
- type: "turn.failed",
2714
+ type: "turn.settled",
2598
2715
  meta: eventMeta(sequence),
2599
- data: { turnId, sequence, code: "internal", message: "boom" },
2600
- }) as const satisfies MessageStreamEvent;
2716
+ data: {
2717
+ turnId,
2718
+ outcome: "failed",
2719
+ error: { code: "internal", message: "boom" },
2720
+ },
2721
+ }) as const satisfies SessionStreamEvent;
2601
2722
  const cancelled = (turnId: string, sequence: number) =>
2602
2723
  ({
2603
- type: "turn.cancelled",
2724
+ type: "turn.settled",
2604
2725
  meta: eventMeta(sequence),
2605
- data: { turnId, sequence },
2606
- }) as const satisfies MessageStreamEvent;
2726
+ data: { turnId, outcome: "cancelled" },
2727
+ }) as const satisfies SessionStreamEvent;
2728
+
2729
+ it.each([
2730
+ [0, 128],
2731
+ [0, 512],
2732
+ [4, 128],
2733
+ [4, 512],
2734
+ ])(
2735
+ "copies %i cached interruptions once when collecting %i more",
2736
+ (cachedCount, appendedCount) => {
2737
+ const previousEvents = Array.from({ length: cachedCount }, (_, index) =>
2738
+ failed(`previous_${index}`, index),
2739
+ );
2740
+ const previousInterruptions = [...previousEvents];
2741
+ const cache: InterruptedTurnEventCache = {
2742
+ lastEvents: previousEvents,
2743
+ interruptions: previousInterruptions,
2744
+ };
2745
+ const events = [
2746
+ ...previousEvents,
2747
+ ...Array.from({ length: appendedCount }, (_, index) =>
2748
+ cancelled(`next_${index}`, cachedCount + index),
2749
+ ),
2750
+ ];
2751
+ const iterator = Array.prototype[Symbol.iterator];
2752
+ let copiedEntries = 0;
2753
+ let interruptions: readonly SessionStreamEvent[];
2754
+ try {
2755
+ // A different copying primitive needs its own work counter.
2756
+ Array.prototype[Symbol.iterator] = function* (this: unknown[]) {
2757
+ for (const entry of iterator.call(this)) {
2758
+ copiedEntries++;
2759
+ yield entry;
2760
+ }
2761
+ return undefined;
2762
+ };
2763
+ interruptions = collectInterruptedTurnEvents(events, cache);
2764
+ } finally {
2765
+ Array.prototype[Symbol.iterator] = iterator;
2766
+ }
2767
+
2768
+ expect(interruptions).toEqual(events);
2769
+ expect(
2770
+ interruptions.every((event, index) => event === events[index]),
2771
+ ).toBe(true);
2772
+ expect(previousInterruptions).toEqual(previousEvents);
2773
+ expect(cache.interruptions).toBe(interruptions);
2774
+ expect(cache.lastEvents).toBe(events);
2775
+ expect(copiedEntries).toBe(cachedCount);
2776
+ },
2777
+ );
2607
2778
 
2608
2779
  it("scans only appended events and keeps the array until an interruption arrives", () => {
2609
2780
  const cache: InterruptedTurnEventCache = {
@@ -2629,4 +2800,29 @@ describe("collectInterruptedTurnEvents", () => {
2629
2800
  collectInterruptedTurnEvents([started("t1", 0), failed("t1", 1)], cache);
2630
2801
  expect(collectInterruptedTurnEvents([started("t2", 0)], cache)).toEqual([]);
2631
2802
  });
2803
+
2804
+ it("records failed session endings and ignores completed endings", () => {
2805
+ const cache: InterruptedTurnEventCache = {
2806
+ lastEvents: [],
2807
+ interruptions: [],
2808
+ };
2809
+ const completed = {
2810
+ type: "session.ended",
2811
+ data: { outcome: "completed" },
2812
+ meta: eventMeta(1),
2813
+ } as const satisfies SessionStreamEvent;
2814
+ const failedSession = {
2815
+ type: "session.ended",
2816
+ data: { outcome: "failed", cause: { turnId: "t1" } },
2817
+ meta: eventMeta(2),
2818
+ } as const satisfies SessionStreamEvent;
2819
+ const events = [completed, failedSession];
2820
+
2821
+ expect(collectInterruptedTurnEvents(events, cache)).toEqual([
2822
+ failedSession,
2823
+ ]);
2824
+ expect(collectInterruptedTurnEvents(events, cache)).toBe(
2825
+ cache.interruptions,
2826
+ );
2827
+ });
2632
2828
  });