fix(session): restore clean API-local turn content

This commit is contained in:
kshitijk4poor 2026-07-14 01:37:53 +05:30
parent 32bdc67e10
commit 8341d775a9
4 changed files with 115 additions and 9 deletions

View file

@ -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}")

View file

@ -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

View file

@ -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.

View file

@ -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 = [