d10bb2ab6f
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.
Parallel directories for one source package, folded into the mirror:
tests/acp -> tests/acp_adapter (its __init__/conftest move with it)
tests/cli -> tests/hermes_cli (prompt_toolkit fixture merged into
hermes_cli/conftest.py)
tests/run_agent -> tests/agent (backoff fixture becomes
agent/conftest.py)
tests/relay -> tests/gateway/relay
tests/state -> tests/hermes_state
246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.
Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.
Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).
Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
122 lines
4.7 KiB
Python
122 lines
4.7 KiB
Python
"""Regression test for issue #22357 — gateway memory-nudge counter hydration.
|
|
|
|
The gateway creates a fresh AIAgent for each inbound message in several
|
|
common scenarios (cache miss, 1h idle eviction at gateway/run.py
|
|
_AGENT_CACHE_IDLE_TTL_SECS, config-signature mismatch, process restart).
|
|
A freshly built AIAgent has _turns_since_memory=0 and _user_turn_count=0.
|
|
|
|
Without hydration from conversation_history, the memory.nudge_interval
|
|
trigger (`_turns_since_memory >= _memory_nudge_interval`) can never be
|
|
reached: every turn looks like turn 1 to the counter, so a user can chat
|
|
for hours without ever seeing a "💾 Self-improvement review:" message.
|
|
|
|
This test pins the hydration behavior added at the top of run_conversation().
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
def _make_minimal_agent():
|
|
"""Build the smallest object that can run the hydration block.
|
|
|
|
The hydration code only touches attributes — no I/O, no API calls.
|
|
We can just set up a SimpleNamespace-like object with the right fields
|
|
and call run_conversation's prelude logic via a thin wrapper.
|
|
|
|
The hydration block itself is straightforward enough that we test it
|
|
by replicating it inline against the same inputs — that's the only
|
|
way to test ~10 lines deep inside a 500+ line method without rewriting
|
|
the whole agent loop.
|
|
"""
|
|
|
|
|
|
def _run_hydration(conversation_history, memory_nudge_interval=10,
|
|
prior_turn_count=0, prior_turns_since_memory=0):
|
|
"""Replicate the hydration block from run_agent.py:11128-11150.
|
|
Keeping this in sync with the production code is a one-line job; the
|
|
block has no dependencies on anything except primitives + history.
|
|
"""
|
|
user_turn_count = prior_turn_count
|
|
turns_since_memory = prior_turns_since_memory
|
|
|
|
if conversation_history and user_turn_count == 0:
|
|
prior_user_turns = sum(
|
|
1 for m in conversation_history if m.get("role") == "user"
|
|
)
|
|
if prior_user_turns > 0:
|
|
user_turn_count = prior_user_turns
|
|
if memory_nudge_interval > 0 and turns_since_memory == 0:
|
|
turns_since_memory = prior_user_turns % memory_nudge_interval
|
|
|
|
return user_turn_count, turns_since_memory
|
|
|
|
|
|
def test_no_history_leaves_counters_at_zero():
|
|
user_turn, since_mem = _run_hydration([], memory_nudge_interval=10)
|
|
assert user_turn == 0
|
|
assert since_mem == 0
|
|
|
|
|
|
def test_seven_user_turns_history_hydrates_to_seven():
|
|
"""Mid-cycle history: 7 prior user turns, interval 10 → counter at 7."""
|
|
history = []
|
|
for i in range(7):
|
|
history.append({"role": "user", "content": f"q{i}"})
|
|
history.append({"role": "assistant", "content": f"a{i}"})
|
|
|
|
user_turn, since_mem = _run_hydration(history, memory_nudge_interval=10)
|
|
|
|
assert user_turn == 7
|
|
assert since_mem == 7 # 7 % 10 = 7, next 3 turns will trigger review
|
|
|
|
|
|
|
|
|
|
def test_idempotent_when_counters_already_set():
|
|
"""A cached agent with existing counters must NOT have them clobbered.
|
|
|
|
Without the `_user_turn_count == 0` guard, cached agents would lose
|
|
their accumulated state every time they re-entered the function.
|
|
"""
|
|
history = [{"role": "user", "content": "q1"}, {"role": "assistant", "content": "a1"}]
|
|
user_turn, since_mem = _run_hydration(
|
|
history, memory_nudge_interval=10,
|
|
prior_turn_count=15, prior_turns_since_memory=5,
|
|
)
|
|
# Existing counters preserved (cache hit case)
|
|
assert user_turn == 15
|
|
assert since_mem == 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_production_code_contains_hydration_block():
|
|
"""Smoke test: confirm the hydration code is actually wired into the
|
|
turn path. If someone deletes it, tests above still pass against the
|
|
inline replica — this fails them awake.
|
|
|
|
The agent-loop prologue now lives in ``agent/turn_context.py``
|
|
(``build_turn_context``), with the loop body in
|
|
``agent/conversation_loop.py``. Assert the block is present in the
|
|
turn subsystem — if it disappears entirely, this guard fails loudly.
|
|
Either module counts so the guard tolerates legitimate relocation
|
|
within the turn subsystem.
|
|
"""
|
|
from pathlib import Path
|
|
repo = Path(__file__).resolve().parents[2]
|
|
turn_src = "".join(
|
|
(repo / "agent" / name).read_text(encoding="utf-8")
|
|
for name in ("conversation_loop.py", "turn_context.py")
|
|
)
|
|
# Anchor on the unique comment + the modulo line.
|
|
assert "Hydrate per-session nudge counters from persisted history" in turn_src, (
|
|
"Hydration comment missing from the turn subsystem "
|
|
"(conversation_loop.py / turn_context.py)"
|
|
)
|
|
assert (
|
|
"agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval"
|
|
in turn_src
|
|
), "Hydration modulo assignment missing from the turn subsystem"
|