@librechat/agents 3.1.55 → 3.1.57
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.
- package/dist/cjs/graphs/Graph.cjs +1 -1
- package/dist/cjs/llm/openai/index.cjs +1 -1
- package/dist/cjs/main.cjs +1 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +118 -32
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/run.cjs +5 -2
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/stream.cjs +9 -0
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +1 -1
- package/dist/cjs/utils/tokens.cjs +33 -45
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +1 -1
- package/dist/esm/llm/openai/index.mjs +1 -1
- package/dist/esm/main.mjs +1 -1
- package/dist/esm/messages/format.mjs +119 -33
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/run.mjs +5 -2
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/stream.mjs +9 -0
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +1 -1
- package/dist/esm/utils/tokens.mjs +33 -46
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/types/types/graph.d.ts +2 -0
- package/dist/types/types/stream.d.ts +2 -0
- package/dist/types/utils/tokens.d.ts +6 -18
- package/package.json +3 -2
- package/src/messages/ensureThinkingBlock.test.ts +502 -27
- package/src/messages/format.ts +155 -44
- package/src/run.ts +6 -2
- package/src/scripts/bedrock-cache-debug.ts +15 -15
- package/src/scripts/code_exec_multi_session.ts +8 -13
- package/src/scripts/image.ts +2 -1
- package/src/scripts/multi-agent-parallel-start.ts +3 -4
- package/src/scripts/multi-agent-sequence.ts +3 -4
- package/src/scripts/single-agent-metadata-test.ts +3 -6
- package/src/scripts/test-tool-before-handoff-role-order.ts +2 -3
- package/src/scripts/test-tools-before-handoff.ts +2 -3
- package/src/scripts/tools.ts +1 -7
- package/src/specs/token-memoization.test.ts +35 -34
- package/src/specs/tokens.test.ts +64 -0
- package/src/stream.ts +12 -0
- package/src/types/graph.ts +2 -0
- package/src/types/stream.ts +2 -0
- 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]
|
|
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
|
-
|
|
373
|
-
expect(
|
|
374
|
-
expect(
|
|
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
|
-
|
|
406
|
-
expect(
|
|
407
|
-
expect(
|
|
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
|
-
|
|
449
|
-
expect(
|
|
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
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
|
|
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(
|
|
534
|
-
expect(result[4]).toBeInstanceOf(
|
|
535
|
-
expect(result[5]).toBeInstanceOf(
|
|
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
|
-
//
|
|
580
|
-
|
|
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(
|
|
584
|
-
expect(result[
|
|
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).
|
|
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]
|
|
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
|
});
|