Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

# Conflicts:
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson
2026-05-07 21:19:31 -04:00
288 changed files with 34632 additions and 1647 deletions
+27 -10
View File
@@ -14,6 +14,7 @@ from agent.anthropic_adapter import (
_to_plain_data,
_write_claude_code_credentials,
build_anthropic_client,
build_anthropic_bedrock_client,
build_anthropic_kwargs,
convert_messages_to_anthropic,
convert_tools_to_anthropic,
@@ -66,11 +67,9 @@ class TestBuildAnthropicClient:
assert "claude-code-20250219" in betas
assert "interleaved-thinking-2025-05-14" in betas
assert "fine-grained-tool-streaming-2025-05-14" in betas
# Default: 1M-context beta stays IN for OAuth so 1M-capable
# subscriptions keep full context. The reactive recovery path
# in run_agent.py flips it off only after a subscription
# actually rejects the beta.
assert "context-1m-2025-08-07" in betas
# Native Anthropic does not get context-1m by default; accounts
# without that beta reject even short auxiliary requests.
assert "context-1m-2025-08-07" not in betas
assert "api_key" not in kwargs
def test_oauth_drop_context_1m_beta_strips_only_1m(self):
@@ -99,7 +98,7 @@ class TestBuildAnthropicClient:
# API key auth should still get common betas
betas = kwargs["default_headers"]["anthropic-beta"]
assert "interleaved-thinking-2025-05-14" in betas
assert "context-1m-2025-08-07" in betas
assert "context-1m-2025-08-07" not in betas
assert "oauth-2025-04-20" not in betas # OAuth-only beta NOT present
assert "claude-code-20250219" not in betas # OAuth-only beta NOT present
@@ -109,9 +108,27 @@ class TestBuildAnthropicClient:
kwargs = mock_sdk.Anthropic.call_args[1]
assert kwargs["base_url"] == "https://custom.api.com"
assert kwargs["default_headers"] == {
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-1m-2025-08-07"
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
}
def test_azure_anthropic_endpoint_keeps_context_1m_beta(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
"azure-key",
base_url="https://example.services.ai.azure.com/models/anthropic",
)
kwargs = mock_sdk.Anthropic.call_args[1]
betas = kwargs["default_headers"]["anthropic-beta"]
assert "context-1m-2025-08-07" in betas
def test_bedrock_client_keeps_context_1m_beta(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
mock_sdk.AnthropicBedrock = MagicMock()
build_anthropic_bedrock_client("us-east-1")
kwargs = mock_sdk.AnthropicBedrock.call_args[1]
betas = kwargs["default_headers"]["anthropic-beta"]
assert "context-1m-2025-08-07" in betas
def test_minimax_anthropic_endpoint_uses_bearer_auth_for_regular_api_keys(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
@@ -986,8 +1003,8 @@ class TestBuildAnthropicKwargs:
)
assert kwargs["model"] == "claude-sonnet-4-20250514"
def test_fast_mode_oauth_default_keeps_context_1m_beta(self):
"""Default OAuth fast-mode requests still carry context-1m-2025-08-07."""
def test_fast_mode_oauth_default_omits_context_1m_beta(self):
"""Default OAuth fast-mode avoids context-1m for subscriptions without it."""
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
messages=[{"role": "user", "content": "Hi"}],
@@ -1000,7 +1017,7 @@ class TestBuildAnthropicKwargs:
betas = kwargs["extra_headers"]["anthropic-beta"]
assert "fast-mode-2026-02-01" in betas
assert "oauth-2025-04-20" in betas
assert "context-1m-2025-08-07" in betas
assert "context-1m-2025-08-07" not in betas
def test_fast_mode_oauth_drop_context_1m_beta_strips_only_1m(self):
"""drop_context_1m_beta=True strips context-1m from fast-mode
+94
View File
@@ -3,7 +3,9 @@
import json
import logging
import os
import time
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch, MagicMock, AsyncMock
import pytest
@@ -24,6 +26,7 @@ from agent.auxiliary_client import (
_normalize_aux_provider,
_try_payment_fallback,
_resolve_auto,
_CodexCompletionsAdapter,
)
@@ -57,6 +60,18 @@ def codex_auth_dir(tmp_path, monkeypatch):
return codex_dir
class TestAuxiliaryMaxTokensParam:
def test_uses_max_completion_tokens_for_github_copilot_custom_base(self):
with patch("agent.auxiliary_client._resolve_custom_runtime", return_value=("https://api.githubcopilot.com", "key", None)), \
patch("agent.auxiliary_client._read_nous_auth", return_value=None):
assert auxiliary_max_tokens_param(2048) == {"max_completion_tokens": 2048}
def test_uses_max_completion_tokens_for_github_copilot_custom_base_path(self):
with patch("agent.auxiliary_client._resolve_custom_runtime", return_value=("https://api.githubcopilot.com/chat/completions", "key", None)), \
patch("agent.auxiliary_client._read_nous_auth", return_value=None):
assert auxiliary_max_tokens_param(2048) == {"max_completion_tokens": 2048}
class TestNormalizeAuxProvider:
def test_maps_github_copilot_aliases(self):
assert _normalize_aux_provider("github") == "copilot"
@@ -1882,6 +1897,85 @@ class TestVisionAutoSkipsKimiCoding:
})
class TestCodexAuxiliaryAdapterTimeout:
def test_forwards_timeout_to_responses_stream(self):
class FakeStream:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
return iter(())
def get_final_response(self):
return SimpleNamespace(
output=[SimpleNamespace(
type="message",
content=[SimpleNamespace(type="output_text", text="summary")],
)],
usage=None,
)
class FakeResponses:
def __init__(self):
self.kwargs = None
def stream(self, **kwargs):
self.kwargs = kwargs
return FakeStream()
fake_client = SimpleNamespace(responses=FakeResponses())
adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5")
response = adapter.create(
messages=[{"role": "user", "content": "summarize this"}],
timeout=12.5,
)
assert fake_client.responses.kwargs["timeout"] == 12.5
assert response.choices[0].message.content == "summary"
def test_enforces_total_timeout_while_stream_keeps_emitting_events(self):
class SlowAliveStream:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
for _ in range(5):
time.sleep(0.03)
yield SimpleNamespace(type="response.in_progress")
def get_final_response(self):
return SimpleNamespace(
output=[SimpleNamespace(
type="message",
content=[SimpleNamespace(type="output_text", text="late")],
)],
usage=None,
)
class FakeResponses:
def stream(self, **kwargs):
return SlowAliveStream()
fake_client = SimpleNamespace(responses=FakeResponses(), close=lambda: None)
adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5")
started = time.monotonic()
with pytest.raises(TimeoutError):
adapter.create(
messages=[{"role": "user", "content": "summarize this"}],
timeout=0.05,
)
assert time.monotonic() - started < 0.14
# ---------------------------------------------------------------------------
# _build_call_kwargs — tool dedup at API boundary
# ---------------------------------------------------------------------------
+1
View File
@@ -994,6 +994,7 @@ class TestStreamConverseWithCallbacks:
events, on_reasoning_delta=lambda t: reasoning.append(t),
)
assert reasoning == ["Let me think..."]
assert result.choices[0].message.reasoning_content == "Let me think..."
# ---------------------------------------------------------------------------
+24
View File
@@ -191,6 +191,30 @@ class TestNonStringContent:
kwargs = mock_call.call_args.kwargs
assert "temperature" not in kwargs
def test_summary_prompt_avoids_filter_sensitive_handoff_framing(self):
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "ok"
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True)
messages = [
{"role": "user", "content": "do something"},
{"role": "assistant", "content": "ok"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call:
c._generate_summary(messages)
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
assert "Your output will be injected" not in prompt
assert "Do NOT respond" not in prompt
assert "DIFFERENT assistant" not in prompt
assert "different assistant" not in prompt
assert "Treat the conversation turns below as source material" in prompt
assert "structured checkpoint summary" in prompt
def test_summary_call_passes_live_main_runtime(self):
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
+73
View File
@@ -250,6 +250,42 @@ def test_exhausted_402_entry_resets_after_one_hour(tmp_path, monkeypatch):
assert entry.last_status == "ok"
def test_exhausted_401_entry_resets_after_five_minutes(tmp_path, monkeypatch):
"""Transient auth failures should not strand single-key setups for an hour."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"openrouter": [
{
"id": "cred-1",
"label": "primary",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "***",
"base_url": "https://openrouter.ai/api/v1",
"last_status": "exhausted",
"last_status_at": time.time() - 310,
"last_error_code": 401,
}
]
},
},
)
from agent.credential_pool import load_pool
pool = load_pool("openrouter")
entry = pool.select()
assert entry is not None
assert entry.id == "cred-1"
assert entry.last_status == "ok"
def test_explicit_reset_timestamp_overrides_default_429_ttl(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
# Prevent auto-seeding from Codex CLI tokens on the host
@@ -924,6 +960,43 @@ def test_get_custom_provider_pool_key(tmp_path, monkeypatch):
assert get_custom_provider_pool_key("") is None
def test_get_custom_provider_pool_key_prefers_name_over_base_url(tmp_path, monkeypatch):
"""When two custom providers share the same base_url, provider_name resolves to the correct one."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
import yaml
config_path = tmp_path / "hermes" / "config.yaml"
config_path.write_text(yaml.dump({
"custom_providers": [
{
"name": "provider-a",
"base_url": "http://gateway:8080/v1",
"api_key": "sk-aaa",
},
{
"name": "provider-b",
"base_url": "http://gateway:8080/v1",
"api_key": "sk-bbb",
},
]
}))
from agent.credential_pool import get_custom_provider_pool_key
# Without provider_name, first match wins (backward compatible)
assert get_custom_provider_pool_key("http://gateway:8080/v1") == "custom:provider-a"
# With provider_name, exact name match wins regardless of order
assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="provider-b") == "custom:provider-b"
assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="provider-a") == "custom:provider-a"
# Name match with non-matching base_url still works via fallback
assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="nonexistent") == "custom:provider-a"
# Empty provider_name is same as None (backward compatible)
assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="") == "custom:provider-a"
def test_list_custom_pool_providers(tmp_path, monkeypatch):
"""list_custom_pool_providers returns custom: pool keys from auth.json."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
+48
View File
@@ -8,12 +8,21 @@ from agent.display import (
build_tool_preview,
capture_local_edit_snapshot,
extract_edit_diff,
get_cute_tool_message,
set_tool_preview_max_len,
_render_inline_unified_diff,
_summarize_rendered_diff_sections,
render_edit_diff_with_delta,
)
@pytest.fixture(autouse=True)
def reset_tool_preview_max_len():
set_tool_preview_max_len(0)
yield
set_tool_preview_max_len(0)
class TestBuildToolPreview:
"""Tests for build_tool_preview defensive handling and normal operation."""
@@ -102,6 +111,45 @@ class TestBuildToolPreview:
assert build_tool_preview("terminal", []) is None
class TestCuteToolMessagePreviewLength:
def test_terminal_preview_unlimited_when_config_is_zero(self):
set_tool_preview_max_len(0)
command = "curl -s http://localhost:9222/json/list | jq -r '.[] | select(.type==\"page\")' | head -5"
line = get_cute_tool_message("terminal", {"command": command}, 0.1)
assert command in line
assert "..." not in line
def test_terminal_preview_uses_positive_configured_limit(self):
set_tool_preview_max_len(80)
command = "curl -s http://localhost:9222/json/list | jq -r '.[] | select(.type==\"page\")' | head -5"
line = get_cute_tool_message("terminal", {"command": command}, 0.1)
assert command[:77] in line
assert "..." in line
assert "head -5" not in line
def test_search_files_preview_uses_positive_configured_limit_not_default(self):
set_tool_preview_max_len(80)
pattern = "function.formatToolCall.context.preview.compactPreview.maxLength.truncate"
line = get_cute_tool_message("search_files", {"pattern": pattern}, 0.1)
assert pattern in line
assert "..." not in line
def test_path_preview_uses_positive_configured_limit_not_default(self):
set_tool_preview_max_len(80)
path = "/tmp/hermes-test-preview-length/deeply/nested/path/test-output.txt"
line = get_cute_tool_message("read_file", {"path": path}, 0.1)
assert path in line
assert "..." not in line
class TestEditDiffPreview:
def test_extract_edit_diff_for_patch(self):
diff = extract_edit_diff("patch", '{"success": true, "diff": "--- a/x\\n+++ b/x\\n"}')
+79 -6
View File
@@ -109,6 +109,21 @@ class TestDecideImageInputMode:
with patch("agent.image_routing._lookup_supports_vision", return_value=True):
assert decide_image_input_mode("anthropic", "claude-sonnet-4", cfg) == "native"
def test_auto_uses_text_for_text_only_modalities_even_with_attachment_flag(self):
registry = {
"xiaomi": {
"models": {
"mimo-v2.5-pro": {
"attachment": True,
"modalities": {"input": ["text"]},
"tool_call": True,
},
},
},
}
with patch("agent.models_dev.fetch_models_dev", return_value=registry):
assert decide_image_input_mode("xiaomi", "mimo-v2.5-pro", {}) == "text"
# ─── build_native_content_parts ──────────────────────────────────────────────
@@ -127,7 +142,11 @@ class TestBuildNativeContentParts:
parts, skipped = build_native_content_parts("hello", [str(img)])
assert skipped == []
assert len(parts) == 2
assert parts[0] == {"type": "text", "text": "hello"}
assert parts[0]["type"] == "text"
# User caption is preserved and a per-image path hint is appended so
# the model can use the local path as a string argument for tools
# that take ``image_url: str`` (issue #18960).
assert parts[0]["text"] == f"hello\n\n[Image attached at: {img}]"
assert parts[1]["type"] == "image_url"
assert parts[1]["image_url"]["url"].startswith("data:image/png;base64,")
@@ -137,17 +156,51 @@ class TestBuildNativeContentParts:
parts, skipped = build_native_content_parts("", [str(img)])
assert skipped == []
# Even with empty user text, we insert a neutral prompt so the turn
# isn't just pixels.
# isn't just pixels, and the path hint is appended after.
assert parts[0]["type"] == "text"
assert parts[0]["text"] == "What do you see in this image?"
assert parts[0]["text"] == (
f"What do you see in this image?\n\n[Image attached at: {img}]"
)
assert parts[1]["type"] == "image_url"
def test_missing_file_is_skipped(self, tmp_path: Path):
parts, skipped = build_native_content_parts("hi", [str(tmp_path / "missing.png")])
assert skipped == [str(tmp_path / "missing.png")]
# Only text remains.
# Skipped paths are NOT advertised in the path hints — the model
# would otherwise be told a non-existent file is attached.
assert parts == [{"type": "text", "text": "hi"}]
def test_path_hint_appended(self, tmp_path: Path):
"""The local path of each attached image is appended to the user
text part so MCP/skill tools that take ``image_url: str`` can be
invoked on the same image (issue #18960). Mirrors text-mode
behaviour (`Runner._enrich_message_with_vision`).
"""
img = tmp_path / "scan.png"
img.write_bytes(_png_bytes())
parts, _ = build_native_content_parts("attach this", [str(img)])
text_part = next(p for p in parts if p.get("type") == "text")
assert "[Image attached at:" in text_part["text"]
assert str(img) in text_part["text"]
# User caption is preserved verbatim ahead of the hint.
assert text_part["text"].startswith("attach this")
def test_path_hint_one_per_attached_image(self, tmp_path: Path):
"""Each successfully attached image gets its own path hint line;
skipped images do NOT appear in the hints.
"""
good = tmp_path / "good.png"
good.write_bytes(_png_bytes())
missing = tmp_path / "missing.png" # never created
parts, skipped = build_native_content_parts(
"see attached", [str(good), str(missing)]
)
assert skipped == [str(missing)]
text_part = next(p for p in parts if p.get("type") == "text")
assert text_part["text"].count("[Image attached at:") == 1
assert str(good) in text_part["text"]
assert str(missing) not in text_part["text"]
def test_multiple_images(self, tmp_path: Path):
img1 = tmp_path / "a.png"
img2 = tmp_path / "b.png"
@@ -157,21 +210,41 @@ class TestBuildNativeContentParts:
assert skipped == []
image_parts = [p for p in parts if p.get("type") == "image_url"]
assert len(image_parts) == 2
# Both paths surface in the text part, one per line.
text_part = next(p for p in parts if p.get("type") == "text")
assert text_part["text"].count("[Image attached at:") == 2
assert str(img1) in text_part["text"]
assert str(img2) in text_part["text"]
def test_mime_inference_jpg(self, tmp_path: Path):
# Real JPEG bytes (SOI marker FF D8 FF): sniffing now wins over suffix.
img = tmp_path / "photo.jpg"
img.write_bytes(_png_bytes()) # bytes are PNG but extension is jpg
img.write_bytes(b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01" + b"\x00" * 32)
parts, _ = build_native_content_parts("x", [str(img)])
url = parts[1]["image_url"]["url"]
assert url.startswith("data:image/jpeg;base64,")
def test_mime_inference_webp(self, tmp_path: Path):
# Real WEBP bytes (RIFF....WEBP): sniffing now wins over suffix.
img = tmp_path / "pic.webp"
img.write_bytes(_png_bytes())
img.write_bytes(b"RIFF\x24\x00\x00\x00WEBPVP8 " + b"\x00" * 32)
parts, _ = build_native_content_parts("", [str(img)])
url = parts[1]["image_url"]["url"]
assert url.startswith("data:image/webp;base64,")
def test_mime_sniff_overrides_misleading_extension(self, tmp_path: Path):
"""Discord-style bug: file is named .webp but contains PNG bytes.
Anthropic rejects on MIME mismatch (HTTP 400) so we MUST sniff.
Regression guard for the user-reported Discord PNG-as-WEBP failure.
"""
img = tmp_path / "discord_cached.webp"
img.write_bytes(_png_bytes()) # bytes are PNG, suffix lies
parts, _ = build_native_content_parts("", [str(img)])
url = parts[1]["image_url"]["url"]
assert url.startswith("data:image/png;base64,"), (
f"Expected MIME sniffing to detect PNG bytes regardless of .webp suffix, got: {url[:60]}"
)
# ─── Oversize handling ───────────────────────────────────────────────────────
+15 -1
View File
@@ -223,6 +223,13 @@ CAPS_REGISTRY = {
"tool_call": True,
"limit": {"context": 32000, "output": 8192},
},
"text-only-with-stale-attachment": {
"id": "text-only-with-stale-attachment",
"attachment": True,
"tool_call": True,
"modalities": {"input": ["text"]},
"limit": {"context": 128000, "output": 8192},
},
},
},
"anthropic": {
@@ -243,7 +250,7 @@ class TestGetModelCapabilities:
"""Tests for get_model_capabilities vision detection."""
def test_vision_from_attachment_flag(self):
"""Models with attachment=True should report supports_vision=True."""
"""Models with attachment=True and no modalities should report supports_vision=True."""
with patch("agent.models_dev.fetch_models_dev", return_value=CAPS_REGISTRY):
caps = get_model_capabilities("anthropic", "claude-sonnet-4")
assert caps is not None
@@ -257,6 +264,13 @@ class TestGetModelCapabilities:
assert caps is not None
assert caps.supports_vision is True
def test_text_only_modalities_override_stale_attachment_flag(self):
"""Text-only modalities must win over stale attachment=True metadata."""
with patch("agent.models_dev.fetch_models_dev", return_value=CAPS_REGISTRY):
caps = get_model_capabilities("google", "text-only-with-stale-attachment")
assert caps is not None
assert caps.supports_vision is False
def test_no_vision_without_attachment_or_modalities(self):
"""Models with neither attachment nor image modality should be non-vision."""
with patch("agent.models_dev.fetch_models_dev", return_value=CAPS_REGISTRY):
+131
View File
@@ -177,6 +177,137 @@ class TestScanSkillCommands:
assert "/telegram-only" not in telegram_again
assert "/discord-only" in telegram_again
def test_get_skill_commands_rescans_when_session_platform_changes(self, tmp_path):
"""``HERMES_SESSION_PLATFORM`` from the gateway session context must
also trigger a rescan, not just ``HERMES_PLATFORM`` (#14536).
Exercises the real ContextVar path: the gateway sets the active
adapter via ``set_session_vars(platform=...)`` and the resolver
reads it via ``get_session_env``. Setting ``HERMES_SESSION_PLATFORM``
in ``os.environ`` would only test ``get_session_env``'s legacy
env-var fallback a regression that swapped ``get_session_env``
for plain ``os.getenv`` would still pass while breaking concurrent
gateway sessions, which is the bug the ContextVar plumbing exists
to prevent in the first place.
"""
import agent.skill_commands as sc_mod
from agent.skill_commands import get_skill_commands
from gateway.session_context import (
clear_session_vars,
get_session_env,
set_session_vars,
)
def _disabled_skills():
platform = (
os.getenv("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM")
)
if platform == "telegram":
return {"telegram-only"}
if platform == "discord":
return {"discord-only"}
return set()
with (
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
patch("tools.skills_tool._get_disabled_skill_names", side_effect=_disabled_skills),
patch.object(sc_mod, "_skill_commands", {}),
patch.object(sc_mod, "_skill_commands_platform", None),
):
_make_skill(tmp_path, "shared")
_make_skill(tmp_path, "telegram-only")
_make_skill(tmp_path, "discord-only")
# First simulated gateway request: telegram handler.
tokens = set_session_vars(platform="telegram")
try:
telegram_commands = dict(get_skill_commands())
finally:
clear_session_vars(tokens)
assert "/shared" in telegram_commands
assert "/discord-only" in telegram_commands
assert "/telegram-only" not in telegram_commands
# Second simulated gateway request: discord handler. The cache
# was just populated for telegram; the rescan trigger must fire
# off the ContextVar change, not just an env-var change.
tokens = set_session_vars(platform="discord")
try:
discord_commands = dict(get_skill_commands())
finally:
clear_session_vars(tokens)
assert "/shared" in discord_commands
assert "/telegram-only" in discord_commands
assert "/discord-only" not in discord_commands
def test_get_skill_commands_rescans_when_leaving_platform_scope(self, tmp_path, monkeypatch):
"""Returning to no-platform-scope (CLI / cron / RL) after a gateway
session must rescan so the unfiltered view is repopulated (#14536).
A long-lived process running both gateway sessions and bare CLI
invocations would otherwise stay stuck on whichever platform's
filter was last applied.
"""
import agent.skill_commands as sc_mod
from agent.skill_commands import get_skill_commands
def _disabled_skills():
if os.getenv("HERMES_PLATFORM") == "telegram":
return {"telegram-only"}
return set()
with (
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
patch("tools.skills_tool._get_disabled_skill_names", side_effect=_disabled_skills),
patch.object(sc_mod, "_skill_commands", {}),
patch.object(sc_mod, "_skill_commands_platform", None),
):
_make_skill(tmp_path, "shared")
_make_skill(tmp_path, "telegram-only")
monkeypatch.setenv("HERMES_PLATFORM", "telegram")
telegram_commands = dict(get_skill_commands())
assert "/telegram-only" not in telegram_commands
# Drop back to no platform scope — bare CLI / cron / RL rollouts.
monkeypatch.delenv("HERMES_PLATFORM", raising=False)
bare_commands = dict(get_skill_commands())
assert "/telegram-only" in bare_commands
assert sc_mod._skill_commands_platform is None
def test_get_skill_commands_does_not_rescan_when_platform_unchanged(self, tmp_path):
"""Same-platform back-to-back calls must hit the cache, not rescan.
The rescan trigger is *change* in platform scope, not "always
re-resolve." A gateway serving consecutive telegram requests must
not pay the scan cost for each one.
"""
import agent.skill_commands as sc_mod
from agent.skill_commands import get_skill_commands
with (
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
patch.object(sc_mod, "_skill_commands", {}),
patch.object(sc_mod, "_skill_commands_platform", None),
patch.dict(os.environ, {"HERMES_PLATFORM": "telegram"}),
):
_make_skill(tmp_path, "shared")
# Prime the cache.
get_skill_commands()
# Spy on rescans during the subsequent same-platform calls.
with patch(
"agent.skill_commands.scan_skill_commands",
wraps=sc_mod.scan_skill_commands,
) as scan_spy:
get_skill_commands()
get_skill_commands()
get_skill_commands()
assert scan_spy.call_count == 0
def test_special_chars_stripped_from_cmd_key(self, tmp_path):
"""Skill names with +, /, or other special chars produce clean cmd keys."""
@@ -142,6 +142,24 @@ class TestBedrockNormalize:
assert len(nr.tool_calls) == 1
assert nr.tool_calls[0].name == "terminal"
def test_raw_reasoning_content_response(self, transport):
raw = {
"output": {
"message": {
"role": "assistant",
"content": [
{"reasoningContent": {"text": "Let me think..."}},
{"text": "Answer."},
],
}
},
"stopReason": "end_turn",
"usage": {"inputTokens": 10, "outputTokens": 5, "totalTokens": 15},
}
nr = transport.normalize_response(raw)
assert nr.reasoning == "Let me think..."
assert nr.content == "Answer."
def test_already_normalized_response(self, transport):
"""Test normalize_response handles already-normalized SimpleNamespace (from dispatch site)."""
pre_normalized = SimpleNamespace(