From 8341d775a97f7dfda65827617564d703e27719cb Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:37:53 +0530 Subject: [PATCH] fix(session): restore clean API-local turn content --- agent/turn_finalizer.py | 9 +++ run_agent.py | 16 ++-- ...rn_finalizer_final_response_persistence.py | 81 ++++++++++++++++++- tests/run_agent/test_run_agent.py | 18 +++++ 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 1adc3c6c3632..fdf5babe1aea 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -228,6 +228,15 @@ def finalize_turn( if _tail_role != "assistant": messages.append({"role": "assistant", "content": final_response}) + # The model has completed its request, so replace API-local + # voice/model/skill guidance with the clean user input before writing the + # final durable snapshot and returning the continuation history. Earlier + # turn-start flushes use the DB-only override because their messages are + # still needed for the API request; this finalizer runs after that request + # is complete (#48677 / #63766). + _apply_override = getattr(agent, "_apply_persist_user_message_override", None) + if callable(_apply_override): + _apply_override(messages) agent._persist_session(messages, conversation_history) except Exception as _persist_err: _cleanup_errors.append(f"persist_session: {_persist_err}") diff --git a/run_agent.py b/run_agent.py index cf8e63ff9b52..19f4639b4940 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1663,14 +1663,14 @@ class AIAgent: msg = messages[idx] if isinstance(msg, dict) and msg.get("role") == "user": # 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): + # and a cleaner transcript string separately. Before the API + # call, a plain-text override must not replace native image/audio + # blocks. A list override, however, is the original clean + # multimodal payload (for example before a queued /model note) + # and must replace the API-local list once the turn is final. + if override is not None and ( + not isinstance(msg.get("content"), list) or isinstance(override, list) + ): msg["content"] = override if timestamp is not None: msg["timestamp"] = timestamp diff --git a/tests/agent/test_turn_finalizer_final_response_persistence.py b/tests/agent/test_turn_finalizer_final_response_persistence.py index 2a54fd6e837f..9c6089aacbd8 100644 --- a/tests/agent/test_turn_finalizer_final_response_persistence.py +++ b/tests/agent/test_turn_finalizer_final_response_persistence.py @@ -51,7 +51,14 @@ class FakeAgent: pass def _persist_session(self, messages, conversation_history): - self.persisted_messages = list(messages) + # Capture the durable write before finalization restores API-local + # guidance to the returned/live transcript. + self.persisted_messages = [dict(message) for message in messages] + + def _apply_persist_user_message_override(self, messages): + from run_agent import AIAgent + + return AIAgent._apply_persist_user_message_override(self, messages) def _file_mutation_verifier_enabled(self): return False @@ -69,6 +76,78 @@ class FakeAgent: pass +def test_finalizer_restores_clean_api_local_text_before_return(monkeypatch): + """One-shot CLI notes do not replay through same-process history.""" + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: []) + agent = FakeAgent() + messages = [ + {"role": "user", "content": "[MODEL SWITCH NOTE]\n\nclean prompt"}, + {"role": "assistant", "content": "Done."}, + ] + agent._persist_user_message_idx = 0 + agent._persist_user_message_override = "clean prompt" + agent._persist_user_message_timestamp = None + + result = finalize_turn( + agent, + final_response="Done.", + api_call_count=1, + interrupted=False, + failed=False, + messages=messages, + conversation_history=[], + effective_task_id="task", + turn_id="turn", + user_message="[MODEL SWITCH NOTE]\n\nclean prompt", + original_user_message="clean prompt", + _should_review_memory=False, + _turn_exit_reason="text_response(finish_reason=stop)", + ) + + assert agent.persisted_messages[0]["content"] == "clean prompt" + assert result["messages"][0]["content"] == "clean prompt" + + +def test_finalizer_restores_clean_api_local_multimodal_before_return(monkeypatch): + """A queued note does not remain in the next-turn native image payload.""" + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: []) + agent = FakeAgent() + clean_content = [ + {"type": "text", "text": "Describe the image"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + ] + api_content = [ + {"type": "text", "text": "[MODEL SWITCH NOTE]\n\nDescribe the image"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + ] + messages = [ + {"role": "user", "content": api_content}, + {"role": "assistant", "content": "Done."}, + ] + agent._persist_user_message_idx = 0 + agent._persist_user_message_override = clean_content + agent._persist_user_message_timestamp = None + + result = finalize_turn( + agent, + final_response="Done.", + api_call_count=1, + interrupted=False, + failed=False, + messages=messages, + conversation_history=[], + effective_task_id="task", + turn_id="turn", + user_message=api_content, + original_user_message=clean_content, + _should_review_memory=False, + _turn_exit_reason="text_response(finish_reason=stop)", + ) + + assert agent.persisted_messages[0]["content"] == clean_content + assert result["messages"][0]["content"] == clean_content + + def test_final_response_closes_tool_tail_before_persistence(monkeypatch): """A recovered/previewed final response must be durable in session history. diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index b0b9a2b93162..85572ef4fc2b 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -144,6 +144,24 @@ def test_persist_user_message_override_preserves_multimodal_turns(agent): assert messages == [{"role": "user", "content": multimodal_content}] +def test_persist_user_message_override_restores_clean_multimodal_note(agent): + clean_content = [ + {"type": "text", "text": "Describe this screenshot"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + ] + api_content = [ + {"type": "text", "text": "[MODEL SWITCH NOTE]\n\nDescribe this screenshot"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + ] + messages = [{"role": "user", "content": api_content}] + agent._persist_user_message_idx = 0 + agent._persist_user_message_override = clean_content + + agent._apply_persist_user_message_override(messages) + + assert messages == [{"role": "user", "content": clean_content}] + + def test_flush_persist_override_replaces_api_local_multimodal_note(agent): """A note-added multimodal API payload stores the original clean content.""" clean_content = [