Skip to content

Commit d2486c3

Browse files
jpr5AlemTuzlak
authored andcommitted
test: add tests for multi-part AIMessage content extraction
Verifies langchain_messages_to_copilotkit correctly concatenates all text parts from list-style content blocks (Anthropic models). Includes regression tests for the exact scenario from CopilotKit#1748 (text + image blocks) and tests for string lists, mixed content, empty lists, and edge cases.
1 parent 3bcff87 commit d2486c3

1 file changed

Lines changed: 191 additions & 0 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""Tests for multi-part content handling in langchain_messages_to_copilotkit.
2+
3+
Covers the fix in PR #3844 / issue #1748: when AIMessage.content is a list
4+
of content blocks (e.g. Anthropic models), all text parts must be extracted
5+
and concatenated — not just the first element.
6+
"""
7+
8+
import pytest
9+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
10+
11+
from copilotkit.langgraph import langchain_messages_to_copilotkit
12+
13+
14+
class TestMultiPartContentList:
15+
"""AIMessage.content as a list should concatenate all text parts."""
16+
17+
def test_list_of_text_dicts(self):
18+
"""Multiple {"type": "text", "text": "..."} dicts are all concatenated."""
19+
msg = AIMessage(
20+
id="ai-1",
21+
content=[
22+
{"type": "text", "text": "Hello "},
23+
{"type": "text", "text": "world"},
24+
],
25+
)
26+
result = langchain_messages_to_copilotkit([msg])
27+
assert len(result) == 1
28+
assert result[0]["content"] == "Hello world"
29+
assert result[0]["role"] == "assistant"
30+
31+
def test_list_of_strings(self):
32+
"""Content list of plain strings should be concatenated."""
33+
msg = AIMessage(
34+
id="ai-2",
35+
content=["Part A", " Part B"],
36+
)
37+
result = langchain_messages_to_copilotkit([msg])
38+
assert len(result) == 1
39+
assert result[0]["content"] == "Part A Part B"
40+
41+
def test_mixed_strings_and_text_dicts(self):
42+
"""Mix of plain strings and text dicts should all be concatenated."""
43+
msg = AIMessage(
44+
id="ai-3",
45+
content=[
46+
"Start ",
47+
{"type": "text", "text": "middle "},
48+
{"text": "end"},
49+
],
50+
)
51+
result = langchain_messages_to_copilotkit([msg])
52+
assert len(result) == 1
53+
assert result[0]["content"] == "Start middle end"
54+
55+
def test_non_text_parts_are_skipped(self):
56+
"""Non-text content blocks (e.g. images) should be ignored."""
57+
msg = AIMessage(
58+
id="ai-4",
59+
content=[
60+
{"type": "text", "text": "Sample png file"},
61+
{
62+
"type": "image",
63+
"image_data": {"data": "base64data", "format": "image/png"},
64+
},
65+
],
66+
)
67+
result = langchain_messages_to_copilotkit([msg])
68+
assert len(result) == 1
69+
assert result[0]["content"] == "Sample png file"
70+
71+
def test_empty_list_returns_empty_content(self):
72+
"""Empty content list should produce empty string, not crash."""
73+
msg = AIMessage(
74+
id="ai-5",
75+
content=[],
76+
)
77+
result = langchain_messages_to_copilotkit([msg])
78+
# Empty content means message is not included (content is falsy)
79+
assert len(result) == 0
80+
81+
def test_single_text_dict_in_list(self):
82+
"""Single text dict in a list should still be extracted."""
83+
msg = AIMessage(
84+
id="ai-6",
85+
content=[{"type": "text", "text": "Only one part"}],
86+
)
87+
result = langchain_messages_to_copilotkit([msg])
88+
assert len(result) == 1
89+
assert result[0]["content"] == "Only one part"
90+
91+
def test_dict_without_type_but_with_text_key(self):
92+
"""A dict with "text" key but no "type" should still have text extracted."""
93+
msg = AIMessage(
94+
id="ai-7",
95+
content=[{"text": "no type field"}],
96+
)
97+
result = langchain_messages_to_copilotkit([msg])
98+
assert len(result) == 1
99+
assert result[0]["content"] == "no type field"
100+
101+
102+
class TestSingleDictContent:
103+
"""AIMessage.content as a single dict (Anthropic style) should extract text.
104+
105+
Note: langchain_core.messages.AIMessage validates content as str | list,
106+
so a raw dict cannot be passed directly. We use a mock to exercise the
107+
dict-handling code path in langchain_messages_to_copilotkit, which exists
108+
to handle edge cases from deserialized or non-standard message objects.
109+
"""
110+
111+
def test_dict_with_text_key(self):
112+
"""A content dict with "text" key should have its text extracted."""
113+
from unittest.mock import MagicMock
114+
115+
msg = MagicMock(spec=AIMessage)
116+
msg.content = {"text": "dict content"}
117+
msg.id = "ai-8"
118+
msg.tool_calls = []
119+
result = langchain_messages_to_copilotkit([msg])
120+
assert len(result) == 1
121+
assert result[0]["content"] == "dict content"
122+
123+
124+
class TestPlainStringContent:
125+
"""Standard string content should still work as before."""
126+
127+
def test_plain_string_content(self):
128+
"""Normal string content passes through unchanged."""
129+
msg = AIMessage(
130+
id="ai-9",
131+
content="Just a string",
132+
)
133+
result = langchain_messages_to_copilotkit([msg])
134+
assert len(result) == 1
135+
assert result[0]["content"] == "Just a string"
136+
137+
def test_human_message_string(self):
138+
"""HumanMessage with string content still works."""
139+
msg = HumanMessage(id="human-1", content="Hello")
140+
result = langchain_messages_to_copilotkit([msg])
141+
assert len(result) == 1
142+
assert result[0]["role"] == "user"
143+
assert result[0]["content"] == "Hello"
144+
145+
def test_system_message_string(self):
146+
"""SystemMessage with string content still works."""
147+
msg = SystemMessage(id="sys-1", content="System prompt")
148+
result = langchain_messages_to_copilotkit([msg])
149+
assert len(result) == 1
150+
assert result[0]["role"] == "system"
151+
assert result[0]["content"] == "System prompt"
152+
153+
154+
class TestIssue1748Reproduction:
155+
"""Directly reproduces the scenario from issue #1748.
156+
157+
The original bug: when content is a list of dicts including an image block,
158+
only the first element was taken via `content[0]`, which was the dict itself,
159+
not a string. This caused the message to be silently dropped or mangled.
160+
"""
161+
162+
def test_text_and_image_content_preserves_text(self):
163+
"""The exact scenario from issue #1748: text + image content blocks."""
164+
msg = AIMessage(
165+
id="ai-repro",
166+
content=[
167+
{"type": "text", "text": "Sample png file"},
168+
{
169+
"type": "image",
170+
"image_data": {"data": "aW1hZ2VfZGF0YQ==", "format": "image/png"},
171+
},
172+
],
173+
)
174+
result = langchain_messages_to_copilotkit([msg])
175+
assert len(result) == 1
176+
assert result[0]["content"] == "Sample png file"
177+
assert result[0]["role"] == "assistant"
178+
179+
def test_multiple_text_parts_are_not_truncated(self):
180+
"""The core bug: only the first element was kept. All text must survive."""
181+
msg = AIMessage(
182+
id="ai-trunc",
183+
content=[
184+
{"type": "text", "text": "First part. "},
185+
{"type": "text", "text": "Second part. "},
186+
{"type": "text", "text": "Third part."},
187+
],
188+
)
189+
result = langchain_messages_to_copilotkit([msg])
190+
assert len(result) == 1
191+
assert result[0]["content"] == "First part. Second part. Third part."

0 commit comments

Comments
 (0)