@assistant-ui/eve 0.0.23 → 0.0.25

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 +151 -122
  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 +940 -775
  25. package/src/convertEveMessages.ts +290 -233
  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,14 +792,14 @@ describe("convertEveMessages", () => {
579
792
  ],
580
793
  },
581
794
  ],
582
- } satisfies EveMessageData;
795
+ } satisfies MessageData;
583
796
 
584
797
  const [message] = convertEveMessages(data);
585
798
 
586
799
  expect(message?.content).toEqual([{ type: "text", text: "Hello" }]);
587
800
  });
588
801
 
589
- it("converts a user file part into content and a file attachment", () => {
802
+ it("converts a user file part into a file attachment and keeps it out of content", () => {
590
803
  const data = {
591
804
  messages: [
592
805
  {
@@ -604,19 +817,12 @@ describe("convertEveMessages", () => {
604
817
  ],
605
818
  },
606
819
  ],
607
- } satisfies EveMessageData;
820
+ } satisfies MessageData;
608
821
 
609
822
  const [message] = convertEveMessages(data);
610
823
 
611
824
  expect(message?.content).toEqual([
612
825
  { type: "text", text: "See the report" },
613
- {
614
- type: "file",
615
- data: "https://example.com/report.pdf",
616
- mimeType: "application/pdf",
617
- filename: "report.pdf",
618
- sourceType: "url",
619
- },
620
826
  ]);
621
827
  expect(message?.attachments).toEqual([
622
828
  {
@@ -654,19 +860,11 @@ describe("convertEveMessages", () => {
654
860
  ],
655
861
  },
656
862
  ],
657
- } satisfies EveMessageData;
863
+ } satisfies MessageData;
658
864
 
659
865
  const [message] = convertEveMessages(data);
660
866
 
661
- expect(message?.content).toEqual([
662
- {
663
- type: "file",
664
- data: "https://example.com/photo.png",
665
- mimeType: "image/png",
666
- filename: "photo.png",
667
- sourceType: "url",
668
- },
669
- ]);
867
+ expect(message?.content).toEqual([]);
670
868
  expect(message?.attachments).toEqual([
671
869
  {
672
870
  id: "0",
@@ -700,17 +898,11 @@ describe("convertEveMessages", () => {
700
898
  ],
701
899
  },
702
900
  ],
703
- } satisfies EveMessageData;
901
+ } satisfies MessageData;
704
902
 
705
903
  const [message] = convertEveMessages(data);
706
904
 
707
- expect(message?.content).toEqual([
708
- {
709
- type: "file",
710
- data: "data:application/pdf;base64,QUJD",
711
- mimeType: "application/pdf",
712
- },
713
- ]);
905
+ expect(message?.content).toEqual([]);
714
906
  expect(message?.attachments).toEqual([
715
907
  {
716
908
  id: "0",
@@ -750,7 +942,7 @@ describe("convertEveMessages", () => {
750
942
  ],
751
943
  },
752
944
  ],
753
- } satisfies EveMessageData;
945
+ } satisfies MessageData;
754
946
 
755
947
  const [message] = convertEveMessages(data);
756
948
 
@@ -760,71 +952,6 @@ describe("convertEveMessages", () => {
760
952
  ]);
761
953
  });
762
954
 
763
- it("defaults a file part with a missing mediaType to application/octet-stream", () => {
764
- const data = {
765
- messages: [
766
- {
767
- id: "u1",
768
- role: "user",
769
- parts: [{ type: "file", url: "https://example.com/blob" }],
770
- },
771
- ],
772
- } as unknown as EveMessageData;
773
-
774
- const [message] = convertEveMessages(data);
775
-
776
- expect(message?.content).toEqual([
777
- {
778
- type: "file",
779
- data: "https://example.com/blob",
780
- mimeType: "application/octet-stream",
781
- sourceType: "url",
782
- },
783
- ]);
784
- expect(message?.attachments).toEqual([
785
- {
786
- id: "0",
787
- type: "file",
788
- name: "file",
789
- content: [
790
- {
791
- type: "file",
792
- data: "https://example.com/blob",
793
- mimeType: "application/octet-stream",
794
- sourceType: "url",
795
- },
796
- ],
797
- contentType: "application/octet-stream",
798
- status: { type: "complete" },
799
- },
800
- ]);
801
- });
802
-
803
- it("reads the media type from a data URL when eve omits mediaType", () => {
804
- const data = {
805
- messages: [
806
- {
807
- id: "u1",
808
- role: "user",
809
- parts: [{ type: "file", url: "data:image/png;base64,iVBORw0KGgo=" }],
810
- },
811
- ],
812
- } as unknown as EveMessageData;
813
-
814
- const [message] = convertEveMessages(data);
815
-
816
- expect(message?.content).toEqual([
817
- {
818
- type: "file",
819
- data: "data:image/png;base64,iVBORw0KGgo=",
820
- mimeType: "image/png",
821
- },
822
- ]);
823
- expect(message?.attachments?.map((a) => [a.type, a.contentType])).toEqual([
824
- ["image", "image/png"],
825
- ]);
826
- });
827
-
828
955
  it("prefers an explicit mediaType over the data URL declaration", () => {
829
956
  const data = {
830
957
  messages: [
@@ -840,13 +967,13 @@ describe("convertEveMessages", () => {
840
967
  ],
841
968
  },
842
969
  ],
843
- } satisfies EveMessageData;
970
+ } satisfies MessageData;
844
971
 
845
972
  const [message] = convertEveMessages(data);
846
973
 
847
- expect(message?.content[0]).toMatchObject({
974
+ expect(message?.attachments?.[0]).toMatchObject({
848
975
  type: "file",
849
- mimeType: "application/pdf",
976
+ contentType: "application/pdf",
850
977
  });
851
978
  });
852
979
 
@@ -867,7 +994,7 @@ describe("convertEveMessages", () => {
867
994
  ],
868
995
  },
869
996
  ],
870
- } satisfies EveMessageData;
997
+ } satisfies MessageData;
871
998
 
872
999
  const [message] = convertEveMessages(data);
873
1000
 
@@ -883,6 +1010,25 @@ describe("convertEveMessages", () => {
883
1010
  ]);
884
1011
  });
885
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
+
886
1032
  it("drops non-convertible part types instead of throwing", () => {
887
1033
  const data = {
888
1034
  messages: [
@@ -890,14 +1036,12 @@ describe("convertEveMessages", () => {
890
1036
  id: "a1",
891
1037
  role: "assistant",
892
1038
  parts: [
893
- { type: "step-start" },
894
1039
  { type: "future-part", payload: {} },
895
- { type: "file", mediaType: "application/pdf" },
896
1040
  { type: "text", text: "Done" },
897
1041
  ],
898
1042
  },
899
1043
  ],
900
- } as unknown as EveMessageData;
1044
+ } as unknown as MessageData;
901
1045
 
902
1046
  const [message] = convertEveMessages(data);
903
1047
 
@@ -911,26 +1055,22 @@ describe("convertEveMessages", () => {
911
1055
  id: "a1",
912
1056
  role: "assistant",
913
1057
  parts: [
914
- {
915
- type: "authorization",
916
- state: "required",
917
- name: "github",
918
- description: "Sign in to GitHub",
919
- displayName: "GitHub",
920
- stepIndex: 0,
921
- turnId: "turn_1",
922
- authorization: {
1058
+ signInPart({
1059
+ kind: "sign-in",
1060
+ prompt: "Sign in to GitHub",
1061
+ signIn: {
1062
+ name: "github",
923
1063
  displayName: "GitHub",
924
1064
  instructions: "Enter the code on the device page",
925
1065
  url: "https://github.com/login/device",
926
1066
  userCode: "ABCD-1234",
927
1067
  expiresAt: "2026-01-01T00:00:00Z",
928
1068
  },
929
- },
1069
+ }),
930
1070
  ],
931
1071
  },
932
1072
  ],
933
- } satisfies EveMessageData;
1073
+ } satisfies MessageData;
934
1074
 
935
1075
  const [message] = convertEveMessages(data);
936
1076
 
@@ -959,15 +1099,15 @@ describe("convertEveMessages", () => {
959
1099
  id: "a1",
960
1100
  role: "assistant",
961
1101
  parts: [
962
- {
963
- type: "authorization",
964
- state: "required",
965
- name: "github",
966
- },
1102
+ signInPart({
1103
+ kind: "sign-in",
1104
+ prompt: "",
1105
+ signIn: { name: "github" },
1106
+ }),
967
1107
  ],
968
1108
  },
969
1109
  ],
970
- } as unknown as EveMessageData;
1110
+ } satisfies MessageData;
971
1111
 
972
1112
  const [message] = convertEveMessages(data);
973
1113
 
@@ -975,7 +1115,7 @@ describe("convertEveMessages", () => {
975
1115
  {
976
1116
  type: "data",
977
1117
  name: "authorization",
978
- data: { state: "required", name: "github" },
1118
+ data: { state: "required", name: "github", description: "" },
979
1119
  },
980
1120
  ]);
981
1121
  });
@@ -987,19 +1127,19 @@ describe("convertEveMessages", () => {
987
1127
  id: "a1",
988
1128
  role: "assistant",
989
1129
  parts: [
990
- {
991
- type: "authorization",
992
- state: "required",
993
- name: "github",
994
- authorization: {
1130
+ signInPart({
1131
+ kind: "sign-in",
1132
+ prompt: "",
1133
+ signIn: {
1134
+ name: "github",
995
1135
  url: "javascript:alert(1)",
996
1136
  userCode: "ABCD-1234",
997
1137
  },
998
- },
1138
+ }),
999
1139
  ],
1000
1140
  },
1001
1141
  ],
1002
- } as unknown as EveMessageData;
1142
+ } satisfies MessageData;
1003
1143
 
1004
1144
  const [message] = convertEveMessages(data);
1005
1145
 
@@ -1007,7 +1147,12 @@ describe("convertEveMessages", () => {
1007
1147
  {
1008
1148
  type: "data",
1009
1149
  name: "authorization",
1010
- data: { state: "required", name: "github", userCode: "ABCD-1234" },
1150
+ data: {
1151
+ state: "required",
1152
+ name: "github",
1153
+ description: "",
1154
+ userCode: "ABCD-1234",
1155
+ },
1011
1156
  },
1012
1157
  ]);
1013
1158
  });
@@ -1019,20 +1164,18 @@ describe("convertEveMessages", () => {
1019
1164
  id: "a1",
1020
1165
  role: "assistant",
1021
1166
  parts: [
1022
- {
1023
- type: "authorization",
1024
- state: "completed",
1025
- name: "github",
1026
- description: "Sign in to GitHub",
1027
- displayName: "GitHub",
1028
- stepIndex: 0,
1029
- turnId: "turn_1",
1030
- outcome: "authorized",
1031
- },
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
+ ),
1032
1175
  ],
1033
1176
  },
1034
1177
  ],
1035
- } satisfies EveMessageData;
1178
+ } satisfies MessageData;
1036
1179
 
1037
1180
  const [message] = convertEveMessages(data);
1038
1181
 
@@ -1054,7 +1197,7 @@ describe("convertEveMessages", () => {
1054
1197
  describe("assistant message status mapping", () => {
1055
1198
  const withStatus = (
1056
1199
  status: "streaming" | "complete" | "failed",
1057
- ): EveMessageData => ({
1200
+ ): MessageData => ({
1058
1201
  messages: [
1059
1202
  {
1060
1203
  id: "a1",
@@ -1118,7 +1261,7 @@ describe("convertEveMessages", () => {
1118
1261
  parts: [{ type: "text", text: "Try again" }],
1119
1262
  },
1120
1263
  ],
1121
- } satisfies EveMessageData;
1264
+ } satisfies MessageData;
1122
1265
 
1123
1266
  const [assistant] = convertEveMessages(data, {
1124
1267
  isRunning: true,
@@ -1148,6 +1291,101 @@ describe("convertEveMessages", () => {
1148
1291
  expect(message?.status).toEqual({ type: "incomplete", reason: "error" });
1149
1292
  });
1150
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
+
1151
1389
  it("keeps requires-action for pending approvals when not running", () => {
1152
1390
  const data = {
1153
1391
  messages: [
@@ -1155,21 +1393,12 @@ describe("convertEveMessages", () => {
1155
1393
  id: "a1",
1156
1394
  role: "assistant",
1157
1395
  metadata: { status: "streaming" },
1158
- parts: [
1159
- {
1160
- type: "dynamic-tool",
1161
- state: "approval-requested",
1162
- toolCallId: "call_1",
1163
- toolName: "send_email",
1164
- input: {},
1165
- approval: { id: "req_1" },
1166
- },
1167
- ],
1396
+ parts: [toolPart({ interaction: approvalInteraction() })],
1168
1397
  },
1169
1398
  ],
1170
- } satisfies EveMessageData;
1399
+ } satisfies MessageData;
1171
1400
 
1172
- const [message] = convertEveMessages(data, { isRunning: false });
1401
+ const message = convertEveMessages(data, { isRunning: false }).at(-1);
1173
1402
 
1174
1403
  expect(message?.status).toEqual({
1175
1404
  type: "requires-action",
@@ -1185,19 +1414,15 @@ describe("convertEveMessages", () => {
1185
1414
  role: "assistant",
1186
1415
  metadata: { status: "streaming" },
1187
1416
  parts: [
1188
- {
1189
- type: "authorization",
1190
- state: "required",
1191
- name: "github",
1192
- description: "Sign in to GitHub",
1193
- displayName: "GitHub",
1194
- stepIndex: 0,
1195
- turnId: "turn_1",
1196
- },
1417
+ signInPart({
1418
+ kind: "sign-in",
1419
+ prompt: "Sign in to GitHub",
1420
+ signIn: { name: "github", displayName: "GitHub" },
1421
+ }),
1197
1422
  ],
1198
1423
  },
1199
1424
  ],
1200
- } satisfies EveMessageData;
1425
+ } satisfies MessageData;
1201
1426
 
1202
1427
  const [message] = convertEveMessages(data, { isRunning: false });
1203
1428
 
@@ -1215,27 +1440,16 @@ describe("convertEveMessages", () => {
1215
1440
  role: "assistant",
1216
1441
  metadata: { status: "streaming" },
1217
1442
  parts: [
1218
- {
1219
- type: "dynamic-tool",
1220
- state: "approval-requested",
1221
- toolCallId: "call_1",
1222
- toolName: "send_email",
1223
- input: {},
1224
- approval: { id: "req_1" },
1225
- },
1226
- {
1227
- type: "authorization",
1228
- state: "required",
1229
- name: "github",
1230
- description: "Sign in to GitHub",
1231
- displayName: "GitHub",
1232
- stepIndex: 0,
1233
- turnId: "turn_1",
1234
- },
1443
+ toolPart({ interaction: approvalInteraction() }),
1444
+ signInPart({
1445
+ kind: "sign-in",
1446
+ prompt: "Sign in to GitHub",
1447
+ signIn: { name: "github", displayName: "GitHub" },
1448
+ }),
1235
1449
  ],
1236
1450
  },
1237
1451
  ],
1238
- } satisfies EveMessageData;
1452
+ } satisfies MessageData;
1239
1453
 
1240
1454
  const [message] = convertEveMessages(data, { isRunning: false });
1241
1455
 
@@ -1246,52 +1460,26 @@ describe("convertEveMessages", () => {
1246
1460
  });
1247
1461
 
1248
1462
  it("settles a cancelled turn with a pending authorization", () => {
1249
- const reducer = defaultMessageReducer();
1250
- const events: readonly EveAgentReducerEvent[] = [
1251
- {
1252
- type: "authorization.required",
1253
- meta: eventMeta(0),
1254
- data: {
1255
- turnId: "turn_1",
1256
- stepIndex: 0,
1257
- sequence: 0,
1258
- name: "github",
1259
- description: "Sign in to GitHub",
1260
- },
1261
- },
1262
- {
1263
- type: "turn.cancelled",
1264
- meta: eventMeta(1),
1265
- data: { turnId: "turn_1", sequence: 1 },
1266
- },
1267
- ];
1268
- const pendingData = events
1269
- .slice(0, 1)
1270
- .reduce(
1271
- (state, event) => reducer.reduce(state, event),
1272
- reducer.initial(),
1273
- );
1463
+ const events = [...initialTurnEvents, signInOpened("github")];
1464
+ const pendingData = replay(events);
1274
1465
  const pendingAssistant = pendingData.messages.find(
1275
1466
  (message) => message.role === "assistant",
1276
1467
  );
1277
1468
  expect(pendingAssistant?.metadata?.status).toBe("streaming");
1278
1469
  expect(
1279
1470
  convertEveMessages(pendingData, { isRunning: false }).at(-1)?.status,
1280
- ).toEqual({
1281
- type: "requires-action",
1282
- reason: "interrupt",
1283
- });
1284
-
1285
- const data = events
1286
- .slice(1)
1287
- .reduce((state, event) => reducer.reduce(state, event), pendingData);
1288
-
1289
- const [message] = convertEveMessages(data, { isRunning: false });
1471
+ ).toEqual({ type: "requires-action", reason: "interrupt" });
1290
1472
 
1291
- expect(message?.status).toEqual({
1292
- type: "complete",
1293
- reason: "stop",
1294
- });
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);
1481
+
1482
+ expect(message?.status).toEqual({ type: "complete", reason: "stop" });
1295
1483
  });
1296
1484
 
1297
1485
  it("reports a failed turn with a pending authorization", () => {
@@ -1302,19 +1490,15 @@ describe("convertEveMessages", () => {
1302
1490
  role: "assistant",
1303
1491
  metadata: { status: "streaming" },
1304
1492
  parts: [
1305
- {
1306
- type: "authorization",
1307
- state: "required",
1308
- name: "github",
1309
- description: "Sign in to GitHub",
1310
- displayName: "GitHub",
1311
- stepIndex: 0,
1312
- turnId: "turn_1",
1313
- },
1493
+ signInPart({
1494
+ kind: "sign-in",
1495
+ prompt: "Sign in to GitHub",
1496
+ signIn: { name: "github", displayName: "GitHub" },
1497
+ }),
1314
1498
  ],
1315
1499
  },
1316
1500
  ],
1317
- } satisfies EveMessageData;
1501
+ } satisfies MessageData;
1318
1502
 
1319
1503
  const error = new Error("authorization failed");
1320
1504
  const [message] = convertEveMessages(data, {
@@ -1340,15 +1524,11 @@ describe("convertEveMessages", () => {
1340
1524
  role: "assistant",
1341
1525
  metadata: { status: "streaming" },
1342
1526
  parts: [
1343
- {
1344
- type: "authorization",
1345
- state: "required",
1346
- name: "github",
1347
- description: "Sign in to GitHub",
1348
- displayName: "GitHub",
1349
- stepIndex: 0,
1350
- turnId: "turn_1",
1351
- },
1527
+ signInPart({
1528
+ kind: "sign-in",
1529
+ prompt: "Sign in to GitHub",
1530
+ signIn: { name: "github", displayName: "GitHub" },
1531
+ }),
1352
1532
  ],
1353
1533
  },
1354
1534
  {
@@ -1358,7 +1538,7 @@ describe("convertEveMessages", () => {
1358
1538
  parts: [{ type: "text", text: "Later turn" }],
1359
1539
  },
1360
1540
  ],
1361
- } satisfies EveMessageData;
1541
+ } satisfies MessageData;
1362
1542
 
1363
1543
  const [authorization] = convertEveMessages(data, {
1364
1544
  isRunning: false,
@@ -1379,93 +1559,70 @@ describe("convertEveMessages", () => {
1379
1559
  role: "assistant",
1380
1560
  metadata: { status: "complete" },
1381
1561
  parts: [
1382
- {
1383
- type: "authorization",
1384
- state: "completed",
1385
- name: "github",
1386
- description: "Sign in to GitHub",
1387
- displayName: "GitHub",
1388
- stepIndex: 0,
1389
- turnId: "turn_1",
1390
- outcome: "authorized",
1391
- },
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
+ ),
1392
1570
  { type: "text", text: "Done" },
1393
1571
  ],
1394
1572
  },
1395
1573
  ],
1396
- } satisfies EveMessageData;
1574
+ } satisfies MessageData;
1397
1575
 
1398
1576
  const [message] = convertEveMessages(data, { isRunning: false });
1399
1577
 
1400
1578
  expect(message?.status).toEqual({ type: "complete", reason: "stop" });
1401
1579
  });
1402
1580
 
1403
- describe("contract with eve's default reducer", () => {
1404
- const replay = (events: readonly EveAgentReducerEvent[]) => {
1405
- const reducer = defaultMessageReducer();
1406
- return events.reduce(
1407
- (state, event) => reducer.reduce(state, event),
1408
- reducer.initial(),
1409
- );
1410
- };
1411
-
1412
- const midStreamEvents: readonly EveAgentReducerEvent[] = [
1413
- {
1414
- type: "client.message.submitted",
1415
- data: { submissionId: "sub_1", message: "hi", createdAt: 0 },
1416
- },
1417
- {
1418
- type: "turn.started",
1419
- meta: eventMeta(0),
1420
- data: { turnId: "turn_1", sequence: 0 },
1421
- },
1422
- {
1423
- type: "step.started",
1424
- meta: eventMeta(1),
1425
- data: {
1426
- turnId: "turn_1",
1427
- stepIndex: 0,
1428
- sequence: 1,
1429
- modelId: "test-model",
1430
- },
1431
- },
1581
+ describe("contract with eve's conversation projection", () => {
1582
+ const midStreamEvents: readonly SessionEvent[] = [
1583
+ ...initialTurnEvents,
1432
1584
  {
1433
- type: "message.appended",
1434
- meta: eventMeta(2),
1435
- data: {
1436
- turnId: "turn_1",
1437
- stepIndex: 0,
1438
- sequence: 2,
1439
- messageDelta: "Let me th",
1440
- },
1585
+ type: "content.delta",
1586
+ scope: { turnId: "turn_1", runId: "run_1" },
1587
+ data: { partId: "part_1", kind: "text", delta: "Let me th" },
1441
1588
  },
1442
1589
  ];
1443
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
+
1444
1613
  it("carries the challenge Eve projects onto an authorization part", () => {
1445
1614
  const state = replay([
1446
1615
  ...midStreamEvents,
1447
- {
1448
- type: "authorization.required",
1449
- meta: eventMeta(3),
1450
- data: {
1451
- turnId: "turn_1",
1452
- stepIndex: 0,
1453
- sequence: 3,
1454
- name: "github",
1455
- description: "Authorization required for github",
1456
- authorization: {
1457
- displayName: "GitHub",
1458
- instructions: "Enter the code on the device page",
1459
- url: "https://github.com/login/device",
1460
- userCode: "ABCD-1234",
1461
- expiresAt: "2026-01-01T00:00:00Z",
1462
- },
1463
- },
1464
- },
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
+ }),
1465
1623
  ]);
1466
1624
 
1467
1625
  const converted = convertEveMessages(state, { isRunning: false });
1468
-
1469
1626
  expect(converted.at(-1)?.content).toContainEqual({
1470
1627
  type: "data",
1471
1628
  name: "authorization",
@@ -1485,39 +1642,19 @@ describe("convertEveMessages", () => {
1485
1642
  it("releases the hold when Eve settles the authorization part in place", () => {
1486
1643
  const state = replay([
1487
1644
  ...midStreamEvents,
1645
+ signInOpened("github"),
1488
1646
  {
1489
- type: "authorization.required",
1490
- meta: eventMeta(3),
1491
- data: {
1492
- turnId: "turn_1",
1493
- stepIndex: 0,
1494
- sequence: 3,
1495
- name: "github",
1496
- description: "Authorization required for github",
1497
- },
1498
- },
1499
- {
1500
- type: "authorization.completed",
1501
- meta: eventMeta(4),
1502
- data: {
1503
- turnId: "turn_1",
1504
- stepIndex: 0,
1505
- sequence: 4,
1506
- name: "github",
1507
- outcome: "authorized",
1508
- },
1647
+ type: "interaction.settled",
1648
+ data: { interactionId: "auth_github", outcome: "accepted" },
1509
1649
  },
1510
1650
  ]);
1511
1651
 
1512
1652
  const assistant = state.messages.find((m) => m.role === "assistant");
1513
1653
  const authorizationParts = assistant?.parts.filter(
1514
- (part) => part.type === "authorization",
1654
+ (part) => part.type === "interaction",
1515
1655
  );
1516
1656
  expect(authorizationParts).toHaveLength(1);
1517
- expect(authorizationParts?.[0]).toMatchObject({ state: "completed" });
1518
-
1519
- // Eve leaves the streaming marker set, so the released turn falls back
1520
- // to the stale-marker mapping rather than to a terminal status.
1657
+ expect(authorizationParts?.[0]).toMatchObject({ status: "settled" });
1521
1658
  expect(
1522
1659
  convertEveMessages(state, { isRunning: false }).at(-1)?.status,
1523
1660
  ).toEqual({ type: "incomplete", reason: "cancelled" });
@@ -1526,38 +1663,11 @@ describe("convertEveMessages", () => {
1526
1663
  it("keeps the hold while another connector is still required", () => {
1527
1664
  const state = replay([
1528
1665
  ...midStreamEvents,
1666
+ signInOpened("github"),
1667
+ signInOpened("slack"),
1529
1668
  {
1530
- type: "authorization.required",
1531
- meta: eventMeta(3),
1532
- data: {
1533
- turnId: "turn_1",
1534
- stepIndex: 0,
1535
- sequence: 3,
1536
- name: "github",
1537
- description: "Authorization required for github",
1538
- },
1539
- },
1540
- {
1541
- type: "authorization.required",
1542
- meta: eventMeta(4),
1543
- data: {
1544
- turnId: "turn_1",
1545
- stepIndex: 0,
1546
- sequence: 4,
1547
- name: "slack",
1548
- description: "Authorization required for slack",
1549
- },
1550
- },
1551
- {
1552
- type: "authorization.completed",
1553
- meta: eventMeta(5),
1554
- data: {
1555
- turnId: "turn_1",
1556
- stepIndex: 0,
1557
- sequence: 5,
1558
- name: "github",
1559
- outcome: "authorized",
1560
- },
1669
+ type: "interaction.settled",
1670
+ data: { interactionId: "auth_github", outcome: "accepted" },
1561
1671
  },
1562
1672
  ]);
1563
1673
 
@@ -1567,51 +1677,41 @@ describe("convertEveMessages", () => {
1567
1677
  });
1568
1678
 
1569
1679
  it("projects the input request onto the approval part it gates", () => {
1570
- const events: readonly EveAgentReducerEvent[] = [
1680
+ const events: readonly SessionEvent[] = [
1571
1681
  ...midStreamEvents,
1572
1682
  {
1573
- type: "input.requested",
1683
+ ...callRequested,
1574
1684
  data: {
1575
- turnId: "turn_1",
1576
- stepIndex: 0,
1577
- sequence: 3,
1578
- requests: [
1579
- {
1580
- requestId: "req_1",
1581
- prompt: "What should the subject line be?",
1582
- kind: "question",
1583
- display: "text",
1584
- action: {
1585
- kind: "tool-call",
1586
- callId: "call_1",
1587
- toolName: "ask_question",
1588
- input: {},
1589
- },
1590
- },
1591
- ],
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
+ },
1592
1700
  },
1593
- meta: eventMeta(3),
1594
1701
  },
1595
1702
  ];
1596
-
1597
- // Rehydration replays the *stored* event log, which has been through
1598
- // the wire and back, so the reloaded case is the serialized payload
1599
- // rather than the same objects a second time.
1600
- const rehydrated = JSON.parse(
1601
- JSON.stringify(events),
1602
- ) as readonly EveAgentReducerEvent[];
1703
+ const rehydrated = JSON.parse(JSON.stringify(events)) as SessionEvent[];
1603
1704
 
1604
1705
  for (const state of [replay(events), replay(rehydrated)]) {
1605
1706
  const part = state.messages
1606
1707
  .find((message) => message.role === "assistant")
1607
- ?.parts.find((candidate) => candidate.type === "dynamic-tool");
1708
+ ?.parts.find((candidate) => candidate.type === "tool");
1608
1709
 
1609
1710
  expect(part).toMatchObject({
1610
- state: "approval-requested",
1611
- approval: { id: "req_1" },
1711
+ type: "tool",
1712
+ interaction: { interactionId: "req_1", status: "open" },
1612
1713
  });
1613
1714
  expect(findEveInputRequest(state, "req_1")).toMatchObject({
1614
- requestId: "req_1",
1615
1715
  prompt: "What should the subject line be?",
1616
1716
  display: "text",
1617
1717
  });
@@ -1620,188 +1720,110 @@ describe("convertEveMessages", () => {
1620
1720
 
1621
1721
  it("leaves a reasoning part unsettled when a tool call follows it", () => {
1622
1722
  const state = replay([
1623
- ...midStreamEvents.slice(0, 3),
1723
+ ...initialTurnEvents,
1624
1724
  {
1625
- type: "reasoning.appended",
1626
- meta: eventMeta(2),
1627
- data: {
1628
- turnId: "turn_1",
1629
- stepIndex: 0,
1630
- sequence: 2,
1631
- reasoningDelta: "Think",
1632
- },
1633
- },
1634
- {
1635
- type: "actions.requested",
1636
- meta: eventMeta(3),
1637
- data: {
1638
- turnId: "turn_1",
1639
- stepIndex: 0,
1640
- sequence: 3,
1641
- actions: [
1642
- {
1643
- kind: "tool-call",
1644
- callId: "call_1",
1645
- toolName: "search",
1646
- input: {},
1647
- },
1648
- ],
1649
- },
1725
+ type: "content.delta",
1726
+ scope: { turnId: "turn_1", runId: "run_1" },
1727
+ data: { partId: "reason_1", kind: "reasoning", delta: "Think" },
1650
1728
  },
1729
+ callRequested,
1651
1730
  ]);
1652
1731
 
1653
1732
  const message = state.messages.find((m) => m.role === "assistant");
1654
1733
  expect(message?.parts).toEqual(
1655
1734
  expect.arrayContaining([
1656
1735
  expect.objectContaining({ type: "reasoning", state: "streaming" }),
1657
- expect.objectContaining({ type: "dynamic-tool" }),
1736
+ expect.objectContaining({ type: "tool" }),
1658
1737
  ]),
1659
1738
  );
1660
-
1661
1739
  const content = convertEveMessages(state, { isRunning: true }).at(
1662
1740
  -1,
1663
1741
  )?.content;
1664
1742
  expect(content).toEqual([
1665
- expect.objectContaining({ type: "reasoning" }),
1666
1743
  expect.objectContaining({ type: "tool-call" }),
1744
+ expect.objectContaining({ type: "reasoning" }),
1667
1745
  ]);
1668
- expect(content?.[0]).not.toHaveProperty("status");
1746
+ expect(content?.[1]).not.toHaveProperty("status");
1669
1747
  });
1670
1748
 
1671
1749
  it("settles leftover reasoning after the model stream completes a tool call", () => {
1672
1750
  const state = replay([
1673
- ...midStreamEvents.slice(0, 3),
1674
- {
1675
- type: "reasoning.appended",
1676
- meta: eventMeta(2),
1677
- data: {
1678
- turnId: "turn_1",
1679
- stepIndex: 0,
1680
- sequence: 2,
1681
- reasoningDelta: "Think",
1682
- },
1683
- },
1684
- {
1685
- type: "actions.requested",
1686
- meta: eventMeta(3),
1687
- data: {
1688
- turnId: "turn_1",
1689
- stepIndex: 0,
1690
- sequence: 3,
1691
- actions: [
1692
- {
1693
- kind: "tool-call",
1694
- callId: "call_1",
1695
- toolName: "search",
1696
- input: {},
1697
- },
1698
- ],
1699
- },
1700
- },
1751
+ ...initialTurnEvents,
1701
1752
  {
1702
- type: "reasoning.completed",
1703
- meta: eventMeta(4),
1704
- data: {
1705
- turnId: "turn_1",
1706
- stepIndex: 0,
1707
- sequence: 4,
1708
- reasoning: "Think",
1709
- },
1753
+ type: "content.delta",
1754
+ scope: { turnId: "turn_1", runId: "run_1" },
1755
+ data: { partId: "reason_1", kind: "reasoning", delta: "Think" },
1710
1756
  },
1757
+ callRequested,
1758
+ completedContent("reasoning", "reason_1", "Think"),
1711
1759
  ]);
1712
1760
 
1713
1761
  const message = state.messages.find((m) => m.role === "assistant");
1714
1762
  expect(message?.parts).toEqual(
1715
1763
  expect.arrayContaining([
1716
1764
  expect.objectContaining({ type: "reasoning", state: "done" }),
1717
- expect.objectContaining({ type: "dynamic-tool" }),
1765
+ expect.objectContaining({ type: "tool" }),
1718
1766
  ]),
1719
1767
  );
1720
-
1721
1768
  const content = convertEveMessages(state, { isRunning: true }).at(
1722
1769
  -1,
1723
1770
  )?.content;
1724
1771
  expect(content).toEqual([
1772
+ expect.objectContaining({ type: "tool-call" }),
1725
1773
  expect.objectContaining({
1726
1774
  type: "reasoning",
1727
1775
  status: { type: "complete" },
1728
1776
  }),
1729
- expect.objectContaining({ type: "tool-call" }),
1730
1777
  ]);
1731
1778
  });
1732
1779
 
1733
1780
  it("keeps a later step's live text part unsettled after an earlier step completed", () => {
1734
1781
  const state = replay([
1735
- ...midStreamEvents.slice(0, 3),
1782
+ ...initialTurnEvents,
1736
1783
  {
1737
- type: "message.appended",
1738
- meta: eventMeta(2),
1739
- data: {
1740
- turnId: "turn_1",
1741
- stepIndex: 0,
1742
- sequence: 2,
1743
- messageDelta: "First",
1744
- },
1784
+ type: "content.delta",
1785
+ scope: { turnId: "turn_1", runId: "run_1" },
1786
+ data: { partId: "part_1", kind: "text", delta: "First" },
1745
1787
  },
1788
+ completedContent("text", "part_1", "First"),
1746
1789
  {
1747
- type: "message.completed",
1748
- meta: eventMeta(3),
1790
+ type: "model.settled",
1749
1791
  data: {
1750
- turnId: "turn_1",
1751
- stepIndex: 0,
1752
- sequence: 3,
1792
+ runId: "run_1",
1793
+ outcome: "completed",
1753
1794
  finishReason: "tool-calls",
1754
- message: "First",
1755
1795
  },
1756
1796
  },
1757
1797
  {
1758
- type: "step.completed",
1759
- meta: eventMeta(4),
1760
- data: {
1761
- turnId: "turn_1",
1762
- stepIndex: 0,
1763
- sequence: 4,
1764
- finishReason: "tool-calls",
1765
- },
1798
+ type: "model.requested",
1799
+ scope: { turnId: "turn_1" },
1800
+ data: { runId: "run_2", owner: { turnId: "turn_1" } },
1766
1801
  },
1767
1802
  {
1768
- type: "step.started",
1769
- meta: eventMeta(5),
1770
- data: {
1771
- turnId: "turn_1",
1772
- stepIndex: 1,
1773
- sequence: 5,
1774
- modelId: "test-model",
1775
- },
1803
+ type: "model.started",
1804
+ scope: { turnId: "turn_1", runId: "run_2" },
1805
+ data: { runId: "run_2", modelId: "test-model", attempt: 0 },
1776
1806
  },
1777
1807
  {
1778
- type: "message.appended",
1779
- meta: eventMeta(6),
1780
- data: {
1781
- turnId: "turn_1",
1782
- stepIndex: 1,
1783
- sequence: 6,
1784
- messageDelta: "Sec",
1785
- },
1808
+ type: "content.delta",
1809
+ scope: { turnId: "turn_1", runId: "run_2" },
1810
+ data: { partId: "part_2", kind: "text", delta: "Sec" },
1786
1811
  },
1787
1812
  ]);
1788
1813
 
1789
1814
  const assistant = state.messages.find((m) => m.role === "assistant");
1790
1815
  expect(assistant?.parts).toEqual([
1791
- expect.objectContaining({ type: "step-start" }),
1792
1816
  expect.objectContaining({
1793
1817
  type: "text",
1794
1818
  text: "First",
1795
1819
  state: "done",
1796
1820
  }),
1797
- expect.objectContaining({ type: "step-start" }),
1798
1821
  expect.objectContaining({
1799
1822
  type: "text",
1800
1823
  text: "Sec",
1801
1824
  state: "streaming",
1802
1825
  }),
1803
1826
  ]);
1804
-
1805
1827
  const content = convertEveMessages(state, { isRunning: true }).at(
1806
1828
  -1,
1807
1829
  )?.content;
@@ -1818,59 +1840,68 @@ describe("convertEveMessages", () => {
1818
1840
 
1819
1841
  it("a locally aborted turn keeps its streaming marker and converts to cancelled", () => {
1820
1842
  const state = replay(midStreamEvents);
1821
-
1822
1843
  const assistant = state.messages.find((m) => m.role === "assistant");
1823
1844
  expect(assistant?.metadata?.status).toBe("streaming");
1824
-
1825
- const converted = convertEveMessages(state, { isRunning: false });
1826
- expect(converted.at(-1)?.status).toEqual({
1845
+ expect(
1846
+ convertEveMessages(state, { isRunning: false }).at(-1)?.status,
1847
+ ).toEqual({
1827
1848
  type: "incomplete",
1828
1849
  reason: "cancelled",
1829
1850
  });
1830
1851
  });
1831
1852
 
1832
1853
  it("a failed session keeps its streaming marker and converts to error", () => {
1833
- const state = replay([
1834
- ...midStreamEvents,
1835
- {
1836
- type: "session.failed",
1837
- meta: eventMeta(3),
1838
- 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" },
1839
1861
  },
1840
- ]);
1841
-
1862
+ } as const satisfies SessionStreamEvent;
1863
+ const state = replay([...midStreamEvents, sessionFailure]);
1842
1864
  const assistant = state.messages.find((m) => m.role === "assistant");
1843
1865
  expect(assistant?.metadata?.status).toBe("streaming");
1844
-
1845
- const converted = convertEveMessages(state, {
1846
- isRunning: false,
1847
- error: new Error("boom"),
1848
- });
1849
- 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({
1850
1872
  type: "incomplete",
1851
1873
  reason: "error",
1852
1874
  error: { code: "unknown", message: "boom" },
1853
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
+ });
1854
1886
  });
1855
1887
 
1856
1888
  it("a failed turn stays incomplete after Eve settles its message", () => {
1857
1889
  const failureEvent = {
1858
- type: "turn.failed",
1859
- meta: eventMeta(3),
1890
+ type: "turn.settled",
1891
+ meta: eventMeta(8),
1860
1892
  data: {
1861
1893
  turnId: "turn_1",
1862
- sequence: 3,
1863
- code: "internal",
1864
- message: "boom",
1894
+ outcome: "failed",
1895
+ error: { code: "internal", message: "boom" },
1865
1896
  },
1866
- } as const satisfies MessageStreamEvent;
1897
+ } as const satisfies SessionStreamEvent;
1867
1898
  const state = replay([...midStreamEvents, failureEvent]);
1868
-
1869
- const converted = convertEveMessages(state, {
1870
- isRunning: false,
1871
- events: [failureEvent],
1872
- });
1873
- expect(converted.at(-1)?.status).toEqual({
1899
+ expect(
1900
+ convertEveMessages(state, {
1901
+ isRunning: false,
1902
+ events: [failureEvent],
1903
+ }).at(-1)?.status,
1904
+ ).toEqual({
1874
1905
  type: "incomplete",
1875
1906
  reason: "error",
1876
1907
  error: { code: "internal", message: "boom" },
@@ -1879,44 +1910,45 @@ describe("convertEveMessages", () => {
1879
1910
 
1880
1911
  it("a cancelled turn stays cancelled after Eve settles its message", () => {
1881
1912
  const cancelEvent = {
1882
- type: "turn.cancelled",
1883
- meta: eventMeta(3),
1884
- data: { turnId: "turn_1", sequence: 3 },
1885
- } as const satisfies MessageStreamEvent;
1913
+ type: "turn.settled",
1914
+ meta: eventMeta(8),
1915
+ data: { turnId: "turn_1", outcome: "cancelled" },
1916
+ } as const satisfies SessionStreamEvent;
1886
1917
  const state = replay([...midStreamEvents, cancelEvent]);
1887
-
1888
1918
  expect(
1889
1919
  convertEveMessages(state, { isRunning: false }).at(-1)?.status,
1890
- ).toEqual({ type: "complete", reason: "stop" });
1920
+ ).toEqual({
1921
+ type: "complete",
1922
+ reason: "stop",
1923
+ });
1891
1924
  expect(
1892
1925
  convertEveMessages(state, {
1893
1926
  isRunning: false,
1894
1927
  events: [cancelEvent],
1895
1928
  }).at(-1)?.status,
1896
- ).toEqual({ type: "incomplete", reason: "cancelled" });
1929
+ ).toEqual({
1930
+ type: "incomplete",
1931
+ reason: "cancelled",
1932
+ });
1897
1933
  });
1898
1934
 
1899
1935
  it("a completed turn terminalizes the streaming marker and converts to complete", () => {
1900
1936
  const state = replay([
1901
1937
  ...midStreamEvents,
1938
+ completedContent("text", "part_1", "Let me think"),
1902
1939
  {
1903
- type: "message.completed",
1904
- meta: eventMeta(3),
1940
+ type: "model.settled",
1905
1941
  data: {
1906
- turnId: "turn_1",
1907
- stepIndex: 0,
1908
- sequence: 3,
1942
+ runId: "run_1",
1943
+ outcome: "completed",
1909
1944
  finishReason: "stop",
1910
- message: "Let me think",
1911
1945
  },
1912
1946
  },
1913
1947
  {
1914
- type: "turn.completed",
1915
- meta: eventMeta(4),
1916
- data: { turnId: "turn_1", sequence: 4 },
1948
+ type: "turn.settled",
1949
+ data: { turnId: "turn_1", outcome: "completed", reply: ["part_1"] },
1917
1950
  },
1918
1951
  ]);
1919
-
1920
1952
  const assistant = state.messages.find((m) => m.role === "assistant");
1921
1953
  expect(assistant?.metadata?.status).toBe("complete");
1922
1954
  expect(assistant?.parts).toEqual(
@@ -1924,7 +1956,6 @@ describe("convertEveMessages", () => {
1924
1956
  expect.objectContaining({ type: "text", state: "done" }),
1925
1957
  ]),
1926
1958
  );
1927
-
1928
1959
  const converted = convertEveMessages(state, { isRunning: false });
1929
1960
  expect(converted.at(-1)?.status).toEqual({
1930
1961
  type: "complete",
@@ -1952,7 +1983,7 @@ describe("convertEveMessages", () => {
1952
1983
  parts: [{ type: "text", text: "Hello" }],
1953
1984
  },
1954
1985
  ],
1955
- } satisfies EveMessageData;
1986
+ } satisfies MessageData;
1956
1987
 
1957
1988
  const [message] = convertEveMessages(data, {
1958
1989
  getCreatedAt: () => createdAt,
@@ -1982,11 +2013,42 @@ describe("getEveMessageContent", () => {
1982
2013
  expect(getEveMessageContent(message)).toBe("Hello");
1983
2014
  });
1984
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
+
1985
2047
  it("declares the data URL subtype of an image part", () => {
1986
2048
  const message = {
1987
2049
  ...baseAppendMessage,
1988
2050
  content: [{ type: "image", image: "data:image/jpeg;base64,/9j/4AAQ" }],
1989
- } as unknown as AppendMessage;
2051
+ } as AppendMessage;
1990
2052
 
1991
2053
  expect(getEveMessageContent(message)).toEqual([
1992
2054
  {
@@ -2007,7 +2069,7 @@ describe("getEveMessageContent", () => {
2007
2069
  "data:application/octet-stream;base64,iVBORw0KGgoAAAANSUhEUgAAAAE=",
2008
2070
  },
2009
2071
  ],
2010
- } as unknown as AppendMessage;
2072
+ } as AppendMessage;
2011
2073
 
2012
2074
  expect(getEveMessageContent(message)).toEqual([
2013
2075
  {
@@ -2028,7 +2090,7 @@ describe("getEveMessageContent", () => {
2028
2090
  filename: "photo",
2029
2091
  },
2030
2092
  ],
2031
- } as unknown as AppendMessage;
2093
+ } as AppendMessage;
2032
2094
 
2033
2095
  expect(getEveMessageContent(message)).toEqual([
2034
2096
  {
@@ -2054,7 +2116,7 @@ describe("getEveMessageContent", () => {
2054
2116
  status: { type: "complete" },
2055
2117
  },
2056
2118
  ],
2057
- } as unknown as AppendMessage;
2119
+ } as AppendMessage;
2058
2120
 
2059
2121
  expect(getEveMessageContent(message)).toEqual([
2060
2122
  {
@@ -2154,7 +2216,7 @@ describe("getEveMessageContent", () => {
2154
2216
  const message = {
2155
2217
  ...baseAppendMessage,
2156
2218
  content: [{ type: "audio", audio: { data: "QUJD", format: "mp3" } }],
2157
- } as unknown as AppendMessage;
2219
+ } as AppendMessage;
2158
2220
 
2159
2221
  expect(getEveMessageContent(message)).toEqual([
2160
2222
  {
@@ -2169,7 +2231,7 @@ describe("getEveMessageContent", () => {
2169
2231
  const message = {
2170
2232
  ...baseAppendMessage,
2171
2233
  content: [{ type: "audio", audio: { data: "QUJD", format: "wav" } }],
2172
- } as unknown as AppendMessage;
2234
+ } as AppendMessage;
2173
2235
 
2174
2236
  expect(getEveMessageContent(message)).toEqual([
2175
2237
  {
@@ -2189,7 +2251,7 @@ describe("getEveMessageContent", () => {
2189
2251
  audio: { data: "data:audio/mpeg;base64,QUJD", format: "mp3" },
2190
2252
  },
2191
2253
  ],
2192
- } as unknown as AppendMessage;
2254
+ } as AppendMessage;
2193
2255
 
2194
2256
  expect(getEveMessageContent(message)).toEqual([
2195
2257
  {
@@ -2209,7 +2271,7 @@ describe("getEveMessageContent", () => {
2209
2271
  audio: { data: "data:;base64,QUJD", format: "mp3" },
2210
2272
  },
2211
2273
  ],
2212
- } as unknown as AppendMessage;
2274
+ } as AppendMessage;
2213
2275
 
2214
2276
  expect(getEveMessageContent(message)).toEqual([
2215
2277
  {
@@ -2229,7 +2291,7 @@ describe("getEveMessageContent", () => {
2229
2291
  audio: { data: "https://cdn.example.com/memo.mp3", format: "mp3" },
2230
2292
  },
2231
2293
  ],
2232
- } as unknown as AppendMessage;
2294
+ } as AppendMessage;
2233
2295
 
2234
2296
  expect(getEveMessageContent(message)).toEqual([
2235
2297
  {
@@ -2260,7 +2322,7 @@ describe("getEveMessageContent", () => {
2260
2322
  status: { type: "complete" },
2261
2323
  },
2262
2324
  ],
2263
- } as unknown as AppendMessage;
2325
+ } as AppendMessage;
2264
2326
 
2265
2327
  expect(getEveMessageContent(message)).toEqual([
2266
2328
  {
@@ -2287,16 +2349,19 @@ describe("getEveMessageContent", () => {
2287
2349
  ],
2288
2350
  },
2289
2351
  ],
2290
- } satisfies EveMessageData);
2352
+ } satisfies MessageData);
2291
2353
 
2292
- expect(echoed?.content).toEqual([
2293
- {
2294
- type: "file",
2295
- data: "https://example.com/report.pdf",
2296
- mimeType: "application/pdf",
2297
- filename: "report.pdf",
2298
- sourceType: "url",
2299
- },
2354
+ expect(echoed?.content).toEqual([]);
2355
+ expect(echoed?.attachments?.map((a) => a.content)).toEqual([
2356
+ [
2357
+ {
2358
+ type: "file",
2359
+ data: "https://example.com/report.pdf",
2360
+ mimeType: "application/pdf",
2361
+ filename: "report.pdf",
2362
+ sourceType: "url",
2363
+ },
2364
+ ],
2300
2365
  ]);
2301
2366
  });
2302
2367
 
@@ -2307,7 +2372,7 @@ describe("getEveMessageContent", () => {
2307
2372
  { type: "text", text: "hi" },
2308
2373
  { type: "data", name: "chart", data: { x: 1 } },
2309
2374
  ],
2310
- } as unknown as AppendMessage;
2375
+ } as AppendMessage;
2311
2376
 
2312
2377
  expect(getEveMessageContent(message)).toBe("hi");
2313
2378
  });
@@ -2315,7 +2380,7 @@ describe("getEveMessageContent", () => {
2315
2380
 
2316
2381
  const approveCancel = [
2317
2382
  { id: "approve", label: "Approve" },
2318
- { id: "cancel", label: "Cancel" },
2383
+ { id: "deny", label: "Cancel" },
2319
2384
  ];
2320
2385
  const environments = [
2321
2386
  { id: "staging", label: "Staging" },
@@ -2323,9 +2388,8 @@ const environments = [
2323
2388
  ];
2324
2389
 
2325
2390
  const withInputRequest = (
2326
- overrides: Partial<EveMessageInputRequest> = {},
2327
- ): EveMessageInputRequest => ({
2328
- requestId: "req_1",
2391
+ overrides: Partial<InputRequest> = {},
2392
+ ): InputRequest => ({
2329
2393
  prompt: "Send the email?",
2330
2394
  kind: "question",
2331
2395
  ...overrides,
@@ -2343,8 +2407,8 @@ describe("toEveInputResponse", () => {
2343
2407
  withInputRequest({ display: "confirmation", options: approveCancel }),
2344
2408
  ),
2345
2409
  ).toEqual({
2346
- requestId: "req_1",
2347
- optionId: "cancel",
2410
+ interactionId: "req_1",
2411
+ optionId: "deny",
2348
2412
  text: "Not yet",
2349
2413
  });
2350
2414
  });
@@ -2352,7 +2416,7 @@ describe("toEveInputResponse", () => {
2352
2416
  it("keeps the shipped one-argument mapping when no request is available", () => {
2353
2417
  expect(toEveInputResponse({ approvalId: "req_1", approved: true })).toEqual(
2354
2418
  {
2355
- requestId: "req_1",
2419
+ interactionId: "req_1",
2356
2420
  optionId: "approve",
2357
2421
  },
2358
2422
  );
@@ -2363,7 +2427,7 @@ describe("toEveInputResponse", () => {
2363
2427
  approved: false,
2364
2428
  reason: "Not yet",
2365
2429
  }),
2366
- ).toEqual({ requestId: "req_1", optionId: "cancel", text: "Not yet" });
2430
+ ).toEqual({ interactionId: "req_1", optionId: "deny", text: "Not yet" });
2367
2431
 
2368
2432
  expect(
2369
2433
  toEveInputResponse({
@@ -2371,7 +2435,7 @@ describe("toEveInputResponse", () => {
2371
2435
  approved: true,
2372
2436
  optionId: "staging",
2373
2437
  }),
2374
- ).toEqual({ requestId: "req_1", optionId: "staging" });
2438
+ ).toEqual({ interactionId: "req_1", optionId: "staging" });
2375
2439
  });
2376
2440
 
2377
2441
  it("keeps the confirmation mapping when the request carries approve/cancel options", () => {
@@ -2380,7 +2444,7 @@ describe("toEveInputResponse", () => {
2380
2444
  { approvalId: "req_1", approved: true },
2381
2445
  withInputRequest({ display: "confirmation", options: approveCancel }),
2382
2446
  ),
2383
- ).toEqual({ requestId: "req_1", optionId: "approve" });
2447
+ ).toEqual({ interactionId: "req_1", optionId: "approve" });
2384
2448
  });
2385
2449
 
2386
2450
  it("maps a select response to the chosen option id", () => {
@@ -2389,7 +2453,36 @@ describe("toEveInputResponse", () => {
2389
2453
  { approvalId: "req_1", approved: true, optionId: "staging" },
2390
2454
  withInputRequest({ display: "select", options: environments }),
2391
2455
  ),
2392
- ).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
+ );
2393
2486
  });
2394
2487
 
2395
2488
  it.each([
@@ -2400,7 +2493,7 @@ describe("toEveInputResponse", () => {
2400
2493
  "a select display that allows freeform",
2401
2494
  { display: "select", allowFreeform: true, options: environments },
2402
2495
  ],
2403
- ] satisfies [string, Partial<EveMessageInputRequest>][])(
2496
+ ] satisfies [string, Partial<InputRequest>][])(
2404
2497
  "answers a request with %s as free-form text, not a fabricated option id",
2405
2498
  (_label, overrides) => {
2406
2499
  const response = toEveInputResponse(
@@ -2409,7 +2502,7 @@ describe("toEveInputResponse", () => {
2409
2502
  );
2410
2503
 
2411
2504
  expect(response).toEqual({
2412
- requestId: "req_1",
2505
+ interactionId: "req_1",
2413
2506
  text: "Quarterly results",
2414
2507
  });
2415
2508
  expect(response).not.toHaveProperty("optionId");
@@ -2431,7 +2524,7 @@ describe("toEveInputResponse", () => {
2431
2524
  { approvalId: "req_1", approved: true, text: "staging" },
2432
2525
  withInputRequest({ display: "text" }),
2433
2526
  ),
2434
- ).toEqual({ requestId: "req_1", text: "staging" });
2527
+ ).toEqual({ interactionId: "req_1", text: "staging" });
2435
2528
  });
2436
2529
 
2437
2530
  it("prefers the response text over the reason it used to be smuggled in", () => {
@@ -2445,7 +2538,7 @@ describe("toEveInputResponse", () => {
2445
2538
  },
2446
2539
  withInputRequest({ display: "text" }),
2447
2540
  ),
2448
- ).toEqual({ requestId: "req_1", text: "staging" });
2541
+ ).toEqual({ interactionId: "req_1", text: "staging" });
2449
2542
  });
2450
2543
 
2451
2544
  it("never fabricates approve for a text-display request without an answer", () => {
@@ -2474,9 +2567,6 @@ describe("toEveInputResponse", () => {
2474
2567
  ),
2475
2568
  ).toThrow(/no option with id "sandbox".*staging, production/s);
2476
2569
 
2477
- // A named option the request does not carry outranks the decision, even
2478
- // when the decision names an option the request does carry: substituting
2479
- // the literal deny here would discard the choice the caller made.
2480
2570
  expect(() =>
2481
2571
  toEveInputResponse(
2482
2572
  { approvalId: "req_1", approved: false, optionId: "schedule-later" },
@@ -2493,7 +2583,7 @@ describe("toEveInputResponse", () => {
2493
2583
 
2494
2584
  expect(
2495
2585
  toEveInputResponse({ approvalId: "req_1", approved: true }, inputRequest),
2496
- ).toEqual({ requestId: "req_1", optionId: "approve" });
2586
+ ).toEqual({ interactionId: "req_1", optionId: "approve" });
2497
2587
 
2498
2588
  expect(
2499
2589
  toEveInputResponse(
@@ -2501,7 +2591,7 @@ describe("toEveInputResponse", () => {
2501
2591
  inputRequest,
2502
2592
  ),
2503
2593
  ).toEqual({
2504
- requestId: "req_1",
2594
+ interactionId: "req_1",
2505
2595
  optionId: "approve",
2506
2596
  text: "Quarterly results",
2507
2597
  });
@@ -2509,25 +2599,25 @@ describe("toEveInputResponse", () => {
2509
2599
 
2510
2600
  it("answers a tool-approval by kind even when its options are missing", () => {
2511
2601
  const inputRequest = withInputRequest({
2512
- kind: "tool-approval",
2602
+ kind: "approval",
2513
2603
  display: "confirmation",
2514
2604
  });
2515
2605
 
2516
2606
  expect(
2517
2607
  toEveInputResponse({ approvalId: "req_1", approved: true }, inputRequest),
2518
- ).toEqual({ requestId: "req_1", optionId: "approve" });
2608
+ ).toEqual({ interactionId: "req_1", optionId: "approve" });
2519
2609
 
2520
2610
  expect(
2521
2611
  toEveInputResponse(
2522
2612
  { approvalId: "req_1", approved: false, reason: "not now" },
2523
2613
  inputRequest,
2524
2614
  ),
2525
- ).toEqual({ requestId: "req_1", optionId: "cancel", text: "not now" });
2615
+ ).toEqual({ interactionId: "req_1", optionId: "deny", text: "not now" });
2526
2616
  });
2527
2617
 
2528
2618
  it("still requires a declared option when a tool-approval carries its own", () => {
2529
2619
  const inputRequest = withInputRequest({
2530
- kind: "tool-approval",
2620
+ kind: "approval",
2531
2621
  display: "confirmation",
2532
2622
  options: [{ id: "schedule-later", label: "Schedule later" }],
2533
2623
  });
@@ -2541,7 +2631,7 @@ describe("toEveInputResponse", () => {
2541
2631
  { approvalId: "req_1", approved: true, optionId: "schedule-later" },
2542
2632
  inputRequest,
2543
2633
  ),
2544
- ).toEqual({ requestId: "req_1", optionId: "schedule-later" });
2634
+ ).toEqual({ interactionId: "req_1", optionId: "schedule-later" });
2545
2635
  });
2546
2636
 
2547
2637
  it("never answers an optionless confirmation as free-form text", () => {
@@ -2560,41 +2650,25 @@ describe("toEveInputResponse", () => {
2560
2650
  });
2561
2651
 
2562
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;
2563
2659
  const data = {
2564
2660
  messages: [
2565
2661
  {
2566
2662
  id: "a1",
2567
2663
  role: "assistant",
2568
- parts: [
2569
- {
2570
- type: "dynamic-tool",
2571
- state: "approval-requested",
2572
- toolCallId: "call_1",
2573
- toolName: "ask_question",
2574
- input: {},
2575
- approval: { id: "req_1" },
2576
- toolMetadata: {
2577
- eve: {
2578
- kind: "tool-call",
2579
- name: "ask_question",
2580
- inputRequest: {
2581
- requestId: "req_1",
2582
- prompt: "What should the subject line be?",
2583
- kind: "question",
2584
- display: "text",
2585
- allowFreeform: true,
2586
- },
2587
- },
2588
- },
2589
- },
2590
- ],
2664
+ parts: [toolPart({ interaction: approvalInteraction(request) })],
2591
2665
  },
2592
2666
  ],
2593
- } satisfies EveMessageData;
2667
+ } satisfies MessageData;
2594
2668
 
2595
2669
  it("finds the input request by approval id", () => {
2596
2670
  expect(findEveInputRequest(data, "req_1")).toMatchObject({
2597
- requestId: "req_1",
2671
+ prompt: "What should the subject line be?",
2598
2672
  display: "text",
2599
2673
  });
2600
2674
  });
@@ -2603,16 +2677,24 @@ describe("findEveInputRequest", () => {
2603
2677
  expect(findEveInputRequest(data, "req_404")).toBeUndefined();
2604
2678
  });
2605
2679
 
2606
- it.each([
2607
- ["carries no input request", { eve: { kind: "tool-call", name: "ask" } }],
2608
- ["carries no eve tool metadata at all", undefined],
2609
- ])("returns undefined when the matching part %s", (_label, toolMetadata) => {
2610
- const part = { ...data.messages[0]!.parts[0]!, toolMetadata };
2611
- 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
+ });
2612
2686
 
2687
+ it("finds a standalone interaction by its approval id", () => {
2613
2688
  expect(
2614
- findEveInputRequest(bare as EveMessageData, "req_1"),
2615
- ).toBeUndefined();
2689
+ findEveInputRequest(
2690
+ {
2691
+ messages: [
2692
+ { id: "a1", role: "assistant", parts: [signInPart(request)] },
2693
+ ],
2694
+ },
2695
+ "auth_1",
2696
+ ),
2697
+ ).toEqual(request);
2616
2698
  });
2617
2699
  });
2618
2700
 
@@ -2621,20 +2703,78 @@ describe("collectInterruptedTurnEvents", () => {
2621
2703
  ({
2622
2704
  type: "turn.started",
2623
2705
  meta: eventMeta(sequence),
2624
- data: { turnId, sequence },
2625
- }) as const satisfies MessageStreamEvent;
2706
+ data: {
2707
+ turnId,
2708
+ cause: { deliveryId: `delivery_${turnId}` },
2709
+ follows: null,
2710
+ },
2711
+ }) as const satisfies SessionStreamEvent;
2626
2712
  const failed = (turnId: string, sequence: number) =>
2627
2713
  ({
2628
- type: "turn.failed",
2714
+ type: "turn.settled",
2629
2715
  meta: eventMeta(sequence),
2630
- data: { turnId, sequence, code: "internal", message: "boom" },
2631
- }) as const satisfies MessageStreamEvent;
2716
+ data: {
2717
+ turnId,
2718
+ outcome: "failed",
2719
+ error: { code: "internal", message: "boom" },
2720
+ },
2721
+ }) as const satisfies SessionStreamEvent;
2632
2722
  const cancelled = (turnId: string, sequence: number) =>
2633
2723
  ({
2634
- type: "turn.cancelled",
2724
+ type: "turn.settled",
2635
2725
  meta: eventMeta(sequence),
2636
- data: { turnId, sequence },
2637
- }) 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
+ );
2638
2778
 
2639
2779
  it("scans only appended events and keeps the array until an interruption arrives", () => {
2640
2780
  const cache: InterruptedTurnEventCache = {
@@ -2660,4 +2800,29 @@ describe("collectInterruptedTurnEvents", () => {
2660
2800
  collectInterruptedTurnEvents([started("t1", 0), failed("t1", 1)], cache);
2661
2801
  expect(collectInterruptedTurnEvents([started("t2", 0)], cache)).toEqual([]);
2662
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
+ });
2663
2828
  });