@librechat/agents 3.1.55 → 3.1.56

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 (47) hide show
  1. package/dist/cjs/graphs/Graph.cjs +1 -1
  2. package/dist/cjs/llm/openai/index.cjs +1 -1
  3. package/dist/cjs/main.cjs +1 -0
  4. package/dist/cjs/main.cjs.map +1 -1
  5. package/dist/cjs/messages/format.cjs +118 -32
  6. package/dist/cjs/messages/format.cjs.map +1 -1
  7. package/dist/cjs/run.cjs +5 -2
  8. package/dist/cjs/run.cjs.map +1 -1
  9. package/dist/cjs/stream.cjs +9 -0
  10. package/dist/cjs/stream.cjs.map +1 -1
  11. package/dist/cjs/tools/ToolNode.cjs +1 -1
  12. package/dist/cjs/utils/tokens.cjs +33 -45
  13. package/dist/cjs/utils/tokens.cjs.map +1 -1
  14. package/dist/esm/graphs/Graph.mjs +1 -1
  15. package/dist/esm/llm/openai/index.mjs +1 -1
  16. package/dist/esm/main.mjs +1 -1
  17. package/dist/esm/messages/format.mjs +119 -33
  18. package/dist/esm/messages/format.mjs.map +1 -1
  19. package/dist/esm/run.mjs +5 -2
  20. package/dist/esm/run.mjs.map +1 -1
  21. package/dist/esm/stream.mjs +9 -0
  22. package/dist/esm/stream.mjs.map +1 -1
  23. package/dist/esm/tools/ToolNode.mjs +1 -1
  24. package/dist/esm/utils/tokens.mjs +33 -46
  25. package/dist/esm/utils/tokens.mjs.map +1 -1
  26. package/dist/types/types/graph.d.ts +2 -0
  27. package/dist/types/types/stream.d.ts +2 -0
  28. package/dist/types/utils/tokens.d.ts +6 -18
  29. package/package.json +2 -1
  30. package/src/messages/ensureThinkingBlock.test.ts +502 -27
  31. package/src/messages/format.ts +155 -44
  32. package/src/run.ts +6 -2
  33. package/src/scripts/bedrock-cache-debug.ts +15 -15
  34. package/src/scripts/code_exec_multi_session.ts +8 -13
  35. package/src/scripts/image.ts +2 -1
  36. package/src/scripts/multi-agent-parallel-start.ts +3 -4
  37. package/src/scripts/multi-agent-sequence.ts +3 -4
  38. package/src/scripts/single-agent-metadata-test.ts +3 -6
  39. package/src/scripts/test-tool-before-handoff-role-order.ts +2 -3
  40. package/src/scripts/test-tools-before-handoff.ts +2 -3
  41. package/src/scripts/tools.ts +1 -7
  42. package/src/specs/token-memoization.test.ts +35 -34
  43. package/src/specs/tokens.test.ts +64 -0
  44. package/src/stream.ts +12 -0
  45. package/src/types/graph.ts +2 -0
  46. package/src/types/stream.ts +2 -0
  47. package/src/utils/tokens.ts +43 -54
@@ -3,6 +3,22 @@ import type { ExtendedMessageContent } from '@/types';
3
3
  import { ensureThinkingBlockInMessages } from './format';
4
4
  import { Providers, ContentTypes } from '@/common';
5
5
 
6
+ /** Helper: extract concatenated text from a message's content (string or structured array). */
7
+ function getTextContent(msg: {
8
+ content: string | ExtendedMessageContent[];
9
+ }): string {
10
+ if (typeof msg.content === 'string') {
11
+ return msg.content;
12
+ }
13
+ if (Array.isArray(msg.content)) {
14
+ return (msg.content as ExtendedMessageContent[])
15
+ .filter((b) => b.type === 'text')
16
+ .map((b) => String(b.text ?? ''))
17
+ .join('\n');
18
+ }
19
+ return '';
20
+ }
21
+
6
22
  describe('ensureThinkingBlockInMessages', () => {
7
23
  describe('messages with thinking blocks (should not be modified)', () => {
8
24
  test('should not modify AI message that already has thinking block', () => {
@@ -264,7 +280,7 @@ describe('ensureThinkingBlockInMessages', () => {
264
280
  expect(result[2]).toBeInstanceOf(ToolMessage);
265
281
  expect(result[3]).toBeInstanceOf(HumanMessage); // user message
266
282
  expect(result[4]).toBeInstanceOf(HumanMessage); // converted — no thinking in this chain
267
- expect(result[4].content).toContain('[Previous agent context]');
283
+ expect(getTextContent(result[4])).toContain('[Previous agent context]');
268
284
  });
269
285
 
270
286
  test('should detect thinking via additional_kwargs.reasoning_content in chain', () => {
@@ -369,9 +385,10 @@ describe('ensureThinkingBlockInMessages', () => {
369
385
  expect(result[1]).toBeInstanceOf(HumanMessage);
370
386
 
371
387
  // Check that the converted message includes the context prefix
372
- expect(result[1].content).toContain('[Previous agent context]');
373
- expect(result[1].content).toContain('Let me check the weather');
374
- expect(result[1].content).toContain('Sunny, 75°F');
388
+ const text = getTextContent(result[1]);
389
+ expect(text).toContain('[Previous agent context]');
390
+ expect(text).toContain('Let me check the weather');
391
+ expect(text).toContain('Sunny, 75°F');
375
392
  });
376
393
 
377
394
  test('should convert AI message with tool_use in content to HumanMessage', () => {
@@ -402,9 +419,10 @@ describe('ensureThinkingBlockInMessages', () => {
402
419
  expect(result).toHaveLength(2);
403
420
  expect(result[0]).toBeInstanceOf(HumanMessage);
404
421
  expect(result[1]).toBeInstanceOf(HumanMessage);
405
- expect(result[1].content).toContain('[Previous agent context]');
406
- expect(result[1].content).toContain('Searching...');
407
- expect(result[1].content).toContain('Found results');
422
+ const text = getTextContent(result[1]);
423
+ expect(text).toContain('[Previous agent context]');
424
+ expect(text).toContain('Searching...');
425
+ expect(text).toContain('Found results');
408
426
  });
409
427
 
410
428
  test('should handle multiple tool messages in sequence', () => {
@@ -445,8 +463,9 @@ describe('ensureThinkingBlockInMessages', () => {
445
463
  // Should combine all tool messages into one HumanMessage
446
464
  expect(result).toHaveLength(2);
447
465
  expect(result[1]).toBeInstanceOf(HumanMessage);
448
- expect(result[1].content).toContain('Result 1');
449
- expect(result[1].content).toContain('Result 2');
466
+ const text = getTextContent(result[1]);
467
+ expect(text).toContain('Result 1');
468
+ expect(text).toContain('Result 2');
450
469
  });
451
470
  });
452
471
 
@@ -520,19 +539,19 @@ describe('ensureThinkingBlockInMessages', () => {
520
539
  Providers.ANTHROPIC
521
540
  );
522
541
 
523
- // Original message 1: HumanMessage (preserved)
524
- // Original message 2: AIMessage without tools (preserved)
525
- // Original message 3: HumanMessage (preserved)
526
- // Original messages 4-5: AIMessage with tool + ToolMessage (converted to 1 HumanMessage)
527
- // Original message 6: HumanMessage (preserved)
528
- // Original message 7: AIMessage without tools (preserved)
529
- expect(result).toHaveLength(6);
542
+ // Only the trailing sequence after the last HumanMessage is processed.
543
+ // The AI+Tool at indices 3-4 is history — preserved as-is.
544
+ // Last HumanMessage is at index 5 ("Third question").
545
+ // Index 6 (AIMessage without tools) is in the trailing sequence but has
546
+ // no tool calls, so it passes through.
547
+ expect(result).toHaveLength(7);
530
548
  expect(result[0]).toBeInstanceOf(HumanMessage);
531
549
  expect(result[1]).toBeInstanceOf(AIMessage);
532
550
  expect(result[2]).toBeInstanceOf(HumanMessage);
533
- expect(result[3]).toBeInstanceOf(HumanMessage); // Converted
534
- expect(result[4]).toBeInstanceOf(HumanMessage);
535
- expect(result[5]).toBeInstanceOf(AIMessage);
551
+ expect(result[3]).toBeInstanceOf(AIMessage); // History — preserved
552
+ expect(result[4]).toBeInstanceOf(ToolMessage); // History — preserved
553
+ expect(result[5]).toBeInstanceOf(HumanMessage);
554
+ expect(result[6]).toBeInstanceOf(AIMessage);
536
555
  });
537
556
 
538
557
  test('should handle multiple tool-using sequences', () => {
@@ -576,16 +595,19 @@ describe('ensureThinkingBlockInMessages', () => {
576
595
  Providers.ANTHROPIC
577
596
  );
578
597
 
579
- // Each tool sequence should be converted to a HumanMessage
580
- expect(result).toHaveLength(4);
598
+ // Only the trailing sequence after the last HumanMessage is converted.
599
+ // First tool sequence (indices 1-2) is history — preserved.
600
+ // Last HumanMessage is at index 3 ("Do task 2").
601
+ // Trailing sequence (indices 4-5) is converted to 1 HumanMessage.
602
+ expect(result).toHaveLength(5);
581
603
  expect(result[0]).toBeInstanceOf(HumanMessage);
582
604
  expect(result[0].content).toBe('Do task 1');
583
- expect(result[1]).toBeInstanceOf(HumanMessage);
584
- expect(result[1].content).toContain('Doing task 1');
585
- expect(result[2]).toBeInstanceOf(HumanMessage);
586
- expect(result[2].content).toBe('Do task 2');
605
+ expect(result[1]).toBeInstanceOf(AIMessage); // History — preserved
606
+ expect(result[2]).toBeInstanceOf(ToolMessage); // History — preserved
587
607
  expect(result[3]).toBeInstanceOf(HumanMessage);
588
- expect(result[3].content).toContain('Doing task 2');
608
+ expect(result[3].content).toBe('Do task 2');
609
+ expect(result[4]).toBeInstanceOf(HumanMessage); // Converted trailing sequence
610
+ expect(getTextContent(result[4])).toContain('Doing task 2');
589
611
  });
590
612
  });
591
613
 
@@ -647,7 +669,7 @@ describe('ensureThinkingBlockInMessages', () => {
647
669
  expect(result[0]).toBeInstanceOf(HumanMessage);
648
670
  expect(result[0].content).toBe('do something');
649
671
  expect(result[1]).toBeInstanceOf(HumanMessage);
650
- expect(result[1].content).toContain('[Previous agent context]');
672
+ expect(getTextContent(result[1])).toContain('[Previous agent context]');
651
673
  });
652
674
  });
653
675
 
@@ -734,4 +756,457 @@ describe('ensureThinkingBlockInMessages', () => {
734
756
  expect(result[1]).toBeInstanceOf(ToolMessage);
735
757
  });
736
758
  });
759
+
760
+ describe('image content preservation (token amplification fix)', () => {
761
+ const FAKE_BASE64 =
762
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk';
763
+
764
+ /**
765
+ * Reproduces the reported bug: a base64 image from an MCP tool, when
766
+ * serialized by the old getBufferString() path, would become text tokens.
767
+ * With the fix, the base64 data stays in a structured image block.
768
+ */
769
+ test('should not serialize base64 image as text (reported 174x token amplification)', () => {
770
+ const LARGE_BASE64 = 'A'.repeat(10_000);
771
+
772
+ const messages = [
773
+ new HumanMessage({ content: 'Take a screenshot' }),
774
+ new AIMessage({
775
+ content: 'Taking a screenshot.',
776
+ tool_calls: [
777
+ {
778
+ id: 'call_mcp',
779
+ name: 'screenshot',
780
+ args: {},
781
+ type: 'tool_call' as const,
782
+ },
783
+ ],
784
+ }),
785
+ new ToolMessage({
786
+ content: [
787
+ { type: 'text', text: 'Screenshot captured (1280x720)' },
788
+ {
789
+ type: 'image_url',
790
+ image_url: { url: `data:image/png;base64,${LARGE_BASE64}` },
791
+ },
792
+ ],
793
+ tool_call_id: 'call_mcp',
794
+ }),
795
+ ];
796
+
797
+ const result = ensureThinkingBlockInMessages(
798
+ messages,
799
+ Providers.ANTHROPIC
800
+ );
801
+
802
+ expect(result).toHaveLength(2);
803
+ expect(result[1]).toBeInstanceOf(HumanMessage);
804
+
805
+ const content = result[1].content as ExtendedMessageContent[];
806
+ const textBlocks = content.filter((b) => b.type === 'text');
807
+ const imageBlocks = content.filter((b) => b.type === 'image_url');
808
+
809
+ expect(imageBlocks).toHaveLength(1);
810
+
811
+ const allText = textBlocks.map((b) => String(b.text ?? '')).join('\n');
812
+ expect(allText).toContain('[Previous agent context]');
813
+ expect(allText).toContain('Screenshot captured');
814
+ expect(allText).not.toContain(LARGE_BASE64);
815
+ // Text must be orders of magnitude smaller than the image data
816
+ expect(allText.length).toBeLessThan(LARGE_BASE64.length / 10);
817
+ });
818
+
819
+ test('should preserve image_url blocks from ToolMessage instead of serializing as text', () => {
820
+ const messages = [
821
+ new HumanMessage({ content: 'Take a screenshot' }),
822
+ new AIMessage({
823
+ content: 'Taking screenshot now.',
824
+ tool_calls: [
825
+ {
826
+ id: 'call_ss',
827
+ name: 'screenshot',
828
+ args: {},
829
+ type: 'tool_call' as const,
830
+ },
831
+ ],
832
+ }),
833
+ new ToolMessage({
834
+ content: [
835
+ { type: 'text', text: 'Screenshot captured' },
836
+ {
837
+ type: 'image_url',
838
+ image_url: {
839
+ url: `data:image/png;base64,${FAKE_BASE64}`,
840
+ },
841
+ },
842
+ ],
843
+ tool_call_id: 'call_ss',
844
+ }),
845
+ ];
846
+
847
+ const result = ensureThinkingBlockInMessages(
848
+ messages,
849
+ Providers.ANTHROPIC
850
+ );
851
+
852
+ expect(result).toHaveLength(2);
853
+ expect(result[1]).toBeInstanceOf(HumanMessage);
854
+
855
+ // Content should be an array with structured blocks
856
+ const content = result[1].content as ExtendedMessageContent[];
857
+ expect(Array.isArray(content)).toBe(true);
858
+
859
+ // Should have text block(s) and an image_url block
860
+ const textBlocks = content.filter((b) => b.type === 'text');
861
+ const imageBlocks = content.filter((b) => b.type === 'image_url');
862
+
863
+ expect(textBlocks.length).toBeGreaterThanOrEqual(1);
864
+ expect(imageBlocks).toHaveLength(1);
865
+
866
+ // The image block should be preserved as-is (not serialized to text)
867
+ const imageBlock = imageBlocks[0] as {
868
+ type: string;
869
+ image_url: { url: string };
870
+ };
871
+ expect(imageBlock.image_url.url).toContain(FAKE_BASE64);
872
+
873
+ // The text should contain context info but NOT the base64 data
874
+ const allText = textBlocks.map((b) => String(b.text ?? '')).join('\n');
875
+ expect(allText).toContain('[Previous agent context]');
876
+ expect(allText).toContain('Screenshot captured');
877
+ expect(allText).not.toContain(FAKE_BASE64);
878
+ });
879
+
880
+ test('should preserve Anthropic-style image blocks from ToolMessage', () => {
881
+ const messages = [
882
+ new HumanMessage({ content: 'Take a screenshot' }),
883
+ new AIMessage({
884
+ content: 'Let me capture that.',
885
+ tool_calls: [
886
+ {
887
+ id: 'call_ss2',
888
+ name: 'screenshot',
889
+ args: {},
890
+ type: 'tool_call' as const,
891
+ },
892
+ ],
893
+ }),
894
+ new ToolMessage({
895
+ content: [
896
+ { type: 'text', text: 'Here is the screenshot' },
897
+ {
898
+ type: 'image',
899
+ source: {
900
+ type: 'base64',
901
+ media_type: 'image/png',
902
+ data: FAKE_BASE64,
903
+ },
904
+ },
905
+ ],
906
+ tool_call_id: 'call_ss2',
907
+ }),
908
+ ];
909
+
910
+ const result = ensureThinkingBlockInMessages(
911
+ messages,
912
+ Providers.ANTHROPIC
913
+ );
914
+
915
+ expect(result).toHaveLength(2);
916
+ const content = result[1].content as ExtendedMessageContent[];
917
+ expect(Array.isArray(content)).toBe(true);
918
+
919
+ const imageBlocks = content.filter((b) => b.type === 'image');
920
+ expect(imageBlocks).toHaveLength(1);
921
+ const imageBlock = imageBlocks[0] as {
922
+ type: string;
923
+ source: { data: string };
924
+ };
925
+ expect(imageBlock.source.data).toBe(FAKE_BASE64);
926
+
927
+ // Text should not contain base64
928
+ const allText = content
929
+ .filter((b) => b.type === 'text')
930
+ .map((b) => String(b.text ?? ''))
931
+ .join('\n');
932
+ expect(allText).not.toContain(FAKE_BASE64);
933
+ });
934
+
935
+ test('should handle multiple images across multiple ToolMessages', () => {
936
+ const messages = [
937
+ new HumanMessage({ content: 'Compare two pages' }),
938
+ new AIMessage({
939
+ content: 'Taking screenshots of both pages.',
940
+ tool_calls: [
941
+ {
942
+ id: 'call_a',
943
+ name: 'screenshot',
944
+ args: { page: 'A' },
945
+ type: 'tool_call' as const,
946
+ },
947
+ {
948
+ id: 'call_b',
949
+ name: 'screenshot',
950
+ args: { page: 'B' },
951
+ type: 'tool_call' as const,
952
+ },
953
+ ],
954
+ }),
955
+ new ToolMessage({
956
+ content: [
957
+ { type: 'text', text: 'Page A screenshot' },
958
+ {
959
+ type: 'image_url',
960
+ image_url: { url: 'data:image/png;base64,PAGE_A_DATA' },
961
+ },
962
+ ],
963
+ tool_call_id: 'call_a',
964
+ }),
965
+ new ToolMessage({
966
+ content: [
967
+ { type: 'text', text: 'Page B screenshot' },
968
+ {
969
+ type: 'image_url',
970
+ image_url: { url: 'data:image/png;base64,PAGE_B_DATA' },
971
+ },
972
+ ],
973
+ tool_call_id: 'call_b',
974
+ }),
975
+ ];
976
+
977
+ const result = ensureThinkingBlockInMessages(
978
+ messages,
979
+ Providers.ANTHROPIC
980
+ );
981
+
982
+ expect(result).toHaveLength(2);
983
+ const content = result[1].content as ExtendedMessageContent[];
984
+ const imageBlocks = content.filter((b) => b.type === 'image_url');
985
+ expect(imageBlocks).toHaveLength(2);
986
+
987
+ const allText = content
988
+ .filter((b) => b.type === 'text')
989
+ .map((b) => String(b.text ?? ''))
990
+ .join('\n');
991
+ expect(allText).toContain('Page A screenshot');
992
+ expect(allText).toContain('Page B screenshot');
993
+ });
994
+
995
+ test('should still produce text-only content when no images are present', () => {
996
+ const messages = [
997
+ new HumanMessage({ content: 'Do something' }),
998
+ new AIMessage({
999
+ content: 'Doing it.',
1000
+ tool_calls: [
1001
+ {
1002
+ id: 'call_t',
1003
+ name: 'tool',
1004
+ args: { x: 1 },
1005
+ type: 'tool_call' as const,
1006
+ },
1007
+ ],
1008
+ }),
1009
+ new ToolMessage({
1010
+ content: 'plain text result',
1011
+ tool_call_id: 'call_t',
1012
+ }),
1013
+ ];
1014
+
1015
+ const result = ensureThinkingBlockInMessages(
1016
+ messages,
1017
+ Providers.ANTHROPIC
1018
+ );
1019
+
1020
+ expect(result).toHaveLength(2);
1021
+ const content = result[1].content as ExtendedMessageContent[];
1022
+ // When no images, should still be an array with a single text block
1023
+ expect(Array.isArray(content)).toBe(true);
1024
+ expect(content).toHaveLength(1);
1025
+ expect(content[0].type).toBe('text');
1026
+ expect(content[0].text).toContain('[Previous agent context]');
1027
+ expect(content[0].text).toContain('plain text result');
1028
+ });
1029
+
1030
+ test('should not double-serialize when AIMessage has both content tool_use and tool_calls', () => {
1031
+ const messages = [
1032
+ new HumanMessage({ content: 'Search for something' }),
1033
+ new AIMessage({
1034
+ content: [
1035
+ { type: 'text', text: 'Searching...' },
1036
+ {
1037
+ type: 'tool_use',
1038
+ id: 'call_dual',
1039
+ name: 'search',
1040
+ input: { query: 'test' },
1041
+ },
1042
+ ],
1043
+ tool_calls: [
1044
+ {
1045
+ id: 'call_dual',
1046
+ name: 'search',
1047
+ args: { query: 'test' },
1048
+ type: 'tool_call' as const,
1049
+ },
1050
+ ],
1051
+ }),
1052
+ new ToolMessage({
1053
+ content: 'Found 5 results',
1054
+ tool_call_id: 'call_dual',
1055
+ }),
1056
+ ];
1057
+
1058
+ const result = ensureThinkingBlockInMessages(
1059
+ messages,
1060
+ Providers.ANTHROPIC
1061
+ );
1062
+
1063
+ expect(result).toHaveLength(2);
1064
+ const allText = getTextContent(result[1]);
1065
+ // Array content path serializes tool_use blocks but skips appendToolCalls
1066
+ expect(allText).not.toContain('[tool_call]');
1067
+ expect(allText).toContain('[tool_use]');
1068
+ });
1069
+
1070
+ test('should serialize tool_calls when content is empty array (no tool_use blocks)', () => {
1071
+ const messages = [
1072
+ new HumanMessage({ content: 'Do something' }),
1073
+ new AIMessage({
1074
+ content: [],
1075
+ tool_calls: [
1076
+ {
1077
+ id: 'call_empty',
1078
+ name: 'some_tool',
1079
+ args: { x: 1 },
1080
+ type: 'tool_call' as const,
1081
+ },
1082
+ ],
1083
+ }),
1084
+ new ToolMessage({
1085
+ content: 'tool result',
1086
+ tool_call_id: 'call_empty',
1087
+ }),
1088
+ ];
1089
+
1090
+ const result = ensureThinkingBlockInMessages(
1091
+ messages,
1092
+ Providers.ANTHROPIC
1093
+ );
1094
+
1095
+ expect(result).toHaveLength(2);
1096
+ const allText = getTextContent(result[1]);
1097
+ // With empty content array, should fall back to tool_calls
1098
+ expect(allText).toContain('[tool_call]');
1099
+ expect(allText).toContain('some_tool');
1100
+ });
1101
+
1102
+ test('should serialize unrecognized block types instead of dropping them', () => {
1103
+ const messages = [
1104
+ new HumanMessage({ content: 'Fetch resource' }),
1105
+ new AIMessage({
1106
+ content: 'Fetching.',
1107
+ tool_calls: [
1108
+ {
1109
+ id: 'call_res',
1110
+ name: 'fetch_resource',
1111
+ args: {},
1112
+ type: 'tool_call' as const,
1113
+ },
1114
+ ],
1115
+ }),
1116
+ new ToolMessage({
1117
+ content: [
1118
+ { type: 'text', text: 'Resource fetched' },
1119
+ {
1120
+ type: 'resource',
1121
+ resource: { uri: 'file:///data.csv', text: 'a,b,c' },
1122
+ },
1123
+ ],
1124
+ tool_call_id: 'call_res',
1125
+ }),
1126
+ ];
1127
+
1128
+ const result = ensureThinkingBlockInMessages(
1129
+ messages,
1130
+ Providers.ANTHROPIC
1131
+ );
1132
+
1133
+ expect(result).toHaveLength(2);
1134
+ const allText = getTextContent(result[1]);
1135
+ // The resource block should be serialized as text, not silently dropped
1136
+ expect(allText).toContain('[resource]');
1137
+ expect(allText).toContain('data.csv');
1138
+ });
1139
+
1140
+ test('should preserve image blocks when provider is Bedrock', () => {
1141
+ const messages = [
1142
+ new HumanMessage({ content: 'Screenshot' }),
1143
+ new AIMessage({
1144
+ content: 'Taking screenshot.',
1145
+ tool_calls: [
1146
+ {
1147
+ id: 'call_br',
1148
+ name: 'screenshot',
1149
+ args: {},
1150
+ type: 'tool_call' as const,
1151
+ },
1152
+ ],
1153
+ }),
1154
+ new ToolMessage({
1155
+ content: [
1156
+ { type: 'text', text: 'Captured' },
1157
+ {
1158
+ type: 'image_url',
1159
+ image_url: { url: `data:image/png;base64,${FAKE_BASE64}` },
1160
+ },
1161
+ ],
1162
+ tool_call_id: 'call_br',
1163
+ }),
1164
+ ];
1165
+
1166
+ const result = ensureThinkingBlockInMessages(messages, Providers.BEDROCK);
1167
+
1168
+ expect(result).toHaveLength(2);
1169
+ expect(result[1]).toBeInstanceOf(HumanMessage);
1170
+ const content = result[1].content as ExtendedMessageContent[];
1171
+ const imageBlocks = content.filter((b) => b.type === 'image_url');
1172
+ expect(imageBlocks).toHaveLength(1);
1173
+ const allText = getTextContent(result[1]);
1174
+ expect(allText).not.toContain(FAKE_BASE64);
1175
+ });
1176
+
1177
+ test('should shallow-copy image blocks to prevent aliasing', () => {
1178
+ const originalImageBlock = {
1179
+ type: 'image_url',
1180
+ image_url: { url: `data:image/png;base64,${FAKE_BASE64}` },
1181
+ };
1182
+ const messages = [
1183
+ new HumanMessage({ content: 'Screenshot' }),
1184
+ new AIMessage({
1185
+ content: 'Taking screenshot.',
1186
+ tool_calls: [
1187
+ {
1188
+ id: 'call_alias',
1189
+ name: 'screenshot',
1190
+ args: {},
1191
+ type: 'tool_call' as const,
1192
+ },
1193
+ ],
1194
+ }),
1195
+ new ToolMessage({
1196
+ content: [{ type: 'text', text: 'Captured' }, originalImageBlock],
1197
+ tool_call_id: 'call_alias',
1198
+ }),
1199
+ ];
1200
+
1201
+ const result = ensureThinkingBlockInMessages(
1202
+ messages,
1203
+ Providers.ANTHROPIC
1204
+ );
1205
+
1206
+ const content = result[1].content as ExtendedMessageContent[];
1207
+ const outputImageBlock = content.find((b) => b.type === 'image_url');
1208
+ // Should be a different object reference (shallow copy)
1209
+ expect(outputImageBlock).not.toBe(originalImageBlock);
1210
+ });
1211
+ });
737
1212
  });