Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions tests/run_agent/test_run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading