diff --git a/run_agent.py b/run_agent.py index e81bf3b93e7ee..26adb4a083acb 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1466,6 +1466,12 @@ def _apply_persist_user_message_override(self, messages: List[Dict]) -> None: history. When an override is configured for the active turn, mutate the in-memory messages list in place so both persistence and returned history stay clean. + + When the original content is a list (multimodal content blocks, e.g. + images) and the override is a string, merge the override text with the + existing non-text content blocks instead of clobbering them. This + prevents image content blocks from being silently dropped. Fix for + #44242. """ idx = getattr(self, "_persist_user_message_idx", None) override = getattr(self, "_persist_user_message_override", None) @@ -1474,7 +1480,24 @@ def _apply_persist_user_message_override(self, messages: List[Dict]) -> None: if 0 <= idx < len(messages): msg = messages[idx] if isinstance(msg, dict) and msg.get("role") == "user": - msg["content"] = override + original_content = msg.get("content") + # When original content is a list (multimodal) and override + # is a plain string, merge rather than clobber so image/audio + # content blocks survive persistence. + if isinstance(original_content, list) and isinstance(override, str): + merged: list[dict] = [] + text_replaced = False + for block in original_content: + if isinstance(block, dict) and block.get("type") == "text": + merged.append({"type": "text", "text": override}) + text_replaced = True + else: + merged.append(block) + if not text_replaced: + merged.insert(0, {"type": "text", "text": override}) + msg["content"] = merged + else: + msg["content"] = override def _persist_session(self, messages: List[Dict], conversation_history: List[Dict] = None): """Save session state to both JSON log and SQLite on any exit path. diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 9d97693aa7f66..c2e028c1b22f4 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -6179,6 +6179,61 @@ def test_persist_session_rewrites_current_turn_user_message(self, agent): first_db_write = agent._session_db.append_message.call_args_list[0].kwargs assert first_db_write["content"] == "Hello there" + def test_persist_session_preserves_image_blocks(self, agent): + """Multimodal content blocks must survive the persist override (fixes #44242).""" + agent._session_db = MagicMock() + agent.session_id = "session-123" + agent._last_flushed_db_idx = 0 + agent._persist_user_message_idx = 0 + agent._persist_user_message_override = "Describe this image" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123"}}, + ], + }, + {"role": "assistant", "content": "I see a cat."}, + ] + + agent._persist_session(messages, []) + + # The override text should replace the text block, but image blocks + # must be preserved. + content = messages[0]["content"] + assert isinstance(content, list), "Content must remain a list" + text_blocks = [b for b in content if isinstance(b, dict) and b.get("type") == "text"] + image_blocks = [b for b in content if isinstance(b, dict) and b.get("type") == "image_url"] + assert len(text_blocks) == 1 + assert text_blocks[0]["text"] == "Describe this image" + assert len(image_blocks) == 1, "Image block must survive persist override" + + def test_persist_session_preserves_image_blocks_no_text(self, agent): + """Multimodal with no text block gets override prepended (fixes #44242).""" + agent._session_db = MagicMock() + agent.session_id = "session-123" + agent._last_flushed_db_idx = 0 + agent._persist_user_message_idx = 0 + agent._persist_user_message_override = "Analyze this" + messages = [ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123"}}, + ], + }, + {"role": "assistant", "content": "I see a cat."}, + ] + + agent._persist_session(messages, []) + + content = messages[0]["content"] + assert isinstance(content, list), "Content must remain a list" + assert content[0]["type"] == "text" + assert content[0]["text"] == "Analyze this" + assert content[1]["type"] == "image_url" + class TestReasoningReplayForStrictProviders: """Assistant replay must preserve provider-native reasoning fields."""