@@ -69,6 +69,140 @@ async def test_generic_squash_normalize_to_dicts_async():
6969 assert "User message" in result [0 ]["pieces" ][0 ]["converted_value" ]
7070
7171
72+ async def test_generic_squash_preserves_multipart_user_message ():
73+ """Test that squashing keeps non-text user pieces instead of collapsing to plain text."""
74+ conversation_id = "conv-1"
75+ messages = [
76+ _make_message ("system" , "System message" ),
77+ Message (
78+ message_pieces = [
79+ MessagePiece (
80+ role = "user" ,
81+ original_value = "User message" ,
82+ conversation_id = conversation_id ,
83+ sequence = 0 ,
84+ ),
85+ MessagePiece (
86+ role = "user" ,
87+ original_value = "/tmp/example.png" ,
88+ original_value_data_type = "image_path" ,
89+ conversation_id = conversation_id ,
90+ sequence = 0 ,
91+ ),
92+ ]
93+ ),
94+ ]
95+
96+ result = await GenericSystemSquashNormalizer ().normalize_async (messages )
97+
98+ assert len (result ) == 1
99+ assert result [0 ].api_role == "user"
100+ assert len (result [0 ].message_pieces ) == 2
101+ assert result [0 ].get_value () == "### Instructions ###\n \n System message\n \n ######\n \n User message"
102+ assert result [0 ].message_pieces [1 ].converted_value == "/tmp/example.png"
103+ assert result [0 ].message_pieces [1 ].converted_value_data_type == "image_path"
104+
105+
106+ async def test_generic_squash_uses_first_user_message_instead_of_rewriting_assistant ():
107+ """Test that squash targets the first user message even if assistant messages appear first."""
108+ messages = [
109+ _make_message ("system" , "System message" ),
110+ _make_message ("assistant" , "Assistant message" ),
111+ _make_message ("user" , "User message" ),
112+ ]
113+
114+ result = await GenericSystemSquashNormalizer ().normalize_async (messages )
115+
116+ assert len (result ) == 2
117+ assert result [0 ].api_role == "assistant"
118+ assert result [0 ].get_value () == "Assistant message"
119+ assert result [1 ].api_role == "user"
120+ assert result [1 ].get_value () == "### Instructions ###\n \n System message\n \n ######\n \n User message"
121+
122+
123+ async def test_generic_squash_no_user_message_converts_system_to_user ():
124+ """Test that system is converted to user when no user messages exist."""
125+ messages = [
126+ _make_message ("system" , "System message" ),
127+ _make_message ("assistant" , "Assistant message" ),
128+ ]
129+
130+ result = await GenericSystemSquashNormalizer ().normalize_async (messages )
131+
132+ assert len (result ) == 2
133+ assert result [0 ].api_role == "user"
134+ assert result [0 ].get_value () == "System message"
135+ assert result [1 ].api_role == "assistant"
136+ assert result [1 ].get_value () == "Assistant message"
137+
138+
139+ async def test_generic_squash_preserves_image_first_multipart_user_message ():
140+ """Test that squashing merges into the first text piece when an image piece comes first."""
141+ conversation_id = "conv-image-first"
142+ messages = [
143+ _make_message ("system" , "System message" ),
144+ Message (
145+ message_pieces = [
146+ MessagePiece (
147+ role = "user" ,
148+ original_value = "/tmp/example.png" ,
149+ original_value_data_type = "image_path" ,
150+ conversation_id = conversation_id ,
151+ sequence = 0 ,
152+ ),
153+ MessagePiece (
154+ role = "user" ,
155+ original_value = "Describe this image" ,
156+ conversation_id = conversation_id ,
157+ sequence = 0 ,
158+ ),
159+ ]
160+ ),
161+ ]
162+
163+ result = await GenericSystemSquashNormalizer ().normalize_async (messages )
164+
165+ assert len (result ) == 1
166+ assert result [0 ].api_role == "user"
167+ assert len (result [0 ].message_pieces ) == 2
168+ assert result [0 ].message_pieces [0 ].converted_value == "/tmp/example.png"
169+ assert result [0 ].message_pieces [0 ].converted_value_data_type == "image_path"
170+ assert result [0 ].message_pieces [1 ].converted_value_data_type == "text"
171+ assert (
172+ result [0 ].message_pieces [1 ].converted_value
173+ == "### Instructions ###\n \n System message\n \n ######\n \n Describe this image"
174+ )
175+
176+
177+ async def test_generic_squash_user_message_without_text_pieces_prepends_instructions ():
178+ """Test that an instruction-only text piece is prepended when no text piece exists to merge into."""
179+ conversation_id = "conv-no-text"
180+ messages = [
181+ _make_message ("system" , "System message" ),
182+ Message (
183+ message_pieces = [
184+ MessagePiece (
185+ role = "user" ,
186+ original_value = "/tmp/example.png" ,
187+ original_value_data_type = "image_path" ,
188+ conversation_id = conversation_id ,
189+ sequence = 0 ,
190+ ),
191+ ]
192+ ),
193+ ]
194+
195+ result = await GenericSystemSquashNormalizer ().normalize_async (messages )
196+
197+ assert len (result ) == 1
198+ assert result [0 ].api_role == "user"
199+ assert len (result [0 ].message_pieces ) == 2
200+ assert result [0 ].message_pieces [0 ].converted_value_data_type == "text"
201+ assert result [0 ].message_pieces [0 ].converted_value == "### Instructions ###\n \n System message\n \n ######"
202+ assert result [0 ].message_pieces [1 ].converted_value == "/tmp/example.png"
203+ assert result [0 ].message_pieces [1 ].converted_value_data_type == "image_path"
204+
205+
72206async def test_generic_squash_propagates_user_piece_metadata ():
73207 """
74208 Regression: when squashing system + user, the squashed piece must carry the
0 commit comments