diff --git a/cli.py b/cli.py index 0b4c4b194286..45e32090febd 100644 --- a/cli.py +++ b/cli.py @@ -12365,13 +12365,20 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._pending_moa_config = None if _moa_cfg is None: _moa_cfg = None + # Model/skill notes and voice instructions are API-local. Keep + # the original staged input as the durable transcript value so a + # close-path marker follows the same dict into turn setup rather + # than producing a second noted user row (#63766). + _persist_clean_user_message = ( + message if (_voice_prefix or agent_message != message) else None + ) try: result = self.agent.run_conversation( user_message=agent_message, conversation_history=self.conversation_history[:-1], # Exclude the message we just added stream_callback=stream_callback, task_id=self.session_id, - persist_user_message=message if _voice_prefix else None, + persist_user_message=_persist_clean_user_message, moa_config=_moa_cfg, ) if getattr(self, "_pending_moa_disable_after_turn", False): diff --git a/tests/agent/test_turn_context.py b/tests/agent/test_turn_context.py index d024eca56fc8..e0642c5f11bf 100644 --- a/tests/agent/test_turn_context.py +++ b/tests/agent/test_turn_context.py @@ -241,6 +241,24 @@ def test_stale_pending_cli_message_does_not_replace_new_turn_input(): assert agent._pending_cli_user_message is None +def test_pending_cli_message_uses_clean_override_for_api_local_note(): + """A noted API message reuses the clean staged dict and its DB marker.""" + agent = _FakeAgent() + staged = {"role": "user", "content": "clean prompt", "_db_persisted": True} + agent._pending_cli_user_message = staged + + ctx = _build( + agent, + user_message="[MODEL NOTE]\n\nclean prompt", + persist_user_message="clean prompt", + ) + + assert ctx.messages[-1] is staged + assert ctx.messages[-1]["content"] == "[MODEL NOTE]\n\nclean prompt" + assert ctx.messages[-1]["_db_persisted"] is True + assert agent._pending_cli_user_message is None + + def test_memory_nudge_fires_at_interval(): agent = _FakeAgent() agent._memory_nudge_interval = 1 diff --git a/tests/cli/test_cli_interrupt_ack_race.py b/tests/cli/test_cli_interrupt_ack_race.py index a55fe43d2022..dbcd7a15bd09 100644 --- a/tests/cli/test_cli_interrupt_ack_race.py +++ b/tests/cli/test_cli_interrupt_ack_race.py @@ -192,3 +192,42 @@ def test_acknowledged_interrupt_still_requeues_message(): queued.append(cli._pending_input.get_nowait()) assert any("redirect please" in str(q) for q in queued) assert cli._last_turn_interrupted is True + + +def test_chat_persists_clean_input_when_a_queued_note_changes_api_message(): + """Queued notes remain API-local and preserve close-handoff marker identity.""" + cli = _make_cli() + + class _NoteAgent(_StubAgent): + def __init__(self, session_id): + super().__init__(session_id, turn_seconds=0) + self.captured = None + + def run_conversation(self, **kwargs): + self.captured = kwargs + return { + "final_response": "done", + "messages": [{"role": "assistant", "content": "done"}], + "api_calls": 1, + "completed": True, + "partial": True, + "response_previewed": True, + } + + agent = _NoteAgent(cli.session_id) + cli.agent = agent + cli._interrupt_queue = queue.Queue() + cli._pending_input = queue.Queue() + cli._pending_model_switch_note = "[MODEL SWITCH NOTE]" + + with patch.object(cli, "_ensure_runtime_credentials", return_value=True), \ + patch.object(cli, "_resolve_turn_agent_config", return_value={ + "signature": cli._active_agent_route_signature, + "model": None, "runtime": None, "request_overrides": None, + }), \ + patch.object(cli, "_init_agent", return_value=True): + cli.chat("clean prompt") + + assert agent.captured is not None + assert agent.captured["user_message"] == "[MODEL SWITCH NOTE]\n\nclean prompt" + assert agent.captured["persist_user_message"] == "clean prompt" diff --git a/tests/cli/test_cli_shutdown_memory_messages.py b/tests/cli/test_cli_shutdown_memory_messages.py index 85787a72e4a7..63ce29cc033f 100644 --- a/tests/cli/test_cli_shutdown_memory_messages.py +++ b/tests/cli/test_cli_shutdown_memory_messages.py @@ -17,6 +17,7 @@ other tests keep their existing no-arg behaviour. from __future__ import annotations import threading +import types from typing import Any from unittest.mock import MagicMock, patch @@ -491,6 +492,98 @@ def test_cli_close_persists_pending_user_when_agent_snapshot_is_empty(tmp_path, assert staged["_db_persisted"] is True +def test_cli_close_preserves_clean_staged_user_across_noted_worker_turn(tmp_path, monkeypatch): + """A noted API-only turn reuses the close-marked clean staged user row.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + + import cli as cli_mod + from hermes_state import SessionDB + + db = SessionDB(db_path=tmp_path / "state.db") + session_id = "cli-close-noted-staged-user" + db.create_session(session_id=session_id, source="cli") + prefix = [ + {"role": "user", "content": "old prompt"}, + {"role": "assistant", "content": "old answer"}, + ] + agent = _real_agent(db, session_id, prefix) + agent._flush_messages_to_session_db(prefix, []) + staged = {"role": "user", "content": "new prompt"} + agent._pending_cli_user_message = staged + + cli = object.__new__(cli_mod.HermesCLI) + cli.conversation_history = list(prefix) + [staged] + cli.session_id = session_id + cli.agent = agent + + cli._persist_active_session_before_close() + assert staged["_db_persisted"] is True + + # A queued model/skills note changes only the API message. The worker + # reuses the marked clean dict, so the normal persistence seam cannot append + # a second noted user row. + from agent.turn_context import build_turn_context + + agent.quiet_mode = True + agent.max_iterations = 1 + agent.provider = "test" + agent.base_url = "" + agent.api_key = "" + agent.api_mode = "chat_completions" + agent.tools = [] + agent.valid_tool_names = set() + agent.enabled_toolsets = None + agent.disabled_toolsets = None + agent._skip_mcp_refresh = True + agent.compression_enabled = False + agent.context_compressor = types.SimpleNamespace(protect_first_n=2, protect_last_n=2) + agent._memory_store = None + agent._memory_manager = None + agent._memory_nudge_interval = 0 + agent._turns_since_memory = 0 + agent._user_turn_count = 0 + agent._todo_store = types.SimpleNamespace(has_items=lambda: True) + agent._tool_guardrails = types.SimpleNamespace(reset_for_turn=lambda: None) + agent._compression_warning = None + agent._interrupt_requested = False + agent._memory_write_origin = "assistant_tool" + agent._stream_context_scrubber = None + agent._stream_think_scrubber = None + agent._restore_primary_runtime = lambda: None + agent._cleanup_dead_connections = lambda: False + agent._emit_status = lambda _message: None + agent._replay_compression_warning = lambda: None + agent._hydrate_todo_store = lambda *_args: None + agent._safe_print = lambda *_args: None + + worker = build_turn_context( + agent, + "[MODEL SWITCH NOTE]\n\nnew prompt", + None, + prefix, + "task", + None, + "new prompt", + None, + restore_or_build_system_prompt=lambda *_args: None, + install_safe_stdio=lambda: None, + sanitize_surrogates=lambda value: value, + summarize_user_message_for_log=lambda value: value, + set_session_context=lambda _session_id: None, + set_current_write_origin=lambda _origin: None, + ra=lambda: types.SimpleNamespace(_set_interrupt=lambda *_args: None), + ) + assert worker.messages[-1] is staged + assert worker.messages[-1]["content"] == "[MODEL SWITCH NOTE]\n\nnew prompt" + + stored = db.get_messages_as_conversation(session_id) + assert [m["content"] for m in stored] == [ + "old prompt", + "old answer", + "new prompt", + ] + + def test_cli_close_builds_prompt_before_creating_first_session_row(tmp_path, monkeypatch): """First-turn close persistence must not leave a NULL prompt snapshot.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))