refactor(tests): re-architect tests + fix CI failures (#5946)
* refactor: re-architect tests to mirror the codebase
* Update tests.yml
* fix: add missing tool_error imports after registry refactor
* fix(tests): replace patch.dict with monkeypatch to prevent env var leaks under xdist
patch.dict(os.environ) can leak TERMINAL_ENV across xdist workers,
causing test_code_execution tests to hit the Modal remote path.
* fix(tests): fix update_check and telegram xdist failures
- test_update_check: replace patch("hermes_cli.banner.os.getenv") with
monkeypatch.setenv("HERMES_HOME") — banner.py no longer imports os
directly, it uses get_hermes_home() from hermes_constants.
- test_telegram_conflict/approval_buttons: provide real exception classes
for telegram.error mock (NetworkError, TimedOut, BadRequest) so the
except clause in connect() doesn't fail with "catching classes that do
not inherit from BaseException" when xdist pollutes sys.modules.
* fix(tests): accept unavailable_models kwarg in _prompt_model_selection mock
This commit is contained in:
1327
tests/agent/test_anthropic_adapter.py
Normal file
1327
tests/agent/test_anthropic_adapter.py
Normal file
File diff suppressed because it is too large
Load Diff
307
tests/agent/test_auxiliary_config_bridge.py
Normal file
307
tests/agent/test_auxiliary_config_bridge.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""Tests for auxiliary model config bridging — verifies that config.yaml values
|
||||
are properly mapped to environment variables by both CLI and gateway loaders.
|
||||
|
||||
Also tests the vision_tools and browser_tool model override env vars.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
|
||||
def _run_auxiliary_bridge(config_dict, monkeypatch):
|
||||
"""Simulate the auxiliary config → env var bridging logic shared by CLI and gateway.
|
||||
|
||||
This mirrors the code in cli.py load_cli_config() and gateway/run.py.
|
||||
Both use the same pattern; we test it once here.
|
||||
"""
|
||||
# Clear env vars
|
||||
for key in (
|
||||
"AUXILIARY_VISION_PROVIDER", "AUXILIARY_VISION_MODEL",
|
||||
"AUXILIARY_VISION_BASE_URL", "AUXILIARY_VISION_API_KEY",
|
||||
"AUXILIARY_WEB_EXTRACT_PROVIDER", "AUXILIARY_WEB_EXTRACT_MODEL",
|
||||
"AUXILIARY_WEB_EXTRACT_BASE_URL", "AUXILIARY_WEB_EXTRACT_API_KEY",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
# Compression config is read directly from config.yaml — no env var bridging.
|
||||
|
||||
# Auxiliary bridge
|
||||
auxiliary_cfg = config_dict.get("auxiliary", {})
|
||||
if auxiliary_cfg and isinstance(auxiliary_cfg, dict):
|
||||
aux_task_env = {
|
||||
"vision": {
|
||||
"provider": "AUXILIARY_VISION_PROVIDER",
|
||||
"model": "AUXILIARY_VISION_MODEL",
|
||||
"base_url": "AUXILIARY_VISION_BASE_URL",
|
||||
"api_key": "AUXILIARY_VISION_API_KEY",
|
||||
},
|
||||
"web_extract": {
|
||||
"provider": "AUXILIARY_WEB_EXTRACT_PROVIDER",
|
||||
"model": "AUXILIARY_WEB_EXTRACT_MODEL",
|
||||
"base_url": "AUXILIARY_WEB_EXTRACT_BASE_URL",
|
||||
"api_key": "AUXILIARY_WEB_EXTRACT_API_KEY",
|
||||
},
|
||||
}
|
||||
for task_key, env_map in aux_task_env.items():
|
||||
task_cfg = auxiliary_cfg.get(task_key, {})
|
||||
if not isinstance(task_cfg, dict):
|
||||
continue
|
||||
prov = str(task_cfg.get("provider", "")).strip()
|
||||
model = str(task_cfg.get("model", "")).strip()
|
||||
base_url = str(task_cfg.get("base_url", "")).strip()
|
||||
api_key = str(task_cfg.get("api_key", "")).strip()
|
||||
if prov and prov != "auto":
|
||||
os.environ[env_map["provider"]] = prov
|
||||
if model:
|
||||
os.environ[env_map["model"]] = model
|
||||
if base_url:
|
||||
os.environ[env_map["base_url"]] = base_url
|
||||
if api_key:
|
||||
os.environ[env_map["api_key"]] = api_key
|
||||
|
||||
|
||||
# ── Config bridging tests ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAuxiliaryConfigBridge:
|
||||
"""Verify the config.yaml → env var bridging logic used by CLI and gateway."""
|
||||
|
||||
def test_vision_provider_bridged(self, monkeypatch):
|
||||
config = {
|
||||
"auxiliary": {
|
||||
"vision": {"provider": "openrouter", "model": ""},
|
||||
"web_extract": {"provider": "auto", "model": ""},
|
||||
}
|
||||
}
|
||||
_run_auxiliary_bridge(config, monkeypatch)
|
||||
assert os.environ.get("AUXILIARY_VISION_PROVIDER") == "openrouter"
|
||||
# auto should not be set
|
||||
assert os.environ.get("AUXILIARY_WEB_EXTRACT_PROVIDER") is None
|
||||
|
||||
def test_vision_model_bridged(self, monkeypatch):
|
||||
config = {
|
||||
"auxiliary": {
|
||||
"vision": {"provider": "auto", "model": "openai/gpt-4o"},
|
||||
}
|
||||
}
|
||||
_run_auxiliary_bridge(config, monkeypatch)
|
||||
assert os.environ.get("AUXILIARY_VISION_MODEL") == "openai/gpt-4o"
|
||||
# auto provider should not be set
|
||||
assert os.environ.get("AUXILIARY_VISION_PROVIDER") is None
|
||||
|
||||
def test_web_extract_bridged(self, monkeypatch):
|
||||
config = {
|
||||
"auxiliary": {
|
||||
"web_extract": {"provider": "nous", "model": "gemini-2.5-flash"},
|
||||
}
|
||||
}
|
||||
_run_auxiliary_bridge(config, monkeypatch)
|
||||
assert os.environ.get("AUXILIARY_WEB_EXTRACT_PROVIDER") == "nous"
|
||||
assert os.environ.get("AUXILIARY_WEB_EXTRACT_MODEL") == "gemini-2.5-flash"
|
||||
|
||||
def test_direct_endpoint_bridged(self, monkeypatch):
|
||||
config = {
|
||||
"auxiliary": {
|
||||
"vision": {
|
||||
"base_url": "http://localhost:1234/v1",
|
||||
"api_key": "local-key",
|
||||
"model": "qwen2.5-vl",
|
||||
}
|
||||
}
|
||||
}
|
||||
_run_auxiliary_bridge(config, monkeypatch)
|
||||
assert os.environ.get("AUXILIARY_VISION_BASE_URL") == "http://localhost:1234/v1"
|
||||
assert os.environ.get("AUXILIARY_VISION_API_KEY") == "local-key"
|
||||
assert os.environ.get("AUXILIARY_VISION_MODEL") == "qwen2.5-vl"
|
||||
|
||||
def test_empty_values_not_bridged(self, monkeypatch):
|
||||
config = {
|
||||
"auxiliary": {
|
||||
"vision": {"provider": "auto", "model": ""},
|
||||
}
|
||||
}
|
||||
_run_auxiliary_bridge(config, monkeypatch)
|
||||
assert os.environ.get("AUXILIARY_VISION_PROVIDER") is None
|
||||
assert os.environ.get("AUXILIARY_VISION_MODEL") is None
|
||||
|
||||
def test_missing_auxiliary_section_safe(self, monkeypatch):
|
||||
"""Config without auxiliary section should not crash."""
|
||||
config = {"model": {"default": "test-model"}}
|
||||
_run_auxiliary_bridge(config, monkeypatch)
|
||||
assert os.environ.get("AUXILIARY_VISION_PROVIDER") is None
|
||||
|
||||
def test_non_dict_task_config_ignored(self, monkeypatch):
|
||||
"""Malformed task config (e.g. string instead of dict) is safely ignored."""
|
||||
config = {
|
||||
"auxiliary": {
|
||||
"vision": "openrouter", # should be a dict
|
||||
}
|
||||
}
|
||||
_run_auxiliary_bridge(config, monkeypatch)
|
||||
assert os.environ.get("AUXILIARY_VISION_PROVIDER") is None
|
||||
|
||||
def test_mixed_tasks(self, monkeypatch):
|
||||
config = {
|
||||
"auxiliary": {
|
||||
"vision": {"provider": "openrouter", "model": ""},
|
||||
"web_extract": {"provider": "auto", "model": "custom-llm"},
|
||||
}
|
||||
}
|
||||
_run_auxiliary_bridge(config, monkeypatch)
|
||||
assert os.environ.get("AUXILIARY_VISION_PROVIDER") == "openrouter"
|
||||
assert os.environ.get("AUXILIARY_VISION_MODEL") is None
|
||||
assert os.environ.get("AUXILIARY_WEB_EXTRACT_PROVIDER") is None
|
||||
assert os.environ.get("AUXILIARY_WEB_EXTRACT_MODEL") == "custom-llm"
|
||||
|
||||
def test_all_tasks_with_overrides(self, monkeypatch):
|
||||
config = {
|
||||
"auxiliary": {
|
||||
"vision": {"provider": "openrouter", "model": "google/gemini-2.5-flash"},
|
||||
"web_extract": {"provider": "nous", "model": "gemini-3-flash"},
|
||||
}
|
||||
}
|
||||
_run_auxiliary_bridge(config, monkeypatch)
|
||||
assert os.environ.get("AUXILIARY_VISION_PROVIDER") == "openrouter"
|
||||
assert os.environ.get("AUXILIARY_VISION_MODEL") == "google/gemini-2.5-flash"
|
||||
assert os.environ.get("AUXILIARY_WEB_EXTRACT_PROVIDER") == "nous"
|
||||
assert os.environ.get("AUXILIARY_WEB_EXTRACT_MODEL") == "gemini-3-flash"
|
||||
|
||||
def test_whitespace_in_values_stripped(self, monkeypatch):
|
||||
config = {
|
||||
"auxiliary": {
|
||||
"vision": {"provider": " openrouter ", "model": " my-model "},
|
||||
}
|
||||
}
|
||||
_run_auxiliary_bridge(config, monkeypatch)
|
||||
assert os.environ.get("AUXILIARY_VISION_PROVIDER") == "openrouter"
|
||||
assert os.environ.get("AUXILIARY_VISION_MODEL") == "my-model"
|
||||
|
||||
def test_empty_auxiliary_dict_safe(self, monkeypatch):
|
||||
config = {"auxiliary": {}}
|
||||
_run_auxiliary_bridge(config, monkeypatch)
|
||||
assert os.environ.get("AUXILIARY_VISION_PROVIDER") is None
|
||||
assert os.environ.get("AUXILIARY_WEB_EXTRACT_PROVIDER") is None
|
||||
|
||||
|
||||
# ── Gateway bridge parity test ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGatewayBridgeCodeParity:
|
||||
"""Verify the gateway/run.py config bridge contains the auxiliary section."""
|
||||
|
||||
def test_gateway_has_auxiliary_bridge(self):
|
||||
"""The gateway config bridge must include auxiliary.* bridging."""
|
||||
gateway_path = Path(__file__).parent.parent.parent / "gateway" / "run.py"
|
||||
content = gateway_path.read_text()
|
||||
# Check for key patterns that indicate the bridge is present
|
||||
assert "AUXILIARY_VISION_PROVIDER" in content
|
||||
assert "AUXILIARY_VISION_MODEL" in content
|
||||
assert "AUXILIARY_VISION_BASE_URL" in content
|
||||
assert "AUXILIARY_VISION_API_KEY" in content
|
||||
assert "AUXILIARY_WEB_EXTRACT_PROVIDER" in content
|
||||
assert "AUXILIARY_WEB_EXTRACT_MODEL" in content
|
||||
assert "AUXILIARY_WEB_EXTRACT_BASE_URL" in content
|
||||
assert "AUXILIARY_WEB_EXTRACT_API_KEY" in content
|
||||
|
||||
def test_gateway_no_compression_env_bridge(self):
|
||||
"""Gateway should NOT bridge compression config to env vars (config-only)."""
|
||||
gateway_path = Path(__file__).parent.parent.parent / "gateway" / "run.py"
|
||||
content = gateway_path.read_text()
|
||||
assert "CONTEXT_COMPRESSION_PROVIDER" not in content
|
||||
assert "CONTEXT_COMPRESSION_MODEL" not in content
|
||||
|
||||
|
||||
# ── Vision model override tests ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVisionModelOverride:
|
||||
"""Test that AUXILIARY_VISION_MODEL env var overrides the default model in the handler."""
|
||||
|
||||
def test_env_var_overrides_default(self, monkeypatch):
|
||||
monkeypatch.setenv("AUXILIARY_VISION_MODEL", "openai/gpt-4o")
|
||||
from tools.vision_tools import _handle_vision_analyze
|
||||
with patch("tools.vision_tools.vision_analyze_tool", new_callable=MagicMock) as mock_tool:
|
||||
mock_tool.return_value = '{"success": true}'
|
||||
_handle_vision_analyze({"image_url": "http://test.jpg", "question": "test"})
|
||||
call_args = mock_tool.call_args
|
||||
# 3rd positional arg = model
|
||||
assert call_args[0][2] == "openai/gpt-4o"
|
||||
|
||||
def test_default_model_when_no_override(self, monkeypatch):
|
||||
monkeypatch.delenv("AUXILIARY_VISION_MODEL", raising=False)
|
||||
from tools.vision_tools import _handle_vision_analyze
|
||||
with patch("tools.vision_tools.vision_analyze_tool", new_callable=MagicMock) as mock_tool:
|
||||
mock_tool.return_value = '{"success": true}'
|
||||
_handle_vision_analyze({"image_url": "http://test.jpg", "question": "test"})
|
||||
call_args = mock_tool.call_args
|
||||
# With no AUXILIARY_VISION_MODEL env var, model should be None
|
||||
# (the centralized call_llm router picks the provider default)
|
||||
assert call_args[0][2] is None
|
||||
|
||||
|
||||
# ── DEFAULT_CONFIG shape tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDefaultConfigShape:
|
||||
"""Verify the DEFAULT_CONFIG in hermes_cli/config.py has correct auxiliary structure."""
|
||||
|
||||
def test_auxiliary_section_exists(self):
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
assert "auxiliary" in DEFAULT_CONFIG
|
||||
|
||||
def test_vision_task_structure(self):
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
vision = DEFAULT_CONFIG["auxiliary"]["vision"]
|
||||
assert "provider" in vision
|
||||
assert "model" in vision
|
||||
assert vision["provider"] == "auto"
|
||||
assert vision["model"] == ""
|
||||
|
||||
def test_web_extract_task_structure(self):
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
web = DEFAULT_CONFIG["auxiliary"]["web_extract"]
|
||||
assert "provider" in web
|
||||
assert "model" in web
|
||||
assert web["provider"] == "auto"
|
||||
assert web["model"] == ""
|
||||
|
||||
def test_compression_provider_default(self):
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
compression = DEFAULT_CONFIG["compression"]
|
||||
assert "summary_provider" in compression
|
||||
assert compression["summary_provider"] == "auto"
|
||||
|
||||
def test_compression_base_url_default(self):
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
compression = DEFAULT_CONFIG["compression"]
|
||||
assert "summary_base_url" in compression
|
||||
assert compression["summary_base_url"] is None
|
||||
|
||||
|
||||
# ── CLI defaults parity ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCLIDefaultsHaveAuxiliaryKeys:
|
||||
"""Verify cli.py load_cli_config() defaults dict does NOT include auxiliary
|
||||
(it comes from config.yaml deep merge, not hardcoded defaults)."""
|
||||
|
||||
def test_cli_defaults_can_merge_auxiliary(self):
|
||||
"""The load_cli_config deep merge logic handles keys not in defaults.
|
||||
Verify auxiliary would be picked up from config.yaml."""
|
||||
# This is a structural assertion: cli.py's second-pass loop
|
||||
# carries over keys from file_config that aren't in defaults.
|
||||
# So auxiliary config from config.yaml gets merged even though
|
||||
# cli.py's defaults dict doesn't define it.
|
||||
import cli as _cli_mod
|
||||
source = Path(_cli_mod.__file__).read_text()
|
||||
assert "auxiliary_config = defaults.get(\"auxiliary\"" in source
|
||||
assert "AUXILIARY_VISION_PROVIDER" in source
|
||||
assert "AUXILIARY_VISION_MODEL" in source
|
||||
268
tests/agent/test_context_references.py
Normal file
268
tests/agent/test_context_references.py
Normal file
@@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
_git(repo, "init")
|
||||
_git(repo, "config", "user.name", "Hermes Tests")
|
||||
_git(repo, "config", "user.email", "tests@example.com")
|
||||
|
||||
(repo / "src").mkdir()
|
||||
(repo / "src" / "main.py").write_text(
|
||||
"def alpha():\n"
|
||||
" return 'a'\n\n"
|
||||
"def beta():\n"
|
||||
" return 'b'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(repo / "src" / "helper.py").write_text("VALUE = 1\n", encoding="utf-8")
|
||||
(repo / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repo / "blob.bin").write_bytes(b"\x00\x01\x02binary")
|
||||
|
||||
_git(repo, "add", ".")
|
||||
_git(repo, "commit", "-m", "initial")
|
||||
|
||||
(repo / "src" / "main.py").write_text(
|
||||
"def alpha():\n"
|
||||
" return 'changed'\n\n"
|
||||
"def beta():\n"
|
||||
" return 'b'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(repo / "src" / "helper.py").write_text("VALUE = 2\n", encoding="utf-8")
|
||||
_git(repo, "add", "src/helper.py")
|
||||
return repo
|
||||
|
||||
|
||||
def test_parse_typed_references_ignores_emails_and_handles():
|
||||
from agent.context_references import parse_context_references
|
||||
|
||||
message = (
|
||||
"email me at user@example.com and ping @teammate "
|
||||
"but include @file:src/main.py:1-2 plus @diff and @git:2 "
|
||||
"and @url:https://example.com/docs"
|
||||
)
|
||||
|
||||
refs = parse_context_references(message)
|
||||
|
||||
assert [ref.kind for ref in refs] == ["file", "diff", "git", "url"]
|
||||
assert refs[0].target == "src/main.py"
|
||||
assert refs[0].line_start == 1
|
||||
assert refs[0].line_end == 2
|
||||
assert refs[2].target == "2"
|
||||
|
||||
|
||||
def test_parse_references_strips_trailing_punctuation():
|
||||
from agent.context_references import parse_context_references
|
||||
|
||||
refs = parse_context_references(
|
||||
"review @file:README.md, then see (@url:https://example.com/docs)."
|
||||
)
|
||||
|
||||
assert [ref.kind for ref in refs] == ["file", "url"]
|
||||
assert refs[0].target == "README.md"
|
||||
assert refs[1].target == "https://example.com/docs"
|
||||
|
||||
|
||||
def test_expand_file_range_and_folder_listing(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
result = preprocess_context_references(
|
||||
"Review @file:src/main.py:1-2 and @folder:src/",
|
||||
cwd=sample_repo,
|
||||
context_length=100_000,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "Review and" in result.message
|
||||
assert "Review @file:src/main.py:1-2" not in result.message
|
||||
assert "--- Attached Context ---" in result.message
|
||||
assert "def alpha():" in result.message
|
||||
assert "return 'changed'" in result.message
|
||||
assert "def beta():" not in result.message
|
||||
assert "src/" in result.message
|
||||
assert "main.py" in result.message
|
||||
assert "helper.py" in result.message
|
||||
assert result.injected_tokens > 0
|
||||
assert not result.warnings
|
||||
|
||||
|
||||
def test_expand_git_diff_staged_and_log(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
result = preprocess_context_references(
|
||||
"Inspect @diff and @staged and @git:1",
|
||||
cwd=sample_repo,
|
||||
context_length=100_000,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "git diff" in result.message
|
||||
assert "git diff --staged" in result.message
|
||||
assert "git log -1 -p" in result.message
|
||||
assert "initial" in result.message
|
||||
assert "return 'changed'" in result.message
|
||||
assert "VALUE = 2" in result.message
|
||||
|
||||
|
||||
def test_binary_and_missing_files_become_warnings(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
result = preprocess_context_references(
|
||||
"Check @file:blob.bin and @file:nope.txt",
|
||||
cwd=sample_repo,
|
||||
context_length=100_000,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert len(result.warnings) == 2
|
||||
assert "binary" in result.message.lower()
|
||||
assert "not found" in result.message.lower()
|
||||
|
||||
|
||||
def test_soft_budget_warns_and_hard_budget_refuses(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
soft = preprocess_context_references(
|
||||
"Check @file:src/main.py",
|
||||
cwd=sample_repo,
|
||||
context_length=100,
|
||||
)
|
||||
assert soft.expanded
|
||||
assert any("25%" in warning for warning in soft.warnings)
|
||||
|
||||
hard = preprocess_context_references(
|
||||
"Check @file:src/main.py and @file:README.md",
|
||||
cwd=sample_repo,
|
||||
context_length=20,
|
||||
)
|
||||
assert not hard.expanded
|
||||
assert hard.blocked
|
||||
assert "@file:src/main.py" in hard.message
|
||||
assert any("50%" in warning for warning in hard.warnings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_url_expansion_uses_fetcher(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references_async
|
||||
|
||||
async def fake_fetch(url: str) -> str:
|
||||
assert url == "https://example.com/spec"
|
||||
return "# Spec\n\nImportant details."
|
||||
|
||||
result = await preprocess_context_references_async(
|
||||
"Use @url:https://example.com/spec",
|
||||
cwd=sample_repo,
|
||||
context_length=100_000,
|
||||
url_fetcher=fake_fetch,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "Important details." in result.message
|
||||
assert result.injected_tokens > 0
|
||||
|
||||
|
||||
def test_sync_url_expansion_uses_async_fetcher(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
async def fake_fetch(url: str) -> str:
|
||||
await asyncio.sleep(0)
|
||||
return f"Content for {url}"
|
||||
|
||||
result = preprocess_context_references(
|
||||
"Use @url:https://example.com/spec",
|
||||
cwd=sample_repo,
|
||||
context_length=100_000,
|
||||
url_fetcher=fake_fetch,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "Content for https://example.com/spec" in result.message
|
||||
|
||||
|
||||
def test_restricts_paths_to_allowed_root(tmp_path: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "notes.txt").write_text("inside\n", encoding="utf-8")
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("outside\n", encoding="utf-8")
|
||||
|
||||
result = preprocess_context_references(
|
||||
"read @file:../secret.txt and @file:notes.txt",
|
||||
cwd=workspace,
|
||||
context_length=100_000,
|
||||
allowed_root=workspace,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "```\noutside\n```" not in result.message
|
||||
assert "inside" in result.message
|
||||
assert any("outside the allowed workspace" in warning for warning in result.warnings)
|
||||
|
||||
|
||||
def test_defaults_allowed_root_to_cwd(tmp_path: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("outside\n", encoding="utf-8")
|
||||
|
||||
result = preprocess_context_references(
|
||||
f"read @file:{secret}",
|
||||
cwd=workspace,
|
||||
context_length=100_000,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "```\noutside\n```" not in result.message
|
||||
assert any("outside the allowed workspace" in warning for warning in result.warnings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocks_sensitive_home_and_hermes_paths(tmp_path: Path, monkeypatch):
|
||||
from agent.context_references import preprocess_context_references_async
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
|
||||
hermes_env = tmp_path / ".hermes" / ".env"
|
||||
hermes_env.parent.mkdir(parents=True)
|
||||
hermes_env.write_text("API_KEY=super-secret\n", encoding="utf-8")
|
||||
|
||||
ssh_key = tmp_path / ".ssh" / "id_rsa"
|
||||
ssh_key.parent.mkdir(parents=True)
|
||||
ssh_key.write_text("PRIVATE-KEY\n", encoding="utf-8")
|
||||
|
||||
result = await preprocess_context_references_async(
|
||||
"read @file:.hermes/.env and @file:.ssh/id_rsa",
|
||||
cwd=tmp_path,
|
||||
allowed_root=tmp_path,
|
||||
context_length=100_000,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "API_KEY=super-secret" not in result.message
|
||||
assert "PRIVATE-KEY" not in result.message
|
||||
assert any("sensitive credential" in warning for warning in result.warnings)
|
||||
1057
tests/agent/test_credential_pool.py
Normal file
1057
tests/agent/test_credential_pool.py
Normal file
File diff suppressed because it is too large
Load Diff
350
tests/agent/test_credential_pool_routing.py
Normal file
350
tests/agent/test_credential_pool_routing.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""Tests for credential pool preservation through smart routing and 429 recovery.
|
||||
|
||||
Covers:
|
||||
1. credential_pool flows through resolve_turn_route (no-route and fallback paths)
|
||||
2. CLI _resolve_turn_agent_config passes credential_pool to primary dict
|
||||
3. Gateway _resolve_turn_agent_config passes credential_pool to primary dict
|
||||
4. Eager fallback deferred when credential pool has credentials
|
||||
5. Eager fallback fires when no credential pool exists
|
||||
6. Full 429 rotation cycle: retry-same → rotate → exhaust → fallback
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. smart_model_routing: credential_pool preserved in no-route path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSmartRoutingPoolPreservation:
|
||||
def test_no_route_preserves_credential_pool(self):
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
|
||||
fake_pool = MagicMock(name="CredentialPool")
|
||||
primary = {
|
||||
"model": "gpt-5.4",
|
||||
"api_key": "sk-test",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": fake_pool,
|
||||
}
|
||||
# routing disabled
|
||||
result = resolve_turn_route("hello", None, primary)
|
||||
assert result["runtime"]["credential_pool"] is fake_pool
|
||||
|
||||
def test_no_route_none_pool(self):
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
|
||||
primary = {
|
||||
"model": "gpt-5.4",
|
||||
"api_key": "sk-test",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
}
|
||||
result = resolve_turn_route("hello", None, primary)
|
||||
assert result["runtime"]["credential_pool"] is None
|
||||
|
||||
def test_routing_disabled_preserves_pool(self):
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
|
||||
fake_pool = MagicMock(name="CredentialPool")
|
||||
primary = {
|
||||
"model": "gpt-5.4",
|
||||
"api_key": "sk-test",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": fake_pool,
|
||||
}
|
||||
# routing explicitly disabled
|
||||
result = resolve_turn_route("hello", {"enabled": False}, primary)
|
||||
assert result["runtime"]["credential_pool"] is fake_pool
|
||||
|
||||
def test_route_fallback_on_resolve_error_preserves_pool(self, monkeypatch):
|
||||
"""When smart routing picks a cheap model but resolve_runtime_provider
|
||||
fails, the fallback to primary must still include credential_pool."""
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
|
||||
fake_pool = MagicMock(name="CredentialPool")
|
||||
primary = {
|
||||
"model": "gpt-5.4",
|
||||
"api_key": "sk-test",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": fake_pool,
|
||||
}
|
||||
routing_config = {
|
||||
"enabled": True,
|
||||
"cheap_model": "openai/gpt-4.1-mini",
|
||||
"cheap_provider": "openrouter",
|
||||
"max_tokens": 200,
|
||||
"patterns": ["^(hi|hello|hey)"],
|
||||
}
|
||||
# Force resolve_runtime_provider to fail so it falls back to primary
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
MagicMock(side_effect=RuntimeError("no credentials")),
|
||||
)
|
||||
result = resolve_turn_route("hi", routing_config, primary)
|
||||
assert result["runtime"]["credential_pool"] is fake_pool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2 & 3. CLI and Gateway _resolve_turn_agent_config include credential_pool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCliTurnRoutePool:
|
||||
def test_resolve_turn_includes_pool(self, monkeypatch, tmp_path):
|
||||
"""CLI's _resolve_turn_agent_config must pass credential_pool to primary."""
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
captured = {}
|
||||
|
||||
def spy_resolve(user_message, routing_config, primary):
|
||||
captured["primary"] = primary
|
||||
return resolve_turn_route(user_message, routing_config, primary)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent.smart_model_routing.resolve_turn_route", spy_resolve
|
||||
)
|
||||
|
||||
# Build a minimal HermesCLI-like object with the method
|
||||
shell = SimpleNamespace(
|
||||
model="gpt-5.4",
|
||||
api_key="sk-test",
|
||||
base_url=None,
|
||||
provider="openai-codex",
|
||||
api_mode="codex_responses",
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=MagicMock(name="FakePool"),
|
||||
_smart_model_routing={"enabled": False},
|
||||
)
|
||||
|
||||
# Import and bind the real method
|
||||
from cli import HermesCLI
|
||||
bound = HermesCLI._resolve_turn_agent_config.__get__(shell)
|
||||
bound("test message")
|
||||
|
||||
assert "credential_pool" in captured["primary"]
|
||||
assert captured["primary"]["credential_pool"] is shell._credential_pool
|
||||
|
||||
|
||||
class TestGatewayTurnRoutePool:
|
||||
def test_resolve_turn_includes_pool(self, monkeypatch):
|
||||
"""Gateway's _resolve_turn_agent_config must pass credential_pool."""
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
captured = {}
|
||||
|
||||
def spy_resolve(user_message, routing_config, primary):
|
||||
captured["primary"] = primary
|
||||
return resolve_turn_route(user_message, routing_config, primary)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent.smart_model_routing.resolve_turn_route", spy_resolve
|
||||
)
|
||||
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = SimpleNamespace(
|
||||
_smart_model_routing={"enabled": False},
|
||||
)
|
||||
|
||||
runtime_kwargs = {
|
||||
"api_key": "sk-test",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": MagicMock(name="FakePool"),
|
||||
}
|
||||
|
||||
bound = GatewayRunner._resolve_turn_agent_config.__get__(runner)
|
||||
bound("test message", "gpt-5.4", runtime_kwargs)
|
||||
|
||||
assert "credential_pool" in captured["primary"]
|
||||
assert captured["primary"]["credential_pool"] is runtime_kwargs["credential_pool"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4 & 5. Eager fallback deferred/fires based on credential pool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEagerFallbackWithPool:
|
||||
"""Test the eager fallback guard in run_agent.py's error handling loop."""
|
||||
|
||||
def _make_agent(self, has_pool=True, pool_has_creds=True, has_fallback=True):
|
||||
"""Create a minimal AIAgent mock with the fields needed."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
with patch.object(AIAgent, "__init__", lambda self, **kw: None):
|
||||
agent = AIAgent()
|
||||
|
||||
agent._credential_pool = None
|
||||
if has_pool:
|
||||
pool = MagicMock()
|
||||
pool.has_available.return_value = pool_has_creds
|
||||
agent._credential_pool = pool
|
||||
|
||||
agent._fallback_chain = [{"model": "fallback/model"}] if has_fallback else []
|
||||
agent._fallback_index = 0
|
||||
agent._try_activate_fallback = MagicMock(return_value=True)
|
||||
agent._emit_status = MagicMock()
|
||||
|
||||
return agent
|
||||
|
||||
def test_eager_fallback_deferred_when_pool_has_credentials(self):
|
||||
"""429 with active pool should NOT trigger eager fallback."""
|
||||
agent = self._make_agent(has_pool=True, pool_has_creds=True, has_fallback=True)
|
||||
|
||||
# Simulate the check from run_agent.py lines 7180-7191
|
||||
is_rate_limited = True
|
||||
if is_rate_limited and agent._fallback_index < len(agent._fallback_chain):
|
||||
pool = agent._credential_pool
|
||||
pool_may_recover = pool is not None and pool.has_available()
|
||||
if not pool_may_recover:
|
||||
agent._try_activate_fallback()
|
||||
|
||||
agent._try_activate_fallback.assert_not_called()
|
||||
|
||||
def test_eager_fallback_fires_when_no_pool(self):
|
||||
"""429 without pool should trigger eager fallback."""
|
||||
agent = self._make_agent(has_pool=False, has_fallback=True)
|
||||
|
||||
is_rate_limited = True
|
||||
if is_rate_limited and agent._fallback_index < len(agent._fallback_chain):
|
||||
pool = agent._credential_pool
|
||||
pool_may_recover = pool is not None and pool.has_available()
|
||||
if not pool_may_recover:
|
||||
agent._try_activate_fallback()
|
||||
|
||||
agent._try_activate_fallback.assert_called_once()
|
||||
|
||||
def test_eager_fallback_fires_when_pool_exhausted(self):
|
||||
"""429 with exhausted pool should trigger eager fallback."""
|
||||
agent = self._make_agent(has_pool=True, pool_has_creds=False, has_fallback=True)
|
||||
|
||||
is_rate_limited = True
|
||||
if is_rate_limited and agent._fallback_index < len(agent._fallback_chain):
|
||||
pool = agent._credential_pool
|
||||
pool_may_recover = pool is not None and pool.has_available()
|
||||
if not pool_may_recover:
|
||||
agent._try_activate_fallback()
|
||||
|
||||
agent._try_activate_fallback.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Full 429 rotation cycle via _recover_with_credential_pool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPoolRotationCycle:
|
||||
"""Verify the retry-same → rotate → exhaust flow in _recover_with_credential_pool."""
|
||||
|
||||
def _make_agent_with_pool(self, pool_entries=3):
|
||||
from run_agent import AIAgent
|
||||
|
||||
with patch.object(AIAgent, "__init__", lambda self, **kw: None):
|
||||
agent = AIAgent()
|
||||
|
||||
entries = []
|
||||
for i in range(pool_entries):
|
||||
e = MagicMock(name=f"entry_{i}")
|
||||
e.id = f"cred-{i}"
|
||||
entries.append(e)
|
||||
|
||||
pool = MagicMock()
|
||||
pool.has_credentials.return_value = True
|
||||
|
||||
# mark_exhausted_and_rotate returns next entry until exhausted
|
||||
self._rotation_index = 0
|
||||
|
||||
def rotate(status_code=None, error_context=None):
|
||||
self._rotation_index += 1
|
||||
if self._rotation_index < pool_entries:
|
||||
return entries[self._rotation_index]
|
||||
pool.has_credentials.return_value = False
|
||||
return None
|
||||
|
||||
pool.mark_exhausted_and_rotate = MagicMock(side_effect=rotate)
|
||||
agent._credential_pool = pool
|
||||
agent._swap_credential = MagicMock()
|
||||
agent.log_prefix = ""
|
||||
|
||||
return agent, pool, entries
|
||||
|
||||
def test_first_429_sets_retry_flag_no_rotation(self):
|
||||
"""First 429 should just set has_retried_429=True, no rotation."""
|
||||
agent, pool, _ = self._make_agent_with_pool(3)
|
||||
recovered, has_retried = agent._recover_with_credential_pool(
|
||||
status_code=429, has_retried_429=False
|
||||
)
|
||||
assert recovered is False
|
||||
assert has_retried is True
|
||||
pool.mark_exhausted_and_rotate.assert_not_called()
|
||||
|
||||
def test_second_429_rotates_to_next(self):
|
||||
"""Second consecutive 429 should rotate to next credential."""
|
||||
agent, pool, entries = self._make_agent_with_pool(3)
|
||||
recovered, has_retried = agent._recover_with_credential_pool(
|
||||
status_code=429, has_retried_429=True
|
||||
)
|
||||
assert recovered is True
|
||||
assert has_retried is False # reset after rotation
|
||||
pool.mark_exhausted_and_rotate.assert_called_once_with(status_code=429, error_context=None)
|
||||
agent._swap_credential.assert_called_once_with(entries[1])
|
||||
|
||||
def test_pool_exhaustion_returns_false(self):
|
||||
"""When all credentials exhausted, recovery should return False."""
|
||||
agent, pool, _ = self._make_agent_with_pool(1)
|
||||
# First 429 sets flag
|
||||
_, has_retried = agent._recover_with_credential_pool(
|
||||
status_code=429, has_retried_429=False
|
||||
)
|
||||
assert has_retried is True
|
||||
|
||||
# Second 429 tries to rotate but pool is exhausted (only 1 entry)
|
||||
recovered, _ = agent._recover_with_credential_pool(
|
||||
status_code=429, has_retried_429=True
|
||||
)
|
||||
assert recovered is False
|
||||
|
||||
def test_402_immediate_rotation(self):
|
||||
"""402 (billing) should immediately rotate, no retry-first."""
|
||||
agent, pool, entries = self._make_agent_with_pool(3)
|
||||
recovered, has_retried = agent._recover_with_credential_pool(
|
||||
status_code=402, has_retried_429=False
|
||||
)
|
||||
assert recovered is True
|
||||
assert has_retried is False
|
||||
pool.mark_exhausted_and_rotate.assert_called_once_with(status_code=402, error_context=None)
|
||||
|
||||
def test_no_pool_returns_false(self):
|
||||
"""No pool should return (False, unchanged)."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
with patch.object(AIAgent, "__init__", lambda self, **kw: None):
|
||||
agent = AIAgent()
|
||||
agent._credential_pool = None
|
||||
|
||||
recovered, has_retried = agent._recover_with_credential_pool(
|
||||
status_code=429, has_retried_429=False
|
||||
)
|
||||
assert recovered is False
|
||||
assert has_retried is False
|
||||
186
tests/agent/test_crossloop_client_cache.py
Normal file
186
tests/agent/test_crossloop_client_cache.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Tests for cross-loop client cache isolation fix (#2681).
|
||||
|
||||
Verifies that _get_cached_client() returns different AsyncOpenAI clients
|
||||
when called from different event loops, preventing the httpx deadlock
|
||||
that occurs when a cached async client bound to loop A is reused on loop B.
|
||||
|
||||
This test file is self-contained and does not import the full tool chain,
|
||||
so it can run without optional dependencies like firecrawl.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import patch, MagicMock
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal stubs so we can import _get_cached_client without the full tree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _stub_resolve_provider_client(provider, model, async_mode, **kw):
|
||||
"""Return a unique mock client each time, simulating AsyncOpenAI creation."""
|
||||
client = MagicMock(name=f"client-{provider}-async={async_mode}")
|
||||
client.api_key = "test"
|
||||
client.base_url = kw.get("explicit_base_url", "http://localhost:8081/v1")
|
||||
return client, model or "test-model"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_client_cache():
|
||||
"""Clear the client cache before each test."""
|
||||
import importlib
|
||||
# We need to patch before importing
|
||||
with patch.dict("sys.modules", {}):
|
||||
pass
|
||||
# Import and clear
|
||||
import agent.auxiliary_client as ac
|
||||
ac._client_cache.clear()
|
||||
yield
|
||||
ac._client_cache.clear()
|
||||
|
||||
|
||||
class TestCrossLoopCacheIsolation:
|
||||
"""Verify async clients are cached per-event-loop, not globally."""
|
||||
|
||||
def test_same_loop_reuses_client(self):
|
||||
"""Within a single event loop, the same client should be returned."""
|
||||
from agent.auxiliary_client import _get_cached_client, _client_cache
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
with patch("agent.auxiliary_client.resolve_provider_client",
|
||||
side_effect=_stub_resolve_provider_client):
|
||||
client1, _ = _get_cached_client("custom", "m1", async_mode=True,
|
||||
base_url="http://localhost:8081/v1")
|
||||
client2, _ = _get_cached_client("custom", "m1", async_mode=True,
|
||||
base_url="http://localhost:8081/v1")
|
||||
|
||||
assert client1 is client2, (
|
||||
"Same loop should return the same cached client"
|
||||
)
|
||||
loop.close()
|
||||
|
||||
def test_different_loops_get_different_clients(self):
|
||||
"""Different event loops must get separate client instances."""
|
||||
from agent.auxiliary_client import _get_cached_client
|
||||
|
||||
results = {}
|
||||
|
||||
def _get_client_on_new_loop(name):
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
with patch("agent.auxiliary_client.resolve_provider_client",
|
||||
side_effect=_stub_resolve_provider_client):
|
||||
client, _ = _get_cached_client("custom", "m1", async_mode=True,
|
||||
base_url="http://localhost:8081/v1")
|
||||
results[name] = (id(client), id(loop))
|
||||
# Don't close loop — simulates real usage where loops persist
|
||||
|
||||
t1 = threading.Thread(target=_get_client_on_new_loop, args=("a",))
|
||||
t2 = threading.Thread(target=_get_client_on_new_loop, args=("b",))
|
||||
t1.start(); t1.join()
|
||||
t2.start(); t2.join()
|
||||
|
||||
client_id_a, loop_id_a = results["a"]
|
||||
client_id_b, loop_id_b = results["b"]
|
||||
|
||||
assert loop_id_a != loop_id_b, "Test setup error: same loop on both threads"
|
||||
assert client_id_a != client_id_b, (
|
||||
"Different event loops got the SAME cached client — this causes "
|
||||
"httpx cross-loop deadlocks in gateway mode (#2681)"
|
||||
)
|
||||
|
||||
def test_sync_clients_not_affected(self):
|
||||
"""Sync clients (async_mode=False) should still be cached globally,
|
||||
since httpx.Client (sync) doesn't bind to an event loop."""
|
||||
from agent.auxiliary_client import _get_cached_client
|
||||
|
||||
results = {}
|
||||
|
||||
def _get_sync_client(name):
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
with patch("agent.auxiliary_client.resolve_provider_client",
|
||||
side_effect=_stub_resolve_provider_client):
|
||||
client, _ = _get_cached_client("custom", "m1", async_mode=False,
|
||||
base_url="http://localhost:8081/v1")
|
||||
results[name] = id(client)
|
||||
|
||||
t1 = threading.Thread(target=_get_sync_client, args=("a",))
|
||||
t2 = threading.Thread(target=_get_sync_client, args=("b",))
|
||||
t1.start(); t1.join()
|
||||
t2.start(); t2.join()
|
||||
|
||||
assert results["a"] == results["b"], (
|
||||
"Sync clients should be shared across threads (no loop binding)"
|
||||
)
|
||||
|
||||
def test_gateway_simulation_no_deadlock(self):
|
||||
"""Simulate gateway mode: _run_async spawns a thread with asyncio.run(),
|
||||
which creates a new loop. The cached client must be created on THAT loop,
|
||||
not reused from a different one."""
|
||||
from agent.auxiliary_client import _get_cached_client
|
||||
|
||||
# Simulate: first call on "gateway loop"
|
||||
gateway_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(gateway_loop)
|
||||
|
||||
with patch("agent.auxiliary_client.resolve_provider_client",
|
||||
side_effect=_stub_resolve_provider_client):
|
||||
gateway_client, _ = _get_cached_client("custom", "m1", async_mode=True,
|
||||
base_url="http://localhost:8081/v1")
|
||||
|
||||
# Simulate: _run_async spawns a thread with asyncio.run()
|
||||
worker_client_id = [None]
|
||||
def _worker():
|
||||
async def _inner():
|
||||
with patch("agent.auxiliary_client.resolve_provider_client",
|
||||
side_effect=_stub_resolve_provider_client):
|
||||
client, _ = _get_cached_client("custom", "m1", async_mode=True,
|
||||
base_url="http://localhost:8081/v1")
|
||||
worker_client_id[0] = id(client)
|
||||
asyncio.run(_inner())
|
||||
|
||||
t = threading.Thread(target=_worker)
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
assert worker_client_id[0] != id(gateway_client), (
|
||||
"Worker thread (asyncio.run) got the gateway's cached client — "
|
||||
"this is the exact cross-loop scenario that causes httpx deadlocks. "
|
||||
"The cache key must include the event loop identity (#2681)"
|
||||
)
|
||||
gateway_loop.close()
|
||||
|
||||
def test_closed_loop_client_discarded(self):
|
||||
"""A cached client whose loop has closed should be replaced."""
|
||||
from agent.auxiliary_client import _get_cached_client
|
||||
|
||||
loop1 = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop1)
|
||||
|
||||
with patch("agent.auxiliary_client.resolve_provider_client",
|
||||
side_effect=_stub_resolve_provider_client):
|
||||
client1, _ = _get_cached_client("custom", "m1", async_mode=True,
|
||||
base_url="http://localhost:8081/v1")
|
||||
|
||||
loop1.close()
|
||||
|
||||
# New loop on same thread
|
||||
loop2 = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop2)
|
||||
|
||||
with patch("agent.auxiliary_client.resolve_provider_client",
|
||||
side_effect=_stub_resolve_provider_client):
|
||||
client2, _ = _get_cached_client("custom", "m1", async_mode=True,
|
||||
base_url="http://localhost:8081/v1")
|
||||
|
||||
assert client1 is not client2, (
|
||||
"Client from closed loop should not be reused"
|
||||
)
|
||||
loop2.close()
|
||||
202
tests/agent/test_display.py
Normal file
202
tests/agent/test_display.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""Tests for agent/display.py — build_tool_preview() and inline diff previews."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from agent.display import (
|
||||
build_tool_preview,
|
||||
capture_local_edit_snapshot,
|
||||
extract_edit_diff,
|
||||
_render_inline_unified_diff,
|
||||
_summarize_rendered_diff_sections,
|
||||
render_edit_diff_with_delta,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildToolPreview:
|
||||
"""Tests for build_tool_preview defensive handling and normal operation."""
|
||||
|
||||
def test_none_args_returns_none(self):
|
||||
"""PR #453: None args should not crash, should return None."""
|
||||
assert build_tool_preview("terminal", None) is None
|
||||
|
||||
def test_empty_dict_returns_none(self):
|
||||
"""Empty dict has no keys to preview."""
|
||||
assert build_tool_preview("terminal", {}) is None
|
||||
|
||||
def test_known_tool_with_primary_arg(self):
|
||||
"""Known tool with its primary arg should return a preview string."""
|
||||
result = build_tool_preview("terminal", {"command": "ls -la"})
|
||||
assert result is not None
|
||||
assert "ls -la" in result
|
||||
|
||||
def test_web_search_preview(self):
|
||||
result = build_tool_preview("web_search", {"query": "hello world"})
|
||||
assert result is not None
|
||||
assert "hello world" in result
|
||||
|
||||
def test_read_file_preview(self):
|
||||
result = build_tool_preview("read_file", {"path": "/tmp/test.py", "offset": 1})
|
||||
assert result is not None
|
||||
assert "/tmp/test.py" in result
|
||||
|
||||
def test_unknown_tool_with_fallback_key(self):
|
||||
"""Unknown tool but with a recognized fallback key should still preview."""
|
||||
result = build_tool_preview("custom_tool", {"query": "test query"})
|
||||
assert result is not None
|
||||
assert "test query" in result
|
||||
|
||||
def test_unknown_tool_no_matching_key(self):
|
||||
"""Unknown tool with no recognized keys should return None."""
|
||||
result = build_tool_preview("custom_tool", {"foo": "bar"})
|
||||
assert result is None
|
||||
|
||||
def test_long_value_truncated(self):
|
||||
"""Preview should truncate long values."""
|
||||
long_cmd = "a" * 100
|
||||
result = build_tool_preview("terminal", {"command": long_cmd}, max_len=40)
|
||||
assert result is not None
|
||||
assert len(result) <= 43 # max_len + "..."
|
||||
|
||||
def test_process_tool_with_none_args(self):
|
||||
"""Process tool special case should also handle None args."""
|
||||
assert build_tool_preview("process", None) is None
|
||||
|
||||
def test_process_tool_normal(self):
|
||||
result = build_tool_preview("process", {"action": "poll", "session_id": "abc123"})
|
||||
assert result is not None
|
||||
assert "poll" in result
|
||||
|
||||
def test_todo_tool_read(self):
|
||||
result = build_tool_preview("todo", {"merge": False})
|
||||
assert result is not None
|
||||
assert "reading" in result
|
||||
|
||||
def test_todo_tool_with_todos(self):
|
||||
result = build_tool_preview("todo", {"todos": [{"id": "1", "content": "test", "status": "pending"}]})
|
||||
assert result is not None
|
||||
assert "1 task" in result
|
||||
|
||||
def test_memory_tool_add(self):
|
||||
result = build_tool_preview("memory", {"action": "add", "target": "user", "content": "test note"})
|
||||
assert result is not None
|
||||
assert "user" in result
|
||||
|
||||
def test_session_search_preview(self):
|
||||
result = build_tool_preview("session_search", {"query": "find something"})
|
||||
assert result is not None
|
||||
assert "find something" in result
|
||||
|
||||
def test_false_like_args_zero(self):
|
||||
"""Non-dict falsy values should return None, not crash."""
|
||||
assert build_tool_preview("terminal", 0) is None
|
||||
assert build_tool_preview("terminal", "") is None
|
||||
assert build_tool_preview("terminal", []) is None
|
||||
|
||||
|
||||
class TestEditDiffPreview:
|
||||
def test_extract_edit_diff_for_patch(self):
|
||||
diff = extract_edit_diff("patch", '{"success": true, "diff": "--- a/x\\n+++ b/x\\n"}')
|
||||
assert diff is not None
|
||||
assert "+++ b/x" in diff
|
||||
|
||||
def test_render_inline_unified_diff_colors_added_and_removed_lines(self):
|
||||
rendered = _render_inline_unified_diff(
|
||||
"--- a/cli.py\n"
|
||||
"+++ b/cli.py\n"
|
||||
"@@ -1,2 +1,2 @@\n"
|
||||
"-old line\n"
|
||||
"+new line\n"
|
||||
" context\n"
|
||||
)
|
||||
|
||||
assert "a/cli.py" in rendered[0]
|
||||
assert "b/cli.py" in rendered[0]
|
||||
assert any("old line" in line for line in rendered)
|
||||
assert any("new line" in line for line in rendered)
|
||||
assert any("48;2;" in line for line in rendered)
|
||||
|
||||
def test_extract_edit_diff_ignores_non_edit_tools(self):
|
||||
assert extract_edit_diff("web_search", '{"diff": "--- a\\n+++ b\\n"}') is None
|
||||
|
||||
def test_extract_edit_diff_uses_local_snapshot_for_write_file(self, tmp_path):
|
||||
target = tmp_path / "note.txt"
|
||||
target.write_text("old\n", encoding="utf-8")
|
||||
|
||||
snapshot = capture_local_edit_snapshot("write_file", {"path": str(target)})
|
||||
|
||||
target.write_text("new\n", encoding="utf-8")
|
||||
|
||||
diff = extract_edit_diff(
|
||||
"write_file",
|
||||
'{"bytes_written": 4}',
|
||||
function_args={"path": str(target)},
|
||||
snapshot=snapshot,
|
||||
)
|
||||
|
||||
assert diff is not None
|
||||
assert "--- a/" in diff
|
||||
assert "+++ b/" in diff
|
||||
assert "-old" in diff
|
||||
assert "+new" in diff
|
||||
|
||||
def test_render_edit_diff_with_delta_invokes_printer(self):
|
||||
printer = MagicMock()
|
||||
|
||||
rendered = render_edit_diff_with_delta(
|
||||
"patch",
|
||||
'{"diff": "--- a/x\\n+++ b/x\\n@@ -1 +1 @@\\n-old\\n+new\\n"}',
|
||||
print_fn=printer,
|
||||
)
|
||||
|
||||
assert rendered is True
|
||||
assert printer.call_count >= 2
|
||||
calls = [call.args[0] for call in printer.call_args_list]
|
||||
assert any("a/x" in line and "b/x" in line for line in calls)
|
||||
assert any("old" in line for line in calls)
|
||||
assert any("new" in line for line in calls)
|
||||
|
||||
def test_render_edit_diff_with_delta_skips_without_diff(self):
|
||||
rendered = render_edit_diff_with_delta(
|
||||
"patch",
|
||||
'{"success": true}',
|
||||
)
|
||||
|
||||
assert rendered is False
|
||||
|
||||
def test_render_edit_diff_with_delta_handles_renderer_errors(self, monkeypatch):
|
||||
printer = MagicMock()
|
||||
|
||||
monkeypatch.setattr("agent.display._summarize_rendered_diff_sections", MagicMock(side_effect=RuntimeError("boom")))
|
||||
|
||||
rendered = render_edit_diff_with_delta(
|
||||
"patch",
|
||||
'{"diff": "--- a/x\\n+++ b/x\\n"}',
|
||||
print_fn=printer,
|
||||
)
|
||||
|
||||
assert rendered is False
|
||||
assert printer.call_count == 0
|
||||
|
||||
def test_summarize_rendered_diff_sections_truncates_large_diff(self):
|
||||
diff = "--- a/x.py\n+++ b/x.py\n" + "".join(f"+line{i}\n" for i in range(120))
|
||||
|
||||
rendered = _summarize_rendered_diff_sections(diff, max_lines=20)
|
||||
|
||||
assert len(rendered) == 21
|
||||
assert "omitted" in rendered[-1]
|
||||
|
||||
def test_summarize_rendered_diff_sections_limits_file_count(self):
|
||||
diff = "".join(
|
||||
f"--- a/file{i}.py\n+++ b/file{i}.py\n+line{i}\n"
|
||||
for i in range(8)
|
||||
)
|
||||
|
||||
rendered = _summarize_rendered_diff_sections(diff, max_files=3, max_lines=50)
|
||||
|
||||
assert any("a/file0.py" in line for line in rendered)
|
||||
assert any("a/file1.py" in line for line in rendered)
|
||||
assert any("a/file2.py" in line for line in rendered)
|
||||
assert not any("a/file7.py" in line for line in rendered)
|
||||
assert "additional file" in rendered[-1]
|
||||
718
tests/agent/test_insights.py
Normal file
718
tests/agent/test_insights.py
Normal file
@@ -0,0 +1,718 @@
|
||||
"""Tests for agent/insights.py — InsightsEngine analytics and reporting."""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_state import SessionDB
|
||||
from agent.insights import (
|
||||
InsightsEngine,
|
||||
_get_pricing,
|
||||
_estimate_cost,
|
||||
_format_duration,
|
||||
_bar_chart,
|
||||
_has_known_pricing,
|
||||
_DEFAULT_PRICING,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
"""Create a SessionDB with a temp database file."""
|
||||
db_path = tmp_path / "test_insights.db"
|
||||
session_db = SessionDB(db_path=db_path)
|
||||
yield session_db
|
||||
session_db.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def populated_db(db):
|
||||
"""Create a DB with realistic session data for insights testing."""
|
||||
now = time.time()
|
||||
day = 86400
|
||||
|
||||
# Session 1: CLI, claude-sonnet, ended, 2 days ago
|
||||
db.create_session(
|
||||
session_id="s1", source="cli",
|
||||
model="anthropic/claude-sonnet-4-20250514", user_id="user1",
|
||||
)
|
||||
# Backdate the started_at
|
||||
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 's1'", (now - 2 * day,))
|
||||
db.end_session("s1", end_reason="user_exit")
|
||||
db._conn.execute("UPDATE sessions SET ended_at = ? WHERE id = 's1'", (now - 2 * day + 3600,))
|
||||
db.update_token_counts("s1", input_tokens=50000, output_tokens=15000)
|
||||
db.append_message("s1", role="user", content="Hello, help me fix a bug")
|
||||
db.append_message("s1", role="assistant", content="Sure, let me look into that.")
|
||||
db.append_message("s1", role="assistant", content="Let me search the files.",
|
||||
tool_calls=[{"function": {"name": "search_files"}}])
|
||||
db.append_message("s1", role="tool", content="Found 3 matches", tool_name="search_files")
|
||||
db.append_message("s1", role="assistant", content="Let me read the file.",
|
||||
tool_calls=[{"function": {"name": "read_file"}}])
|
||||
db.append_message("s1", role="tool", content="file contents...", tool_name="read_file")
|
||||
db.append_message("s1", role="assistant", content="I found the bug. Let me fix it.",
|
||||
tool_calls=[{"function": {"name": "patch"}}])
|
||||
db.append_message("s1", role="tool", content="patched successfully", tool_name="patch")
|
||||
db.append_message("s1", role="user", content="Thanks!")
|
||||
db.append_message("s1", role="assistant", content="You're welcome!")
|
||||
|
||||
# Session 2: Telegram, gpt-4o, ended, 5 days ago
|
||||
db.create_session(
|
||||
session_id="s2", source="telegram",
|
||||
model="gpt-4o", user_id="user1",
|
||||
)
|
||||
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 's2'", (now - 5 * day,))
|
||||
db.end_session("s2", end_reason="timeout")
|
||||
db._conn.execute("UPDATE sessions SET ended_at = ? WHERE id = 's2'", (now - 5 * day + 1800,))
|
||||
db.update_token_counts("s2", input_tokens=20000, output_tokens=8000)
|
||||
db.append_message("s2", role="user", content="Search the web for something")
|
||||
db.append_message("s2", role="assistant", content="Searching...",
|
||||
tool_calls=[{"function": {"name": "web_search"}}])
|
||||
db.append_message("s2", role="tool", content="results...", tool_name="web_search")
|
||||
db.append_message("s2", role="assistant", content="Here's what I found")
|
||||
|
||||
# Session 3: CLI, deepseek-chat, ended, 10 days ago
|
||||
db.create_session(
|
||||
session_id="s3", source="cli",
|
||||
model="deepseek-chat", user_id="user1",
|
||||
)
|
||||
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 's3'", (now - 10 * day,))
|
||||
db.end_session("s3", end_reason="user_exit")
|
||||
db._conn.execute("UPDATE sessions SET ended_at = ? WHERE id = 's3'", (now - 10 * day + 7200,))
|
||||
db.update_token_counts("s3", input_tokens=100000, output_tokens=40000)
|
||||
db.append_message("s3", role="user", content="Run this terminal command")
|
||||
db.append_message("s3", role="assistant", content="Running...",
|
||||
tool_calls=[{"function": {"name": "terminal"}}])
|
||||
db.append_message("s3", role="tool", content="output...", tool_name="terminal")
|
||||
db.append_message("s3", role="assistant", content="Let me run another",
|
||||
tool_calls=[{"function": {"name": "terminal"}}])
|
||||
db.append_message("s3", role="tool", content="more output...", tool_name="terminal")
|
||||
db.append_message("s3", role="assistant", content="And search files",
|
||||
tool_calls=[{"function": {"name": "search_files"}}])
|
||||
db.append_message("s3", role="tool", content="found stuff", tool_name="search_files")
|
||||
|
||||
# Session 4: Discord, same model as s1, ended, 1 day ago
|
||||
db.create_session(
|
||||
session_id="s4", source="discord",
|
||||
model="anthropic/claude-sonnet-4-20250514", user_id="user2",
|
||||
)
|
||||
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 's4'", (now - 1 * day,))
|
||||
db.end_session("s4", end_reason="user_exit")
|
||||
db._conn.execute("UPDATE sessions SET ended_at = ? WHERE id = 's4'", (now - 1 * day + 900,))
|
||||
db.update_token_counts("s4", input_tokens=10000, output_tokens=5000)
|
||||
db.append_message("s4", role="user", content="Quick question")
|
||||
db.append_message("s4", role="assistant", content="Sure, go ahead")
|
||||
|
||||
# Session 5: Old session, 45 days ago (should be excluded from 30-day window)
|
||||
db.create_session(
|
||||
session_id="s_old", source="cli",
|
||||
model="gpt-4o-mini", user_id="user1",
|
||||
)
|
||||
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 's_old'", (now - 45 * day,))
|
||||
db.end_session("s_old", end_reason="user_exit")
|
||||
db._conn.execute("UPDATE sessions SET ended_at = ? WHERE id = 's_old'", (now - 45 * day + 600,))
|
||||
db.update_token_counts("s_old", input_tokens=5000, output_tokens=2000)
|
||||
db.append_message("s_old", role="user", content="old message")
|
||||
db.append_message("s_old", role="assistant", content="old reply")
|
||||
|
||||
db._conn.commit()
|
||||
return db
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Pricing helpers
|
||||
# =========================================================================
|
||||
|
||||
class TestPricing:
|
||||
def test_provider_prefix_stripped(self):
|
||||
pricing = _get_pricing("anthropic/claude-sonnet-4-20250514")
|
||||
assert pricing["input"] == 3.00
|
||||
assert pricing["output"] == 15.00
|
||||
|
||||
def test_unknown_models_do_not_use_heuristics(self):
|
||||
pricing = _get_pricing("some-new-opus-model")
|
||||
assert pricing == _DEFAULT_PRICING
|
||||
pricing = _get_pricing("anthropic/claude-haiku-future")
|
||||
assert pricing == _DEFAULT_PRICING
|
||||
|
||||
def test_unknown_model_returns_zero_cost(self):
|
||||
"""Unknown/custom models should NOT have fabricated costs."""
|
||||
pricing = _get_pricing("totally-unknown-model-xyz")
|
||||
assert pricing == _DEFAULT_PRICING
|
||||
assert pricing["input"] == 0.0
|
||||
assert pricing["output"] == 0.0
|
||||
|
||||
def test_custom_endpoint_model_zero_cost(self):
|
||||
"""Self-hosted models should return zero cost."""
|
||||
for model in ["FP16_Hermes_4.5", "Hermes_4.5_1T_epoch2", "my-local-llama"]:
|
||||
pricing = _get_pricing(model)
|
||||
assert pricing["input"] == 0.0, f"{model} should have zero cost"
|
||||
assert pricing["output"] == 0.0, f"{model} should have zero cost"
|
||||
|
||||
def test_none_model(self):
|
||||
pricing = _get_pricing(None)
|
||||
assert pricing == _DEFAULT_PRICING
|
||||
|
||||
def test_empty_model(self):
|
||||
pricing = _get_pricing("")
|
||||
assert pricing == _DEFAULT_PRICING
|
||||
|
||||
|
||||
class TestHasKnownPricing:
|
||||
def test_known_commercial_model(self):
|
||||
assert _has_known_pricing("gpt-4o", provider="openai") is True
|
||||
assert _has_known_pricing("anthropic/claude-sonnet-4-20250514") is True
|
||||
assert _has_known_pricing("gpt-4.1", provider="openai") is True
|
||||
|
||||
def test_unknown_custom_model(self):
|
||||
assert _has_known_pricing("FP16_Hermes_4.5") is False
|
||||
assert _has_known_pricing("my-custom-model") is False
|
||||
assert _has_known_pricing("glm-5") is False
|
||||
assert _has_known_pricing("") is False
|
||||
assert _has_known_pricing(None) is False
|
||||
|
||||
def test_heuristic_matched_models_are_not_considered_known(self):
|
||||
assert _has_known_pricing("some-opus-model") is False
|
||||
assert _has_known_pricing("future-sonnet-v2") is False
|
||||
|
||||
|
||||
class TestEstimateCost:
|
||||
def test_basic_cost(self):
|
||||
cost, status = _estimate_cost(
|
||||
"anthropic/claude-sonnet-4-20250514",
|
||||
1_000_000,
|
||||
1_000_000,
|
||||
provider="anthropic",
|
||||
)
|
||||
assert status == "estimated"
|
||||
assert cost == pytest.approx(18.0, abs=0.01)
|
||||
|
||||
def test_zero_tokens(self):
|
||||
cost, status = _estimate_cost("gpt-4o", 0, 0, provider="openai")
|
||||
assert status == "estimated"
|
||||
assert cost == 0.0
|
||||
|
||||
def test_cache_aware_usage(self):
|
||||
cost, status = _estimate_cost(
|
||||
"anthropic/claude-sonnet-4-20250514",
|
||||
1000,
|
||||
500,
|
||||
cache_read_tokens=2000,
|
||||
cache_write_tokens=400,
|
||||
provider="anthropic",
|
||||
)
|
||||
assert status == "estimated"
|
||||
expected = (1000 * 3.0 + 500 * 15.0 + 2000 * 0.30 + 400 * 3.75) / 1_000_000
|
||||
assert cost == pytest.approx(expected, abs=0.0001)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Format helpers
|
||||
# =========================================================================
|
||||
|
||||
class TestFormatDuration:
|
||||
def test_seconds(self):
|
||||
assert _format_duration(45) == "45s"
|
||||
|
||||
def test_minutes(self):
|
||||
assert _format_duration(300) == "5m"
|
||||
|
||||
def test_hours_with_minutes(self):
|
||||
result = _format_duration(5400) # 1.5 hours
|
||||
assert result == "1h 30m"
|
||||
|
||||
def test_exact_hours(self):
|
||||
assert _format_duration(7200) == "2h"
|
||||
|
||||
def test_days(self):
|
||||
result = _format_duration(172800) # 2 days
|
||||
assert result == "2.0d"
|
||||
|
||||
|
||||
class TestBarChart:
|
||||
def test_basic_bars(self):
|
||||
bars = _bar_chart([10, 5, 0, 20], max_width=10)
|
||||
assert len(bars) == 4
|
||||
assert len(bars[3]) == 10 # max value gets full width
|
||||
assert len(bars[0]) == 5 # half of max
|
||||
assert bars[2] == "" # zero gets empty
|
||||
|
||||
def test_empty_values(self):
|
||||
bars = _bar_chart([], max_width=10)
|
||||
assert bars == []
|
||||
|
||||
def test_all_zeros(self):
|
||||
bars = _bar_chart([0, 0, 0], max_width=10)
|
||||
assert all(b == "" for b in bars)
|
||||
|
||||
def test_single_value(self):
|
||||
bars = _bar_chart([5], max_width=10)
|
||||
assert len(bars) == 1
|
||||
assert len(bars[0]) == 10
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# InsightsEngine — empty DB
|
||||
# =========================================================================
|
||||
|
||||
class TestInsightsEmpty:
|
||||
def test_empty_db_returns_empty_report(self, db):
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
assert report["empty"] is True
|
||||
assert report["overview"] == {}
|
||||
|
||||
def test_empty_db_terminal_format(self, db):
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
text = engine.format_terminal(report)
|
||||
assert "No sessions found" in text
|
||||
|
||||
def test_empty_db_gateway_format(self, db):
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
text = engine.format_gateway(report)
|
||||
assert "No sessions found" in text
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# InsightsEngine — populated DB
|
||||
# =========================================================================
|
||||
|
||||
class TestInsightsPopulated:
|
||||
def test_generate_returns_all_sections(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
|
||||
assert report["empty"] is False
|
||||
assert "overview" in report
|
||||
assert "models" in report
|
||||
assert "platforms" in report
|
||||
assert "tools" in report
|
||||
assert "activity" in report
|
||||
assert "top_sessions" in report
|
||||
|
||||
def test_overview_session_count(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
overview = report["overview"]
|
||||
|
||||
# s1, s2, s3, s4 are within 30 days; s_old is 45 days ago
|
||||
assert overview["total_sessions"] == 4
|
||||
|
||||
def test_overview_token_totals(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
overview = report["overview"]
|
||||
|
||||
expected_input = 50000 + 20000 + 100000 + 10000
|
||||
expected_output = 15000 + 8000 + 40000 + 5000
|
||||
assert overview["total_input_tokens"] == expected_input
|
||||
assert overview["total_output_tokens"] == expected_output
|
||||
assert overview["total_tokens"] == expected_input + expected_output
|
||||
|
||||
def test_overview_cost_positive(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
assert report["overview"]["estimated_cost"] > 0
|
||||
|
||||
def test_overview_duration_stats(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
overview = report["overview"]
|
||||
|
||||
# All 4 sessions have durations
|
||||
assert overview["total_hours"] > 0
|
||||
assert overview["avg_session_duration"] > 0
|
||||
|
||||
def test_model_breakdown(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
models = report["models"]
|
||||
|
||||
# Should have 3 distinct models (claude-sonnet x2, gpt-4o, deepseek-chat)
|
||||
model_names = [m["model"] for m in models]
|
||||
assert "claude-sonnet-4-20250514" in model_names
|
||||
assert "gpt-4o" in model_names
|
||||
assert "deepseek-chat" in model_names
|
||||
|
||||
# Claude-sonnet has 2 sessions (s1 + s4)
|
||||
claude = next(m for m in models if "claude-sonnet" in m["model"])
|
||||
assert claude["sessions"] == 2
|
||||
|
||||
def test_platform_breakdown(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
platforms = report["platforms"]
|
||||
|
||||
platform_names = [p["platform"] for p in platforms]
|
||||
assert "cli" in platform_names
|
||||
assert "telegram" in platform_names
|
||||
assert "discord" in platform_names
|
||||
|
||||
cli = next(p for p in platforms if p["platform"] == "cli")
|
||||
assert cli["sessions"] == 2 # s1 + s3
|
||||
|
||||
def test_tool_breakdown(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
tools = report["tools"]
|
||||
|
||||
tool_names = [t["tool"] for t in tools]
|
||||
assert "terminal" in tool_names
|
||||
assert "search_files" in tool_names
|
||||
assert "read_file" in tool_names
|
||||
assert "patch" in tool_names
|
||||
assert "web_search" in tool_names
|
||||
|
||||
# terminal was used 2x in s3
|
||||
terminal = next(t for t in tools if t["tool"] == "terminal")
|
||||
assert terminal["count"] == 2
|
||||
|
||||
# Percentages should sum to ~100%
|
||||
total_pct = sum(t["percentage"] for t in tools)
|
||||
assert total_pct == pytest.approx(100.0, abs=0.1)
|
||||
|
||||
def test_activity_patterns(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
activity = report["activity"]
|
||||
|
||||
assert len(activity["by_day"]) == 7
|
||||
assert len(activity["by_hour"]) == 24
|
||||
assert activity["active_days"] >= 1
|
||||
assert activity["busiest_day"] is not None
|
||||
assert activity["busiest_hour"] is not None
|
||||
|
||||
def test_top_sessions(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
top = report["top_sessions"]
|
||||
|
||||
labels = [t["label"] for t in top]
|
||||
assert "Longest session" in labels
|
||||
assert "Most messages" in labels
|
||||
assert "Most tokens" in labels
|
||||
assert "Most tool calls" in labels
|
||||
|
||||
def test_source_filter_cli(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30, source="cli")
|
||||
|
||||
assert report["overview"]["total_sessions"] == 2 # s1, s3
|
||||
|
||||
def test_source_filter_telegram(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30, source="telegram")
|
||||
|
||||
assert report["overview"]["total_sessions"] == 1 # s2
|
||||
|
||||
def test_source_filter_nonexistent(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30, source="slack")
|
||||
|
||||
assert report["empty"] is True
|
||||
|
||||
def test_days_filter_short(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=3)
|
||||
|
||||
# Only s1 (2 days ago) and s4 (1 day ago) should be included
|
||||
assert report["overview"]["total_sessions"] == 2
|
||||
|
||||
def test_days_filter_long(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=60)
|
||||
|
||||
# All 5 sessions should be included
|
||||
assert report["overview"]["total_sessions"] == 5
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Formatting
|
||||
# =========================================================================
|
||||
|
||||
class TestTerminalFormatting:
|
||||
def test_terminal_format_has_sections(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
text = engine.format_terminal(report)
|
||||
|
||||
assert "Hermes Insights" in text
|
||||
assert "Overview" in text
|
||||
assert "Models Used" in text
|
||||
assert "Top Tools" in text
|
||||
assert "Activity Patterns" in text
|
||||
assert "Notable Sessions" in text
|
||||
|
||||
def test_terminal_format_shows_tokens(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
text = engine.format_terminal(report)
|
||||
|
||||
assert "Input tokens" in text
|
||||
assert "Output tokens" in text
|
||||
assert "Est. cost" in text
|
||||
assert "$" in text
|
||||
|
||||
def test_terminal_format_shows_platforms(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
text = engine.format_terminal(report)
|
||||
|
||||
# Multi-platform, so Platforms section should show
|
||||
assert "Platforms" in text
|
||||
assert "cli" in text
|
||||
assert "telegram" in text
|
||||
|
||||
def test_terminal_format_shows_bar_chart(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
text = engine.format_terminal(report)
|
||||
|
||||
assert "█" in text # Bar chart characters
|
||||
|
||||
def test_terminal_format_shows_na_for_custom_models(self, db):
|
||||
"""Custom models should show N/A instead of fake cost."""
|
||||
db.create_session(session_id="s1", source="cli", model="my-custom-model")
|
||||
db.update_token_counts("s1", input_tokens=1000, output_tokens=500)
|
||||
db._conn.commit()
|
||||
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
text = engine.format_terminal(report)
|
||||
|
||||
assert "N/A" in text
|
||||
assert "custom/self-hosted" in text
|
||||
|
||||
|
||||
class TestGatewayFormatting:
|
||||
def test_gateway_format_is_shorter(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
terminal_text = engine.format_terminal(report)
|
||||
gateway_text = engine.format_gateway(report)
|
||||
|
||||
assert len(gateway_text) < len(terminal_text)
|
||||
|
||||
def test_gateway_format_has_bold(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
text = engine.format_gateway(report)
|
||||
|
||||
assert "**" in text # Markdown bold
|
||||
|
||||
def test_gateway_format_shows_cost(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
text = engine.format_gateway(report)
|
||||
|
||||
assert "$" in text
|
||||
assert "Est. cost" in text
|
||||
|
||||
def test_gateway_format_shows_models(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
text = engine.format_gateway(report)
|
||||
|
||||
assert "Models" in text
|
||||
assert "sessions" in text
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Edge cases
|
||||
# =========================================================================
|
||||
|
||||
class TestEdgeCases:
|
||||
def test_session_with_no_tokens(self, db):
|
||||
"""Sessions with zero tokens should not crash."""
|
||||
db.create_session(session_id="s1", source="cli", model="test-model")
|
||||
db._conn.commit()
|
||||
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
assert report["empty"] is False
|
||||
assert report["overview"]["total_tokens"] == 0
|
||||
assert report["overview"]["estimated_cost"] == 0.0
|
||||
|
||||
def test_session_with_no_end_time(self, db):
|
||||
"""Active (non-ended) sessions should be included but duration = 0."""
|
||||
db.create_session(session_id="s1", source="cli", model="test-model")
|
||||
db.update_token_counts("s1", input_tokens=1000, output_tokens=500)
|
||||
db._conn.commit()
|
||||
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
# Session included
|
||||
assert report["overview"]["total_sessions"] == 1
|
||||
assert report["overview"]["total_tokens"] == 1500
|
||||
# But no duration stats (session not ended)
|
||||
assert report["overview"]["total_hours"] == 0
|
||||
|
||||
def test_session_with_no_model(self, db):
|
||||
"""Sessions with NULL model should not crash."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.update_token_counts("s1", input_tokens=1000, output_tokens=500)
|
||||
db._conn.commit()
|
||||
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
assert report["empty"] is False
|
||||
|
||||
models = report["models"]
|
||||
assert len(models) == 1
|
||||
assert models[0]["model"] == "unknown"
|
||||
assert models[0]["has_pricing"] is False
|
||||
|
||||
def test_custom_model_shows_zero_cost(self, db):
|
||||
"""Custom/self-hosted models should show $0 cost, not fake estimates."""
|
||||
db.create_session(session_id="s1", source="cli", model="FP16_Hermes_4.5")
|
||||
db.update_token_counts("s1", input_tokens=100000, output_tokens=50000)
|
||||
db._conn.commit()
|
||||
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
assert report["overview"]["estimated_cost"] == 0.0
|
||||
assert "FP16_Hermes_4.5" in report["overview"]["models_without_pricing"]
|
||||
|
||||
models = report["models"]
|
||||
custom = next(m for m in models if m["model"] == "FP16_Hermes_4.5")
|
||||
assert custom["cost"] == 0.0
|
||||
assert custom["has_pricing"] is False
|
||||
|
||||
def test_tool_usage_from_tool_calls_json(self, db):
|
||||
"""Tool usage should be extracted from tool_calls JSON when tool_name is NULL."""
|
||||
import json as _json
|
||||
db.create_session(session_id="s1", source="cli", model="test")
|
||||
# Assistant message with tool_calls (this is what CLI produces)
|
||||
db.append_message("s1", role="assistant", content="Let me search",
|
||||
tool_calls=[{"id": "call_1", "type": "function",
|
||||
"function": {"name": "search_files", "arguments": "{}"}}])
|
||||
# Tool response WITHOUT tool_name (this is the CLI bug)
|
||||
db.append_message("s1", role="tool", content="found results",
|
||||
tool_call_id="call_1")
|
||||
db.append_message("s1", role="assistant", content="Now reading",
|
||||
tool_calls=[{"id": "call_2", "type": "function",
|
||||
"function": {"name": "read_file", "arguments": "{}"}}])
|
||||
db.append_message("s1", role="tool", content="file content",
|
||||
tool_call_id="call_2")
|
||||
db.append_message("s1", role="assistant", content="And searching again",
|
||||
tool_calls=[{"id": "call_3", "type": "function",
|
||||
"function": {"name": "search_files", "arguments": "{}"}}])
|
||||
db.append_message("s1", role="tool", content="more results",
|
||||
tool_call_id="call_3")
|
||||
db._conn.commit()
|
||||
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
tools = report["tools"]
|
||||
|
||||
# Should find tools from tool_calls JSON even though tool_name is NULL
|
||||
tool_names = [t["tool"] for t in tools]
|
||||
assert "search_files" in tool_names
|
||||
assert "read_file" in tool_names
|
||||
|
||||
# search_files was called twice
|
||||
sf = next(t for t in tools if t["tool"] == "search_files")
|
||||
assert sf["count"] == 2
|
||||
|
||||
def test_overview_pricing_sets_are_lists(self, db):
|
||||
"""models_with/without_pricing should be JSON-serializable lists."""
|
||||
import json as _json
|
||||
db.create_session(session_id="s1", source="cli", model="gpt-4o")
|
||||
db.create_session(session_id="s2", source="cli", model="my-custom")
|
||||
db._conn.commit()
|
||||
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
overview = report["overview"]
|
||||
|
||||
assert isinstance(overview["models_with_pricing"], list)
|
||||
assert isinstance(overview["models_without_pricing"], list)
|
||||
# Should be JSON-serializable
|
||||
_json.dumps(report["overview"]) # would raise if sets present
|
||||
|
||||
def test_mixed_commercial_and_custom_models(self, db):
|
||||
"""Mix of commercial and custom models: only commercial ones get costs."""
|
||||
db.create_session(session_id="s1", source="cli", model="anthropic/claude-sonnet-4-20250514")
|
||||
db.update_token_counts(
|
||||
"s1",
|
||||
input_tokens=10000,
|
||||
output_tokens=5000,
|
||||
billing_provider="anthropic",
|
||||
)
|
||||
db.create_session(session_id="s2", source="cli", model="my-local-llama")
|
||||
db.update_token_counts("s2", input_tokens=10000, output_tokens=5000)
|
||||
db._conn.commit()
|
||||
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
|
||||
# Cost should only come from gpt-4o, not from the custom model
|
||||
overview = report["overview"]
|
||||
assert overview["estimated_cost"] > 0
|
||||
assert "claude-sonnet-4-20250514" in overview["models_with_pricing"] # list now, not set
|
||||
assert "my-local-llama" in overview["models_without_pricing"]
|
||||
|
||||
# Verify individual model entries
|
||||
claude = next(m for m in report["models"] if m["model"] == "claude-sonnet-4-20250514")
|
||||
assert claude["has_pricing"] is True
|
||||
assert claude["cost"] > 0
|
||||
|
||||
llama = next(m for m in report["models"] if m["model"] == "my-local-llama")
|
||||
assert llama["has_pricing"] is False
|
||||
assert llama["cost"] == 0.0
|
||||
|
||||
def test_single_session_streak(self, db):
|
||||
"""Single session should have streak of 0 or 1."""
|
||||
db.create_session(session_id="s1", source="cli", model="test")
|
||||
db._conn.commit()
|
||||
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
assert report["activity"]["max_streak"] <= 1
|
||||
|
||||
def test_no_tool_calls(self, db):
|
||||
"""Sessions with no tool calls should produce empty tools list."""
|
||||
db.create_session(session_id="s1", source="cli", model="test")
|
||||
db.append_message("s1", role="user", content="hello")
|
||||
db.append_message("s1", role="assistant", content="hi there")
|
||||
db._conn.commit()
|
||||
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
assert report["tools"] == []
|
||||
|
||||
def test_only_one_platform(self, db):
|
||||
"""Single-platform usage should still work."""
|
||||
db.create_session(session_id="s1", source="cli", model="test")
|
||||
db._conn.commit()
|
||||
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
assert len(report["platforms"]) == 1
|
||||
assert report["platforms"][0]["platform"] == "cli"
|
||||
|
||||
# Terminal format should NOT show platform section for single platform
|
||||
text = engine.format_terminal(report)
|
||||
# (it still shows platforms section if there's only cli and nothing else)
|
||||
# Actually the condition is > 1 platforms OR non-cli, so single cli won't show
|
||||
|
||||
def test_large_days_value(self, db):
|
||||
"""Very large days value should not crash."""
|
||||
db.create_session(session_id="s1", source="cli", model="test")
|
||||
db._conn.commit()
|
||||
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=365)
|
||||
assert report["empty"] is False
|
||||
|
||||
def test_zero_days(self, db):
|
||||
"""Zero days should return empty (nothing is in the future)."""
|
||||
db.create_session(session_id="s1", source="cli", model="test")
|
||||
db._conn.commit()
|
||||
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=0)
|
||||
# Depending on timing, might catch the session if created <1s ago
|
||||
# Just verify it doesn't crash
|
||||
assert "empty" in report
|
||||
493
tests/agent/test_model_metadata_local_ctx.py
Normal file
493
tests/agent/test_model_metadata_local_ctx.py
Normal file
@@ -0,0 +1,493 @@
|
||||
"""Tests for _query_local_context_length and the local server fallback in
|
||||
get_model_context_length.
|
||||
|
||||
All tests use synthetic inputs — no filesystem or live server required.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _query_local_context_length — unit tests with mocked httpx
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQueryLocalContextLengthOllama:
|
||||
"""_query_local_context_length with server_type == 'ollama'."""
|
||||
|
||||
def _make_resp(self, status_code, body):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = body
|
||||
return resp
|
||||
|
||||
def test_ollama_model_info_context_length(self):
|
||||
"""Reads context length from model_info dict in /api/show response."""
|
||||
from agent.model_metadata import _query_local_context_length
|
||||
|
||||
show_resp = self._make_resp(200, {
|
||||
"model_info": {"llama.context_length": 131072}
|
||||
})
|
||||
models_resp = self._make_resp(404, {})
|
||||
|
||||
client_mock = MagicMock()
|
||||
client_mock.__enter__ = lambda s: client_mock
|
||||
client_mock.__exit__ = MagicMock(return_value=False)
|
||||
client_mock.post.return_value = show_resp
|
||||
client_mock.get.return_value = models_resp
|
||||
|
||||
with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"), \
|
||||
patch("httpx.Client", return_value=client_mock):
|
||||
result = _query_local_context_length("omnicoder-9b", "http://localhost:11434/v1")
|
||||
|
||||
assert result == 131072
|
||||
|
||||
def test_ollama_parameters_num_ctx(self):
|
||||
"""Falls back to num_ctx in parameters string when model_info lacks context_length."""
|
||||
from agent.model_metadata import _query_local_context_length
|
||||
|
||||
show_resp = self._make_resp(200, {
|
||||
"model_info": {},
|
||||
"parameters": "num_ctx 32768\ntemperature 0.7\n"
|
||||
})
|
||||
models_resp = self._make_resp(404, {})
|
||||
|
||||
client_mock = MagicMock()
|
||||
client_mock.__enter__ = lambda s: client_mock
|
||||
client_mock.__exit__ = MagicMock(return_value=False)
|
||||
client_mock.post.return_value = show_resp
|
||||
client_mock.get.return_value = models_resp
|
||||
|
||||
with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"), \
|
||||
patch("httpx.Client", return_value=client_mock):
|
||||
result = _query_local_context_length("some-model", "http://localhost:11434/v1")
|
||||
|
||||
assert result == 32768
|
||||
|
||||
def test_ollama_show_404_falls_through(self):
|
||||
"""When /api/show returns 404, falls through to /v1/models/{model}."""
|
||||
from agent.model_metadata import _query_local_context_length
|
||||
|
||||
show_resp = self._make_resp(404, {})
|
||||
model_detail_resp = self._make_resp(200, {"max_model_len": 65536})
|
||||
|
||||
client_mock = MagicMock()
|
||||
client_mock.__enter__ = lambda s: client_mock
|
||||
client_mock.__exit__ = MagicMock(return_value=False)
|
||||
client_mock.post.return_value = show_resp
|
||||
client_mock.get.return_value = model_detail_resp
|
||||
|
||||
with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"), \
|
||||
patch("httpx.Client", return_value=client_mock):
|
||||
result = _query_local_context_length("some-model", "http://localhost:11434/v1")
|
||||
|
||||
assert result == 65536
|
||||
|
||||
|
||||
class TestQueryLocalContextLengthVllm:
|
||||
"""_query_local_context_length with vLLM-style /v1/models/{model} response."""
|
||||
|
||||
def _make_resp(self, status_code, body):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = body
|
||||
return resp
|
||||
|
||||
def test_vllm_max_model_len(self):
|
||||
"""Reads max_model_len from /v1/models/{model} response."""
|
||||
from agent.model_metadata import _query_local_context_length
|
||||
|
||||
detail_resp = self._make_resp(200, {"id": "omnicoder-9b", "max_model_len": 100000})
|
||||
list_resp = self._make_resp(404, {})
|
||||
|
||||
client_mock = MagicMock()
|
||||
client_mock.__enter__ = lambda s: client_mock
|
||||
client_mock.__exit__ = MagicMock(return_value=False)
|
||||
client_mock.post.return_value = self._make_resp(404, {})
|
||||
client_mock.get.return_value = detail_resp
|
||||
|
||||
with patch("agent.model_metadata.detect_local_server_type", return_value="vllm"), \
|
||||
patch("httpx.Client", return_value=client_mock):
|
||||
result = _query_local_context_length("omnicoder-9b", "http://localhost:8000/v1")
|
||||
|
||||
assert result == 100000
|
||||
|
||||
def test_vllm_context_length_key(self):
|
||||
"""Reads context_length from /v1/models/{model} response."""
|
||||
from agent.model_metadata import _query_local_context_length
|
||||
|
||||
detail_resp = self._make_resp(200, {"id": "some-model", "context_length": 32768})
|
||||
|
||||
client_mock = MagicMock()
|
||||
client_mock.__enter__ = lambda s: client_mock
|
||||
client_mock.__exit__ = MagicMock(return_value=False)
|
||||
client_mock.post.return_value = self._make_resp(404, {})
|
||||
client_mock.get.return_value = detail_resp
|
||||
|
||||
with patch("agent.model_metadata.detect_local_server_type", return_value="vllm"), \
|
||||
patch("httpx.Client", return_value=client_mock):
|
||||
result = _query_local_context_length("some-model", "http://localhost:8000/v1")
|
||||
|
||||
assert result == 32768
|
||||
|
||||
|
||||
class TestQueryLocalContextLengthModelsList:
|
||||
"""_query_local_context_length: falls back to /v1/models list."""
|
||||
|
||||
def _make_resp(self, status_code, body):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = body
|
||||
return resp
|
||||
|
||||
def test_models_list_max_model_len(self):
|
||||
"""Finds context length for model in /v1/models list."""
|
||||
from agent.model_metadata import _query_local_context_length
|
||||
|
||||
detail_resp = self._make_resp(404, {})
|
||||
list_resp = self._make_resp(200, {
|
||||
"data": [
|
||||
{"id": "other-model", "max_model_len": 4096},
|
||||
{"id": "omnicoder-9b", "max_model_len": 131072},
|
||||
]
|
||||
})
|
||||
|
||||
call_count = [0]
|
||||
def side_effect(url, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return detail_resp # /v1/models/omnicoder-9b
|
||||
return list_resp # /v1/models
|
||||
|
||||
client_mock = MagicMock()
|
||||
client_mock.__enter__ = lambda s: client_mock
|
||||
client_mock.__exit__ = MagicMock(return_value=False)
|
||||
client_mock.post.return_value = self._make_resp(404, {})
|
||||
client_mock.get.side_effect = side_effect
|
||||
|
||||
with patch("agent.model_metadata.detect_local_server_type", return_value=None), \
|
||||
patch("httpx.Client", return_value=client_mock):
|
||||
result = _query_local_context_length("omnicoder-9b", "http://localhost:1234")
|
||||
|
||||
assert result == 131072
|
||||
|
||||
def test_models_list_model_not_found_returns_none(self):
|
||||
"""Returns None when model is not in the /v1/models list."""
|
||||
from agent.model_metadata import _query_local_context_length
|
||||
|
||||
detail_resp = self._make_resp(404, {})
|
||||
list_resp = self._make_resp(200, {
|
||||
"data": [{"id": "other-model", "max_model_len": 4096}]
|
||||
})
|
||||
|
||||
call_count = [0]
|
||||
def side_effect(url, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return detail_resp
|
||||
return list_resp
|
||||
|
||||
client_mock = MagicMock()
|
||||
client_mock.__enter__ = lambda s: client_mock
|
||||
client_mock.__exit__ = MagicMock(return_value=False)
|
||||
client_mock.post.return_value = self._make_resp(404, {})
|
||||
client_mock.get.side_effect = side_effect
|
||||
|
||||
with patch("agent.model_metadata.detect_local_server_type", return_value=None), \
|
||||
patch("httpx.Client", return_value=client_mock):
|
||||
result = _query_local_context_length("omnicoder-9b", "http://localhost:1234")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestQueryLocalContextLengthLmStudio:
|
||||
"""_query_local_context_length with LM Studio native /api/v1/models response."""
|
||||
|
||||
def _make_resp(self, status_code, body):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = body
|
||||
return resp
|
||||
|
||||
def _make_client(self, native_resp, detail_resp, list_resp):
|
||||
"""Build a mock httpx.Client with sequenced GET responses."""
|
||||
client_mock = MagicMock()
|
||||
client_mock.__enter__ = lambda s: client_mock
|
||||
client_mock.__exit__ = MagicMock(return_value=False)
|
||||
client_mock.post.return_value = self._make_resp(404, {})
|
||||
|
||||
responses = [native_resp, detail_resp, list_resp]
|
||||
call_idx = [0]
|
||||
|
||||
def get_side_effect(url, **kwargs):
|
||||
idx = call_idx[0]
|
||||
call_idx[0] += 1
|
||||
if idx < len(responses):
|
||||
return responses[idx]
|
||||
return self._make_resp(404, {})
|
||||
|
||||
client_mock.get.side_effect = get_side_effect
|
||||
return client_mock
|
||||
|
||||
def test_lmstudio_exact_key_match(self):
|
||||
"""Reads max_context_length when key matches exactly."""
|
||||
from agent.model_metadata import _query_local_context_length
|
||||
|
||||
native_resp = self._make_resp(200, {
|
||||
"models": [
|
||||
{"key": "nvidia/nvidia-nemotron-super-49b-v1", "id": "nvidia/nvidia-nemotron-super-49b-v1",
|
||||
"max_context_length": 131072},
|
||||
]
|
||||
})
|
||||
client_mock = self._make_client(
|
||||
native_resp,
|
||||
self._make_resp(404, {}),
|
||||
self._make_resp(404, {}),
|
||||
)
|
||||
|
||||
with patch("agent.model_metadata.detect_local_server_type", return_value="lm-studio"), \
|
||||
patch("httpx.Client", return_value=client_mock):
|
||||
result = _query_local_context_length(
|
||||
"nvidia/nvidia-nemotron-super-49b-v1", "http://192.168.1.22:1234/v1"
|
||||
)
|
||||
|
||||
assert result == 131072
|
||||
|
||||
def test_lmstudio_slug_only_matches_key_with_publisher_prefix(self):
|
||||
"""Fuzzy match: bare model slug matches key that includes publisher prefix.
|
||||
|
||||
When the user configures the model as "local:nvidia-nemotron-super-49b-v1"
|
||||
(slug only, no publisher), but LM Studio's native API stores it as
|
||||
"nvidia/nvidia-nemotron-super-49b-v1", the lookup must still succeed.
|
||||
"""
|
||||
from agent.model_metadata import _query_local_context_length
|
||||
|
||||
native_resp = self._make_resp(200, {
|
||||
"models": [
|
||||
{"key": "nvidia/nvidia-nemotron-super-49b-v1",
|
||||
"id": "nvidia/nvidia-nemotron-super-49b-v1",
|
||||
"max_context_length": 131072},
|
||||
]
|
||||
})
|
||||
client_mock = self._make_client(
|
||||
native_resp,
|
||||
self._make_resp(404, {}),
|
||||
self._make_resp(404, {}),
|
||||
)
|
||||
|
||||
with patch("agent.model_metadata.detect_local_server_type", return_value="lm-studio"), \
|
||||
patch("httpx.Client", return_value=client_mock):
|
||||
# Model passed in is just the slug after stripping "local:" prefix
|
||||
result = _query_local_context_length(
|
||||
"nvidia-nemotron-super-49b-v1", "http://192.168.1.22:1234/v1"
|
||||
)
|
||||
|
||||
assert result == 131072
|
||||
|
||||
def test_lmstudio_v1_models_list_slug_fuzzy_match(self):
|
||||
"""Fuzzy match also works for /v1/models list when exact match fails.
|
||||
|
||||
LM Studio's OpenAI-compat /v1/models returns id like
|
||||
"nvidia/nvidia-nemotron-super-49b-v1" — must match bare slug.
|
||||
"""
|
||||
from agent.model_metadata import _query_local_context_length
|
||||
|
||||
# native /api/v1/models: no match
|
||||
native_resp = self._make_resp(404, {})
|
||||
# /v1/models/{model}: no match
|
||||
detail_resp = self._make_resp(404, {})
|
||||
# /v1/models list: model found with publisher prefix, includes context_length
|
||||
list_resp = self._make_resp(200, {
|
||||
"data": [
|
||||
{"id": "nvidia/nvidia-nemotron-super-49b-v1", "context_length": 131072},
|
||||
]
|
||||
})
|
||||
client_mock = self._make_client(native_resp, detail_resp, list_resp)
|
||||
|
||||
with patch("agent.model_metadata.detect_local_server_type", return_value="lm-studio"), \
|
||||
patch("httpx.Client", return_value=client_mock):
|
||||
result = _query_local_context_length(
|
||||
"nvidia-nemotron-super-49b-v1", "http://192.168.1.22:1234/v1"
|
||||
)
|
||||
|
||||
assert result == 131072
|
||||
|
||||
def test_lmstudio_loaded_instances_context_length(self):
|
||||
"""Reads active context_length from loaded_instances when max_context_length absent."""
|
||||
from agent.model_metadata import _query_local_context_length
|
||||
|
||||
native_resp = self._make_resp(200, {
|
||||
"models": [
|
||||
{
|
||||
"key": "nvidia/nvidia-nemotron-super-49b-v1",
|
||||
"id": "nvidia/nvidia-nemotron-super-49b-v1",
|
||||
"loaded_instances": [
|
||||
{"config": {"context_length": 65536}},
|
||||
],
|
||||
},
|
||||
]
|
||||
})
|
||||
client_mock = self._make_client(
|
||||
native_resp,
|
||||
self._make_resp(404, {}),
|
||||
self._make_resp(404, {}),
|
||||
)
|
||||
|
||||
with patch("agent.model_metadata.detect_local_server_type", return_value="lm-studio"), \
|
||||
patch("httpx.Client", return_value=client_mock):
|
||||
result = _query_local_context_length(
|
||||
"nvidia-nemotron-super-49b-v1", "http://192.168.1.22:1234/v1"
|
||||
)
|
||||
|
||||
assert result == 65536
|
||||
|
||||
def test_lmstudio_loaded_instance_beats_max_context_length(self):
|
||||
"""loaded_instances context_length takes priority over max_context_length.
|
||||
|
||||
LM Studio may show max_context_length=1_048_576 (theoretical model max)
|
||||
while the actual loaded context is 122_651 (runtime setting). The loaded
|
||||
value is the real constraint and must be preferred.
|
||||
"""
|
||||
from agent.model_metadata import _query_local_context_length
|
||||
|
||||
native_resp = self._make_resp(200, {
|
||||
"models": [
|
||||
{
|
||||
"key": "nvidia/nvidia-nemotron-3-nano-4b",
|
||||
"id": "nvidia/nvidia-nemotron-3-nano-4b",
|
||||
"max_context_length": 1_048_576,
|
||||
"loaded_instances": [
|
||||
{"config": {"context_length": 122_651}},
|
||||
],
|
||||
},
|
||||
]
|
||||
})
|
||||
client_mock = self._make_client(
|
||||
native_resp,
|
||||
self._make_resp(404, {}),
|
||||
self._make_resp(404, {}),
|
||||
)
|
||||
|
||||
with patch("agent.model_metadata.detect_local_server_type", return_value="lm-studio"), \
|
||||
patch("httpx.Client", return_value=client_mock):
|
||||
result = _query_local_context_length(
|
||||
"nvidia-nemotron-3-nano-4b", "http://192.168.1.22:1234/v1"
|
||||
)
|
||||
|
||||
assert result == 122_651, (
|
||||
f"Expected loaded instance context (122651) but got {result}. "
|
||||
"max_context_length (1048576) must not win over loaded_instances."
|
||||
)
|
||||
|
||||
|
||||
class TestQueryLocalContextLengthNetworkError:
|
||||
"""_query_local_context_length handles network failures gracefully."""
|
||||
|
||||
def test_connection_error_returns_none(self):
|
||||
"""Returns None when the server is unreachable."""
|
||||
from agent.model_metadata import _query_local_context_length
|
||||
|
||||
client_mock = MagicMock()
|
||||
client_mock.__enter__ = lambda s: client_mock
|
||||
client_mock.__exit__ = MagicMock(return_value=False)
|
||||
client_mock.post.side_effect = Exception("Connection refused")
|
||||
client_mock.get.side_effect = Exception("Connection refused")
|
||||
|
||||
with patch("agent.model_metadata.detect_local_server_type", return_value=None), \
|
||||
patch("httpx.Client", return_value=client_mock):
|
||||
result = _query_local_context_length("omnicoder-9b", "http://localhost:11434/v1")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_model_context_length — integration-style tests with mocked helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetModelContextLengthLocalFallback:
|
||||
"""get_model_context_length uses local server query before falling back to 2M."""
|
||||
|
||||
def test_local_endpoint_unknown_model_queries_server(self):
|
||||
"""Unknown model on local endpoint gets ctx from server, not 2M default."""
|
||||
from agent.model_metadata import get_model_context_length
|
||||
|
||||
with patch("agent.model_metadata.get_cached_context_length", return_value=None), \
|
||||
patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \
|
||||
patch("agent.model_metadata.fetch_model_metadata", return_value={}), \
|
||||
patch("agent.model_metadata.is_local_endpoint", return_value=True), \
|
||||
patch("agent.model_metadata._query_local_context_length", return_value=131072), \
|
||||
patch("agent.model_metadata.save_context_length") as mock_save:
|
||||
result = get_model_context_length("omnicoder-9b", "http://localhost:11434/v1")
|
||||
|
||||
assert result == 131072
|
||||
|
||||
def test_local_endpoint_unknown_model_result_is_cached(self):
|
||||
"""Context length returned from local server is persisted to cache."""
|
||||
from agent.model_metadata import get_model_context_length
|
||||
|
||||
with patch("agent.model_metadata.get_cached_context_length", return_value=None), \
|
||||
patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \
|
||||
patch("agent.model_metadata.fetch_model_metadata", return_value={}), \
|
||||
patch("agent.model_metadata.is_local_endpoint", return_value=True), \
|
||||
patch("agent.model_metadata._query_local_context_length", return_value=131072), \
|
||||
patch("agent.model_metadata.save_context_length") as mock_save:
|
||||
get_model_context_length("omnicoder-9b", "http://localhost:11434/v1")
|
||||
|
||||
mock_save.assert_called_once_with("omnicoder-9b", "http://localhost:11434/v1", 131072)
|
||||
|
||||
def test_local_endpoint_server_returns_none_falls_back_to_2m(self):
|
||||
"""When local server returns None, still falls back to 2M probe tier."""
|
||||
from agent.model_metadata import get_model_context_length, CONTEXT_PROBE_TIERS
|
||||
|
||||
with patch("agent.model_metadata.get_cached_context_length", return_value=None), \
|
||||
patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \
|
||||
patch("agent.model_metadata.fetch_model_metadata", return_value={}), \
|
||||
patch("agent.model_metadata.is_local_endpoint", return_value=True), \
|
||||
patch("agent.model_metadata._query_local_context_length", return_value=None):
|
||||
result = get_model_context_length("omnicoder-9b", "http://localhost:11434/v1")
|
||||
|
||||
assert result == CONTEXT_PROBE_TIERS[0]
|
||||
|
||||
def test_non_local_endpoint_does_not_query_local_server(self):
|
||||
"""For non-local endpoints, _query_local_context_length is not called."""
|
||||
from agent.model_metadata import get_model_context_length, CONTEXT_PROBE_TIERS
|
||||
|
||||
with patch("agent.model_metadata.get_cached_context_length", return_value=None), \
|
||||
patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \
|
||||
patch("agent.model_metadata.fetch_model_metadata", return_value={}), \
|
||||
patch("agent.model_metadata.is_local_endpoint", return_value=False), \
|
||||
patch("agent.model_metadata._query_local_context_length") as mock_query:
|
||||
result = get_model_context_length(
|
||||
"unknown-model", "https://some-cloud-api.example.com/v1"
|
||||
)
|
||||
|
||||
mock_query.assert_not_called()
|
||||
|
||||
def test_cached_result_skips_local_query(self):
|
||||
"""Cached context length is returned without querying the local server."""
|
||||
from agent.model_metadata import get_model_context_length
|
||||
|
||||
with patch("agent.model_metadata.get_cached_context_length", return_value=65536), \
|
||||
patch("agent.model_metadata._query_local_context_length") as mock_query:
|
||||
result = get_model_context_length("omnicoder-9b", "http://localhost:11434/v1")
|
||||
|
||||
assert result == 65536
|
||||
mock_query.assert_not_called()
|
||||
|
||||
def test_no_base_url_does_not_query_local_server(self):
|
||||
"""When base_url is empty, local server is not queried."""
|
||||
from agent.model_metadata import get_model_context_length
|
||||
|
||||
with patch("agent.model_metadata.get_cached_context_length", return_value=None), \
|
||||
patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \
|
||||
patch("agent.model_metadata.fetch_model_metadata", return_value={}), \
|
||||
patch("agent.model_metadata._query_local_context_length") as mock_query:
|
||||
result = get_model_context_length("unknown-xyz-model", "")
|
||||
|
||||
mock_query.assert_not_called()
|
||||
Reference in New Issue
Block a user