@assistant-ui/eve 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { EveMessageData } from "eve/react";
3
+ import { defaultMessageReducer, type EveAgentReducerEvent } from "eve/client";
3
4
  import {
4
5
  convertEveMessages,
5
6
  getEveMessageContent,
@@ -195,7 +196,7 @@ describe("convertEveMessages", () => {
195
196
  expect(message?.metadata).not.toHaveProperty("isOptimistic");
196
197
  });
197
198
 
198
- it("falls back to an empty text part for user messages without convertible parts", () => {
199
+ it("falls back to an empty text part for user messages with only url-less file parts", () => {
199
200
  const data = {
200
201
  messages: [
201
202
  {
@@ -209,9 +210,10 @@ describe("convertEveMessages", () => {
209
210
  const [message] = convertEveMessages(data);
210
211
 
211
212
  expect(message?.content).toEqual([{ type: "text", text: "" }]);
213
+ expect(message?.attachments).toEqual([]);
212
214
  });
213
215
 
214
- it("drops non-convertible user parts without triggering the fallback", () => {
216
+ it("drops url-less user file parts without triggering the fallback", () => {
215
217
  const data = {
216
218
  messages: [
217
219
  {
@@ -230,6 +232,253 @@ describe("convertEveMessages", () => {
230
232
  expect(message?.content).toEqual([{ type: "text", text: "Hello" }]);
231
233
  });
232
234
 
235
+ it("converts a user file part into content and a file attachment", () => {
236
+ const data = {
237
+ messages: [
238
+ {
239
+ id: "u1",
240
+ role: "user",
241
+ parts: [
242
+ { type: "text", text: "See the report" },
243
+ {
244
+ type: "file",
245
+ url: "https://example.com/report.pdf",
246
+ mediaType: "application/pdf",
247
+ filename: "report.pdf",
248
+ size: 1024,
249
+ },
250
+ ],
251
+ },
252
+ ],
253
+ } satisfies EveMessageData;
254
+
255
+ const [message] = convertEveMessages(data);
256
+
257
+ expect(message?.content).toEqual([
258
+ { type: "text", text: "See the report" },
259
+ {
260
+ type: "file",
261
+ data: "https://example.com/report.pdf",
262
+ mimeType: "application/pdf",
263
+ filename: "report.pdf",
264
+ sourceType: "url",
265
+ },
266
+ ]);
267
+ expect(message?.attachments).toEqual([
268
+ {
269
+ id: "0",
270
+ type: "file",
271
+ name: "report.pdf",
272
+ content: [
273
+ {
274
+ type: "file",
275
+ data: "https://example.com/report.pdf",
276
+ mimeType: "application/pdf",
277
+ filename: "report.pdf",
278
+ sourceType: "url",
279
+ },
280
+ ],
281
+ contentType: "application/pdf",
282
+ status: { type: "complete" },
283
+ },
284
+ ]);
285
+ });
286
+
287
+ it("converts a user image file part into an image attachment", () => {
288
+ const data = {
289
+ messages: [
290
+ {
291
+ id: "u1",
292
+ role: "user",
293
+ parts: [
294
+ {
295
+ type: "file",
296
+ url: "https://example.com/photo.png",
297
+ mediaType: "image/png",
298
+ filename: "photo.png",
299
+ },
300
+ ],
301
+ },
302
+ ],
303
+ } satisfies EveMessageData;
304
+
305
+ const [message] = convertEveMessages(data);
306
+
307
+ expect(message?.content).toEqual([
308
+ {
309
+ type: "file",
310
+ data: "https://example.com/photo.png",
311
+ mimeType: "image/png",
312
+ filename: "photo.png",
313
+ sourceType: "url",
314
+ },
315
+ ]);
316
+ expect(message?.attachments).toEqual([
317
+ {
318
+ id: "0",
319
+ type: "image",
320
+ name: "photo.png",
321
+ content: [
322
+ {
323
+ type: "image",
324
+ image: "https://example.com/photo.png",
325
+ filename: "photo.png",
326
+ },
327
+ ],
328
+ contentType: "image/png",
329
+ status: { type: "complete" },
330
+ },
331
+ ]);
332
+ });
333
+
334
+ it("omits sourceType and falls back to a generic name for data url file parts", () => {
335
+ const data = {
336
+ messages: [
337
+ {
338
+ id: "u1",
339
+ role: "user",
340
+ parts: [
341
+ {
342
+ type: "file",
343
+ url: "data:application/pdf;base64,QUJD",
344
+ mediaType: "application/pdf",
345
+ },
346
+ ],
347
+ },
348
+ ],
349
+ } satisfies EveMessageData;
350
+
351
+ const [message] = convertEveMessages(data);
352
+
353
+ expect(message?.content).toEqual([
354
+ {
355
+ type: "file",
356
+ data: "data:application/pdf;base64,QUJD",
357
+ mimeType: "application/pdf",
358
+ },
359
+ ]);
360
+ expect(message?.attachments).toEqual([
361
+ {
362
+ id: "0",
363
+ type: "file",
364
+ name: "file",
365
+ content: [
366
+ {
367
+ type: "file",
368
+ data: "data:application/pdf;base64,QUJD",
369
+ mimeType: "application/pdf",
370
+ },
371
+ ],
372
+ contentType: "application/pdf",
373
+ status: { type: "complete" },
374
+ },
375
+ ]);
376
+ });
377
+
378
+ it("assigns sequential attachment ids across multiple file parts", () => {
379
+ const data = {
380
+ messages: [
381
+ {
382
+ id: "u1",
383
+ role: "user",
384
+ parts: [
385
+ {
386
+ type: "file",
387
+ url: "https://example.com/a.pdf",
388
+ mediaType: "application/pdf",
389
+ },
390
+ { type: "file", mediaType: "image/png" },
391
+ {
392
+ type: "file",
393
+ url: "https://example.com/b.png",
394
+ mediaType: "image/png",
395
+ },
396
+ ],
397
+ },
398
+ ],
399
+ } satisfies EveMessageData;
400
+
401
+ const [message] = convertEveMessages(data);
402
+
403
+ expect(message?.attachments?.map((a) => [a.id, a.type])).toEqual([
404
+ ["0", "file"],
405
+ ["1", "image"],
406
+ ]);
407
+ });
408
+
409
+ it("defaults a file part with a missing mediaType to unknown/unknown", () => {
410
+ const data = {
411
+ messages: [
412
+ {
413
+ id: "u1",
414
+ role: "user",
415
+ parts: [{ type: "file", url: "https://example.com/blob" }],
416
+ },
417
+ ],
418
+ } as unknown as EveMessageData;
419
+
420
+ const [message] = convertEveMessages(data);
421
+
422
+ expect(message?.content).toEqual([
423
+ {
424
+ type: "file",
425
+ data: "https://example.com/blob",
426
+ mimeType: "unknown/unknown",
427
+ sourceType: "url",
428
+ },
429
+ ]);
430
+ expect(message?.attachments).toEqual([
431
+ {
432
+ id: "0",
433
+ type: "file",
434
+ name: "file",
435
+ content: [
436
+ {
437
+ type: "file",
438
+ data: "https://example.com/blob",
439
+ mimeType: "unknown/unknown",
440
+ sourceType: "url",
441
+ },
442
+ ],
443
+ contentType: "unknown/unknown",
444
+ status: { type: "complete" },
445
+ },
446
+ ]);
447
+ });
448
+
449
+ it("converts an assistant file part into a file content part", () => {
450
+ const data = {
451
+ messages: [
452
+ {
453
+ id: "a1",
454
+ role: "assistant",
455
+ parts: [
456
+ { type: "text", text: "Here you go" },
457
+ {
458
+ type: "file",
459
+ url: "https://example.com/result.csv",
460
+ mediaType: "text/csv",
461
+ filename: "result.csv",
462
+ },
463
+ ],
464
+ },
465
+ ],
466
+ } satisfies EveMessageData;
467
+
468
+ const [message] = convertEveMessages(data);
469
+
470
+ expect(message?.content).toEqual([
471
+ { type: "text", text: "Here you go" },
472
+ {
473
+ type: "file",
474
+ data: "https://example.com/result.csv",
475
+ mimeType: "text/csv",
476
+ filename: "result.csv",
477
+ sourceType: "url",
478
+ },
479
+ ]);
480
+ });
481
+
233
482
  it("drops non-convertible part types instead of throwing", () => {
234
483
  const data = {
235
484
  messages: [
@@ -238,6 +487,26 @@ describe("convertEveMessages", () => {
238
487
  role: "assistant",
239
488
  parts: [
240
489
  { type: "step-start" },
490
+ { type: "future-part", payload: {} },
491
+ { type: "file", mediaType: "application/pdf" },
492
+ { type: "text", text: "Done" },
493
+ ],
494
+ },
495
+ ],
496
+ } as unknown as EveMessageData;
497
+
498
+ const [message] = convertEveMessages(data);
499
+
500
+ expect(message?.content).toEqual([{ type: "text", text: "Done" }]);
501
+ });
502
+
503
+ it("converts an authorization part into a data content part", () => {
504
+ const data = {
505
+ messages: [
506
+ {
507
+ id: "a1",
508
+ role: "assistant",
509
+ parts: [
241
510
  {
242
511
  type: "authorization",
243
512
  state: "required",
@@ -246,9 +515,14 @@ describe("convertEveMessages", () => {
246
515
  displayName: "GitHub",
247
516
  stepIndex: 0,
248
517
  turnId: "turn_1",
518
+ authorization: {
519
+ displayName: "GitHub",
520
+ instructions: "Enter the code on the device page",
521
+ url: "https://github.com/login/device",
522
+ userCode: "ABCD-1234",
523
+ expiresAt: "2026-01-01T00:00:00Z",
524
+ },
249
525
  },
250
- { type: "file", mediaType: "application/pdf" },
251
- { type: "text", text: "Done" },
252
526
  ],
253
527
  },
254
528
  ],
@@ -256,7 +530,709 @@ describe("convertEveMessages", () => {
256
530
 
257
531
  const [message] = convertEveMessages(data);
258
532
 
259
- expect(message?.content).toEqual([{ type: "text", text: "Done" }]);
533
+ expect(message?.content).toEqual([
534
+ {
535
+ type: "data",
536
+ name: "authorization",
537
+ data: {
538
+ state: "required",
539
+ name: "github",
540
+ displayName: "GitHub",
541
+ description: "Sign in to GitHub",
542
+ url: "https://github.com/login/device",
543
+ userCode: "ABCD-1234",
544
+ instructions: "Enter the code on the device page",
545
+ expiresAt: "2026-01-01T00:00:00Z",
546
+ },
547
+ },
548
+ ]);
549
+ });
550
+
551
+ it("converts an authorization part with missing optional fields", () => {
552
+ const data = {
553
+ messages: [
554
+ {
555
+ id: "a1",
556
+ role: "assistant",
557
+ parts: [
558
+ {
559
+ type: "authorization",
560
+ state: "required",
561
+ name: "github",
562
+ },
563
+ ],
564
+ },
565
+ ],
566
+ } as unknown as EveMessageData;
567
+
568
+ const [message] = convertEveMessages(data);
569
+
570
+ expect(message?.content).toEqual([
571
+ {
572
+ type: "data",
573
+ name: "authorization",
574
+ data: { state: "required", name: "github" },
575
+ },
576
+ ]);
577
+ });
578
+
579
+ it("drops an authorization url that is not an http(s) address", () => {
580
+ const data = {
581
+ messages: [
582
+ {
583
+ id: "a1",
584
+ role: "assistant",
585
+ parts: [
586
+ {
587
+ type: "authorization",
588
+ state: "required",
589
+ name: "github",
590
+ authorization: {
591
+ url: "javascript:alert(1)",
592
+ userCode: "ABCD-1234",
593
+ },
594
+ },
595
+ ],
596
+ },
597
+ ],
598
+ } as unknown as EveMessageData;
599
+
600
+ const [message] = convertEveMessages(data);
601
+
602
+ expect(message?.content).toEqual([
603
+ {
604
+ type: "data",
605
+ name: "authorization",
606
+ data: { state: "required", name: "github", userCode: "ABCD-1234" },
607
+ },
608
+ ]);
609
+ });
610
+
611
+ it("carries the outcome of a completed authorization part", () => {
612
+ const data = {
613
+ messages: [
614
+ {
615
+ id: "a1",
616
+ role: "assistant",
617
+ parts: [
618
+ {
619
+ type: "authorization",
620
+ state: "completed",
621
+ name: "github",
622
+ description: "Sign in to GitHub",
623
+ displayName: "GitHub",
624
+ stepIndex: 0,
625
+ turnId: "turn_1",
626
+ outcome: "authorized",
627
+ },
628
+ ],
629
+ },
630
+ ],
631
+ } satisfies EveMessageData;
632
+
633
+ const [message] = convertEveMessages(data);
634
+
635
+ expect(message?.content).toEqual([
636
+ {
637
+ type: "data",
638
+ name: "authorization",
639
+ data: {
640
+ state: "completed",
641
+ name: "github",
642
+ displayName: "GitHub",
643
+ description: "Sign in to GitHub",
644
+ outcome: "authorized",
645
+ },
646
+ },
647
+ ]);
648
+ });
649
+
650
+ describe("assistant message status mapping", () => {
651
+ const withStatus = (
652
+ status: "streaming" | "complete" | "failed",
653
+ ): EveMessageData => ({
654
+ messages: [
655
+ {
656
+ id: "a1",
657
+ role: "assistant",
658
+ metadata: { status },
659
+ parts: [{ type: "text", text: "Hi" }],
660
+ },
661
+ ],
662
+ });
663
+
664
+ it("maps a running last message to running", () => {
665
+ const [message] = convertEveMessages(withStatus("streaming"), {
666
+ isRunning: true,
667
+ });
668
+
669
+ expect(message?.status).toEqual({ type: "running" });
670
+ });
671
+
672
+ it("keeps the legacy running mapping for a streaming marker when liveness is omitted", () => {
673
+ const [message] = convertEveMessages(withStatus("streaming"));
674
+
675
+ expect(message?.status).toEqual({ type: "running" });
676
+ });
677
+
678
+ it("maps a stale streaming marker to cancelled when no longer running", () => {
679
+ const [message] = convertEveMessages(withStatus("streaming"), {
680
+ isRunning: false,
681
+ });
682
+
683
+ expect(message?.status).toEqual({
684
+ type: "incomplete",
685
+ reason: "cancelled",
686
+ });
687
+ });
688
+
689
+ it("maps a stale streaming marker to error when the session error is set", () => {
690
+ const [message] = convertEveMessages(withStatus("streaming"), {
691
+ isRunning: false,
692
+ error: new Error("boom"),
693
+ });
694
+
695
+ expect(message?.status).toEqual({
696
+ type: "incomplete",
697
+ reason: "error",
698
+ error: { code: "unknown", message: "boom" },
699
+ });
700
+ });
701
+
702
+ it("maps a stuck streaming message to cancelled while a newer turn runs", () => {
703
+ const data = {
704
+ messages: [
705
+ {
706
+ id: "a1",
707
+ role: "assistant",
708
+ metadata: { status: "streaming" },
709
+ parts: [{ type: "text", text: "Interrupted" }],
710
+ },
711
+ {
712
+ id: "u2",
713
+ role: "user",
714
+ parts: [{ type: "text", text: "Try again" }],
715
+ },
716
+ ],
717
+ } satisfies EveMessageData;
718
+
719
+ const [assistant] = convertEveMessages(data, {
720
+ isRunning: true,
721
+ error: new Error("boom"),
722
+ });
723
+
724
+ expect(assistant?.status).toEqual({
725
+ type: "incomplete",
726
+ reason: "cancelled",
727
+ });
728
+ });
729
+
730
+ it("keeps a completed message complete even when the session error is set", () => {
731
+ const [message] = convertEveMessages(withStatus("complete"), {
732
+ isRunning: false,
733
+ error: new Error("boom"),
734
+ });
735
+
736
+ expect(message?.status).toEqual({ type: "complete", reason: "stop" });
737
+ });
738
+
739
+ it("maps an assistant failed marker to an error status", () => {
740
+ const [message] = convertEveMessages(withStatus("failed"), {
741
+ isRunning: false,
742
+ });
743
+
744
+ expect(message?.status).toEqual({ type: "incomplete", reason: "error" });
745
+ });
746
+
747
+ it("keeps requires-action for pending approvals when not running", () => {
748
+ const data = {
749
+ messages: [
750
+ {
751
+ id: "a1",
752
+ role: "assistant",
753
+ metadata: { status: "streaming" },
754
+ parts: [
755
+ {
756
+ type: "dynamic-tool",
757
+ state: "approval-requested",
758
+ toolCallId: "call_1",
759
+ toolName: "send_email",
760
+ input: {},
761
+ approval: { id: "req_1" },
762
+ },
763
+ ],
764
+ },
765
+ ],
766
+ } satisfies EveMessageData;
767
+
768
+ const [message] = convertEveMessages(data, { isRunning: false });
769
+
770
+ expect(message?.status).toEqual({
771
+ type: "requires-action",
772
+ reason: "tool-calls",
773
+ });
774
+ });
775
+
776
+ it("maps an auth-suspended message to requires-action instead of cancelled", () => {
777
+ const data = {
778
+ messages: [
779
+ {
780
+ id: "a1",
781
+ role: "assistant",
782
+ metadata: { status: "streaming" },
783
+ parts: [
784
+ {
785
+ type: "authorization",
786
+ state: "required",
787
+ name: "github",
788
+ description: "Sign in to GitHub",
789
+ displayName: "GitHub",
790
+ stepIndex: 0,
791
+ turnId: "turn_1",
792
+ },
793
+ ],
794
+ },
795
+ ],
796
+ } satisfies EveMessageData;
797
+
798
+ const [message] = convertEveMessages(data, { isRunning: false });
799
+
800
+ expect(message?.status).toEqual({
801
+ type: "requires-action",
802
+ reason: "interrupt",
803
+ });
804
+ });
805
+
806
+ it("reports tool-calls when an approval and an authorization are both pending", () => {
807
+ const data = {
808
+ messages: [
809
+ {
810
+ id: "a1",
811
+ role: "assistant",
812
+ metadata: { status: "streaming" },
813
+ parts: [
814
+ {
815
+ type: "dynamic-tool",
816
+ state: "approval-requested",
817
+ toolCallId: "call_1",
818
+ toolName: "send_email",
819
+ input: {},
820
+ approval: { id: "req_1" },
821
+ },
822
+ {
823
+ type: "authorization",
824
+ state: "required",
825
+ name: "github",
826
+ description: "Sign in to GitHub",
827
+ displayName: "GitHub",
828
+ stepIndex: 0,
829
+ turnId: "turn_1",
830
+ },
831
+ ],
832
+ },
833
+ ],
834
+ } satisfies EveMessageData;
835
+
836
+ const [message] = convertEveMessages(data, { isRunning: false });
837
+
838
+ expect(message?.status).toEqual({
839
+ type: "requires-action",
840
+ reason: "tool-calls",
841
+ });
842
+ });
843
+
844
+ it("settles a cancelled turn with a pending authorization", () => {
845
+ const reducer = defaultMessageReducer();
846
+ const events: readonly EveAgentReducerEvent[] = [
847
+ {
848
+ type: "authorization.required",
849
+ data: {
850
+ turnId: "turn_1",
851
+ stepIndex: 0,
852
+ sequence: 0,
853
+ name: "github",
854
+ description: "Sign in to GitHub",
855
+ },
856
+ },
857
+ {
858
+ type: "turn.cancelled",
859
+ data: { turnId: "turn_1", sequence: 1 },
860
+ },
861
+ ];
862
+ const pendingData = events
863
+ .slice(0, 1)
864
+ .reduce(
865
+ (state, event) => reducer.reduce(state, event),
866
+ reducer.initial(),
867
+ );
868
+ const pendingAssistant = pendingData.messages.find(
869
+ (message) => message.role === "assistant",
870
+ );
871
+ expect(pendingAssistant?.metadata?.status).toBe("streaming");
872
+ expect(
873
+ convertEveMessages(pendingData, { isRunning: false }).at(-1)?.status,
874
+ ).toEqual({
875
+ type: "requires-action",
876
+ reason: "interrupt",
877
+ });
878
+
879
+ const data = events
880
+ .slice(1)
881
+ .reduce((state, event) => reducer.reduce(state, event), pendingData);
882
+
883
+ const [message] = convertEveMessages(data, { isRunning: false });
884
+
885
+ expect(message?.status).toEqual({
886
+ type: "complete",
887
+ reason: "stop",
888
+ });
889
+ });
890
+
891
+ it("reports a failed turn with a pending authorization", () => {
892
+ const data = {
893
+ messages: [
894
+ {
895
+ id: "a1",
896
+ role: "assistant",
897
+ metadata: { status: "streaming" },
898
+ parts: [
899
+ {
900
+ type: "authorization",
901
+ state: "required",
902
+ name: "github",
903
+ description: "Sign in to GitHub",
904
+ displayName: "GitHub",
905
+ stepIndex: 0,
906
+ turnId: "turn_1",
907
+ },
908
+ ],
909
+ },
910
+ ],
911
+ } satisfies EveMessageData;
912
+
913
+ const error = new Error("authorization failed");
914
+ const [message] = convertEveMessages(data, {
915
+ isRunning: false,
916
+ error,
917
+ });
918
+
919
+ expect(message?.status).toEqual({
920
+ type: "incomplete",
921
+ reason: "error",
922
+ error: {
923
+ code: "unknown",
924
+ message: "authorization failed",
925
+ },
926
+ });
927
+ });
928
+
929
+ it("keeps an earlier authorization pending when a later turn fails", () => {
930
+ const data = {
931
+ messages: [
932
+ {
933
+ id: "a1",
934
+ role: "assistant",
935
+ metadata: { status: "streaming" },
936
+ parts: [
937
+ {
938
+ type: "authorization",
939
+ state: "required",
940
+ name: "github",
941
+ description: "Sign in to GitHub",
942
+ displayName: "GitHub",
943
+ stepIndex: 0,
944
+ turnId: "turn_1",
945
+ },
946
+ ],
947
+ },
948
+ {
949
+ id: "a2",
950
+ role: "assistant",
951
+ metadata: { status: "streaming" },
952
+ parts: [{ type: "text", text: "Later turn" }],
953
+ },
954
+ ],
955
+ } satisfies EveMessageData;
956
+
957
+ const [authorization] = convertEveMessages(data, {
958
+ isRunning: false,
959
+ error: new Error("later turn failed"),
960
+ });
961
+
962
+ expect(authorization?.status).toEqual({
963
+ type: "requires-action",
964
+ reason: "interrupt",
965
+ });
966
+ });
967
+
968
+ it("does not hold requires-action for a completed authorization", () => {
969
+ const data = {
970
+ messages: [
971
+ {
972
+ id: "a1",
973
+ role: "assistant",
974
+ metadata: { status: "complete" },
975
+ parts: [
976
+ {
977
+ type: "authorization",
978
+ state: "completed",
979
+ name: "github",
980
+ description: "Sign in to GitHub",
981
+ displayName: "GitHub",
982
+ stepIndex: 0,
983
+ turnId: "turn_1",
984
+ outcome: "authorized",
985
+ },
986
+ { type: "text", text: "Done" },
987
+ ],
988
+ },
989
+ ],
990
+ } satisfies EveMessageData;
991
+
992
+ const [message] = convertEveMessages(data, { isRunning: false });
993
+
994
+ expect(message?.status).toEqual({ type: "complete", reason: "stop" });
995
+ });
996
+
997
+ describe("contract with eve's default reducer", () => {
998
+ const replay = (events: readonly EveAgentReducerEvent[]) => {
999
+ const reducer = defaultMessageReducer();
1000
+ return events.reduce(
1001
+ (state, event) => reducer.reduce(state, event),
1002
+ reducer.initial(),
1003
+ );
1004
+ };
1005
+
1006
+ const midStreamEvents: readonly EveAgentReducerEvent[] = [
1007
+ {
1008
+ type: "client.message.submitted",
1009
+ data: { submissionId: "sub_1", message: "hi", createdAt: 0 },
1010
+ },
1011
+ {
1012
+ type: "turn.started",
1013
+ data: { turnId: "turn_1", sequence: 0 },
1014
+ },
1015
+ {
1016
+ type: "step.started",
1017
+ data: { turnId: "turn_1", stepIndex: 0, sequence: 1 },
1018
+ },
1019
+ {
1020
+ type: "message.appended",
1021
+ data: {
1022
+ turnId: "turn_1",
1023
+ stepIndex: 0,
1024
+ sequence: 2,
1025
+ messageDelta: "Let me th",
1026
+ messageSoFar: "Let me th",
1027
+ },
1028
+ },
1029
+ ];
1030
+
1031
+ it("carries the challenge Eve projects onto an authorization part", () => {
1032
+ const state = replay([
1033
+ ...midStreamEvents,
1034
+ {
1035
+ type: "authorization.required",
1036
+ data: {
1037
+ turnId: "turn_1",
1038
+ stepIndex: 0,
1039
+ sequence: 3,
1040
+ name: "github",
1041
+ description: "Authorization required for github",
1042
+ authorization: {
1043
+ displayName: "GitHub",
1044
+ instructions: "Enter the code on the device page",
1045
+ url: "https://github.com/login/device",
1046
+ userCode: "ABCD-1234",
1047
+ expiresAt: "2026-01-01T00:00:00Z",
1048
+ },
1049
+ },
1050
+ },
1051
+ ]);
1052
+
1053
+ const converted = convertEveMessages(state, { isRunning: false });
1054
+
1055
+ expect(converted.at(-1)?.content).toContainEqual({
1056
+ type: "data",
1057
+ name: "authorization",
1058
+ data: {
1059
+ state: "required",
1060
+ name: "github",
1061
+ displayName: "GitHub",
1062
+ description: "Authorization required for GitHub",
1063
+ url: "https://github.com/login/device",
1064
+ userCode: "ABCD-1234",
1065
+ instructions: "Enter the code on the device page",
1066
+ expiresAt: "2026-01-01T00:00:00Z",
1067
+ },
1068
+ });
1069
+ });
1070
+
1071
+ it("releases the hold when Eve settles the authorization part in place", () => {
1072
+ const state = replay([
1073
+ ...midStreamEvents,
1074
+ {
1075
+ type: "authorization.required",
1076
+ data: {
1077
+ turnId: "turn_1",
1078
+ stepIndex: 0,
1079
+ sequence: 3,
1080
+ name: "github",
1081
+ description: "Authorization required for github",
1082
+ },
1083
+ },
1084
+ {
1085
+ type: "authorization.completed",
1086
+ data: {
1087
+ turnId: "turn_1",
1088
+ stepIndex: 0,
1089
+ sequence: 4,
1090
+ name: "github",
1091
+ outcome: "authorized",
1092
+ },
1093
+ },
1094
+ ]);
1095
+
1096
+ const assistant = state.messages.find((m) => m.role === "assistant");
1097
+ const authorizationParts = assistant?.parts.filter(
1098
+ (part) => part.type === "authorization",
1099
+ );
1100
+ expect(authorizationParts).toHaveLength(1);
1101
+ expect(authorizationParts?.[0]).toMatchObject({ state: "completed" });
1102
+
1103
+ // Eve leaves the streaming marker set, so the released turn falls back
1104
+ // to the stale-marker mapping rather than to a terminal status.
1105
+ expect(
1106
+ convertEveMessages(state, { isRunning: false }).at(-1)?.status,
1107
+ ).toEqual({ type: "incomplete", reason: "cancelled" });
1108
+ });
1109
+
1110
+ it("keeps the hold while another connector is still required", () => {
1111
+ const state = replay([
1112
+ ...midStreamEvents,
1113
+ {
1114
+ type: "authorization.required",
1115
+ data: {
1116
+ turnId: "turn_1",
1117
+ stepIndex: 0,
1118
+ sequence: 3,
1119
+ name: "github",
1120
+ description: "Authorization required for github",
1121
+ },
1122
+ },
1123
+ {
1124
+ type: "authorization.required",
1125
+ data: {
1126
+ turnId: "turn_1",
1127
+ stepIndex: 0,
1128
+ sequence: 4,
1129
+ name: "slack",
1130
+ description: "Authorization required for slack",
1131
+ },
1132
+ },
1133
+ {
1134
+ type: "authorization.completed",
1135
+ data: {
1136
+ turnId: "turn_1",
1137
+ stepIndex: 0,
1138
+ sequence: 5,
1139
+ name: "github",
1140
+ outcome: "authorized",
1141
+ },
1142
+ },
1143
+ ]);
1144
+
1145
+ expect(
1146
+ convertEveMessages(state, { isRunning: false }).at(-1)?.status,
1147
+ ).toEqual({ type: "requires-action", reason: "interrupt" });
1148
+ });
1149
+
1150
+ it("a locally aborted turn keeps its streaming marker and converts to cancelled", () => {
1151
+ const state = replay(midStreamEvents);
1152
+
1153
+ const assistant = state.messages.find((m) => m.role === "assistant");
1154
+ expect(assistant?.metadata?.status).toBe("streaming");
1155
+
1156
+ const converted = convertEveMessages(state, { isRunning: false });
1157
+ expect(converted.at(-1)?.status).toEqual({
1158
+ type: "incomplete",
1159
+ reason: "cancelled",
1160
+ });
1161
+ });
1162
+
1163
+ it("a failed session keeps its streaming marker and converts to error", () => {
1164
+ const state = replay([
1165
+ ...midStreamEvents,
1166
+ {
1167
+ type: "session.failed",
1168
+ data: { sessionId: "session_1", code: "internal", message: "boom" },
1169
+ },
1170
+ ]);
1171
+
1172
+ const assistant = state.messages.find((m) => m.role === "assistant");
1173
+ expect(assistant?.metadata?.status).toBe("streaming");
1174
+
1175
+ const converted = convertEveMessages(state, {
1176
+ isRunning: false,
1177
+ error: new Error("boom"),
1178
+ });
1179
+ expect(converted.at(-1)?.status).toEqual({
1180
+ type: "incomplete",
1181
+ reason: "error",
1182
+ error: { code: "unknown", message: "boom" },
1183
+ });
1184
+ });
1185
+
1186
+ it("a failed turn converts to cancelled because the store surfaces no error for turn.failed", () => {
1187
+ const state = replay([
1188
+ ...midStreamEvents,
1189
+ {
1190
+ type: "turn.failed",
1191
+ data: {
1192
+ turnId: "turn_1",
1193
+ sequence: 3,
1194
+ code: "internal",
1195
+ message: "boom",
1196
+ },
1197
+ },
1198
+ ]);
1199
+
1200
+ const converted = convertEveMessages(state, { isRunning: false });
1201
+ expect(converted.at(-1)?.status).toEqual({
1202
+ type: "incomplete",
1203
+ reason: "cancelled",
1204
+ });
1205
+ });
1206
+
1207
+ it("a completed turn terminalizes the streaming marker and converts to complete", () => {
1208
+ const state = replay([
1209
+ ...midStreamEvents,
1210
+ {
1211
+ type: "message.completed",
1212
+ data: {
1213
+ turnId: "turn_1",
1214
+ stepIndex: 0,
1215
+ sequence: 3,
1216
+ finishReason: "stop",
1217
+ message: "Let me think",
1218
+ },
1219
+ },
1220
+ {
1221
+ type: "turn.completed",
1222
+ data: { turnId: "turn_1", sequence: 4 },
1223
+ },
1224
+ ]);
1225
+
1226
+ const assistant = state.messages.find((m) => m.role === "assistant");
1227
+ expect(assistant?.metadata?.status).toBe("complete");
1228
+
1229
+ const converted = convertEveMessages(state, { isRunning: false });
1230
+ expect(converted.at(-1)?.status).toEqual({
1231
+ type: "complete",
1232
+ reason: "stop",
1233
+ });
1234
+ });
1235
+ });
260
1236
  });
261
1237
 
262
1238
  it("uses the supplied message creation time", () => {
@@ -369,6 +1345,66 @@ describe("getEveMessageContent", () => {
369
1345
  ]);
370
1346
  });
371
1347
 
1348
+ it("round-trips a sent file attachment through the eve echo shape", () => {
1349
+ const message = {
1350
+ ...baseAppendMessage,
1351
+ content: [],
1352
+ attachments: [
1353
+ {
1354
+ id: "1",
1355
+ type: "file",
1356
+ name: "report.pdf",
1357
+ content: [
1358
+ {
1359
+ type: "file",
1360
+ data: "https://example.com/report.pdf",
1361
+ mimeType: "application/pdf",
1362
+ filename: "report.pdf",
1363
+ },
1364
+ ],
1365
+ status: { type: "complete" },
1366
+ },
1367
+ ],
1368
+ } as unknown as AppendMessage;
1369
+
1370
+ expect(getEveMessageContent(message)).toEqual([
1371
+ {
1372
+ type: "file",
1373
+ data: "https://example.com/report.pdf",
1374
+ mediaType: "application/pdf",
1375
+ filename: "report.pdf",
1376
+ },
1377
+ ]);
1378
+
1379
+ const [echoed] = convertEveMessages({
1380
+ messages: [
1381
+ {
1382
+ id: "t1:user",
1383
+ role: "user",
1384
+ metadata: { status: "complete", turnId: "t1" },
1385
+ parts: [
1386
+ {
1387
+ type: "file",
1388
+ url: "https://example.com/report.pdf",
1389
+ mediaType: "application/pdf",
1390
+ filename: "report.pdf",
1391
+ },
1392
+ ],
1393
+ },
1394
+ ],
1395
+ } satisfies EveMessageData);
1396
+
1397
+ expect(echoed?.content).toEqual([
1398
+ {
1399
+ type: "file",
1400
+ data: "https://example.com/report.pdf",
1401
+ mimeType: "application/pdf",
1402
+ filename: "report.pdf",
1403
+ sourceType: "url",
1404
+ },
1405
+ ]);
1406
+ });
1407
+
372
1408
  it("skips data parts while keeping surrounding text", () => {
373
1409
  const message = {
374
1410
  ...baseAppendMessage,