Files
hermes-agent/tests/agent/test_refine_snapshot_isolation.py
T
kshitijk4poor e333113871 fix(review): keep /refine under the background_review origin; attendedness is its own flag
The salvaged commit forked an explicit /refine under a new "refine_review" origin so
the memory delete gate would not treat it as unattended. But is_background_review()
is the key for every other review guard — skill_manager_guards (curator-owned-only,
read-before-write), skill_manager_tool (archive instead of rmtree), skill_ledger
actor, write_approval staging, the [auto] tag — so a /refine fork silently escaped
all of them.

Carry attendedness separately: the fork keeps origin "background_review" and sets
_review_attended; turn_context binds it beside the origin ContextVar; the memory
gate keys on the new is_unattended_review(). Also run the gate AFTER
_validate_single_op / the operations list check, as memory_tool's own docstring
requires, so an invalid replace is rejected now rather than staged and failed at
approve time.
2026-09-09 12:19:13 +05:30

119 lines
4.4 KiB
Python

"""Every review path hands the fork a snapshot that cannot alias the live transcript.
``AIAgent._spawn_background_review`` is the single chokepoint the automatic
post-turn review, the idle-queue deferral and both explicit ``/refine`` entry
points (CLI mixin + gateway slash command) go through; it clones the snapshot
structurally there. A shallow ``list()`` would share the nested
``tool_calls`` / ``content`` containers with the persisted history, so the
fork's in-place transcript sanitization would rewrite the parent's messages
(#100795). These tests drive the real /refine handlers into the real
chokepoint and capture what reaches the spawn.
"""
import threading
from unittest.mock import MagicMock
import pytest
def _agent_with_real_chokepoint():
"""MagicMock agent whose _spawn_background_review is the REAL method.
Everything below the chokepoint (thread spawn) is captured at
``_spawn_background_review_now`` so no fork actually runs.
"""
from run_agent import AIAgent
agent = MagicMock()
agent.valid_tool_names = {"memory"}
agent._delegate_depth = 0
agent._spawn_background_review = AIAgent._spawn_background_review.__get__(agent)
return agent
def _nested_history():
return [
{"role": "user", "content": [{"type": "text", "text": "ask"}]},
{
"role": "assistant",
"content": "ok",
"tool_calls": [{
"id": "call-1",
"function": {"name": "read_file", "arguments": '{"path":"x"}'},
}],
},
]
def _assert_isolated(live, snapshot):
assert snapshot == live # same shape/bytes …
for live_msg, snap_msg in zip(live, snapshot):
assert snap_msg is not live_msg # … but no shared containers
for key in ("content", "tool_calls"):
if isinstance(live_msg.get(key), (dict, list)):
assert snap_msg[key] is not live_msg[key]
# Mutating the snapshot the way the fork's sanitizers do must not leak.
snapshot[0]["content"][0]["text"] = "mutated"
snapshot[1]["tool_calls"][0]["function"]["arguments"] = "{}"
assert live[0]["content"][0]["text"] == "ask"
assert live[1]["tool_calls"][0]["function"]["arguments"] == '{"path":"x"}'
def test_cli_refine_snapshot_does_not_alias_live_history(monkeypatch):
from hermes_cli.cli_commands_mixin import CLICommandsMixin
monkeypatch.setattr("cli._cprint", lambda *a, **k: None, raising=False)
agent = _agent_with_real_chokepoint()
cli = object.__new__(CLICommandsMixin)
cli.agent = agent
cli.conversation_history = _nested_history()
cli._handle_refine_command("/refine")
agent._spawn_background_review_now.assert_called_once()
snapshot = agent._spawn_background_review_now.call_args.kwargs["messages_snapshot"]
_assert_isolated(cli.conversation_history, snapshot)
def test_cli_refine_reaches_spawn_as_explicit(monkeypatch):
"""The explicit /refine path must reach the spawn with ``explicit=True`` so the fork runs
is marked attended and keeps the full memory operation set (#105921 review)."""
from hermes_cli.cli_commands_mixin import CLICommandsMixin
monkeypatch.setattr("cli._cprint", lambda *a, **k: None, raising=False)
agent = _agent_with_real_chokepoint()
cli = object.__new__(CLICommandsMixin)
cli.agent = agent
cli.conversation_history = _nested_history()
cli._handle_refine_command("/refine")
agent._spawn_background_review_now.assert_called_once()
assert agent._spawn_background_review_now.call_args.kwargs["explicit"] is True
@pytest.mark.asyncio
async def test_gateway_refine_snapshot_does_not_alias_live_history():
from gateway.run import GatewayRunner
key = "agent:main:test:dm:1"
agent = _agent_with_real_chokepoint()
agent._session_messages = _nested_history()
runner = object.__new__(GatewayRunner)
runner._running_agents = {}
runner._agent_cache = {key: agent}
runner._agent_cache_lock = threading.Lock()
runner._session_key_for_source = lambda source: key
event = MagicMock()
event.source = object()
event.get_command_args.return_value = ""
out = await runner._handle_refine_command(event)
assert out.startswith("⚗")
agent._spawn_background_review_now.assert_called_once()
snapshot = agent._spawn_background_review_now.call_args.kwargs["messages_snapshot"]
_assert_isolated(agent._session_messages, snapshot)