Files
hermes-agent/tests/agent/test_compression_feasibility.py
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`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.
2026-09-13 09:18:02 -07:00

452 lines
18 KiB
Python

"""Tests for _check_compression_model_feasibility() — warns when the
auxiliary compression model's context is smaller than the main model's
compression threshold.
Two-phase design:
1. __init__ → runs the check, prints via _vprint (CLI), stores warning
2. run_conversation (first call) → replays stored warning through
status_callback (gateway platforms)
"""
from unittest.mock import MagicMock, patch
import pytest
from run_agent import AIAgent
from agent.context_compressor import ContextCompressor
@pytest.fixture(autouse=True)
def _stable_aux_provider_config():
"""Keep feasibility tests independent from the developer's config.yaml."""
with patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", None, None, None, None),
):
yield
def _make_agent(
*,
compression_enabled: bool = True,
threshold_percent: float = 0.50,
main_context: int = 200_000,
) -> AIAgent:
"""Build a minimal AIAgent with a compressor, skipping __init__."""
agent = AIAgent.__new__(AIAgent)
agent.model = "test-main-model"
agent.provider = "openrouter"
agent.base_url = "https://openrouter.ai/api/v1"
agent.api_key = "sk-test"
agent.api_mode = "chat_completions"
agent.quiet_mode = True
agent.log_prefix = ""
agent.compression_enabled = compression_enabled
agent._print_fn = None
agent.suppress_status_output = False
agent._stream_consumers = []
agent._executing_tools = False
agent._mute_post_response = False
agent.status_callback = None
agent.tool_progress_callback = None
agent._compression_warning = None
agent._aux_compression_context_length_config = None
agent._custom_providers = []
agent.tools = []
compressor = MagicMock(spec=ContextCompressor)
compressor.context_length = main_context
compressor.threshold_tokens = int(main_context * threshold_percent)
compressor.summary_target_ratio = 0.20
compressor.tail_token_budget = int(
compressor.threshold_tokens * compressor.summary_target_ratio
)
agent.context_compressor = compressor
return agent
@pytest.mark.parametrize("main_context,aux_context", [(1_000_000, 512_000), (400_000, 80_000)])
def test_aux_sync_keeps_lean_tail_policy(main_context, aux_context):
"""Lowering only the trigger must not change window-relative retention."""
agent = _make_agent(main_context=main_context)
compressor = agent.context_compressor = ContextCompressor(
"test-main-model", config_context_length=main_context,
threshold_percent=0.85, quiet_mode=True,
)
before = compressor.tail_token_budget
agent._emit_status = lambda message: None
client = MagicMock(base_url="http://localhost/v1", api_key="test-key")
with patch("agent.auxiliary_client.get_text_auxiliary_client", return_value=(client, "aux")), \
patch("agent.model_metadata.get_model_context_length", return_value=aux_context):
agent._check_compression_model_feasibility()
assert compressor.threshold_tokens == aux_context
assert compressor.tail_token_budget == before
# Repeated feasibility and subsequent model recalibration retain policy.
agent._check_compression_model_feasibility()
assert compressor.tail_token_budget == before
compressor.update_model("test-main-model", context_length=main_context)
assert compressor.tail_token_budget == before
def test_aux_sync_legacy_tail_follows_lowered_threshold():
"""Explicit legacy retention follows the current trigger, not its old cache."""
agent = _make_agent(main_context=1_000_000)
compressor = agent.context_compressor = ContextCompressor(
"test-main-model", config_context_length=1_000_000,
threshold_percent=0.85, tail_mode="legacy", quiet_mode=True,
)
before = compressor.tail_token_budget
agent._emit_status = lambda message: None
client = MagicMock(base_url="http://localhost/v1", api_key="test-key")
with patch("agent.auxiliary_client.get_text_auxiliary_client", return_value=(client, "aux")), \
patch("agent.model_metadata.get_model_context_length", return_value=512_000):
agent._check_compression_model_feasibility()
assert compressor.threshold_tokens == 512_000
assert compressor.tail_token_budget < before
assert compressor.tail_token_budget == int(compressor.threshold_tokens * compressor.summary_target_ratio)
# ── Core warning logic ──────────────────────────────────────────────
@patch("agent.model_metadata.get_model_context_length", return_value=80_000)
@patch("agent.auxiliary_client.get_text_auxiliary_client")
def test_auto_corrects_threshold_when_aux_context_below_threshold(mock_get_client, mock_ctx_len):
"""Auto-correction: aux >= 64K floor but < threshold → lower threshold
to aux_context so compression still works this session."""
agent = _make_agent(main_context=200_000, threshold_percent=0.50)
# threshold = 100,000 — aux has 80,000 (above 64K floor, below threshold)
mock_client = MagicMock()
mock_client.base_url = "https://openrouter.ai/api/v1"
mock_client.api_key = "sk-aux"
mock_get_client.return_value = (mock_client, "google/gemini-3-flash-preview")
messages = []
agent._emit_status = lambda msg: messages.append(msg)
agent._check_compression_model_feasibility()
assert len(messages) == 1
assert "Compression model" in messages[0]
assert "80,000" in messages[0] # aux context
assert "100,000" in messages[0] # old threshold
assert "Auto-lowered" in messages[0]
# Actionable persistence guidance included
assert "config.yaml" in messages[0]
assert "auxiliary:" in messages[0]
assert "compression:" in messages[0]
# 200K main is under the 512K small-context limit and 80K/200K = 40% sits
# below the 75% floor — a `threshold:` suggestion would be raised back to
# 75% and ignored (#67422), so the message must not offer one and must
# explain the recomputed trigger instead (0.75 * 200K = 150K).
assert "threshold:" not in messages[0]
assert "150,000" in messages[0]
# Warning stored for gateway replay
assert agent._compression_warning is not None
# Threshold on the live compressor was actually lowered to aux_context.
assert agent.context_compressor.threshold_tokens == 80_000
# Every threshold-derived budget must move with it. Keeping the original
# 20K tail here would protect 25% of the lowered threshold instead of the
# configured 20%, and larger real-world mismatches can make the tail's 1.5x
# soft ceiling wider than the entire compression trigger.
assert agent.context_compressor.tail_token_budget == 16_000
@patch("agent.model_metadata.get_model_context_length", return_value=32_768)
@patch("agent.auxiliary_client.get_text_auxiliary_client")
def test_rejects_aux_below_minimum_context(mock_get_client, mock_ctx_len):
"""Hard floor: aux context < MINIMUM_CONTEXT_LENGTH (64K) → session
refuses to start (ValueError), mirroring the main-model rejection."""
agent = _make_agent(main_context=200_000, threshold_percent=0.50)
mock_client = MagicMock()
mock_client.base_url = "https://openrouter.ai/api/v1"
mock_client.api_key = "sk-aux"
mock_get_client.return_value = (mock_client, "tiny-aux-model")
agent._emit_status = lambda msg: None
with pytest.raises(ValueError) as exc_info:
agent._check_compression_model_feasibility()
err = str(exc_info.value)
assert "tiny-aux-model" in err
assert "32,768" in err
assert "64,000" in err
assert "below the minimum" in err
def test_feasibility_check_passes_live_main_runtime():
"""Compression feasibility should probe using the live session runtime."""
agent = _make_agent(main_context=200_000, threshold_percent=0.50)
agent.model = "gpt-5.4"
agent.provider = "openai-codex"
agent.base_url = "https://chatgpt.com/backend-api/codex"
agent.api_key = "codex-token"
agent.api_mode = "codex_responses"
mock_client = MagicMock()
mock_client.base_url = "https://chatgpt.com/backend-api/codex"
mock_client.api_key = "codex-token"
with patch("agent.auxiliary_client.get_text_auxiliary_client", return_value=(mock_client, "gpt-5.4")) as mock_get_client, \
patch("agent.model_metadata.get_model_context_length", return_value=200_000):
agent._emit_status = lambda msg: None
agent._check_compression_model_feasibility()
mock_get_client.assert_called_once_with(
"compression",
main_runtime={
"model": "gpt-5.4",
"provider": "openai-codex",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "codex-token",
"api_mode": "codex_responses",
"auth_mode": "",
},
)
@patch("agent.model_metadata.get_model_context_length", return_value=1_000_000)
@patch("agent.auxiliary_client.get_text_auxiliary_client")
def test_feasibility_check_passes_config_context_length(mock_get_client, mock_ctx_len):
"""auxiliary.compression.context_length from config is forwarded to
get_model_context_length so custom endpoints that lack /models still
report the correct context window (fixes #8499)."""
agent = _make_agent(main_context=200_000, threshold_percent=0.85)
agent._aux_compression_context_length_config = 1_000_000
mock_client = MagicMock()
mock_client.base_url = "http://custom-endpoint:8080/v1"
mock_client.api_key = "sk-custom"
mock_get_client.return_value = (mock_client, "custom/big-model")
agent._emit_status = lambda msg: None
agent._check_compression_model_feasibility()
mock_ctx_len.assert_called_once_with(
"custom/big-model",
base_url="http://custom-endpoint:8080/v1",
api_key="sk-custom",
config_context_length=1_000_000,
provider="openrouter",
custom_providers=[],
)
def test_init_feasibility_check_uses_aux_context_override_from_config():
"""Lazy feasibility check should cache and forward auxiliary.compression.context_length.
NB: feasibility check is deferred from AIAgent.__init__ to the first
actual compression attempt (saves ~400ms cold startup on short sessions
that never trigger compression). The test drives the check explicitly
via ``agent._check_compression_model_feasibility()`` to assert the
config-override threading.
"""
class _StubCompressor:
def __init__(self, *args, **kwargs):
self.context_length = 200_000
self.threshold_tokens = 100_000
self.threshold_percent = 0.50
def get_tool_schemas(self):
return []
def on_session_start(self, *args, **kwargs):
return None
cfg = {
"auxiliary": {
"compression": {
"context_length": 1_000_000,
},
},
}
mock_client = MagicMock()
mock_client.base_url = "http://custom-endpoint:8080/v1"
mock_client.api_key = "sk-custom"
with (
patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg),
patch("model_tools.get_tool_definitions", return_value=[]),
patch("model_tools.check_toolset_requirements", return_value={}),
patch("agent.process_bootstrap.OpenAI"),
patch("agent.agent_init.ContextCompressor", new=_StubCompressor),
patch("agent.auxiliary_client.get_text_auxiliary_client", return_value=(mock_client, "custom/big-model")),
patch("agent.model_metadata.get_model_context_length", return_value=1_000_000) as mock_ctx_len,
):
agent = AIAgent(
api_key="test-key-1234567890",
base_url="https://openrouter.ai/api/v1",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
# Config override is captured eagerly in __init__ (still needed
# because the threshold-derivation logic at construction time
# consults it).
assert agent._aux_compression_context_length_config == 1_000_000
# The expensive feasibility probe is deferred. Drive it manually
# to validate the call shape still forwards the override correctly.
agent._check_compression_model_feasibility()
mock_ctx_len.assert_called_once_with(
"custom/big-model",
base_url="http://custom-endpoint:8080/v1",
api_key="sk-custom",
config_context_length=1_000_000,
provider="",
custom_providers=[],
)
@patch("agent.auxiliary_client.get_text_auxiliary_client")
def test_warns_when_no_auxiliary_provider(mock_get_client):
"""Warning emitted when no auxiliary provider is configured."""
agent = _make_agent()
mock_get_client.return_value = (None, None)
messages = []
agent._emit_status = lambda msg: messages.append(msg)
agent._check_compression_model_feasibility()
assert len(messages) == 1
assert "No auxiliary LLM provider" in messages[0]
assert agent._compression_warning is not None
def test_no_unavailable_warning_when_configured_fallback_chain_resolves():
"""Primary compression provider can be down if configured fallback works."""
agent = _make_agent(main_context=200_000, threshold_percent=0.50)
fallback_client = MagicMock()
fallback_client.base_url = "https://chatgpt.com/backend-api/codex"
fallback_client.api_key = "codex-oauth-token"
messages = []
agent._emit_status = lambda msg: messages.append(msg)
with patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("ollama-cloud", "deepseek-v4-flash:cloud", None, None, None),
), patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(None, None),
), patch(
"agent.auxiliary_client._try_configured_fallback_for_unavailable_client",
return_value=(fallback_client, "gpt-5.4-mini", "fallback_chain[0](openai-codex)"),
) as mock_fallback, patch(
"agent.model_metadata.get_model_context_length",
return_value=200_000,
) as mock_ctx_len:
agent._check_compression_model_feasibility()
assert messages == []
assert agent._compression_warning is None
mock_fallback.assert_called_once_with("compression", "ollama-cloud")
mock_ctx_len.assert_called_once()
assert mock_ctx_len.call_args.args == ("gpt-5.4-mini",)
assert mock_ctx_len.call_args.kwargs["provider"] == "openai-codex"
# ── Two-phase: __init__ + run_conversation replay ───────────────────
@patch("agent.model_metadata.get_model_context_length", return_value=80_000)
@patch("agent.auxiliary_client.get_text_auxiliary_client")
def test_warning_stored_for_gateway_replay(mock_get_client, mock_ctx_len):
"""__init__ stores the warning; _replay sends it through status_callback."""
agent = _make_agent(main_context=200_000, threshold_percent=0.50)
mock_client = MagicMock()
mock_client.base_url = "https://openrouter.ai/api/v1"
mock_client.api_key = "sk-aux"
mock_get_client.return_value = (mock_client, "google/gemini-3-flash-preview")
# Phase 1: __init__ — _emit_status prints (CLI) but callback is None
vprint_messages = []
agent._emit_status = lambda msg: vprint_messages.append(msg)
agent._check_compression_model_feasibility()
assert len(vprint_messages) == 1 # CLI got it
assert agent._compression_warning is not None # stored for replay
# Phase 2: gateway wires callback post-init, then run_conversation replays
callback_events = []
agent.status_callback = lambda ev, msg: callback_events.append((ev, msg))
agent._replay_compression_warning()
assert any(
ev == "lifecycle" and "Auto-lowered" in msg
for ev, msg in callback_events
)
@patch("agent.model_metadata.get_model_context_length", return_value=200_000)
@patch("agent.auxiliary_client.get_text_auxiliary_client")
def test_no_replay_when_no_warning(mock_get_client, mock_ctx_len):
"""_replay_compression_warning is a no-op when there's no stored warning."""
agent = _make_agent(main_context=200_000, threshold_percent=0.50)
mock_client = MagicMock()
mock_client.base_url = "https://openrouter.ai/api/v1"
mock_client.api_key = "sk-aux"
mock_get_client.return_value = (mock_client, "big-model")
agent._emit_status = lambda msg: None
agent._check_compression_model_feasibility()
assert agent._compression_warning is None
callback_events = []
agent.status_callback = lambda ev, msg: callback_events.append((ev, msg))
agent._replay_compression_warning()
assert len(callback_events) == 0
# ── #67422: threshold suggestion must survive the small-context floor ────────
@patch("agent.model_metadata.get_model_context_length", return_value=300_000)
@patch("agent.auxiliary_client.get_text_auxiliary_client")
def test_threshold_suggestion_kept_for_large_context_main(mock_get_client, mock_ctx_len):
"""Main window >= 512K has no floor — any suggestion is honored, so the
`threshold:` option stays even below 75%."""
agent = _make_agent(main_context=1_000_000, threshold_percent=0.50)
# threshold = 500,000 — aux has 300,000
mock_client = MagicMock()
mock_client.base_url = "https://openrouter.ai/api/v1"
mock_client.api_key = "sk-aux"
mock_get_client.return_value = (mock_client, "google/gemini-3-flash-preview")
messages = []
agent._emit_status = lambda msg: messages.append(msg)
agent._check_compression_model_feasibility()
assert len(messages) == 1
assert "threshold: 0.30" in messages[0]