/ tests / run_agent / test_run_agent_multimodal_prologue.py
test_run_agent_multimodal_prologue.py
  1  """Regression tests for run_conversation's prologue handling of multimodal content.
  2  
  3  PR #5621 and earlier multimodal PRs hit an ``AttributeError`` in
  4  ``run_agent.run_conversation`` because the prologue unconditionally called
  5  ``user_message[:80] + "..."`` / ``.replace()`` / ``_safe_print(f"...{user_message[:60]}")``
  6  on what was now a list.  These tests cover the two fixes:
  7  
  8    1. ``_summarize_user_message_for_log`` accepts strings, lists, and ``None``.
  9    2. ``_chat_content_to_responses_parts`` converts chat-style content to the
 10       Responses API ``input_text`` / ``input_image`` shape.
 11  
 12  They do NOT boot the full AIAgent — the prologue-fix guarantees are pure
 13  function contracts at module scope.
 14  """
 15  
 16  from run_agent import _summarize_user_message_for_log
 17  from agent.codex_responses_adapter import _chat_content_to_responses_parts
 18  
 19  
 20  class TestSummarizeUserMessageForLog:
 21      def test_plain_string_passthrough(self):
 22          assert _summarize_user_message_for_log("hello world") == "hello world"
 23  
 24      def test_none_returns_empty_string(self):
 25          assert _summarize_user_message_for_log(None) == ""
 26  
 27      def test_text_only_list(self):
 28          content = [{"type": "text", "text": "hi"}, {"type": "text", "text": "there"}]
 29          assert _summarize_user_message_for_log(content) == "hi there"
 30  
 31      def test_list_with_image_only(self):
 32          content = [{"type": "image_url", "image_url": {"url": "https://x"}}]
 33          # Image-only: "[1 image]" marker, no trailing space.
 34          assert _summarize_user_message_for_log(content) == "[1 image]"
 35  
 36      def test_list_with_text_and_image(self):
 37          content = [
 38              {"type": "text", "text": "describe this"},
 39              {"type": "image_url", "image_url": {"url": "https://x"}},
 40          ]
 41          summary = _summarize_user_message_for_log(content)
 42          assert "[1 image]" in summary
 43          assert "describe this" in summary
 44  
 45      def test_list_with_multiple_images(self):
 46          content = [
 47              {"type": "text", "text": "compare these"},
 48              {"type": "image_url", "image_url": {"url": "a"}},
 49              {"type": "image_url", "image_url": {"url": "b"}},
 50          ]
 51          summary = _summarize_user_message_for_log(content)
 52          assert "[2 images]" in summary
 53  
 54      def test_scalar_fallback(self):
 55          assert _summarize_user_message_for_log(42) == "42"
 56  
 57      def test_list_supports_slice_and_replace(self):
 58          """The whole point of this helper: its output must be a plain str."""
 59          content = [{"type": "text", "text": "x" * 200}, {"type": "image_url", "image_url": {"url": "y"}}]
 60          summary = _summarize_user_message_for_log(content)
 61          # These are the operations the run_conversation prologue performs.
 62          _ = summary[:80] + "..."
 63          _ = summary.replace("\n", " ")
 64  
 65  
 66  class TestChatContentToResponsesParts:
 67      def test_non_list_returns_empty(self):
 68          assert _chat_content_to_responses_parts("hi") == []
 69          assert _chat_content_to_responses_parts(None) == []
 70  
 71      def test_text_parts_become_input_text(self):
 72          content = [{"type": "text", "text": "hello"}]
 73          assert _chat_content_to_responses_parts(content) == [{"type": "input_text", "text": "hello"}]
 74  
 75      def test_image_url_object_becomes_input_image(self):
 76          content = [{"type": "image_url", "image_url": {"url": "https://x", "detail": "high"}}]
 77          assert _chat_content_to_responses_parts(content) == [
 78              {"type": "input_image", "image_url": "https://x", "detail": "high"},
 79          ]
 80  
 81      def test_bare_string_image_url(self):
 82          content = [{"type": "image_url", "image_url": "https://x"}]
 83          assert _chat_content_to_responses_parts(content) == [{"type": "input_image", "image_url": "https://x"}]
 84  
 85      def test_responses_format_passthrough(self):
 86          """Input already in Responses format should round-trip cleanly."""
 87          content = [
 88              {"type": "input_text", "text": "hi"},
 89              {"type": "input_image", "image_url": "https://x"},
 90          ]
 91          assert _chat_content_to_responses_parts(content) == [
 92              {"type": "input_text", "text": "hi"},
 93              {"type": "input_image", "image_url": "https://x"},
 94          ]
 95  
 96      def test_unknown_parts_skipped(self):
 97          """Unknown types shouldn't crash — filtered silently at this level
 98          (the API server's normalizer rejects them earlier)."""
 99          content = [{"type": "text", "text": "ok"}, {"type": "audio", "x": "y"}]
100          assert _chat_content_to_responses_parts(content) == [{"type": "input_text", "text": "ok"}]
101  
102      def test_empty_url_image_skipped(self):
103          content = [{"type": "image_url", "image_url": {"url": ""}}]
104          assert _chat_content_to_responses_parts(content) == []