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) == []