Fix message list mutation and multimodal content loss
Cluster 44242 · agent. Data as of
1 open item. Fix merged in #56300 on 1 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 14 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #56300. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #47907, #56300. That is separate from the fix event. - Still open
- 1 issue and 0 PRs.
Next step: read #56300, then check whether your report is the one still open.
Read #56300Pick up a Lab taskMost members address bugs where early persistence operations mutate shared or caller-owned message lists, silently dropping observed context or multimodal content before API calls. Several fixes involve copying message lists and skipping or correcting persist overrides when content is multimodal.
Open issues1
- Issue#56303persist override still mutates live message list on tool-loop flush path (sibling of #48677)
Open PRs0
Fixes on record
-
#47907 fix(agent): preserve multimodal user content through crash-resilience persist
files:
run_agent.py,scripts/release.py,tests/run_agent/test_run_agent.pyShow the diff
-
run_agent.py@@ -1483,7 +1483,15 @@ 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": - if override is not None: + # Text-only call paths may pass a synthetic API-facing prompt + # and a cleaner transcript string separately. Multimodal + # turns, however, keep image/audio blocks in the live + # messages list that is still used for the API request after + # early crash-resilience persistence. Do not replace those + # blocks with the text-only persistence override before the + # model call is built. The paired timestamp override still + # applies — it is metadata, not content. + if override is not None and not isinstance(msg.get("content"), list): msg["content"] = override if timestamp is not None: msg["timestamp"] = timestamp -
scripts/release.py@@ -243,6 +243,7 @@ "[email protected]": "dusterbloom", "[email protected]": "basilalshukaili", "[email protected]": "liuhao1024", + "[email protected]": "Rivuza", "[email protected]": "annguyenNous", "[email protected]": "annguyenNous", "[email protected]": "kylekahraman", -
tests/run_agent/test_run_agent.py@@ -75,6 +75,33 @@ def agent(): return a +def test_persist_user_message_override_rewrites_text_turns(agent): + messages = [{"role": "user", "content": "API-only synthetic prefix\nhello"}] + agent._persist_user_message_idx = 0 + agent._persist_user_message_override = "hello" + + agent._apply_persist_user_message_override(messages) + + assert messages == [{"role": "user", "content": "hello"}] + + +def test_persist_user_message_override_preserves_multimodal_turns(agent): + multimodal_content = [ + {"type": "text", "text": "What color is this?"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,AAAA"}, + }, + ] + messages = [{"role": "user", "content": multimodal_content}] + agent._persist_user_message_idx = 0 + agent._persist_user_message_override = "What color is this? [Image attachment]" + + agent._apply_persist_user_message_override(messages) + + assert messages == [{"role": "user", "content": multimodal_content}] + + @pytest.fixture() def agent_with_memory_tool(): """Agent whose valid_tool_names includes 'memory'."""
-
-
#56300 fix(agent): persist on a copy so early persist can't strip live user message (salvage #48817)
files:
run_agent.py,tests/run_agent/test_run_agent.pyShow the diff
-
run_agent.py@@ -1649,9 +1649,17 @@ def _persist_session(self, messages: List[Dict], conversation_history: List[Dict """Save session state to both JSON log and SQLite on any exit path. Ensures conversations are never lost, even on errors or early returns. + + Trailing empty-response scaffolding is dropped from the live list in + place (it is ephemeral junk the real transcript should shed). The + persist user-message *override* is NOT applied here — it is resolved + inside ``_flush_messages_to_session_db`` and written only to the DB row, + never mutating the live message list used by the API call (#48677 is + thus closed for every persist caller, not just this one). """ + # Scaffolding removal mutates the live list (desired — ephemeral + # retry/failure sentinels must not survive into the real transcript). self._drop_trailing_empty_response_scaffolding(messages) - self._apply_persist_user_message_override(messages) self._session_messages = messages self._save_session_log(messages) self._flush_messages_to_session_db(messages, conversation_history) @@ -1742,7 +1750,18 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo return if not self._session_db: return - self._apply_persist_user_message_override(messages) + # Persist user-message override (#48677 chokepoint): historically this + # mutated the live `messages` list in place, which — on the early + # crash-resilience persist that runs BEFORE the API call is built — + # stripped observed group-chat context off the live user message and + # silently dropped it. Instead, resolve the override here and apply it + # ONLY to the value written to the DB (see the write loop below); the + # live dict is never mutated, so every caller (early persist, mid-loop + # flush, /resume, /branch) is protected uniformly. Timestamp override is + # metadata and is likewise applied only to the written row. + _ov_idx = getattr(self, "_persist_user_message_idx", None) + _ov_content = getattr(self, "_persist_user_message_override", None) + _ov_timestamp = getattr(self, "_persist_user_message_timestamp", None) try: # Retry row creation if the earlier attempt failed transiently. if not self._session_db_created: @@ -1784,7 +1803,7 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo if isinstance(item, dict) } - for msg in messages: + for _msg_idx, msg in enumerate(messages): if not isinstance(msg, dict): continue # Never write ephemeral recovery scaffolding to the session @@ -1808,6 +1827,16 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo continue role = msg.get("role", "unknown") content = msg.get("content") + _row_timestamp = msg.get("timestamp") + # Apply the persist override to THIS row's written values only + # (never to the live dict). Match the original guard: text-only + # content is replaced; multimodal (list) content is left intact + # so image/audio blocks aren't clobbered by the text override. + if _ov_idx == _msg_idx and msg.get("role") == "user": + if _ov_content is not None and not isinstance(content, list): + content = _ov_content + if _ov_timestamp is not None: + _row_timestamp = _ov_timestamp # Persist multimodal tool results as their text summary only — # base64 images would bloat the session DB and aren't useful # for cross-session replay. @@ -1843,7 +1872,7 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo reasoning_details=msg.get("reasoning_details") if role == "assistant" else None, codex_reasoning_items=msg.get("codex_reasoning_items") if role == "assistant" else None, codex_message_items=msg.get("codex_message_items") if role == "assistant" else None, - timestamp=msg.get("timestamp"), + timestamp=_row_timestamp, ) msg[_DB_PERSISTED_MARKER] = True # The intrinsic markers are now the sole source of truth. Reset the -
tests/run_agent/test_run_agent.py@@ -7151,7 +7151,16 @@ def test_persist_session_rewrites_current_turn_user_message(self, agent): agent._persist_session(messages, []) - assert messages[0]["content"] == "Hello there" + # The original messages list must NOT be mutated — the persist + # override is applied only to the DB row (resolved inside the flush + # chokepoint), so the live list keeps the original content for the + # API call (#48677). + assert ( + messages[0]["content"] + == "[Voice input — respond concisely and conversationally, " + "2-3 sentences max. No code blocks or markdown.] Hello there" + ) + # But the DB write must get the override. first_db_write = agent._session_db.append_message.call_args_list[0].kwargs assert first_db_write["content"] == "Hello there"
-
9 PRs closed without merging
From the close comments: done elsewhere 2 · duplicate 2 · superseded by another PR 2 · merged via another change 1 · other 2
-
#44247 fix(agent): skip persist_user_message override when content is a multimodal list · superseded by another PR →
#47907,#44249,#44242 -
#44248 fix(agent): don't clobber multimodal user content with persist_user_message override · duplicate →
#44247 -
#44249 fix: preserve ACP multimodal content during persistence · merged via another change →
3d21666,#47907,#44242 -
#44371 fix(agent): preserve multimodal content blocks in persist override (#44242) · superseded by another PR →
#44247,#44242 -
#48694 fix(gateway): stop early persist from dropping observed context before the API call · done elsewhere →
7eb9716,#48677
Show 4 more
-
#48817 fix(agent): persist_session mutates copy, not live messages (#48677) · duplicate →
#48694,#48677 -
#56318 fix(agent): copy messages in _flush_messages_to_session_db to prevent persist-override mutation · done elsewhere →
7eb9716 - #56326 fix(agent): avoid mutating live persist overrides · other
-
#56475 fix(agent): make persist user-message override non-mutating · other →
#56303,#56318,#56326
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.13: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab