main
py 905 lines 29.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from unittest.mock import patch
5
6 import pytest
7 from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
8 from openai.types.responses.response_function_tool_call_output_item import (
9 ResponseFunctionToolCallOutputItem,
10 )
11 from openai.types.responses.response_output_message import ResponseOutputMessage
12 from openai.types.responses.response_output_text import ResponseOutputText
13 from openai.types.responses.response_reasoning_item import (
14 Content,
15 ResponseReasoningItem,
16 Summary,
17 )
18
19 from vllm.entrypoints.openai.responses.utils import (
20 _construct_message_from_response_item,
21 construct_chat_messages_with_tool_call,
22 construct_input_messages,
23 should_continue_final_message,
24 )
25 from vllm.exceptions import VLLMValidationError
26
27
28 def _single_chat_message(item):
29 message = _construct_message_from_response_item(item)
30 assert message is not None
31 return message
32
33
34 def make_output_message(
35 text: str,
36 *,
37 id: str = "msg_1",
38 status: str = "completed",
39 ) -> ResponseOutputMessage:
40 return ResponseOutputMessage(
41 id=id,
42 content=[
43 ResponseOutputText(
44 annotations=[],
45 text=text,
46 type="output_text",
47 logprobs=None,
48 )
49 ],
50 role="assistant",
51 status=status,
52 type="message",
53 )
54
55
56 def make_reasoning_item(
57 *,
58 content_text: str | None = None,
59 summary_text: str | None = None,
60 content: list[Content] | None = None,
61 summary: list[Summary] | None = None,
62 encrypted_content: str | None = None,
63 id: str = "reasoning_1",
64 status: str | None = None,
65 ) -> ResponseReasoningItem:
66 if content is None and content_text is not None:
67 content = [Content(text=content_text, type="reasoning_text")]
68 if summary is None and summary_text is not None:
69 summary = [Summary(text=summary_text, type="summary_text")]
70
71 return ResponseReasoningItem(
72 id=id,
73 summary=[] if summary is None else summary,
74 type="reasoning",
75 content=content,
76 encrypted_content=encrypted_content,
77 status=status,
78 )
79
80
81 def make_function_call(
82 *,
83 call_id: str,
84 name: str = "test_function",
85 arguments: str = "{}",
86 id: str = "tool_id",
87 status: str | None = None,
88 ) -> ResponseFunctionToolCall:
89 kwargs = {
90 "type": "function_call",
91 "id": id,
92 "call_id": call_id,
93 "name": name,
94 "arguments": arguments,
95 }
96 if status is not None:
97 kwargs["status"] = status
98
99 return ResponseFunctionToolCall(**kwargs)
100
101
102 def make_function_call_output(
103 *,
104 call_id: str,
105 output: str = "42",
106 id: str = "output_1",
107 status: str = "completed",
108 ) -> ResponseFunctionToolCallOutputItem:
109 return ResponseFunctionToolCallOutputItem(
110 id=id,
111 type="function_call_output",
112 call_id=call_id,
113 output=output,
114 status=status,
115 )
116
117
118 class TestResponsesUtils:
119 """Tests for Responses API utils."""
120
121 def test_construct_chat_messages_with_tool_call(self):
122 """Test construction of chat messages with tool calls."""
123 reasoning_item = ResponseReasoningItem(
124 id="lol",
125 summary=[],
126 type="reasoning",
127 content=[
128 Content(
129 text="Leroy Jenkins",
130 type="reasoning_text",
131 )
132 ],
133 encrypted_content=None,
134 status=None,
135 )
136 mcp_tool_item = ResponseFunctionToolCall(
137 id="mcp_123",
138 call_id="call_123",
139 type="function_call",
140 status="completed",
141 name="python",
142 arguments='{"code": "123+456"}',
143 )
144 input_items = [reasoning_item, mcp_tool_item]
145 messages = construct_chat_messages_with_tool_call(input_items)
146
147 assert len(messages) == 1
148 message = messages[0]
149 assert message["role"] == "assistant"
150 assert message["reasoning"] == "Leroy Jenkins"
151 assert message["tool_calls"][0]["id"] == "call_123"
152 assert message["tool_calls"][0]["function"]["name"] == "python"
153 assert (
154 message["tool_calls"][0]["function"]["arguments"] == '{"code": "123+456"}'
155 )
156
157 def test_construct_chat_messages_preserves_single_item_conversions(self):
158 item = ResponseReasoningItem(
159 id="lol",
160 summary=[],
161 type="reasoning",
162 content=[
163 Content(
164 text="Leroy Jenkins",
165 type="reasoning_text",
166 )
167 ],
168 encrypted_content=None,
169 status=None,
170 )
171 formatted_item = _single_chat_message(item)
172 assert formatted_item["role"] == "assistant"
173 assert formatted_item["reasoning"] == "Leroy Jenkins"
174
175 item = ResponseReasoningItem(
176 id="lol",
177 summary=[
178 Summary(
179 text='Hmm, the user has just started with a simple "Hello,"',
180 type="summary_text",
181 )
182 ],
183 type="reasoning",
184 content=None,
185 encrypted_content=None,
186 status=None,
187 )
188
189 formatted_item = _single_chat_message(item)
190 assert formatted_item["role"] == "assistant"
191 assert (
192 formatted_item["reasoning"]
193 == 'Hmm, the user has just started with a simple "Hello,"'
194 )
195
196 tool_call_output = ResponseFunctionToolCallOutputItem(
197 id="temp_id",
198 type="function_call_output",
199 call_id="temp",
200 output="1234",
201 status="completed",
202 )
203 formatted_item = _single_chat_message(tool_call_output)
204 assert formatted_item["role"] == "tool"
205 assert formatted_item["content"] == "1234"
206 assert formatted_item["tool_call_id"] == "temp"
207
208 formatted_item = _single_chat_message(
209 {
210 "type": "function_call_output",
211 "call_id": "temp_dict",
212 "output": "5678",
213 }
214 )
215 assert formatted_item["role"] == "tool"
216 assert formatted_item["content"] == "5678"
217 assert formatted_item["tool_call_id"] == "temp_dict"
218
219 item = ResponseReasoningItem(
220 id="lol",
221 summary=[],
222 type="reasoning",
223 content=None,
224 encrypted_content="TOP_SECRET_MESSAGE",
225 status=None,
226 )
227 with pytest.raises(VLLMValidationError) as exc_info:
228 construct_chat_messages_with_tool_call([item])
229 assert exc_info.value.parameter == "input"
230
231 output_item = ResponseOutputMessage(
232 id="msg_bf585bbbe3d500e0",
233 content=[
234 ResponseOutputText(
235 annotations=[],
236 text="dongyi",
237 type="output_text",
238 logprobs=None,
239 )
240 ],
241 role="assistant",
242 status="completed",
243 type="message",
244 )
245
246 formatted_item = _single_chat_message(output_item)
247 assert formatted_item["role"] == "assistant"
248 assert formatted_item["content"] == "dongyi"
249
250
251 class TestReasoningItemContentPriority:
252 """Tests that content is prioritized over summary for reasoning items."""
253
254 def test_content_preferred_over_summary(self):
255 """When both content and summary are present, content should win."""
256 item = ResponseReasoningItem(
257 id="reasoning_1",
258 summary=[
259 Summary(
260 text="This is a summary",
261 type="summary_text",
262 )
263 ],
264 type="reasoning",
265 content=[
266 Content(
267 text="This is the actual content",
268 type="reasoning_text",
269 )
270 ],
271 encrypted_content=None,
272 status=None,
273 )
274 formatted = _single_chat_message(item)
275 assert formatted["reasoning"] == "This is the actual content"
276
277 def test_content_only(self):
278 """When only content is present (no summary), content is used."""
279 item = ResponseReasoningItem(
280 id="reasoning_2",
281 summary=[],
282 type="reasoning",
283 content=[
284 Content(
285 text="Content without summary",
286 type="reasoning_text",
287 )
288 ],
289 encrypted_content=None,
290 status=None,
291 )
292 formatted = _single_chat_message(item)
293 assert formatted["reasoning"] == "Content without summary"
294
295 @patch("vllm.entrypoints.openai.responses.utils.logger")
296 def test_summary_fallback_when_no_content(self, mock_logger):
297 """When content is absent, summary is used as fallback with warning."""
298 item = ResponseReasoningItem(
299 id="reasoning_3",
300 summary=[
301 Summary(
302 text="Fallback summary text",
303 type="summary_text",
304 )
305 ],
306 type="reasoning",
307 content=None,
308 encrypted_content=None,
309 status=None,
310 )
311 formatted = _single_chat_message(item)
312 assert formatted["reasoning"] == "Fallback summary text"
313 mock_logger.warning.assert_called_once()
314 assert (
315 "summary text as reasoning content" in mock_logger.warning.call_args[0][0]
316 )
317
318 @patch("vllm.entrypoints.openai.responses.utils.logger")
319 def test_summary_fallback_when_content_empty(self, mock_logger):
320 """When content is an empty list, summary is used as fallback."""
321 item = ResponseReasoningItem(
322 id="reasoning_4",
323 summary=[
324 Summary(
325 text="Summary when content empty",
326 type="summary_text",
327 )
328 ],
329 type="reasoning",
330 content=[],
331 encrypted_content=None,
332 status=None,
333 )
334 formatted = _single_chat_message(item)
335 assert formatted["reasoning"] == "Summary when content empty"
336 mock_logger.warning.assert_called_once()
337 assert (
338 "summary text as reasoning content" in mock_logger.warning.call_args[0][0]
339 )
340
341 def test_neither_content_nor_summary(self):
342 """When neither content nor summary is present, reasoning is empty."""
343 item = ResponseReasoningItem(
344 id="reasoning_5",
345 summary=[],
346 type="reasoning",
347 content=None,
348 encrypted_content=None,
349 status=None,
350 )
351 formatted = _single_chat_message(item)
352 assert formatted["reasoning"] == ""
353
354 def test_encrypted_content_raises(self):
355 """Encrypted content should raise VLLMValidationError."""
356 item = ResponseReasoningItem(
357 id="reasoning_6",
358 summary=[
359 Summary(
360 text="Some summary",
361 type="summary_text",
362 )
363 ],
364 type="reasoning",
365 content=[
366 Content(
367 text="Some content",
368 type="reasoning_text",
369 )
370 ],
371 encrypted_content="ENCRYPTED",
372 status=None,
373 )
374 with pytest.raises(VLLMValidationError) as exc_info:
375 construct_chat_messages_with_tool_call([item])
376 assert exc_info.value.parameter == "input"
377
378 @patch("vllm.entrypoints.openai.responses.utils.logger")
379 def test_summary_with_multiple_entries_uses_first(self, mock_logger):
380 """When multiple summary entries exist, the first one is used."""
381 item = ResponseReasoningItem(
382 id="reasoning_7",
383 summary=[
384 Summary(
385 text="First summary",
386 type="summary_text",
387 ),
388 Summary(
389 text="Second summary",
390 type="summary_text",
391 ),
392 ],
393 type="reasoning",
394 content=None,
395 encrypted_content=None,
396 status=None,
397 )
398 formatted = _single_chat_message(item)
399 assert formatted["reasoning"] == "First summary"
400 mock_logger.warning.assert_called_once()
401 assert (
402 "summary text as reasoning content" in mock_logger.warning.call_args[0][0]
403 )
404
405 @patch("vllm.entrypoints.openai.responses.utils.logger")
406 def test_no_warning_when_content_used(self, mock_logger):
407 """No warning should be emitted when content is available."""
408 item = ResponseReasoningItem(
409 id="reasoning_8",
410 summary=[
411 Summary(
412 text="Summary text",
413 type="summary_text",
414 )
415 ],
416 type="reasoning",
417 content=[
418 Content(
419 text="Content text",
420 type="reasoning_text",
421 )
422 ],
423 encrypted_content=None,
424 status=None,
425 )
426 construct_chat_messages_with_tool_call([item])
427 mock_logger.warning.assert_not_called()
428
429
430 class TestShouldContinueFinalMessage:
431 """Tests for should_continue_final_message function.
432
433 This function enables Anthropic-style partial message completion, where
434 users can provide an incomplete assistant message and have the model
435 continue from where it left off.
436 """
437
438 def test_string_input_returns_false(self):
439 """String input is always a user message, so should not continue."""
440 assert should_continue_final_message("Hello, world!") is False
441
442 def test_empty_list_returns_false(self):
443 """Empty list should not continue."""
444 assert should_continue_final_message([]) is False
445
446 def test_completed_message_returns_false(self):
447 """Completed message should not be continued."""
448 output_item = ResponseOutputMessage(
449 id="msg_123",
450 content=[
451 ResponseOutputText(
452 annotations=[],
453 text="The answer is 42.",
454 type="output_text",
455 logprobs=None,
456 )
457 ],
458 role="assistant",
459 status="completed",
460 type="message",
461 )
462 assert should_continue_final_message([output_item]) is False
463
464 def test_in_progress_message_returns_true(self):
465 """In-progress message should be continued.
466
467 This is the key use case for partial message completion.
468 Example: The user provides "The best answer is (" and wants
469 the model to continue from there.
470 """
471 output_item = ResponseOutputMessage(
472 id="msg_123",
473 content=[
474 ResponseOutputText(
475 annotations=[],
476 text="The best answer is (",
477 type="output_text",
478 logprobs=None,
479 )
480 ],
481 role="assistant",
482 status="in_progress",
483 type="message",
484 )
485 assert should_continue_final_message([output_item]) is True
486
487 def test_incomplete_message_returns_true(self):
488 """Incomplete message should be continued."""
489 output_item = ResponseOutputMessage(
490 id="msg_123",
491 content=[
492 ResponseOutputText(
493 annotations=[],
494 text="The answer",
495 type="output_text",
496 logprobs=None,
497 )
498 ],
499 role="assistant",
500 status="incomplete",
501 type="message",
502 )
503 assert should_continue_final_message([output_item]) is True
504
505 def test_in_progress_reasoning_returns_true(self):
506 """In-progress reasoning should be continued."""
507 reasoning_item = ResponseReasoningItem(
508 id="reasoning_123",
509 summary=[],
510 type="reasoning",
511 content=[
512 Content(
513 text="Let me think about this...",
514 type="reasoning_text",
515 )
516 ],
517 encrypted_content=None,
518 status="in_progress",
519 )
520 assert should_continue_final_message([reasoning_item]) is True
521
522 def test_incomplete_reasoning_returns_true(self):
523 """Incomplete reasoning should be continued."""
524 reasoning_item = ResponseReasoningItem(
525 id="reasoning_123",
526 summary=[],
527 type="reasoning",
528 content=[
529 Content(
530 text="Let me think",
531 type="reasoning_text",
532 )
533 ],
534 encrypted_content=None,
535 status="incomplete",
536 )
537 assert should_continue_final_message([reasoning_item]) is True
538
539 reasoning_item = {
540 "id": "reasoning_123",
541 "summary": [],
542 "type": "reasoning",
543 "content": [],
544 "status": "incomplete",
545 }
546 assert should_continue_final_message([reasoning_item]) is True
547
548 def test_completed_reasoning_returns_false(self):
549 """Completed reasoning should not be continued."""
550 reasoning_item = ResponseReasoningItem(
551 id="reasoning_123",
552 summary=[],
553 type="reasoning",
554 content=[
555 Content(
556 text="I have thought about this.",
557 type="reasoning_text",
558 )
559 ],
560 encrypted_content=None,
561 status="completed",
562 )
563 assert should_continue_final_message([reasoning_item]) is False
564
565 def test_reasoning_with_none_status_returns_false(self):
566 """Reasoning with None status should not be continued."""
567 reasoning_item = ResponseReasoningItem(
568 id="reasoning_123",
569 summary=[],
570 type="reasoning",
571 content=[
572 Content(
573 text="Some reasoning",
574 type="reasoning_text",
575 )
576 ],
577 encrypted_content=None,
578 status=None,
579 )
580 assert should_continue_final_message([reasoning_item]) is False
581
582 def test_only_last_item_matters(self):
583 """Only the last item in the list determines continuation."""
584 completed_item = ResponseOutputMessage(
585 id="msg_1",
586 content=[
587 ResponseOutputText(
588 annotations=[],
589 text="Complete message.",
590 type="output_text",
591 logprobs=None,
592 )
593 ],
594 role="assistant",
595 status="completed",
596 type="message",
597 )
598 in_progress_item = ResponseOutputMessage(
599 id="msg_2",
600 content=[
601 ResponseOutputText(
602 annotations=[],
603 text="Partial message...",
604 type="output_text",
605 logprobs=None,
606 )
607 ],
608 role="assistant",
609 status="in_progress",
610 type="message",
611 )
612
613 # In-progress as last item -> should continue
614 assert should_continue_final_message([completed_item, in_progress_item]) is True
615
616 # Completed as last item -> should not continue
617 assert (
618 should_continue_final_message([in_progress_item, completed_item]) is False
619 )
620
621 def test_tool_call_returns_false(self):
622 """Tool calls should not trigger continuation."""
623 tool_call = ResponseFunctionToolCall(
624 id="fc_123",
625 call_id="call_123",
626 type="function_call",
627 status="in_progress",
628 name="get_weather",
629 arguments='{"location": "NYC"}',
630 )
631 assert should_continue_final_message([tool_call]) is False
632
633 tool_call = {
634 "id": "msg_123",
635 "call_id": "call_123",
636 "type": "function_call",
637 "status": "in_progress",
638 "name": "get_weather",
639 "arguments": '{"location": "NYC"}',
640 }
641 assert should_continue_final_message([tool_call]) is False
642
643 # Tests for dict inputs (e.g., from curl requests)
644 def test_dict_in_progress_message_returns_true(self):
645 """Dict with in_progress status should be continued (curl input)."""
646 dict_item = {
647 "id": "msg_123",
648 "type": "message",
649 "role": "assistant",
650 "status": "in_progress",
651 "content": [{"type": "output_text", "text": "The answer is ("}],
652 }
653 assert should_continue_final_message([dict_item]) is True
654
655 def test_dict_incomplete_message_returns_true(self):
656 """Dict with incomplete status should be continued (curl input)."""
657 dict_item = {
658 "id": "msg_123",
659 "type": "message",
660 "role": "assistant",
661 "status": "incomplete",
662 "content": [{"type": "output_text", "text": "Partial answer"}],
663 }
664 assert should_continue_final_message([dict_item]) is True
665
666 def test_dict_completed_message_returns_false(self):
667 """Dict with completed status should not be continued (curl input)."""
668 dict_item = {
669 "id": "msg_123",
670 "type": "message",
671 "role": "assistant",
672 "status": "completed",
673 "content": [{"type": "output_text", "text": "Complete answer."}],
674 }
675 assert should_continue_final_message([dict_item]) is False
676
677 def test_dict_reasoning_in_progress_returns_true(self):
678 """Dict reasoning item with in_progress status should be continued."""
679 dict_item = {
680 "id": "reasoning_123",
681 "type": "reasoning",
682 "status": "in_progress",
683 "content": [{"type": "reasoning_text", "text": "Let me think..."}],
684 }
685 assert should_continue_final_message([dict_item]) is True
686
687 def test_dict_without_status_returns_false(self):
688 """Dict without status field should not be continued."""
689 dict_item = {
690 "id": "msg_123",
691 "type": "message",
692 "role": "assistant",
693 "content": [{"type": "output_text", "text": "Some text"}],
694 }
695 assert should_continue_final_message([dict_item]) is False
696
697 def test_dict_with_none_status_returns_false(self):
698 """Dict with None status should not be continued."""
699 dict_item = {
700 "id": "msg_123",
701 "type": "message",
702 "role": "assistant",
703 "status": None,
704 "content": [{"type": "output_text", "text": "Some text"}],
705 }
706 assert should_continue_final_message([dict_item]) is False
707
708
709 class TestConstructChatMessagesCombinePolicy:
710 """Tests for contiguous assistant-side merging."""
711
712 @pytest.mark.parametrize(
713 ("items", "expected_content", "expected_reasoning", "expected_tool_call_ids"),
714 [
715 pytest.param(
716 [
717 make_reasoning_item(content_text="Let me think"),
718 make_output_message("Hello"),
719 ],
720 "Hello",
721 "Let me think",
722 None,
723 id="reasoning-output-messages",
724 ),
725 pytest.param(
726 [
727 make_function_call(call_id="call_123"),
728 make_function_call(call_id="call_456"),
729 ],
730 None,
731 None,
732 ["call_123", "call_456"],
733 id="consecutive-tool-calls",
734 ),
735 pytest.param(
736 [
737 make_reasoning_item(content_text="Let me think"),
738 make_function_call(call_id="call_123"),
739 ],
740 None,
741 "Let me think",
742 ["call_123"],
743 id="reasoning-tool-call",
744 ),
745 pytest.param(
746 [
747 make_output_message("Hello"),
748 make_function_call(call_id="call_123"),
749 ],
750 "Hello",
751 None,
752 ["call_123"],
753 id="output-tool-call",
754 ),
755 pytest.param(
756 [
757 make_reasoning_item(content_text="Thinking"),
758 make_output_message("Hello"),
759 make_function_call(call_id="call_123"),
760 make_function_call(call_id="call_456"),
761 ],
762 "Hello",
763 "Thinking",
764 ["call_123", "call_456"],
765 id="reasoning-output-tool-call",
766 ),
767 pytest.param(
768 [
769 make_reasoning_item(content_text="Let me think"),
770 {"type": "message", "role": "assistant", "content": "Hello"},
771 ],
772 "Hello",
773 "Let me think",
774 None,
775 id="reasoning-easyinput-assistant",
776 ),
777 ],
778 )
779 def test_assistant_side_items_merge_until_tool_output(
780 self,
781 items,
782 expected_content,
783 expected_reasoning,
784 expected_tool_call_ids,
785 ):
786 messages = construct_chat_messages_with_tool_call(items)
787
788 assert len(messages) == 1
789 assert messages[0]["role"] == "assistant"
790 if expected_content is None:
791 assert "content" not in messages[0]
792 else:
793 assert messages[0]["content"] == expected_content
794 if expected_reasoning is None:
795 assert "reasoning" not in messages[0]
796 else:
797 assert messages[0]["reasoning"] == expected_reasoning
798 if expected_tool_call_ids is None:
799 assert "tool_calls" not in messages[0]
800 else:
801 assert [tool_call["id"] for tool_call in messages[0]["tool_calls"]] == (
802 expected_tool_call_ids
803 )
804
805 @pytest.mark.parametrize(
806 ("items", "num_expected_messages"),
807 [
808 pytest.param(
809 [
810 make_output_message("Hello"),
811 make_output_message("World"),
812 ],
813 2,
814 id="consecutive-output-messages",
815 ),
816 pytest.param(
817 [
818 make_reasoning_item(content_text="Let me think"),
819 make_reasoning_item(content_text="Let me think more"),
820 ],
821 2,
822 id="consecutive-reasoning-messages",
823 ),
824 pytest.param(
825 [
826 make_function_call(call_id="call_123"),
827 make_function_call_output(call_id="call_123", output="42"),
828 make_function_call(call_id="call_456"),
829 ],
830 3,
831 id="interrupted-by-non-assistant-item",
832 ),
833 ],
834 )
835 def test_merge_chain_breaks(self, items, num_expected_messages):
836 messages = construct_chat_messages_with_tool_call(items)
837 assert len(messages) == num_expected_messages
838
839
840 class TestConstructInputMessagesInstructionsLeak:
841 """Regression tests for #37697: instructions from a prior response
842 should NOT leak through previous_response_id."""
843
844 def test_old_instructions_stripped_from_prev_msg(self):
845 """System message in prev_msg must be dropped so the new request's
846 instructions are the only system message in the conversation."""
847 prev = [
848 {"role": "system", "content": "old instructions"},
849 {"role": "user", "content": "What is 2+2?"},
850 {"role": "assistant", "content": "4"},
851 ]
852 msgs = construct_input_messages(
853 request_instructions="new instructions",
854 request_input="What is 3+3?",
855 prev_msg=prev,
856 )
857 system_msgs = [m for m in msgs if m.get("role") == "system"]
858 assert len(system_msgs) == 1
859 assert system_msgs[0]["content"] == "new instructions"
860
861 def test_no_instructions_in_new_request(self):
862 """If the new request has no instructions, old ones should still
863 be stripped -- they must not carry over."""
864 prev = [
865 {"role": "system", "content": "old instructions"},
866 {"role": "user", "content": "Hi"},
867 {"role": "assistant", "content": "Hello"},
868 ]
869 msgs = construct_input_messages(
870 request_instructions=None,
871 request_input="What is 3+3?",
872 prev_msg=prev,
873 )
874 system_msgs = [m for m in msgs if m.get("role") == "system"]
875 assert len(system_msgs) == 0
876
877 def test_non_system_messages_preserved(self):
878 """User/assistant messages from prev_msg must remain intact."""
879 prev = [
880 {"role": "system", "content": "old instructions"},
881 {"role": "user", "content": "Hi"},
882 {"role": "assistant", "content": "Hello"},
883 ]
884 msgs = construct_input_messages(
885 request_instructions="new instructions",
886 request_input="Follow up",
887 prev_msg=prev,
888 )
889 roles = [m["role"] for m in msgs]
890 assert roles == ["system", "user", "assistant", "user"]
891 assert msgs[0]["content"] == "new instructions"
892 assert msgs[1]["content"] == "Hi"
893 assert msgs[2]["content"] == "Hello"
894 assert msgs[3]["content"] == "Follow up"
895
896 def test_no_prev_msg(self):
897 """Baseline: when there's no prev_msg, instructions work normally."""
898 msgs = construct_input_messages(
899 request_instructions="be helpful",
900 request_input="hello",
901 prev_msg=None,
902 )
903 assert len(msgs) == 2
904 assert msgs[0] == {"role": "system", "content": "be helpful"}
905 assert msgs[1] == {"role": "user", "content": "hello"}