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
11 changes: 11 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2497,6 +2497,11 @@ def _honcho_save_user_observation(self, content: str) -> str:
"""
if not content or not content.strip():
return json.dumps({"success": False, "error": "Content cannot be empty."})
# Block cron sessions from writing observations — same rationale as
# _honcho_sync: cron-generated observations are agent-internal, not
# user statements. See #4052.
if self.platform == "cron":
return json.dumps({"success": False, "error": "Honcho writes disabled for cron sessions."})
try:
session = self._honcho.get_or_create(self._honcho_session_key)
session.add_message("user", f"[observation] {content.strip()}")
Expand All @@ -2514,6 +2519,12 @@ def _honcho_sync(self, user_content: str, assistant_content: str) -> None:
"""Sync the user/assistant message pair to Honcho."""
if not self._honcho or not self._honcho_session_key:
return
# Skip Honcho writes for cron sessions — cron prompts contain system
# instructions ("You are Hermes...") that would be misattributed to
# the user peer, corrupting the user representation. See #4052.
if self.platform == "cron":
logger.debug("Skipping Honcho sync for cron session")
return
try:
session = self._honcho.get_or_create(self._honcho_session_key)
session.add_message("user", user_content)
Expand Down
1 change: 1 addition & 0 deletions tests/test_cli_provider_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ def _resolve_provider(requested, **kwargs):

monkeypatch.setattr("hermes_cli.auth.resolve_provider", _resolve_provider)
monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices: len(choices) - 1)
monkeypatch.setattr("sys.stdin", type("FakeTTY", (), {"isatty": lambda self: True})())

hermes_main.cmd_model(SimpleNamespace())
output = capsys.readouterr().out
Expand Down
121 changes: 121 additions & 0 deletions tests/test_honcho_cron_write_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Tests for Honcho cron session write guard (#4052).

Cron sessions must not write messages to Honcho — the cron prompt
contains system instructions ("You are Hermes...") that would be
misattributed to the user peer, corrupting the user representation.

Verifies that:
1. _honcho_sync() is a no-op when platform == "cron"
2. _honcho_save_user_observation() rejects writes when platform == "cron"
3. Non-cron sessions still sync normally
"""

import json
import types
import sys
import pytest
from unittest.mock import MagicMock, patch


@pytest.fixture(autouse=True)
def _stub_deps(monkeypatch):
"""Stub heavy dependencies so run_agent can import without side effects."""
for mod_name in (
"dotenv",
"yaml",
"rich",
"rich.console",
"rich.panel",
"rich.markdown",
"rich.syntax",
"rich.live",
"rich.text",
"rich.table",
"rich.box",
"rich.theme",
):
if mod_name not in sys.modules:
monkeypatch.setitem(sys.modules, mod_name, types.ModuleType(mod_name))

fake_dotenv = sys.modules["dotenv"]
fake_dotenv.load_dotenv = lambda *a, **kw: None


def _make_agent(platform: str = "cron"):
"""Create a minimal AIAgent-like object with Honcho state wired up."""
from run_agent import AIAgent

agent = object.__new__(AIAgent)
agent.platform = platform
agent.quiet_mode = True

# Mock Honcho session manager
agent._honcho = MagicMock()
agent._honcho_session_key = "test-session"

mock_session = MagicMock()
mock_session.messages = []
agent._honcho.get_or_create.return_value = mock_session

return agent, mock_session


class TestHonchoSyncCronGuard:
"""_honcho_sync must skip writes for cron sessions."""

def test_cron_session_skips_sync(self):
agent, mock_session = _make_agent(platform="cron")
agent._honcho_sync("You are Hermes, an AI assistant...", "Sure, here's the result.")

# Should never touch the session
agent._honcho.get_or_create.assert_not_called()
mock_session.add_message.assert_not_called()
agent._honcho.save.assert_not_called()

def test_non_cron_session_syncs_normally(self):
agent, mock_session = _make_agent(platform="telegram")
agent._honcho_sync("Hello!", "Hi there!")

agent._honcho.get_or_create.assert_called_once_with("test-session")
assert mock_session.add_message.call_count == 2
mock_session.add_message.assert_any_call("user", "Hello!")
mock_session.add_message.assert_any_call("assistant", "Hi there!")
agent._honcho.save.assert_called_once()

def test_cli_session_syncs_normally(self):
agent, mock_session = _make_agent(platform="cli")
agent._honcho_sync("What's the weather?", "I don't have weather tools.")

agent._honcho.get_or_create.assert_called_once()
assert mock_session.add_message.call_count == 2

def test_none_platform_syncs_normally(self):
"""Platform=None (e.g. direct AIAgent usage) should still sync."""
agent, mock_session = _make_agent(platform=None)
agent._honcho_sync("test", "response")

agent._honcho.get_or_create.assert_called_once()


class TestHonchoObservationCronGuard:
"""_honcho_save_user_observation must reject writes for cron sessions."""

def test_cron_session_rejects_observation(self):
agent, mock_session = _make_agent(platform="cron")
result = json.loads(agent._honcho_save_user_observation("User prefers dark mode"))

assert result["success"] is False
assert "cron" in result["error"].lower()
agent._honcho.get_or_create.assert_not_called()

def test_non_cron_session_saves_observation(self):
agent, mock_session = _make_agent(platform="telegram")
result = json.loads(agent._honcho_save_user_observation("User prefers dark mode"))

assert result["success"] is True
agent._honcho.get_or_create.assert_called_once()
mock_session.add_message.assert_called_once()
# Verify the observation prefix is present
call_args = mock_session.add_message.call_args
assert call_args[0][0] == "user"
assert "[observation]" in call_args[0][1]
5 changes: 3 additions & 2 deletions tests/test_trajectory_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,12 +405,13 @@ def test_generate_summary_handles_none_content(self):
@pytest.mark.asyncio
async def test_generate_summary_async_handles_none_content(self):
tc = _make_compressor()
tc.async_client = MagicMock()
tc.async_client.chat.completions.create = AsyncMock(
mock_client = MagicMock()
mock_client.chat.completions.create = AsyncMock(
return_value=SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content=None))]
)
)
tc._get_async_client = MagicMock(return_value=mock_client)
metrics = TrajectoryMetrics()

summary = await tc._generate_summary_async("Turn content", metrics)
Expand Down
7 changes: 6 additions & 1 deletion tests/tools/test_browser_camofox.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,13 @@ def test_get_images(self, mock_get, mock_post, monkeypatch):
mock_post.return_value = _mock_response(json_data={"tabId": "tab10", "url": "https://x.com"})
camofox_navigate("https://x.com", task_id="t10")

# camofox_get_images parses images from the accessibility tree snapshot
snapshot_text = (
'- img "Logo"\n'
' /url: https://x.com/img.png\n'
)
mock_get.return_value = _mock_response(json_data={
"images": [{"src": "https://x.com/img.png", "alt": "Logo"}],
"snapshot": snapshot_text,
})
result = json.loads(camofox_get_images(task_id="t10"))
assert result["success"] is True
Expand Down
Loading