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

This commit is contained in:
Brooklyn Nicholson
2026-05-04 12:47:53 -05:00
182 changed files with 9843 additions and 974 deletions
+39
View File
@@ -1113,6 +1113,45 @@ class TestBuildAnthropicKwargs:
assert _forbids_sampling_params("claude-opus-4-6") is False
assert _forbids_sampling_params("claude-sonnet-4-5") is False
def test_supports_fast_mode_predicate(self):
"""Fast mode is Opus 4.6 only — Opus 4.7 and others must be excluded."""
from agent.anthropic_adapter import _supports_fast_mode
assert _supports_fast_mode("claude-opus-4-6") is True
assert _supports_fast_mode("anthropic/claude-opus-4-6") is True
assert _supports_fast_mode("claude-opus-4-7") is False
assert _supports_fast_mode("claude-sonnet-4-6") is False
assert _supports_fast_mode("claude-haiku-4-5") is False
assert _supports_fast_mode("") is False
def test_fast_mode_omitted_for_unsupported_model(self):
"""fast_mode=True on Opus 4.7 must NOT inject speed=fast (API 400s)."""
kwargs = build_anthropic_kwargs(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
tools=None,
max_tokens=1024,
reasoning_config=None,
fast_mode=True,
)
# extra_body either absent or doesn't carry "speed"
assert "speed" not in kwargs.get("extra_body", {})
# No fast-mode beta header should be added either
beta_header = (kwargs.get("extra_headers") or {}).get("anthropic-beta", "")
assert "fast-mode-2026-02-01" not in beta_header
def test_fast_mode_still_applied_on_opus_46(self):
"""Regression guard — fast mode must still work on Opus 4.6."""
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
messages=[{"role": "user", "content": "hi"}],
tools=None,
max_tokens=1024,
reasoning_config=None,
fast_mode=True,
)
assert kwargs.get("extra_body", {}).get("speed") == "fast"
assert "fast-mode-2026-02-01" in kwargs["extra_headers"]["anthropic-beta"]
def test_reasoning_disabled(self):
kwargs = build_anthropic_kwargs(
model="claude-sonnet-4-20250514",
+50
View File
@@ -1893,3 +1893,53 @@ class TestOpenRouterExplicitApiKey:
assert call_kwargs["api_key"] == "env-fallback-key", (
f"Expected env fallback key to be used when explicit_api_key is None, got: {call_kwargs['api_key']}"
)
class TestAnthropicExplicitApiKey:
"""Test that explicit_api_key is correctly propagated to _try_anthropic().
Parity with the OpenRouter fix in #18768: resolve_provider_client() passes
explicit_api_key to _try_openrouter(), but the anthropic branch was not
updated — _try_anthropic() always fell back to resolve_anthropic_token()
even when an explicit key was supplied (e.g. from a fallback_model entry).
"""
def test_try_anthropic_uses_explicit_api_key_over_env(self):
"""_try_anthropic(explicit_api_key) must use the supplied key, not the env fallback."""
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="env-fallback-key"), \
patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
from agent.auxiliary_client import _try_anthropic
client, model = _try_anthropic("explicit-pool-key")
assert client is not None
assert mock_build.call_args.args[0] == "explicit-pool-key", (
f"Expected explicit_api_key to be passed, got: {mock_build.call_args.args[0]}"
)
assert mock_build.call_args.args[0] != "env-fallback-key"
def test_try_anthropic_without_explicit_key_falls_back_to_resolve(self):
"""Without explicit_api_key, _try_anthropic falls back to resolve_anthropic_token."""
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="env-fallback-key"), \
patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
from agent.auxiliary_client import _try_anthropic
client, model = _try_anthropic()
assert client is not None
assert mock_build.call_args.args[0] == "env-fallback-key"
def test_resolve_provider_client_passes_explicit_api_key_to_anthropic(self):
"""resolve_provider_client(provider='anthropic', explicit_api_key=...) must propagate the key."""
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="env-key"), \
patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
client, model = resolve_provider_client(
provider="anthropic",
explicit_api_key="explicit-fallback-key",
)
assert client is not None
assert mock_build.call_args.args[0] == "explicit-fallback-key", (
"resolve_provider_client must forward explicit_api_key to _try_anthropic()"
)
+6
View File
@@ -1283,18 +1283,21 @@ class TestIsStaleConnectionError:
"""Classifier that decides whether an exception warrants client eviction."""
def test_detects_botocore_connection_closed_error(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import is_stale_connection_error
from botocore.exceptions import ConnectionClosedError
exc = ConnectionClosedError(endpoint_url="https://bedrock.example")
assert is_stale_connection_error(exc) is True
def test_detects_botocore_endpoint_connection_error(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import is_stale_connection_error
from botocore.exceptions import EndpointConnectionError
exc = EndpointConnectionError(endpoint_url="https://bedrock.example")
assert is_stale_connection_error(exc) is True
def test_detects_botocore_read_timeout(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import is_stale_connection_error
from botocore.exceptions import ReadTimeoutError
exc = ReadTimeoutError(endpoint_url="https://bedrock.example")
@@ -1355,6 +1358,7 @@ class TestCallConverseInvalidatesOnStaleError:
reconnects instead of reusing the dead socket."""
def test_converse_evicts_client_on_stale_error(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import (
_bedrock_runtime_client_cache,
call_converse,
@@ -1381,6 +1385,7 @@ class TestCallConverseInvalidatesOnStaleError:
)
def test_converse_stream_evicts_client_on_stale_error(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import (
_bedrock_runtime_client_cache,
call_converse_stream,
@@ -1406,6 +1411,7 @@ class TestCallConverseInvalidatesOnStaleError:
def test_converse_does_not_evict_on_non_stale_error(self):
"""Non-stale errors (e.g. ValidationException) leave the client cache alone."""
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import (
_bedrock_runtime_client_cache,
call_converse,
+41
View File
@@ -1281,6 +1281,47 @@ class TestTokenBudgetTailProtection:
assert isinstance(cut, int)
assert 0 <= cut <= len(messages)
def test_generous_budget_protects_everything_floor_does_not_override(
self, budget_compressor
):
"""A budget that covers the whole transcript must prune nothing —
``protect_tail_count`` is a minimum floor, not a ceiling."""
c = budget_compressor
# 100 alternating assistant/tool messages. Each tool result has
# *unique* content so the dedup pass (Pass 1, which is independent
# of prune_boundary) is a no-op and we isolate the boundary logic.
messages = []
for i in range(50):
messages.append({
"role": "assistant", "content": None,
"tool_calls": [{
"id": f"c{i}",
"type": "function",
"function": {"name": "noop", "arguments": "{}"},
}],
})
messages.append({
"role": "tool",
"tool_call_id": f"c{i}",
"content": f"unique-tool-output-{i:03d}-" + ("x" * 250),
})
# Budget large enough to cover the whole transcript many times over,
# so the budget walk completes without hitting its break condition
# and the boundary lands at 0 ("protect everything").
_, pruned = c._prune_old_tool_results(
messages,
protect_tail_count=20,
protect_tail_tokens=10_000_000,
)
assert pruned == 0, (
"budget said protect everything, but the floor still pruned "
f"{pruned} messages — protect_tail_count is acting as a ceiling, "
"not a minimum floor"
)
class TestUpdateModelBudgets:
"""Regression: update_model() must recalculate token budgets."""
+117
View File
@@ -154,6 +154,7 @@ def test_unused_skill_transitions_to_stale(curator_env):
long_ago = (datetime.now(timezone.utc) - timedelta(days=45)).isoformat()
data = u.load_usage()
data["old-skill"] = u._empty_record()
data["old-skill"]["created_by"] = "agent"
data["old-skill"]["last_used_at"] = long_ago
data["old-skill"]["created_at"] = long_ago
u.save_usage(data)
@@ -172,6 +173,7 @@ def test_very_old_skill_gets_archived(curator_env):
super_old = (datetime.now(timezone.utc) - timedelta(days=120)).isoformat()
data = u.load_usage()
data["ancient"] = u._empty_record()
data["ancient"]["created_by"] = "agent"
data["ancient"]["last_used_at"] = super_old
data["ancient"]["created_at"] = super_old
u.save_usage(data)
@@ -192,6 +194,7 @@ def test_pinned_skill_is_never_touched(curator_env):
super_old = (datetime.now(timezone.utc) - timedelta(days=365)).isoformat()
data = u.load_usage()
data["precious"] = u._empty_record()
data["precious"]["created_by"] = "agent"
data["precious"]["last_used_at"] = super_old
data["precious"]["created_at"] = super_old
data["precious"]["pinned"] = True
@@ -214,6 +217,7 @@ def test_stale_skill_reactivates_on_recent_use(curator_env):
recent = datetime.now(timezone.utc).isoformat()
data = u.load_usage()
data["revived"] = u._empty_record()
data["revived"]["created_by"] = "agent"
data["revived"]["state"] = "stale"
data["revived"]["last_used_at"] = recent
data["revived"]["created_at"] = recent
@@ -240,6 +244,27 @@ def test_new_skill_without_last_used_not_immediately_archived(curator_env):
assert (skills_dir / "fresh").exists()
def test_manual_skill_is_not_auto_archived(curator_env):
"""Manual skills can have usage records, but without the agent-created
marker they must stay out of curator transitions."""
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
skill_dir = _write_skill(skills_dir, "manual")
super_old = (datetime.now(timezone.utc) - timedelta(days=365)).isoformat()
data = u.load_usage()
data["manual"] = u._empty_record()
data["manual"]["last_used_at"] = super_old
data["manual"]["created_at"] = super_old
u.save_usage(data)
counts = c.apply_automatic_transitions()
assert counts["checked"] == 0
assert counts["archived"] == 0
assert skill_dir.exists()
def test_bundled_skill_not_touched_by_transitions(curator_env):
c = curator_env["curator"]
u = curator_env["usage"]
@@ -267,8 +292,10 @@ def test_bundled_skill_not_touched_by_transitions(curator_env):
def test_run_review_records_state(curator_env):
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "a")
u.mark_agent_created("a")
result = c.run_curator_review(synchronous=True)
assert "started_at" in result
@@ -284,8 +311,10 @@ def test_dry_run_does_not_advance_state(curator_env, monkeypatch):
`hermes curator status`. Fixes #18373.
"""
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "a")
u.mark_agent_created("a")
# Stub the LLM so the test doesn't need a provider.
monkeypatch.setattr(
@@ -311,8 +340,10 @@ def test_dry_run_injects_report_only_banner(curator_env, monkeypatch):
skips automatic transitions — but the LLM prompt is the only guard
against the model calling skill_manage directly."""
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "a")
u.mark_agent_created("a")
captured = {}
def _stub(prompt):
@@ -331,8 +362,10 @@ def test_dry_run_skips_automatic_transitions(curator_env, monkeypatch):
archives skills deterministically, and a preview must not touch the
filesystem."""
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "a")
u.mark_agent_created("a")
called = {"n": 0}
def _explode(*_a, **_kw):
@@ -351,8 +384,10 @@ def test_dry_run_skips_automatic_transitions(curator_env, monkeypatch):
def test_run_review_synchronous_invokes_llm_stub(curator_env, monkeypatch):
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "a")
u.mark_agent_created("a")
calls = []
def _stub(prompt):
@@ -409,8 +444,10 @@ def test_maybe_run_curator_enforces_idle_gate(curator_env, monkeypatch):
def test_maybe_run_curator_runs_when_eligible(curator_env, monkeypatch):
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "a")
u.mark_agent_created("a")
# Seed last_run_at far in the past so the interval gate opens — the
# "no state" path intentionally defers the first run now (#18373).
long_ago = datetime.now(timezone.utc) - timedelta(hours=c.get_interval_hours() * 2)
@@ -645,6 +682,86 @@ def test_review_model_honors_auxiliary_curator_slot(curator_env):
)
def test_review_runtime_passes_auxiliary_curator_credentials(curator_env):
"""Per-slot api_key/base_url must ride into resolve_runtime_provider (not main-only creds)."""
curator = curator_env["curator"]
cfg = {
"model": {"provider": "openrouter", "default": "openai/gpt-5.5"},
"auxiliary": {
"curator": {
"provider": "custom",
"model": "local-mini",
"api_key": "sk-curator-only",
"base_url": "http://localhost:11434/v1",
},
},
}
binding = curator._resolve_review_runtime(cfg)
assert binding.provider == "custom"
assert binding.model == "local-mini"
assert binding.explicit_api_key == "sk-curator-only"
assert binding.explicit_base_url == "http://localhost:11434/v1"
def test_review_runtime_strips_blank_aux_credentials(curator_env):
curator = curator_env["curator"]
cfg = {
"model": {"provider": "openrouter", "default": "openai/gpt-5.5"},
"auxiliary": {
"curator": {
"provider": "openrouter",
"model": "x/y",
"api_key": " ",
"base_url": "",
},
},
}
binding = curator._resolve_review_runtime(cfg)
assert binding.explicit_api_key is None
assert binding.explicit_base_url is None
def test_review_runtime_ignores_auxiliary_credentials_when_using_main(curator_env):
"""Falling through to main model must not pick up stray auxiliary.curator secrets."""
curator = curator_env["curator"]
cfg = {
"model": {"provider": "openrouter", "default": "openai/gpt-5.5"},
"auxiliary": {
"curator": {
"provider": "auto",
"model": "",
"api_key": "must-not-leak",
"base_url": "http://curator-slot-ignored/",
},
},
}
binding = curator._resolve_review_runtime(cfg)
assert (binding.provider, binding.model) == ("openrouter", "openai/gpt-5.5")
assert binding.explicit_api_key is None
assert binding.explicit_base_url is None
def test_review_runtime_legacy_auxiliary_carry_credentials(curator_env, caplog):
curator = curator_env["curator"]
cfg = {
"model": {"provider": "openrouter", "default": "openai/gpt-5.5"},
"curator": {
"auxiliary": {
"provider": "custom",
"model": "m",
"api_key": "legacy-key",
"base_url": "http://legacy/v1",
},
},
}
import logging
with caplog.at_level(logging.INFO, logger="agent.curator"):
binding = curator._resolve_review_runtime(cfg)
assert binding.explicit_api_key == "legacy-key"
assert binding.explicit_base_url == "http://legacy/v1"
assert any("deprecated curator.auxiliary" in rec.message for rec in caplog.records)
def test_review_model_auxiliary_curator_partial_override_falls_back(curator_env):
"""Only one of slot provider/model set → fall back to the main pair.
@@ -220,6 +220,81 @@ def test_classify_handles_malformed_arguments_string(curator_env):
assert len(result["pruned"]) == 1
def test_classify_no_false_positive_short_name_in_file_path(curator_env):
"""Short skill name that is a substring of another filename = pruned, not consolidated."""
# e.g. "api" should NOT match "references/api-design.md"
result = curator_env._classify_removed_skills(
removed=["api"],
added=[],
after_names={"conventions"},
tool_calls=[
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "write_file",
"name": "conventions",
"file_path": "references/api-design.md",
"file_content": "# API Design\n...",
}),
},
],
)
assert result["consolidated"] == [], (
f"Short name 'api' should NOT match file_path 'references/api-design.md'"
)
assert len(result["pruned"]) == 1
assert result["pruned"][0]["name"] == "api"
def test_classify_no_false_positive_short_name_in_content(curator_env):
"""Short skill name embedded in longer word in content = pruned, not consolidated."""
# e.g. "test" should NOT match content "running latest tests"
result = curator_env._classify_removed_skills(
removed=["test"],
added=[],
after_names={"umbrella"},
tool_calls=[
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "patch",
"name": "umbrella",
"old_string": "old",
"new_string": "running latest tests with pytest",
}),
},
],
)
assert result["consolidated"] == [], (
f"Short name 'test' should NOT match 'latest' via word boundary"
)
assert len(result["pruned"]) == 1
def test_classify_still_matches_exact_word_in_content(curator_env):
"""Word-boundary match still works for exact word occurrences."""
# "api" SHOULD match content "use the api gateway"
result = curator_env._classify_removed_skills(
removed=["api"],
added=[],
after_names={"gateway"},
tool_calls=[
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "edit",
"name": "gateway",
"content": "# Gateway\n\nUse the api gateway for all requests.\n",
}),
},
],
)
assert len(result["consolidated"]) == 1, (
f"'api' should match as a standalone word in content"
)
assert result["consolidated"][0]["into"] == "gateway"
def test_report_md_splits_consolidated_and_pruned_sections(curator_env):
"""End-to-end: REPORT.md shows both sections distinctly."""
curator = curator_env
+32
View File
@@ -410,6 +410,24 @@ class TestClassifyApiError:
result = classify_api_error(e, approx_tokens=1000, context_length=200000)
assert result.reason == FailoverReason.format_error
def test_400_generic_many_messages_below_large_context_pressure_is_format_error(self):
"""Large-context sessions should not overflow solely due to message count."""
e = MockAPIError(
"Error",
status_code=400,
body={"error": {"message": "Error"}},
)
result = classify_api_error(
e,
provider="openai-codex",
model="gpt-5.5",
approx_tokens=74320,
context_length=1_000_000,
num_messages=432,
)
assert result.reason == FailoverReason.format_error
assert result.should_compress is False
# ── Server disconnect + large session ──
def test_disconnect_large_session_context_overflow(self):
@@ -425,6 +443,20 @@ class TestClassifyApiError:
result = classify_api_error(e, approx_tokens=5000, context_length=200000)
assert result.reason == FailoverReason.timeout
def test_disconnect_many_messages_below_large_context_pressure_is_timeout(self):
"""Large-context disconnects should not overflow solely due to message count."""
e = Exception("server disconnected without sending complete message")
result = classify_api_error(
e,
provider="openai-codex",
model="gpt-5.5",
approx_tokens=74320,
context_length=1_000_000,
num_messages=432,
)
assert result.reason == FailoverReason.timeout
assert result.should_compress is False
# ── Provider-specific: Anthropic thinking signature ──
def test_anthropic_thinking_signature(self):
+34 -10
View File
@@ -13,16 +13,13 @@ def test_vision_call_uses_resolved_provider_args():
usage=MagicMock(prompt_tokens=10, completion_tokens=5),
)
with (
patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("my-resolved-provider", "my-resolved-model", "http://resolved", "resolved-key", "chat_completions"),
),
patch(
"agent.auxiliary_client.resolve_vision_provider_client",
return_value=("my-resolved-provider", fake_client, "my-resolved-model"),
) as mock_vision,
):
with patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("my-resolved-provider", "my-resolved-model", "http://resolved", "resolved-key", "chat_completions"),
), patch(
"agent.auxiliary_client.resolve_vision_provider_client",
return_value=("my-resolved-provider", fake_client, "my-resolved-model"),
) as mock_vision:
call_llm(
"vision",
provider="raw-provider",
@@ -38,3 +35,30 @@ def test_vision_call_uses_resolved_provider_args():
assert call_args.kwargs["model"] == "my-resolved-model"
assert call_args.kwargs["base_url"] == "http://resolved"
assert call_args.kwargs["api_key"] == "resolved-key"
def test_vision_base_url_override_keeps_explicit_provider():
"""Explicit provider should still drive credential resolution with custom base_url."""
from agent.auxiliary_client import resolve_vision_provider_client
fake_client = MagicMock()
with patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=(
"zai",
"glm-4v",
"https://open.bigmodel.cn/api/paas/v4",
None,
"chat_completions",
),
), patch(
"agent.auxiliary_client.resolve_provider_client",
return_value=(fake_client, "glm-4v"),
) as mock_resolve:
provider, client, model = resolve_vision_provider_client()
assert provider == "zai"
assert client is fake_client
assert model == "glm-4v"
assert mock_resolve.call_args.args[0] == "zai"
assert mock_resolve.call_args.kwargs["explicit_base_url"] == "https://open.bigmodel.cn/api/paas/v4"
@@ -126,6 +126,20 @@ class TestCodexBuildKwargs:
)
assert kw.get("extra_headers", {}).get("x-grok-conv-id") == "conv-123"
def test_xai_headers_preserve_request_override_headers(self, transport):
messages = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="grok-3", messages=messages, tools=[],
session_id="conv-123",
is_xai_responses=True,
request_overrides={"extra_headers": {"X-Test": "1", "X-Trace": "abc"}},
)
assert kw.get("extra_headers") == {
"X-Test": "1",
"X-Trace": "abc",
"x-grok-conv-id": "conv-123",
}
def test_minimal_effort_clamped(self, transport):
messages = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
+7
View File
@@ -123,6 +123,13 @@ class TestBusyInputMode:
cli.process_command("/queue follow up")
assert cli._pending_input.get_nowait() == "follow up"
def test_q_alias_queues_prompt(self):
"""The /q alias should resolve to /queue, not /quit."""
cli = _make_cli()
cli._agent_running = False
assert cli.process_command("/q follow up") is True
assert cli._pending_input.get_nowait() == "follow up"
def test_queue_mode_routes_busy_enter_to_pending(self):
"""In queue mode, Enter while busy should go to _pending_input, not _interrupt_queue."""
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "queue"}})
+17
View File
@@ -22,6 +22,23 @@ def test_final_assistant_content_uses_markdown_renderable():
assert "two" in output
def test_final_assistant_content_preserves_windows_hidden_dir_paths():
renderable = _render_final_assistant_content(
r"D:\Projects\SourceCode\hermes-agent\.ai\skills" + "\\"
)
output = _render_to_text(renderable)
assert r"D:\Projects\SourceCode\hermes-agent\.ai\skills" + "\\" in output
def test_final_assistant_content_keeps_non_path_markdown_escapes():
renderable = _render_final_assistant_content(r"1\. Not an ordered list")
output = _render_to_text(renderable)
assert "1. Not an ordered list" in output
assert r"1\." not in output
def test_final_assistant_content_strips_ansi_before_markdown_rendering():
renderable = _render_final_assistant_content("\x1b[31m# Title\x1b[0m")
+57 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import importlib
import os
import sys
from datetime import timedelta
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
from hermes_state import SessionDB
@@ -219,3 +219,59 @@ def test_new_session_resets_token_counters(tmp_path):
assert comp.last_total_tokens == 0
assert comp.compression_count == 0
assert comp._context_probed is False
def test_new_session_with_title(capsys):
"""new_session(title=...) creates a session and sets the title."""
cli = _make_cli()
cli._session_db = MagicMock()
cli.agent = _FakeAgent("old_session_id", datetime.now())
cli.conversation_history = []
cli.new_session(title="My Test Session")
# Assert set_session_title was called with the new session ID and sanitized title
cli._session_db.set_session_title.assert_called_once()
call_args = cli._session_db.set_session_title.call_args
assert call_args[0][0] == cli.session_id
assert call_args[0][1] == "My Test Session"
captured = capsys.readouterr()
assert "My Test Session" in captured.out
def test_new_session_with_duplicate_title_surfaces_error(capsys):
"""new_session(title=...) handles ValueError from a duplicate-title conflict.
The session is still created; the title assignment fails; the success banner
must not claim the rejected title as the session name.
"""
cli = _make_cli()
cli._session_db = MagicMock()
cli._session_db.set_session_title.side_effect = ValueError(
"Title 'Dup' is already in use by session abc-123"
)
cli.agent = _FakeAgent("old_session_id", datetime.now())
cli.conversation_history = []
# Capture warnings printed via cli._cprint. After importlib.reload(),
# the method's __globals__ dict is the one from the live module — patch
# the exact dict the method will read.
warnings: list[str] = []
method_globals = cli.new_session.__globals__
original = method_globals["_cprint"]
method_globals["_cprint"] = lambda msg: warnings.append(msg)
try:
cli.new_session(title="Dup")
finally:
method_globals["_cprint"] = original
cli._session_db.set_session_title.assert_called_once()
joined = "\n".join(warnings)
assert "already in use" in joined
assert "session started untitled" in joined
# The success banner must NOT claim the rejected title as the session name.
captured = capsys.readouterr()
assert "New session started: Dup" not in captured.out
assert "New session started!" in captured.out
+80 -86
View File
@@ -1,107 +1,101 @@
"""Tests that load_cli_config() guards against lazy-import TERMINAL_CWD clobbering.
"""Tests for CLI/TUI CWD resolution in load_cli_config().
When the gateway resolves TERMINAL_CWD at startup and cli.py is later
imported lazily (via delegate_tool CLI_CONFIG), load_cli_config() must
not overwrite the already-resolved value with os.getcwd().
config.yaml terminal.cwd is the canonical source of truth.
.env TERMINAL_CWD and MESSAGING_CWD are deprecated.
See issue #10817.
Rules:
- Local backend CLI/TUI: always os.getcwd(), ignoring config and inherited env.
- Non-local with placeholder: pop cwd for backend default.
- Non-local with explicit path: keep as-is.
"""
import os
import pytest
# The sentinel values that mean "resolve at runtime"
_CWD_PLACEHOLDERS = (".", "auto", "cwd")
def _resolve_terminal_cwd(terminal_config: dict, defaults: dict, env: dict):
"""Simulate the CWD resolution logic from load_cli_config().
def _resolve_cwd(terminal_config: dict, defaults: dict, env: dict):
"""Mirror the CWD resolution logic from cli.py load_cli_config()."""
effective_backend = terminal_config.get("env_type", "local")
This mirrors the code in cli.py that checks for a pre-resolved
TERMINAL_CWD before falling back to os.getcwd().
"""
if terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
_existing_cwd = env.get("TERMINAL_CWD", "")
if _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS and os.path.isabs(_existing_cwd):
terminal_config["cwd"] = _existing_cwd
defaults["terminal"]["cwd"] = _existing_cwd
else:
effective_backend = terminal_config.get("env_type", "local")
if effective_backend == "local":
terminal_config["cwd"] = "/fake/getcwd" # stand-in for os.getcwd()
defaults["terminal"]["cwd"] = terminal_config["cwd"]
else:
terminal_config.pop("cwd", None)
if effective_backend == "local":
terminal_config["cwd"] = "/fake/getcwd"
defaults["terminal"]["cwd"] = terminal_config["cwd"]
elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
terminal_config.pop("cwd", None)
# Simulate the bridging loop: write terminal_config["cwd"] to env
_file_has_terminal = defaults.get("_file_has_terminal", False)
# Bridge: TERMINAL_CWD always exported in CLI, skipped in gateway
_is_gateway = env.get("_HERMES_GATEWAY") == "1"
if "cwd" in terminal_config:
if _file_has_terminal or "TERMINAL_CWD" not in env:
if _is_gateway:
pass # don't touch env
else:
env["TERMINAL_CWD"] = str(terminal_config["cwd"])
return env.get("TERMINAL_CWD", "")
class TestLazyImportGuard:
"""TERMINAL_CWD resolved by gateway must survive a lazy cli.py import."""
class TestLocalBackendCli:
"""Local backend always uses os.getcwd()."""
def test_gateway_resolved_cwd_survives(self):
"""Gateway set TERMINAL_CWD → lazy cli import must not clobber."""
env = {"TERMINAL_CWD": "/home/user/workspace"}
terminal_config = {"cwd": ".", "env_type": "local"}
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/home/user/workspace"
def test_gateway_resolved_cwd_survives_with_file_terminal(self):
"""Even when config.yaml has a terminal: section, resolved CWD survives."""
env = {"TERMINAL_CWD": "/home/user/workspace"}
terminal_config = {"cwd": ".", "env_type": "local"}
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": True}
result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/home/user/workspace"
class TestConfigCwdResolution:
"""config.yaml terminal.cwd is the canonical source of truth."""
def test_explicit_config_cwd_wins(self):
"""terminal.cwd: /explicit/path always wins."""
env = {"TERMINAL_CWD": "/old/gateway/value"}
terminal_config = {"cwd": "/explicit/path"}
defaults = {"terminal": {"cwd": "/explicit/path"}, "_file_has_terminal": True}
result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/explicit/path"
def test_dot_cwd_resolves_to_getcwd_when_no_prior(self):
"""With no pre-set TERMINAL_CWD, "." resolves to os.getcwd()."""
def test_explicit_config_ignored(self):
env = {}
terminal_config = {"cwd": "."}
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
tc = {"cwd": "/explicit/path", "env_type": "local"}
d = {"terminal": {"cwd": "/explicit/path"}}
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
result = _resolve_terminal_cwd(terminal_config, defaults, env)
def test_inherited_env_overwritten(self):
env = {"TERMINAL_CWD": "/parent/hermes"}
tc = {"cwd": "/home/user", "env_type": "local"}
d = {"terminal": {"cwd": "/home/user"}}
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
def test_placeholder_resolved(self):
env = {}
tc = {"cwd": "."}
d = {"terminal": {"cwd": "."}}
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
def test_env_and_no_config_file(self):
env = {"TERMINAL_CWD": "/stale/value"}
tc = {"cwd": ".", "env_type": "local"}
d = {"terminal": {"cwd": "."}}
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
class TestNonLocalBackends:
"""Non-local backends use config or per-backend defaults."""
def test_placeholder_popped(self):
env = {}
tc = {"cwd": ".", "env_type": "docker"}
d = {"terminal": {"cwd": "."}}
assert _resolve_cwd(tc, d, env) == ""
def test_explicit_path_kept(self):
env = {}
tc = {"cwd": "/srv/app", "env_type": "ssh"}
d = {"terminal": {"cwd": "/srv/app"}}
assert _resolve_cwd(tc, d, env) == "/srv/app"
def test_auto_placeholder_popped(self):
env = {}
tc = {"cwd": "auto", "env_type": "modal"}
d = {"terminal": {"cwd": "auto"}}
assert _resolve_cwd(tc, d, env) == ""
class TestGatewayLazyImport:
"""Gateway lazy import of cli.py must not clobber TERMINAL_CWD."""
def test_gateway_cwd_preserved(self):
env = {"_HERMES_GATEWAY": "1", "TERMINAL_CWD": "/home/user/project"}
tc = {"cwd": "/home/user", "env_type": "local"}
d = {"terminal": {"cwd": "/home/user"}}
result = _resolve_cwd(tc, d, env)
assert result == "/home/user/project"
def test_cli_overwrites_stale_env(self):
env = {"TERMINAL_CWD": "/stale/from/dotenv"}
tc = {"cwd": "/home/user", "env_type": "local"}
d = {"terminal": {"cwd": "/home/user"}}
result = _resolve_cwd(tc, d, env)
assert result == "/fake/getcwd"
def test_remote_backend_pops_cwd(self):
"""Remote backend + placeholder cwd → popped for backend default."""
env = {}
terminal_config = {"cwd": ".", "env_type": "docker"}
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "" # cwd popped, no env var set
def test_remote_backend_with_prior_cwd_preserves(self):
"""Remote backend + pre-resolved TERMINAL_CWD → adopted."""
env = {"TERMINAL_CWD": "/project"}
terminal_config = {"cwd": ".", "env_type": "docker"}
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/project"
+66 -19
View File
@@ -128,17 +128,34 @@ class TestPriorityProcessingModels(unittest.TestCase):
assert model_supports_fast_mode(model), f"{model} should support fast mode"
def test_all_anthropic_models_supported(self):
"""Per Anthropic docs, fast mode is currently Opus 4.6 only.
Sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
Pre-fix this test asserted all Claude variants supported fast mode,
which mirrored the bug rather than the API contract.
"""
from hermes_cli.models import model_supports_fast_mode
# All Claude models support Anthropic Fast Mode — Opus, Sonnet, Haiku.
# Supported: Opus 4.6 in any form
supported = [
"claude-opus-4-7", "claude-opus-4-6", "claude-opus-4.6",
"claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-4",
"claude-haiku-4-5", "claude-3-5-haiku",
"claude-opus-4-6", "claude-opus-4.6",
"anthropic/claude-opus-4-6", "anthropic/claude-opus-4.6",
]
for model in supported:
assert model_supports_fast_mode(model), f"{model} should support fast mode"
# Unsupported per Anthropic API: Opus 4.7, Sonnet, Haiku
unsupported = [
"claude-opus-4-7",
"claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-4",
"claude-haiku-4-5", "claude-3-5-haiku",
]
for model in unsupported:
assert not model_supports_fast_mode(model), (
f"{model} should NOT support fast mode — Anthropic restricts "
f"speed=fast to Opus 4.6"
)
def test_codex_models_excluded(self):
"""Codex models route through Responses API and don't accept service_tier."""
from hermes_cli.models import model_supports_fast_mode
@@ -257,18 +274,20 @@ class TestAnthropicFastMode(unittest.TestCase):
assert model_supports_fast_mode("anthropic/claude-opus-4-6") is True
assert model_supports_fast_mode("anthropic/claude-opus-4.6") is True
def test_anthropic_all_claude_models_supported(self):
def test_anthropic_non_opus46_models_excluded(self):
"""Anthropic restricts fast mode to Opus 4.6 — others must be excluded.
Per https://platform.claude.com/docs/en/build-with-claude/fast-mode,
sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
"""
from hermes_cli.models import model_supports_fast_mode
# All Claude models support fast mode — Opus, Sonnet, Haiku.
# The anthropic adapter gates speed=fast on native Anthropic
# endpoints only, so third-party proxies that reject the beta
# are protected downstream (see _is_third_party_anthropic_endpoint).
assert model_supports_fast_mode("claude-sonnet-4-6") is True
assert model_supports_fast_mode("claude-sonnet-4.6") is True
assert model_supports_fast_mode("claude-haiku-4-5") is True
assert model_supports_fast_mode("claude-opus-4-7") is True
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is True
assert model_supports_fast_mode("claude-sonnet-4-6") is False
assert model_supports_fast_mode("claude-sonnet-4.6") is False
assert model_supports_fast_mode("claude-haiku-4-5") is False
assert model_supports_fast_mode("claude-opus-4-7") is False
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False
assert model_supports_fast_mode("anthropic/claude-opus-4-7") is False
def test_non_claude_models_not_anthropic_fast(self):
"""Non-Claude models should not be treated as Anthropic fast-mode."""
@@ -294,6 +313,17 @@ class TestAnthropicFastMode(unittest.TestCase):
result = resolve_fast_mode_overrides("anthropic/claude-opus-4.6")
assert result == {"speed": "fast"}
def test_resolve_overrides_returns_none_for_unsupported_claude(self):
"""Opus 4.7 and other Claude models don't support fast mode (API 400s).
Per Anthropic docs, fast mode is currently Opus 4.6 only.
"""
from hermes_cli.models import resolve_fast_mode_overrides
assert resolve_fast_mode_overrides("claude-opus-4-7") is None
assert resolve_fast_mode_overrides("claude-sonnet-4-6") is None
assert resolve_fast_mode_overrides("claude-haiku-4-5") is None
def test_resolve_overrides_returns_service_tier_for_openai(self):
"""OpenAI models should still get service_tier, not speed."""
from hermes_cli.models import resolve_fast_mode_overrides
@@ -302,13 +332,21 @@ class TestAnthropicFastMode(unittest.TestCase):
assert result == {"service_tier": "priority"}
def test_is_anthropic_fast_model(self):
"""Fast mode is currently Opus 4.6 only — other Claude variants must be excluded."""
from hermes_cli.models import _is_anthropic_fast_model
# Supported: Opus 4.6 in any form
assert _is_anthropic_fast_model("claude-opus-4-6") is True
assert _is_anthropic_fast_model("claude-opus-4.6") is True
assert _is_anthropic_fast_model("claude-sonnet-4-6") is True
assert _is_anthropic_fast_model("claude-haiku-4-5") is True
assert _is_anthropic_fast_model("anthropic/claude-opus-4-6") is True
assert _is_anthropic_fast_model("claude-opus-4.6:fast") is True
# Unsupported per Anthropic API contract — would 400 if we sent speed=fast
assert _is_anthropic_fast_model("claude-opus-4-7") is False
assert _is_anthropic_fast_model("claude-sonnet-4-6") is False
assert _is_anthropic_fast_model("claude-haiku-4-5") is False
# Non-Claude
assert _is_anthropic_fast_model("gpt-5.4") is False
assert _is_anthropic_fast_model("") is False
@@ -320,14 +358,23 @@ class TestAnthropicFastMode(unittest.TestCase):
)
assert cli_mod.HermesCLI._fast_command_available(stub) is True
def test_fast_command_exposed_for_anthropic_sonnet(self):
"""Sonnet now supports Anthropic Fast Mode — the adapter gates on base_url."""
def test_fast_command_hidden_for_anthropic_sonnet(self):
"""Sonnet doesn't support fast mode (Opus 4.6 only) — /fast must be hidden."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
model="claude-sonnet-4-6", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is True
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_fast_command_hidden_for_anthropic_opus_47(self):
"""Opus 4.7 doesn't support fast mode — /fast must be hidden."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
model="claude-opus-4-7", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_fast_command_hidden_for_non_claude_non_openai(self):
"""Non-Claude, non-OpenAI models should not expose /fast."""
+68
View File
@@ -647,6 +647,74 @@ class TestGetDueJobs:
assert get_due_jobs() == []
assert get_job("oneshot-stale")["next_run_at"] is None
def test_broken_cron_without_next_run_is_recovered(self, tmp_cron_dir, monkeypatch):
now = datetime(2026, 3, 18, 10, 0, 0, tzinfo=timezone.utc)
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
save_jobs(
[{
"id": "cron-recover",
"name": "AI Daily Digest",
"prompt": "...",
"schedule": {"kind": "cron", "expr": "0 12 * * *", "display": "0 12 * * *"},
"schedule_display": "0 12 * * *",
"repeat": {"times": None, "completed": 0},
"enabled": True,
"state": "scheduled",
"paused_at": None,
"paused_reason": None,
"created_at": "2026-03-18T09:00:00+00:00",
"next_run_at": None,
"last_run_at": None,
"last_status": None,
"last_error": None,
"deliver": "local",
"origin": None,
}]
)
assert get_due_jobs() == []
recovered = get_job("cron-recover")["next_run_at"]
assert recovered is not None
recovered_dt = datetime.fromisoformat(recovered)
if recovered_dt.tzinfo is None:
recovered_dt = recovered_dt.replace(tzinfo=timezone.utc)
assert recovered_dt > now
def test_broken_interval_without_next_run_is_recovered(self, tmp_cron_dir, monkeypatch):
now = datetime(2026, 3, 18, 10, 0, 0, tzinfo=timezone.utc)
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
save_jobs(
[{
"id": "interval-recover",
"name": "Hourly heartbeat",
"prompt": "...",
"schedule": {"kind": "interval", "minutes": 60, "display": "every 60m"},
"schedule_display": "every 1h",
"repeat": {"times": None, "completed": 0},
"enabled": True,
"state": "scheduled",
"paused_at": None,
"paused_reason": None,
"created_at": "2026-03-18T09:00:00+00:00",
"next_run_at": None,
"last_run_at": None,
"last_status": None,
"last_error": None,
"deliver": "local",
"origin": None,
}]
)
assert get_due_jobs() == []
recovered = get_job("interval-recover")["next_run_at"]
assert recovered is not None
recovered_dt = datetime.fromisoformat(recovered)
if recovered_dt.tzinfo is None:
recovered_dt = recovered_dt.replace(tzinfo=timezone.utc)
assert recovered_dt > now
class TestEnabledToolsets:
def test_enabled_toolsets_stored(self, tmp_cron_dir):
+48
View File
@@ -1857,6 +1857,54 @@ class TestBuildJobPromptMissingSkill:
assert "go" in result
class TestBuildJobPromptBumpUse:
"""Verify that cron jobs bump skill usage counters so the curator sees them as active."""
def test_bump_use_called_for_loaded_skill(self):
"""bump_use is called for each successfully loaded skill."""
def _skill_view(name: str) -> str:
return json.dumps({"success": True, "content": f"Content for {name}."})
with patch("tools.skills_tool.skill_view", side_effect=_skill_view), \
patch("tools.skill_usage.bump_use") as mock_bump:
_build_job_prompt({"skills": ["alpha", "beta"], "prompt": "go"})
assert mock_bump.call_count == 2
calls = [c[0][0] for c in mock_bump.call_args_list]
assert "alpha" in calls
assert "beta" in calls
def test_bump_use_not_called_for_missing_skill(self):
"""bump_use is NOT called when a skill fails to load."""
def _missing_view(name: str) -> str:
return json.dumps({"success": False, "error": "not found"})
with patch("tools.skills_tool.skill_view", side_effect=_missing_view), \
patch("tools.skill_usage.bump_use") as mock_bump:
_build_job_prompt({"skills": ["ghost"], "prompt": "go"})
assert mock_bump.call_count == 0
def test_bump_failure_does_not_break_prompt(self, caplog):
"""If bump_use raises, the prompt still builds — error is logged at DEBUG."""
def _skill_view(name: str) -> str:
return json.dumps({"success": True, "content": "Works."})
with patch("tools.skills_tool.skill_view", side_effect=_skill_view), \
patch("tools.skill_usage.bump_use", side_effect=RuntimeError("boom")), \
caplog.at_level(logging.DEBUG, logger="cron.scheduler"):
result = _build_job_prompt({"skills": ["good-skill"], "prompt": "go"})
# Prompt should still contain the skill content and original instruction
assert "Works." in result
assert "go" in result
# The error should be logged at DEBUG level, not crash
assert any("failed to bump" in r.message for r in caplog.records)
class TestSendMediaViaAdapter:
"""Unit tests for _send_media_via_adapter — routes files to typed adapter methods."""
+23
View File
@@ -138,6 +138,29 @@ class TestSlashCommands:
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "compress" in response_text.lower() or "context" in response_text.lower()
@pytest.mark.asyncio
async def test_quick_command_alias_targets_builtin_command_with_args(
self, adapter, runner, platform
):
"""Alias targets with args must reach the built-in command handler."""
runner.config.quick_commands = {
"s": {"type": "alias", "target": "/status extra-arg"}
}
async def _handle_status(event):
assert event.get_command_args() == "extra-arg"
return "status via alias"
runner._handle_status_command = AsyncMock(side_effect=_handle_status)
send = await send_and_capture(adapter, "/s", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert response_text == "status via alias"
runner._handle_status_command.assert_awaited_once()
runner._handle_message_with_agent.assert_not_awaited()
class TestSessionLifecycle:
"""Verify session state changes across command sequences."""
+42
View File
@@ -240,6 +240,48 @@ class TestAdapterInit:
"http://127.0.0.1:3000",
)
def test_invalid_port_from_env_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv("API_SERVER_PORT", "not-a-port")
config = PlatformConfig(enabled=True)
adapter = APIServerAdapter(config)
assert adapter._port == 8642
def test_create_agent_forwards_config_reasoning_effort(self, monkeypatch):
captured = {}
class FakeAgent:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr("run_agent.AIAgent", FakeAgent)
monkeypatch.setattr(
"gateway.run._resolve_runtime_agent_kwargs",
lambda: {
"provider": "openai-codex",
"base_url": "https://example.test/v1",
"api_mode": "codex_responses",
},
)
monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "gpt-5.5")
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"agent": {"reasoning_effort": "xhigh"}},
)
monkeypatch.setattr(
"gateway.run.GatewayRunner._load_reasoning_config",
staticmethod(lambda: {"enabled": True, "effort": "xhigh"}),
)
monkeypatch.setattr("gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None))
monkeypatch.setattr("hermes_cli.tools_config._get_platform_tools", lambda *_: set())
adapter = APIServerAdapter(PlatformConfig(enabled=True))
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
agent = adapter._create_agent(session_id="api-session")
assert isinstance(agent, FakeAgent)
assert captured["reasoning_config"] == {"enabled": True, "effort": "xhigh"}
# ---------------------------------------------------------------------------
# Auth checking
@@ -0,0 +1,78 @@
"""Gateway command help rendering tests."""
import pytest
from gateway.config import Platform
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource
def _make_event(text: str, platform: Platform) -> MessageEvent:
return MessageEvent(
text=text,
source=SessionSource(
platform=platform,
chat_id="chat-1",
user_id="user-1",
user_name="tester",
chat_type="dm",
),
)
def _make_runner():
from gateway.run import GatewayRunner
return object.__new__(GatewayRunner)
@pytest.mark.asyncio
async def test_help_sanitizes_slash_command_mentions_for_telegram(monkeypatch):
"""Telegram help output must not expose invalid uppercase/hyphenated slashes."""
monkeypatch.setattr(
"agent.skill_commands.get_skill_commands",
lambda: {
"/Linear": {"description": "Open Linear"},
"/Custom-Thing": {"description": "Run a custom thing"},
},
)
result = await _make_runner()._handle_help_command(
_make_event("/help", Platform.TELEGRAM)
)
assert "`/linear`" in result
assert "`/custom_thing`" in result
assert "`/Linear`" not in result
assert "`/Custom-Thing`" not in result
@pytest.mark.asyncio
async def test_commands_sanitizes_slash_command_mentions_for_telegram(monkeypatch):
"""Paginated Telegram /commands output uses Telegram-valid slash mentions."""
monkeypatch.setattr(
"agent.skill_commands.get_skill_commands",
lambda: {"/Linear": {"description": "Open Linear"}},
)
result = await _make_runner()._handle_commands_command(
_make_event("/commands 999", Platform.TELEGRAM)
)
assert "`/linear`" in result
assert "`/Linear`" not in result
@pytest.mark.asyncio
async def test_help_keeps_non_telegram_slash_command_mentions_unchanged(monkeypatch):
"""Only Telegram needs slash mentions rewritten to Telegram command names."""
monkeypatch.setattr(
"agent.skill_commands.get_skill_commands",
lambda: {"/Linear": {"description": "Open Linear"}},
)
result = await _make_runner()._handle_help_command(
_make_event("/help", Platform.DISCORD)
)
assert "`/Linear`" in result
+44
View File
@@ -191,6 +191,50 @@ class TestVoiceAttachmentSSRFProtection:
assert kwargs.get("follow_redirects") is True
assert kwargs.get("event_hooks", {}).get("response") == [_ssrf_redirect_guard]
# ---------------------------------------------------------------------------
# WebSocket proxy handling
# ---------------------------------------------------------------------------
class TestQQWebSocketProxy:
@pytest.mark.asyncio
async def test_open_ws_honors_proxy_env(self, monkeypatch):
from gateway.platforms.qqbot import QQAdapter
for key in (
"WSS_PROXY",
"wss_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:7897")
adapter = QQAdapter(_make_config(app_id="a", client_secret="b"))
seen_session_kwargs = {}
seen_ws_kwargs = {}
class FakeSession:
def __init__(self, **kwargs):
seen_session_kwargs.update(kwargs)
self.closed = False
async def close(self):
self.closed = True
async def ws_connect(self, *args, **kwargs):
seen_ws_kwargs.update(kwargs)
return mock.AsyncMock(closed=False)
with mock.patch("gateway.platforms.qqbot.adapter.aiohttp.ClientSession", side_effect=FakeSession):
await adapter._open_ws("wss://api.sgroup.qq.com/websocket")
assert seen_session_kwargs.get("trust_env") is True
assert seen_ws_kwargs.get("proxy") == "http://127.0.0.1:7897"
# ---------------------------------------------------------------------------
# _strip_at_mention
# ---------------------------------------------------------------------------
@@ -124,6 +124,10 @@ async def test_resume_clears_session_scoped_approval_and_yolo_state():
runner, session_key = _make_resume_runner()
other_key = "agent:main:telegram:dm:other-chat"
runner._pending_skills_reload_notes = {
session_key: "[USER INITIATED SKILLS RELOAD: target]",
other_key: "[USER INITIATED SKILLS RELOAD: other]",
}
approve_session(session_key, "recursive delete")
approve_session(other_key, "recursive delete")
enable_session_yolo(session_key)
@@ -140,10 +144,12 @@ async def test_resume_clears_session_scoped_approval_and_yolo_state():
assert is_session_yolo_enabled(session_key) is False
assert session_key not in runner._pending_approvals
assert session_key not in runner._update_prompt_pending
assert session_key not in runner._pending_skills_reload_notes
assert is_approved(other_key, "recursive delete") is True
assert is_session_yolo_enabled(other_key) is True
assert other_key in runner._pending_approvals
assert other_key in runner._update_prompt_pending
assert other_key in runner._pending_skills_reload_notes
@pytest.mark.asyncio
@@ -151,6 +157,10 @@ async def test_branch_clears_session_scoped_approval_and_yolo_state():
runner, session_key = _make_branch_runner()
other_key = "agent:main:telegram:dm:other-chat"
runner._pending_skills_reload_notes = {
session_key: "[USER INITIATED SKILLS RELOAD: target]",
other_key: "[USER INITIATED SKILLS RELOAD: other]",
}
approve_session(session_key, "recursive delete")
approve_session(other_key, "recursive delete")
enable_session_yolo(session_key)
@@ -167,10 +177,12 @@ async def test_branch_clears_session_scoped_approval_and_yolo_state():
assert is_session_yolo_enabled(session_key) is False
assert session_key not in runner._pending_approvals
assert session_key not in runner._update_prompt_pending
assert session_key not in runner._pending_skills_reload_notes
assert is_approved(other_key, "recursive delete") is True
assert is_session_yolo_enabled(other_key) is True
assert other_key in runner._pending_approvals
assert other_key in runner._update_prompt_pending
assert other_key in runner._pending_skills_reload_notes
@pytest.mark.asyncio
@@ -216,6 +228,7 @@ def test_clear_session_boundary_security_state_is_scoped():
runner = object.__new__(GatewayRunner)
runner._pending_approvals = {}
runner._update_prompt_pending = {}
runner._pending_skills_reload_notes = {}
source = _make_source()
session_key = build_session_key(source)
@@ -229,6 +242,12 @@ def test_clear_session_boundary_security_state_is_scoped():
runner._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
runner._update_prompt_pending[session_key] = True
runner._update_prompt_pending[other_key] = True
runner._pending_skills_reload_notes[session_key] = (
"[USER INITIATED SKILLS RELOAD: target]"
)
runner._pending_skills_reload_notes[other_key] = (
"[USER INITIATED SKILLS RELOAD: other]"
)
runner._clear_session_boundary_security_state(session_key)
@@ -237,16 +256,19 @@ def test_clear_session_boundary_security_state_is_scoped():
assert is_session_yolo_enabled(session_key) is False
assert session_key not in runner._pending_approvals
assert session_key not in runner._update_prompt_pending
assert session_key not in runner._pending_skills_reload_notes
# Other session untouched
assert is_approved(other_key, "recursive delete") is True
assert is_session_yolo_enabled(other_key) is True
assert other_key in runner._pending_approvals
assert other_key in runner._update_prompt_pending
assert other_key in runner._pending_skills_reload_notes
# Empty session_key is a no-op
runner._clear_session_boundary_security_state("")
assert is_approved(other_key, "recursive delete") is True
assert other_key in runner._update_prompt_pending
assert other_key in runner._pending_skills_reload_notes
def test_clear_session_boundary_security_state_wakes_blocked_approvals():
+44 -2
View File
@@ -169,9 +169,9 @@ class TestSmsRequirements:
class TestWebhookHostConfig:
"""Verify SMS_WEBHOOK_HOST env var and default."""
def test_default_host_is_all_interfaces(self):
def test_default_host_is_localhost(self):
from gateway.platforms.sms import DEFAULT_WEBHOOK_HOST
assert DEFAULT_WEBHOOK_HOST == "0.0.0.0"
assert DEFAULT_WEBHOOK_HOST == "127.0.0.1"
def test_host_from_env(self):
from gateway.platforms.sms import SmsAdapter
@@ -242,6 +242,48 @@ class TestStartupGuard:
result = await adapter.connect()
assert result is False
@pytest.mark.asyncio
async def test_missing_webhook_url_is_non_retryable(self):
adapter = self._make_adapter()
await adapter.connect()
assert adapter.has_fatal_error is True
assert adapter.fatal_error_retryable is False
assert "sms_missing_webhook_url" == adapter.fatal_error_code
@pytest.mark.asyncio
async def test_missing_phone_number_is_non_retryable(self):
from gateway.platforms.sms import SmsAdapter
env = {
"TWILIO_ACCOUNT_SID": "ACtest",
"TWILIO_AUTH_TOKEN": "tok",
"TWILIO_PHONE_NUMBER": "",
"SMS_WEBHOOK_URL": "",
}
with patch.dict(os.environ, env, clear=True):
pc = PlatformConfig(enabled=True, api_key="tok")
adapter = SmsAdapter(pc)
await adapter.connect()
assert adapter.has_fatal_error is True
assert adapter.fatal_error_retryable is False
assert adapter.fatal_error_code == "sms_missing_phone_number"
@pytest.mark.asyncio
async def test_insecure_flag_does_not_set_fatal_error(self):
mock_session = AsyncMock()
with patch.dict(os.environ, {"SMS_INSECURE_NO_SIGNATURE": "true"}), \
patch("aiohttp.web.AppRunner") as mock_runner_cls, \
patch("aiohttp.web.TCPSite") as mock_site_cls, \
patch("aiohttp.ClientSession", return_value=mock_session):
mock_runner_cls.return_value.setup = AsyncMock()
mock_runner_cls.return_value.cleanup = AsyncMock()
mock_site_cls.return_value.start = AsyncMock()
adapter = self._make_adapter()
result = await adapter.connect()
assert result is True
assert adapter.has_fatal_error is False
await adapter.disconnect()
@pytest.mark.asyncio
async def test_insecure_flag_allows_start_without_url(self):
mock_session = AsyncMock()
+25 -1
View File
@@ -313,9 +313,33 @@ class TestTeamsPluginRegistration:
# ---------------------------------------------------------------------------
# Tests: Connect / Disconnect
# Tests: Interactive setup (import fix regression — #18325 / #19173)
# ---------------------------------------------------------------------------
class TestTeamsInteractiveSetup:
def test_interactive_setup_persists_credentials(self, tmp_path, monkeypatch):
"""Regression for #19173: interactive_setup must import prompt helpers
from hermes_cli.cli_output (not hermes_cli.config) and persist
credentials to .env without crashing.
"""
hermes_home = tmp_path / "hermes"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
import hermes_cli.cli_output as cli_output_mod
answers = iter(["client-id", "client-secret", "tenant-id", "aad-1, aad-2"])
monkeypatch.setattr(cli_output_mod, "prompt", lambda *_a, **_kw: next(answers))
monkeypatch.setattr(cli_output_mod, "prompt_yes_no", lambda *_a, **_kw: True)
monkeypatch.setattr(cli_output_mod, "print_info", lambda *_a, **_kw: None)
monkeypatch.setattr(cli_output_mod, "print_success", lambda *_a, **_kw: None)
monkeypatch.setattr(cli_output_mod, "print_warning", lambda *_a, **_kw: None)
_teams_mod.interactive_setup()
env_text = (hermes_home / ".env").read_text(encoding="utf-8")
assert "TEAMS_CLIENT_ID=client-id" in env_text
assert "TEAMS_TENANT_ID=tenant-id" in env_text
class TestTeamsConnect:
@pytest.mark.asyncio
async def test_connect_fails_without_sdk(self, monkeypatch):
@@ -261,6 +261,57 @@ def test_group_allow_from_is_enforced_by_gateway_authorization_not_trigger_gate(
assert adapter._should_process_message(_group_message("hello", from_user_id=333)) is True
def test_top_level_require_mention_bridges_to_telegram(monkeypatch, tmp_path):
"""require_mention at the config.yaml top level (alongside group_sessions_per_user)
must behave identically to telegram.require_mention: true (#3979).
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
# Intentionally no "telegram:" section — keys are at the top level.
(hermes_home / "config.yaml").write_text(
"require_mention: true\n"
"group_sessions_per_user: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False)
config = load_gateway_config()
assert config is not None
assert __import__("os").environ.get("TELEGRAM_REQUIRE_MENTION") == "true"
# The adapter's extra dict must also carry the setting so that
# _telegram_require_mention() works even without the env var.
tg_cfg = config.platforms.get(__import__("gateway.config", fromlist=["Platform"]).Platform.TELEGRAM)
if tg_cfg is not None:
assert tg_cfg.extra.get("require_mention") is True
def test_top_level_require_mention_does_not_override_telegram_section(monkeypatch, tmp_path):
"""When telegram.require_mention is explicitly set, top-level require_mention
must not override it (platform-specific config takes precedence).
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"require_mention: true\n"
"telegram:\n"
" require_mention: false\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False)
config = load_gateway_config()
assert config is not None
# The telegram-specific "false" must win over the top-level "true".
assert __import__("os").environ.get("TELEGRAM_REQUIRE_MENTION") == "false"
def test_config_bridges_telegram_ignored_threads(monkeypatch, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
+152 -2
View File
@@ -5,11 +5,12 @@ across all gateway messenger platforms.
"""
import os
from unittest.mock import MagicMock, patch
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import Platform
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource
@@ -206,3 +207,152 @@ class TestTitleInHelp:
import inspect
source = inspect.getsource(GatewayRunner._handle_message)
assert '"title"' in source
# ---------------------------------------------------------------------------
# /new with title
# ---------------------------------------------------------------------------
class TestResetCommandWithTitle:
"""Tests for GatewayRunner._handle_reset_command with a title argument."""
@pytest.mark.asyncio
async def test_reset_command_with_title(self):
"""Sending /new <title> resets session and sets the title."""
from datetime import datetime
from gateway.run import GatewayRunner
from gateway.session import SessionEntry, SessionSource, build_session_key
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
adapter = MagicMock()
adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: adapter}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner._session_model_overrides = {}
runner._pending_model_notes = {}
runner._background_tasks = set()
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="12345",
chat_id="67890",
user_name="testuser",
)
session_key = build_session_key(source)
new_session_entry = SessionEntry(
session_key=session_key,
session_id="sess-new",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="dm",
)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = new_session_entry
runner.session_store.reset_session.return_value = new_session_entry
runner.session_store._entries = {session_key: new_session_entry}
runner.session_store._generate_session_key.return_value = session_key
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._session_db = MagicMock()
runner._agent_cache = {}
runner._agent_cache_lock = None
runner._is_user_authorized = lambda _source: True
runner._format_session_info = lambda: ""
event = _make_event(text="/new Custom Name")
result = await runner._handle_reset_command(event)
runner.session_store.reset_session.assert_called_once()
runner._session_db.set_session_title.assert_called_once_with(
"sess-new", "Custom Name"
)
# Header reflects the applied title
assert "Custom Name" in str(result)
@pytest.mark.asyncio
async def test_reset_command_duplicate_title_surfaces_warning(self):
"""/new <title> with an already-in-use title returns a warning in the reply."""
from datetime import datetime
from gateway.run import GatewayRunner
from gateway.session import SessionEntry, SessionSource, build_session_key
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
adapter = MagicMock()
adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: adapter}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner._session_model_overrides = {}
runner._pending_model_notes = {}
runner._background_tasks = set()
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="12345",
chat_id="67890",
user_name="testuser",
)
session_key = build_session_key(source)
new_session_entry = SessionEntry(
session_key=session_key,
session_id="sess-new",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="dm",
)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = new_session_entry
runner.session_store.reset_session.return_value = new_session_entry
runner.session_store._entries = {session_key: new_session_entry}
runner.session_store._generate_session_key.return_value = session_key
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._session_db = MagicMock()
runner._session_db.set_session_title.side_effect = ValueError(
"Title 'Dup' is already in use by session abc-123"
)
runner._agent_cache = {}
runner._agent_cache_lock = None
runner._is_user_authorized = lambda _source: True
runner._format_session_info = lambda: ""
event = _make_event(text="/new Dup")
result = await runner._handle_reset_command(event)
runner._session_db.set_session_title.assert_called_once()
reply = str(result)
assert "already in use" in reply
assert "session started untitled" in reply
# Header must NOT claim the rejected title as the session name
assert "New session started: Dup" not in reply
# ---------------------------------------------------------------------------
# /new in help output
# ---------------------------------------------------------------------------
class TestNewInHelp:
"""Verify /new appears in help text with the [name] args hint."""
def test_new_command_in_help_output(self):
"""The gateway help output includes /new with the [name] hint."""
from hermes_cli.commands import gateway_help_lines
lines = gateway_help_lines()
new_line = next((line for line in lines if line.startswith("`/new ")), None)
assert new_line is not None
assert "[name]" in new_line
+40
View File
@@ -954,6 +954,46 @@ class TestVoiceChannelCommands:
assert "Test transcript" in msg
assert "42" in msg # user_id in mention
@pytest.mark.asyncio
async def test_input_suppresses_duplicate_transcript(self, runner):
"""Near-immediate duplicate STT output should not dispatch twice."""
from gateway.config import Platform
mock_adapter = AsyncMock()
mock_adapter._voice_text_channels = {111: 123}
mock_adapter._voice_sources = {}
mock_channel = AsyncMock()
mock_adapter._client = MagicMock()
mock_adapter._client.get_channel = MagicMock(return_value=mock_channel)
mock_adapter.handle_message = AsyncMock()
runner.adapters[Platform.DISCORD] = mock_adapter
await runner._handle_voice_channel_input(111, 42, "Hello from VC")
await runner._handle_voice_channel_input(111, 42, "Hello from VC")
mock_adapter.handle_message.assert_called_once()
mock_channel.send.assert_called_once()
@pytest.mark.asyncio
async def test_input_suppresses_near_duplicate_transcript(self, runner):
"""Small STT wording drift should still be treated as the same utterance."""
from gateway.config import Platform
mock_adapter = AsyncMock()
mock_adapter._voice_text_channels = {111: 123}
mock_adapter._voice_sources = {}
mock_channel = AsyncMock()
mock_adapter._client = MagicMock()
mock_adapter._client.get_channel = MagicMock(return_value=mock_channel)
mock_adapter.handle_message = AsyncMock()
runner.adapters[Platform.DISCORD] = mock_adapter
await runner._handle_voice_channel_input(111, 42, "This is a test of the voice system")
await runner._handle_voice_channel_input(111, 42, "This is a test for the voice system")
mock_adapter.handle_message.assert_called_once()
mock_channel.send.assert_called_once()
# -- _get_guild_id --
def test_get_guild_id_from_guild(self, runner):
+5
View File
@@ -36,6 +36,11 @@ class TestWeComRequirements:
class TestWeComAdapterInit:
def test_declares_non_editable_message_capability(self):
from gateway.platforms.wecom import WeComAdapter
assert WeComAdapter.SUPPORTS_MESSAGE_EDITING is False
def test_reads_config_from_extra(self):
from gateway.platforms.wecom import WeComAdapter
+41 -1
View File
@@ -5,7 +5,7 @@ import base64
import json
import os
from pathlib import Path
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch
from gateway.config import PlatformConfig
from gateway.config import GatewayConfig, HomeChannel, Platform, _apply_env_overrides
@@ -788,3 +788,43 @@ class TestIsStaleSessionRet:
def test_success_codes_are_not_stale(self):
assert weixin._is_stale_session_ret(0, 0, "") is False
assert weixin._is_stale_session_ret(None, None, "unknown error") is False
class TestWeixinContentDedup:
"""Regression tests for Issue #16182 — upstream API sends duplicate content
with different message_ids, bypassing message_id deduplication.
"""
def test_duplicate_content_with_different_message_ids_is_dropped(self):
adapter = _make_adapter()
adapter._poll_session = object()
adapter.handle_message = AsyncMock()
base_msg = {
"from_user_id": "wxid_user1",
"item_list": [{"type": 1, "text_item": {"text": "hello world"}}],
}
asyncio.run(adapter._process_message({**base_msg, "message_id": "msg-1"}))
asyncio.run(adapter._process_message({**base_msg, "message_id": "msg-2"}))
assert adapter.handle_message.await_count == 1
event = adapter.handle_message.await_args[0][0]
assert event.text == "hello world"
def test_content_dedup_not_called_for_messages_without_text(self):
adapter = _make_adapter()
adapter._poll_session = object()
adapter.handle_message = AsyncMock()
adapter._dedup.is_duplicate = Mock(return_value=False)
empty_msg = {
"from_user_id": "wxid_user1",
"message_id": "msg-1",
"item_list": [],
}
asyncio.run(adapter._process_message(empty_msg))
assert adapter.handle_message.await_count == 0
# is_duplicate should only be called for message_id, never for content
assert all("content:" not in str(call) for call in adapter._dedup.is_duplicate.call_args_list)
+283
View File
@@ -896,3 +896,286 @@ def test_refresh_non_reuse_error_keeps_original_description():
assert "Refresh session has been revoked" in str(exc_info.value)
# Must not have been rewritten with the reuse message.
assert "external process" not in str(exc_info.value).lower()
# =============================================================================
# Shared Nous token store — cross-profile persistence (Codex-style auto-import)
# =============================================================================
@pytest.fixture
def shared_store_env(tmp_path, monkeypatch):
"""Redirect HERMES_SHARED_AUTH_DIR to a tmp_path.
Required for every test that exercises the shared Nous store the
in-auth.py seat belt refuses to touch the real user's shared store
under pytest, so tests that forget this fixture fail loudly instead
of corrupting real state.
"""
shared_dir = tmp_path / "shared"
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(shared_dir))
return shared_dir
def test_shared_store_seat_belt_refuses_real_home_under_pytest(monkeypatch):
"""Without HERMES_SHARED_AUTH_DIR override, the seat belt must trip.
Mirrors the existing ``_auth_file_path`` seat belt: forgetting to
redirect this store in a test must fail loudly instead of silently
writing to the user's real ``~/.hermes/shared/`` across CI runs.
"""
from hermes_cli.auth import _nous_shared_store_path
monkeypatch.delenv("HERMES_SHARED_AUTH_DIR", raising=False)
with pytest.raises(RuntimeError, match="shared Nous auth store"):
_nous_shared_store_path()
def test_shared_store_honors_env_override(tmp_path, monkeypatch):
"""HERMES_SHARED_AUTH_DIR must redirect the path."""
from hermes_cli.auth import _nous_shared_store_path, NOUS_SHARED_STORE_FILENAME
custom_dir = tmp_path / "custom_shared"
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(custom_dir))
path = _nous_shared_store_path()
assert path == custom_dir / NOUS_SHARED_STORE_FILENAME
def test_shared_store_read_missing_returns_none(shared_store_env):
"""Missing file → ``_read_shared_nous_state()`` returns None."""
from hermes_cli.auth import _read_shared_nous_state
assert _read_shared_nous_state() is None
def test_shared_store_read_malformed_returns_none(shared_store_env):
"""Unreadable / non-JSON file → None, not an exception."""
from hermes_cli.auth import _nous_shared_store_path, _read_shared_nous_state
path = _nous_shared_store_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{ not json")
assert _read_shared_nous_state() is None
def test_shared_store_read_missing_required_fields_returns_none(shared_store_env):
"""Payload without refresh_token → None (nothing worth importing)."""
from hermes_cli.auth import _nous_shared_store_path, _read_shared_nous_state
path = _nous_shared_store_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"_schema": 1, "access_token": "abc"}))
assert _read_shared_nous_state() is None
def test_shared_store_write_and_read_roundtrip(shared_store_env):
"""Write → read must preserve refresh_token + OAuth URLs."""
from hermes_cli.auth import (
_nous_shared_store_path,
_read_shared_nous_state,
_write_shared_nous_state,
)
_write_shared_nous_state(_full_state_fixture())
path = _nous_shared_store_path()
assert path.is_file()
# Permissions should be 0600 where the platform supports it.
mode = path.stat().st_mode & 0o777
assert mode == 0o600 or mode == 0o644 # 0o644 on platforms without chmod
loaded = _read_shared_nous_state()
assert loaded is not None
assert loaded["refresh_token"] == "refresh-tok"
assert loaded["access_token"] == "access-tok"
assert loaded["portal_base_url"] == "https://portal.example.com"
assert loaded["inference_base_url"] == "https://inference.example.com/v1"
# Volatile agent_key MUST NOT be persisted to the shared store
# (24h TTL, profile-specific — only long-lived OAuth tokens are
# cross-profile useful).
assert "agent_key" not in loaded
def test_shared_store_write_skips_when_refresh_token_missing(shared_store_env):
"""Write is a no-op when refresh_token is absent (nothing to share)."""
from hermes_cli.auth import _nous_shared_store_path, _write_shared_nous_state
state = dict(_full_state_fixture())
state["refresh_token"] = ""
_write_shared_nous_state(state)
assert not _nous_shared_store_path().is_file()
def test_persist_nous_credentials_mirrors_to_shared_store(
tmp_path, monkeypatch, shared_store_env,
):
"""persist_nous_credentials must populate BOTH per-profile auth.json
AND the shared store, so a future profile's `hermes auth add nous
--type oauth` can one-tap import instead of redoing device-code.
"""
from hermes_cli.auth import (
_nous_shared_store_path,
_read_shared_nous_state,
persist_nous_credentials,
)
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(
json.dumps({"version": 1, "providers": {}})
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
persist_nous_credentials(_full_state_fixture())
# Per-profile auth.json populated
payload = json.loads((hermes_home / "auth.json").read_text())
assert "nous" in payload.get("providers", {})
# Shared store populated with the same refresh_token
shared = _read_shared_nous_state()
assert shared is not None
assert shared["refresh_token"] == "refresh-tok"
# Shared file path lives under the tmp override, NOT the real home
assert str(_nous_shared_store_path()).startswith(str(shared_store_env))
def test_try_import_shared_returns_none_when_store_missing(shared_store_env):
"""No shared store → no rehydrate (fall through to device-code)."""
from hermes_cli.auth import _try_import_shared_nous_state
assert _try_import_shared_nous_state() is None
def test_try_import_shared_returns_none_on_refresh_failure(
shared_store_env, monkeypatch,
):
"""If the portal rejects the stored refresh_token (revoked, expired,
portal down), _try_import_shared_nous_state must return None so the
login flow falls back to a fresh device-code run.
"""
from hermes_cli import auth as auth_mod
# Seed the shared store
auth_mod._write_shared_nous_state(_full_state_fixture())
# Make refresh fail
def _boom(*_args, **_kwargs):
raise AuthError(
"Refresh session has been revoked",
provider="nous",
code="invalid_grant",
relogin_required=True,
)
monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _boom)
assert auth_mod._try_import_shared_nous_state() is None
def test_try_import_shared_rehydrates_on_success(shared_store_env, monkeypatch):
"""Happy path: stored refresh_token is accepted, forced refresh+mint
returns a fresh access_token + agent_key, and the returned dict has
every field persist_nous_credentials() needs.
"""
from hermes_cli import auth as auth_mod
auth_mod._write_shared_nous_state(_full_state_fixture())
def _fake_refresh(state, **kwargs):
# Simulate portal returning fresh tokens + a new agent_key
assert kwargs.get("force_refresh") is True
assert kwargs.get("force_mint") is True
return {
**state,
"access_token": "fresh-access-tok",
"refresh_token": "fresh-refresh-tok", # rotated
"agent_key": "new-agent-key",
"agent_key_expires_at": "2026-04-19T22:00:00+00:00",
}
monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _fake_refresh)
result = auth_mod._try_import_shared_nous_state()
assert result is not None
assert result["access_token"] == "fresh-access-tok"
assert result["refresh_token"] == "fresh-refresh-tok"
assert result["agent_key"] == "new-agent-key"
# Preserved from shared state
assert result["portal_base_url"] == "https://portal.example.com"
assert result["client_id"] == "hermes-cli"
def test_shared_store_survives_across_profile_switch(
tmp_path, monkeypatch, shared_store_env,
):
"""End-to-end: profile A logs in → shared store populated → profile B
(different HERMES_HOME) sees the same shared state and can rehydrate
without re-running device-code.
"""
from hermes_cli import auth as auth_mod
# Profile A: login, which mirrors to shared store
profile_a = tmp_path / "profile_a"
profile_a.mkdir(parents=True, exist_ok=True)
(profile_a / "auth.json").write_text(
json.dumps({"version": 1, "providers": {}})
)
monkeypatch.setenv("HERMES_HOME", str(profile_a))
auth_mod.persist_nous_credentials(_full_state_fixture())
# Profile A's auth.json has nous
a_payload = json.loads((profile_a / "auth.json").read_text())
assert "nous" in a_payload.get("providers", {})
# Profile B: fresh HERMES_HOME, no auth yet, but the shared store
# persists — _read_shared_nous_state() must still return the tokens.
profile_b = tmp_path / "profile_b"
profile_b.mkdir(parents=True, exist_ok=True)
(profile_b / "auth.json").write_text(
json.dumps({"version": 1, "providers": {}})
)
monkeypatch.setenv("HERMES_HOME", str(profile_b))
# B's own auth.json has no nous
b_payload = json.loads((profile_b / "auth.json").read_text())
assert "nous" not in b_payload.get("providers", {})
# But the shared store is visible
shared = auth_mod._read_shared_nous_state()
assert shared is not None
assert shared["refresh_token"] == "refresh-tok"
# And a successful rehydrate + persist lands nous into profile B
def _fake_refresh(state, **kwargs):
return {
**state,
"access_token": "b-access-tok",
"refresh_token": "b-refresh-tok",
"agent_key": "b-agent-key",
"agent_key_expires_at": "2026-04-19T22:00:00+00:00",
}
monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _fake_refresh)
result = auth_mod._try_import_shared_nous_state()
assert result is not None
auth_mod.persist_nous_credentials(result)
b_payload = json.loads((profile_b / "auth.json").read_text())
assert "nous" in b_payload.get("providers", {})
assert b_payload["providers"]["nous"]["refresh_token"] == "b-refresh-tok"
# Shared store was updated with the rotated refresh_token too
shared_after = auth_mod._read_shared_nous_state()
assert shared_after is not None
assert shared_after["refresh_token"] == "b-refresh-tok"
+73
View File
@@ -471,6 +471,32 @@ class TestImport:
with pytest.raises(SystemExit):
run_import(args)
@pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions only")
def test_restores_secret_files_with_0600_perms(self, tmp_path, monkeypatch):
"""Secret files must end up at 0600 after restore (zipfile drops mode bits)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
"config.yaml": "model: openrouter\n",
".env": "OPENROUTER_API_KEY=sk-secret\n",
"auth.json": '{"providers": {"nous": "token"}}',
"state.db": b"SQLite format 3\x00",
"profiles/coder/.env": "ANTHROPIC_API_KEY=sk-ant-secret\n",
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
for rel in (".env", "auth.json", "state.db", "profiles/coder/.env"):
mode = (hermes_home / rel).stat().st_mode & 0o777
assert mode == 0o600, f"{rel} restored with mode {oct(mode)}, expected 0o600"
# ---------------------------------------------------------------------------
# Round-trip test
@@ -1348,6 +1374,53 @@ class TestPreUpdateBackup:
from hermes_cli.backup import create_pre_update_backup
assert create_pre_update_backup(hermes_home=tmp_path / "does-not-exist") is None
def test_keep_zero_does_not_delete_freshly_created_backup(self, hermes_home):
"""Regression: ``backup_keep: 0`` previously triggered ``backups[0:]``
in the pruner wiping the just-created zip and leaving the user
with no recovery point. The floor (keep>=1) preserves the new file
regardless of misconfiguration; users who don't want backups should
set ``pre_update_backup: false`` instead.
"""
from hermes_cli.backup import create_pre_update_backup
out = create_pre_update_backup(hermes_home=hermes_home, keep=0)
assert out is not None
assert out.exists(), (
"keep=0 silently deleted the freshly-created backup; floor "
"should preserve the just-written file."
)
def test_keep_negative_does_not_delete_freshly_created_backup(self, hermes_home):
"""Mirror coverage: any value <1 should be floored, not literally
applied as a slice index."""
from hermes_cli.backup import create_pre_update_backup
out = create_pre_update_backup(hermes_home=hermes_home, keep=-3)
assert out is not None
assert out.exists()
def test_keep_zero_still_prunes_older_backups(self, hermes_home):
"""The floor preserves the new backup but should NOT regress the
rotation behaviour for older zips: a third call with keep=0 must
still remove pre-existing backups beyond the (floored) limit of 1.
"""
import time as _t
from hermes_cli.backup import create_pre_update_backup
first = create_pre_update_backup(hermes_home=hermes_home, keep=5)
_t.sleep(1.05)
second = create_pre_update_backup(hermes_home=hermes_home, keep=5)
_t.sleep(1.05)
third = create_pre_update_backup(hermes_home=hermes_home, keep=0)
remaining = {
p.name for p in (hermes_home / "backups").iterdir()
if p.name.startswith("pre-update-")
}
assert third.name in remaining, "Floor must preserve the new backup"
assert first.name not in remaining and second.name not in remaining, (
f"keep=0 floor of 1 should still prune older backups; "
f"remaining={remaining}"
)
class TestRunPreUpdateBackup:
"""Tests for the ``_run_pre_update_backup`` wrapper in main.py —
+20
View File
@@ -236,6 +236,13 @@ class TestTelegramBotCommands:
tg_name = cmd.name.replace("-", "_")
assert tg_name not in names
def test_excludes_commands_with_required_args(self):
names = {name for name, _ in telegram_bot_commands()}
assert "background" not in names
assert "queue" not in names
assert "steer" not in names
assert "background" in GATEWAY_KNOWN_COMMANDS
class TestSlackSubcommandMap:
def test_returns_dict(self):
@@ -1661,6 +1668,19 @@ class TestPluginCommandEnumeration:
names = {name for name, _desc in telegram_bot_commands()}
assert "metricas" in names
def test_plugin_command_with_required_args_excluded_from_telegram_menu(self, monkeypatch):
"""Telegram BotCommand selections cannot supply required arguments."""
self._patch_plugin_commands(monkeypatch, {
"background-job": {
"handler": lambda _a: "ok",
"description": "Run a background job",
"args_hint": "<prompt>",
"plugin": "jobs-plugin",
}
})
names = {name for name, _desc in telegram_bot_commands()}
assert "background_job" not in names
def test_plugin_command_appears_in_slack_subcommand_map(self, monkeypatch):
"""/hermes metricas must route through the Slack subcommand map."""
self._patch_plugin_commands(monkeypatch, {
+9 -1
View File
@@ -114,6 +114,12 @@ def test_status_shows_most_and_least_used_sections(curator_status_env):
env["make_skill"]("top-dog")
env["make_skill"]("middling")
env["make_skill"]("never-used")
# Mark all three as agent-created so they enter the curator's catalog.
# Under the provenance-marker semantics, skills must be explicitly opted
# into curator management (normally via the background-review fork when
# it creates a skill through skill_manage).
for n in ("top-dog", "middling", "never-used"):
env["skill_usage"].mark_agent_created(n)
# Bump use_count differentially. All three counters (use/view/patch) feed
# into activity_count, so bumping use alone is enough to make activity
@@ -150,7 +156,9 @@ def test_status_hides_most_active_when_all_zero(curator_status_env):
env = curator_status_env
env["make_skill"]("a")
env["make_skill"]("b")
# No bumps.
# Mark both as agent-created so the catalog lists them. No bumps.
env["skill_usage"].mark_agent_created("a")
env["skill_usage"].mark_agent_created("b")
out = _capture_status(env["curator_cli"])
@@ -56,7 +56,6 @@ class TestCustomProviderModelSwitch:
"sk-test",
"https://vllm.example.com/v1",
timeout=8.0,
api_mode=None,
)
def test_can_switch_to_different_model(self, config_home):
@@ -141,12 +140,18 @@ class TestCustomProviderModelSwitch:
"api_mode": "anthropic_messages",
}
with patch("hermes_cli.models.fetch_api_models", return_value=["claude-3"]), \
with patch("hermes_cli.models.fetch_api_models", return_value=["claude-3"]) as mock_fetch, \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
mock_fetch.assert_called_once_with(
"***",
"https://proxy.example.com/anthropic",
timeout=8.0,
api_mode="anthropic_messages",
)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
@@ -215,7 +220,6 @@ class TestCustomProviderModelSwitch:
"sk-live-example-provider",
"https://api.example-provider.test/v1",
timeout=8.0,
api_mode=None,
)
config = yaml.safe_load(config_path.read_text()) or {}
assert config["model"]["api_key"] == "${EXAMPLE_PROVIDER_API_KEY}"
+213
View File
@@ -273,6 +273,101 @@ class TestCaptureLogSnapshot:
assert "rotated agent data" in snap.full_text
# ---------------------------------------------------------------------------
# Capture log redaction (force=True applies regardless of HERMES_REDACT_SECRETS)
# ---------------------------------------------------------------------------
# A vendor-prefixed token used across redaction tests. Long enough to clear
# the redactor's `floor` parameter so it actually masks rather than fully blanks.
_REDACT_FIXTURE_TOKEN = "sk-proj-A1B2C3D4E5F6G7H8I9J0aA"
class TestCaptureLogSnapshotRedaction:
"""Pin upload-time redaction at the _capture_log_snapshot boundary."""
@pytest.fixture
def hermes_home_with_secret(self, tmp_path, monkeypatch):
"""Isolated HERMES_HOME whose agent.log contains a vendor-prefixed token."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# Critical: ensure the user has NOT opted in to redaction. The whole
# point of this PR is that share-time redaction works for users who
# never set this env var.
monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False)
logs_dir = home / "logs"
logs_dir.mkdir()
(logs_dir / "agent.log").write_text(
f"2026-04-12 17:00:00 INFO config: api_key={_REDACT_FIXTURE_TOKEN} loaded\n"
)
(logs_dir / "errors.log").write_text("")
(logs_dir / "gateway.log").write_text("")
return home
def test_default_redacts_tail_and_full_text(self, hermes_home_with_secret):
from hermes_cli.debug import _capture_log_snapshot
snap = _capture_log_snapshot("agent", tail_lines=10)
# Both views the upload uses must be sanitized.
assert _REDACT_FIXTURE_TOKEN not in snap.tail_text
assert snap.full_text is not None
assert _REDACT_FIXTURE_TOKEN not in snap.full_text
def test_redact_false_passes_through(self, hermes_home_with_secret):
from hermes_cli.debug import _capture_log_snapshot
snap = _capture_log_snapshot("agent", tail_lines=10, redact=False)
# Original token survives when the caller opts out.
assert _REDACT_FIXTURE_TOKEN in snap.tail_text
assert _REDACT_FIXTURE_TOKEN in (snap.full_text or "")
def test_force_true_overrides_unset_env_var(self, hermes_home_with_secret):
"""Regression test: redact_sensitive_text short-circuits without force=True.
If a future refactor drops `force=True` from `_redact_log_text`, this
test fails immediately. Without `force=True`, the redactor returns the
input unchanged when HERMES_REDACT_SECRETS is unset, and the feature
ships silently broken for its target audience.
"""
import os
from hermes_cli.debug import _capture_log_snapshot
# Belt-and-suspenders: confirm the env var is genuinely unset for this
# test so we know we're exercising the force=True path.
assert os.environ.get("HERMES_REDACT_SECRETS", "") == ""
snap = _capture_log_snapshot("agent", tail_lines=10)
assert _REDACT_FIXTURE_TOKEN not in snap.tail_text
assert snap.full_text is not None
assert _REDACT_FIXTURE_TOKEN not in snap.full_text
def test_capture_default_log_snapshots_threads_redact(
self, hermes_home_with_secret
):
from hermes_cli.debug import _capture_default_log_snapshots
snaps = _capture_default_log_snapshots(50)
# Default threads redact=True to all three captured logs.
assert _REDACT_FIXTURE_TOKEN not in snaps["agent"].tail_text
assert _REDACT_FIXTURE_TOKEN not in (snaps["agent"].full_text or "")
def test_capture_default_log_snapshots_no_redact_passes_through(
self, hermes_home_with_secret
):
from hermes_cli.debug import _capture_default_log_snapshots
snaps = _capture_default_log_snapshots(50, redact=False)
assert _REDACT_FIXTURE_TOKEN in snaps["agent"].tail_text
assert _REDACT_FIXTURE_TOKEN in (snaps["agent"].full_text or "")
# ---------------------------------------------------------------------------
# Debug report collection
# ---------------------------------------------------------------------------
@@ -556,6 +651,124 @@ class TestRunDebugShare:
assert "all failed" in out.err
# ---------------------------------------------------------------------------
# Share-time redaction wiring + visible banner
# ---------------------------------------------------------------------------
class TestRunDebugShareRedaction:
"""End-to-end: --no-redact flag, banner injection, default behavior."""
@pytest.fixture
def hermes_home_with_secret(self, tmp_path, monkeypatch):
"""Isolated HERMES_HOME whose agent.log contains a vendor-prefixed token."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False)
logs_dir = home / "logs"
logs_dir.mkdir()
(logs_dir / "agent.log").write_text(
f"2026-04-12 17:00:00 INFO config: api_key={_REDACT_FIXTURE_TOKEN} loaded\n"
)
(logs_dir / "errors.log").write_text("")
(logs_dir / "gateway.log").write_text(
f"2026-04-12 17:00:01 INFO gateway.run: token {_REDACT_FIXTURE_TOKEN}\n"
)
return home
def test_default_share_redacts_uploaded_content(
self, hermes_home_with_secret, capsys
):
"""The uploaded report and full-log pastes do not contain the raw token."""
from hermes_cli.debug import run_debug_share
args = MagicMock()
args.lines = 50
args.expire = 7
args.local = False
args.no_redact = False
captured: list[str] = []
def fake_upload(content, expiry_days=7):
captured.append(content)
return f"https://paste.rs/{len(captured)}"
with patch("hermes_cli.dump.run_dump"), \
patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \
patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload):
run_debug_share(args)
# At least the report plus one full log paste reached the upload path.
assert len(captured) >= 2
for content in captured:
assert _REDACT_FIXTURE_TOKEN not in content, (
"raw token leaked into upload-bound content"
)
def test_default_share_includes_redaction_banner(
self, hermes_home_with_secret, capsys
):
"""Each upload-bound paste carries the visible redaction banner."""
from hermes_cli.debug import run_debug_share
args = MagicMock()
args.lines = 50
args.expire = 7
args.local = False
args.no_redact = False
captured: list[str] = []
def fake_upload(content, expiry_days=7):
captured.append(content)
return f"https://paste.rs/{len(captured)}"
with patch("hermes_cli.dump.run_dump"), \
patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \
patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload):
run_debug_share(args)
for content in captured:
assert "redacted at upload time" in content, (
"redaction banner missing from upload-bound content"
)
def test_no_redact_flag_disables_redaction_and_banner(
self, hermes_home_with_secret, capsys
):
"""--no-redact preserves original log content and omits the banner."""
from hermes_cli.debug import run_debug_share
args = MagicMock()
args.lines = 50
args.expire = 7
args.local = False
args.no_redact = True
captured: list[str] = []
def fake_upload(content, expiry_days=7):
captured.append(content)
return f"https://paste.rs/{len(captured)}"
with patch("hermes_cli.dump.run_dump"), \
patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \
patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload):
run_debug_share(args)
# The agent.log paste should now contain the raw token.
assert any(_REDACT_FIXTURE_TOKEN in c for c in captured), (
"expected raw token in --no-redact upload"
)
# No banner anywhere when redaction is disabled.
for content in captured:
assert "redacted at upload time" not in content, (
"banner present with --no-redact"
)
# ---------------------------------------------------------------------------
# run_debug router
# ---------------------------------------------------------------------------
+40
View File
@@ -481,6 +481,46 @@ def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
)
def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / ".env").write_text("KIMI_CN_API_KEY=***\n", encoding="utf-8")
(home / "config.yaml").write_text(
"model:\n"
" provider: kimi-coding-cn\n"
" default: kimi-k2.6\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'kimi-coding-cn' is not a recognised provider" not in out
def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
+4
View File
@@ -310,6 +310,10 @@ def test_find_gateway_pids_falls_back_to_pid_file_when_process_scan_fails(monkey
def fake_run(cmd, **kwargs):
if cmd[:4] == ["ps", "-A", "eww", "-o"]:
return SimpleNamespace(returncode=1, stdout="", stderr="ps failed")
if cmd[:3] == ["ps", "-o", "ppid="]:
# _get_ancestor_pids() walks up the tree; return "no parent" so
# the loop terminates cleanly.
return SimpleNamespace(returncode=1, stdout="", stderr="")
raise AssertionError(f"Unexpected command: {cmd}")
monkeypatch.setattr(gateway.subprocess, "run", fake_run)
+57
View File
@@ -107,6 +107,61 @@ class TestSystemdServiceRefresh:
]
def test_run_gateway_refreshes_outdated_unit_on_boot(self, tmp_path, monkeypatch):
"""run_gateway() should refresh the systemd unit on boot so that
restart settings take effect even when the process was respawned
via exit-code-75 (bypassing `hermes gateway restart`)."""
unit_path = tmp_path / "hermes-gateway.service"
unit_path.write_text("old unit\n", encoding="utf-8")
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n")
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
calls = []
def fake_run(cmd, check=True, **kwargs):
calls.append(cmd)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
# Prevent run_gateway from actually starting the gateway
def fake_start_gateway(**kwargs):
import asyncio
f = asyncio.Future()
f.set_result(True)
return f
monkeypatch.setattr("gateway.run.start_gateway", fake_start_gateway)
gateway_cli.run_gateway()
assert unit_path.read_text(encoding="utf-8") == "new unit\n"
assert ["systemctl", "--user", "daemon-reload"] in calls
class TestRequireServiceInstalled:
def test_exits_with_install_hint_when_unit_missing(self, tmp_path, monkeypatch, capsys):
unit_path = tmp_path / "hermes-gateway.service"
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
with pytest.raises(SystemExit) as exc_info:
gateway_cli._require_service_installed("start")
assert exc_info.value.code == 1
out = capsys.readouterr().out
assert "not installed" in out
assert "hermes gateway install" in out
def test_passes_when_unit_exists(self, tmp_path, monkeypatch):
unit_path = tmp_path / "hermes-gateway.service"
unit_path.write_text("[Unit]\n", encoding="utf-8")
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
gateway_cli._require_service_installed("start")
class TestGeneratedSystemdUnits:
def test_user_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self):
unit = gateway_cli.generate_systemd_unit(system=False)
@@ -487,6 +542,7 @@ class TestGatewaySystemServiceRouting:
calls = []
monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False)
monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None)
monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: calls.append(("refresh", system)))
monkeypatch.setattr(
"gateway.status.get_running_pid",
@@ -541,6 +597,7 @@ class TestGatewaySystemServiceRouting:
def test_systemd_restart_recovers_failed_planned_restart(self, monkeypatch, capsys):
monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False)
monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None)
monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: None)
monkeypatch.setattr(
"gateway.status.read_runtime_status",
+483
View File
@@ -0,0 +1,483 @@
"""Tests for the multi-board kanban layer (``hermes kanban boards …``).
Covers the pieces added when boards became a first-class concept:
* Slug validation and normalisation.
* Path resolution for ``default`` (legacy ``<root>/kanban.db``) vs
named boards (``<root>/kanban/boards/<slug>/kanban.db``).
* Current-board persistence via ``<root>/kanban/current`` and
``HERMES_KANBAN_BOARD`` env var.
* ``connect(board=)`` isolation writes on one board don't leak.
* ``create_board`` / ``list_boards`` / ``remove_board`` round trip.
* CLI surface: ``hermes kanban boards list/create/switch/rm``.
* ``_default_spawn`` injects ``HERMES_KANBAN_BOARD`` into worker env.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
# Ensure the worktree (not the stale global clone) is first on sys.path.
_WORKTREE = Path(__file__).resolve().parents[2]
if str(_WORKTREE) not in sys.path:
sys.path.insert(0, str(_WORKTREE))
from hermes_cli import kanban_db as kb
# ---------------------------------------------------------------------------
# Fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def fresh_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME with no prior kanban state.
The autouse hermetic conftest already nukes credentials + TZ; this
fixture layers a per-test HERMES_HOME plus a path-init cache reset
so each test sees a truly empty board set.
"""
home = tmp_path / "hermes_home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
for var in (
"HERMES_KANBAN_DB",
"HERMES_KANBAN_WORKSPACES_ROOT",
"HERMES_KANBAN_HOME",
"HERMES_KANBAN_BOARD",
):
monkeypatch.delenv(var, raising=False)
# Also reset hermes_constants cache so get_default_hermes_root() re-reads.
try:
import hermes_constants
hermes_constants._cached_default_hermes_root = None # type: ignore[attr-defined]
except Exception:
pass
# Kanban module-level init cache must not leak between tests.
kb._INITIALIZED_PATHS.clear()
return home
# ---------------------------------------------------------------------------
# Slug validation
# ---------------------------------------------------------------------------
class TestSlugValidation:
@pytest.mark.parametrize("good", [
"default", "atm10-server", "hermes-agent", "proj_1", "a",
"very-long-but-still-ok-slug-with-hyphens-and-numbers-1234",
])
def test_accepts_valid(self, good):
assert kb._normalize_board_slug(good) == good
@pytest.mark.parametrize("bad", [
"-leading-hyphen", "_leading_underscore",
"with/slash", "with space",
"has.dot", "has?question",
"..", "../etc", "foo\x00bar",
])
def test_rejects_invalid(self, bad):
with pytest.raises(ValueError):
kb._normalize_board_slug(bad)
def test_empty_returns_none(self):
assert kb._normalize_board_slug(None) is None
assert kb._normalize_board_slug("") is None
assert kb._normalize_board_slug(" ") is None
def test_auto_lowercases(self):
# Uppercase is auto-downcased (friendlier than rejecting). ``Default``
# → ``default``, ``ATM10`` → ``atm10``. The on-disk slug is always
# lowercase regardless of what the user typed.
assert kb._normalize_board_slug("Default") == "default"
assert kb._normalize_board_slug("ATM10-Server") == "atm10-server"
# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------
class TestPathResolution:
def test_default_board_legacy_path(self, fresh_home):
"""The default board's DB lives at ``<root>/kanban.db`` for back-compat."""
assert kb.kanban_db_path() == fresh_home / "kanban.db"
assert kb.kanban_db_path(board="default") == fresh_home / "kanban.db"
def test_named_board_under_boards_dir(self, fresh_home):
p = kb.kanban_db_path(board="atm10-server")
assert p == fresh_home / "kanban" / "boards" / "atm10-server" / "kanban.db"
def test_workspaces_per_board(self, fresh_home):
assert kb.workspaces_root() == fresh_home / "kanban" / "workspaces"
# Uppercase input gets auto-downcased to the on-disk slug.
assert kb.workspaces_root(board="projA") == (
fresh_home / "kanban" / "boards" / "proja" / "workspaces"
)
def test_logs_per_board(self, fresh_home):
assert kb.worker_logs_dir() == fresh_home / "kanban" / "logs"
assert kb.worker_logs_dir(board="other") == (
fresh_home / "kanban" / "boards" / "other" / "logs"
)
def test_env_var_db_override_still_wins(self, fresh_home, tmp_path, monkeypatch):
"""``HERMES_KANBAN_DB`` pins the file regardless of board= arg."""
forced = tmp_path / "custom.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(forced))
assert kb.kanban_db_path() == forced
assert kb.kanban_db_path(board="ignored") == forced
def test_env_var_workspaces_override(self, fresh_home, tmp_path, monkeypatch):
forced = tmp_path / "ws"
monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", str(forced))
assert kb.workspaces_root(board="any") == forced
# ---------------------------------------------------------------------------
# Current-board resolution
# ---------------------------------------------------------------------------
class TestCurrentBoard:
def test_default_when_unset(self, fresh_home):
assert kb.get_current_board() == "default"
def test_env_var_takes_precedence(self, fresh_home, monkeypatch):
# Create the board so the env-var value is honoured (get_current_board
# trusts env-var validity, but the resolution chain doesn't require
# the board to exist; we just test that env trumps).
kb.create_board("envboard")
monkeypatch.setenv("HERMES_KANBAN_BOARD", "envboard")
assert kb.get_current_board() == "envboard"
def test_file_pointer_honoured(self, fresh_home):
kb.create_board("filepick")
kb.set_current_board("filepick")
assert kb.get_current_board() == "filepick"
def test_env_beats_file(self, fresh_home, monkeypatch):
kb.create_board("a")
kb.create_board("b")
kb.set_current_board("a")
monkeypatch.setenv("HERMES_KANBAN_BOARD", "b")
assert kb.get_current_board() == "b"
def test_invalid_env_falls_through(self, fresh_home, monkeypatch):
monkeypatch.setenv("HERMES_KANBAN_BOARD", "!!bad!!")
# Should not crash — falls through to default.
assert kb.get_current_board() == "default"
def test_clear_current_board(self, fresh_home):
kb.create_board("x")
kb.set_current_board("x")
kb.clear_current_board()
assert kb.get_current_board() == "default"
def test_kanban_db_path_reads_current(self, fresh_home):
"""kanban_db_path() with no args respects the on-disk pointer."""
kb.create_board("my-proj")
kb.set_current_board("my-proj")
expected = fresh_home / "kanban" / "boards" / "my-proj" / "kanban.db"
assert kb.kanban_db_path() == expected
# ---------------------------------------------------------------------------
# Board CRUD
# ---------------------------------------------------------------------------
class TestBoardCRUD:
def test_create_and_list(self, fresh_home):
assert [b["slug"] for b in kb.list_boards()] == ["default"]
kb.create_board("foo", name="Foo Board", description="test")
slugs = [b["slug"] for b in kb.list_boards()]
assert slugs == ["default", "foo"]
def test_create_is_idempotent(self, fresh_home):
kb.create_board("bar")
kb.create_board("bar") # no error
slugs = [b["slug"] for b in kb.list_boards()]
assert slugs == ["default", "bar"]
def test_create_writes_metadata(self, fresh_home):
meta = kb.create_board(
"baz",
name="Baz",
description="desc",
icon="📦",
color="#abcdef",
)
assert meta["slug"] == "baz"
assert meta["name"] == "Baz"
assert meta["icon"] == "📦"
# Round-trip via read_board_metadata.
again = kb.read_board_metadata("baz")
assert again["name"] == "Baz"
assert again["description"] == "desc"
assert again["icon"] == "📦"
def test_remove_archive(self, fresh_home):
kb.create_board("toremove")
res = kb.remove_board("toremove")
assert res["action"] == "archived"
assert Path(res["new_path"]).exists()
assert "toremove" not in [b["slug"] for b in kb.list_boards()]
def test_remove_hard_delete(self, fresh_home):
kb.create_board("nuke")
d = kb.board_dir("nuke")
assert d.exists()
res = kb.remove_board("nuke", archive=False)
assert res["action"] == "deleted"
assert not d.exists()
def test_remove_default_forbidden(self, fresh_home):
with pytest.raises(ValueError, match="default"):
kb.remove_board("default")
def test_remove_nonexistent_raises(self, fresh_home):
with pytest.raises(ValueError, match="does not exist"):
kb.remove_board("nosuch")
def test_remove_clears_current_pointer(self, fresh_home):
kb.create_board("pinned")
kb.set_current_board("pinned")
kb.remove_board("pinned")
assert kb.get_current_board() == "default"
def test_rename_updates_metadata(self, fresh_home):
kb.create_board("slug-immutable")
kb.write_board_metadata("slug-immutable", name="New Display Name")
assert kb.read_board_metadata("slug-immutable")["name"] == "New Display Name"
# Slug must not change.
assert kb.board_exists("slug-immutable")
# ---------------------------------------------------------------------------
# Connection isolation
# ---------------------------------------------------------------------------
class TestConnectionIsolation:
def test_tasks_do_not_leak_across_boards(self, fresh_home):
kb.create_board("alpha")
kb.create_board("beta")
with kb.connect(board="alpha") as conn:
kb.create_task(conn, title="alpha-task-1", assignee="dev")
kb.create_task(conn, title="alpha-task-2", assignee="dev")
with kb.connect(board="beta") as conn:
kb.create_task(conn, title="beta-only", assignee="dev")
with kb.connect(board="alpha") as conn:
a = kb.list_tasks(conn)
with kb.connect(board="beta") as conn:
b = kb.list_tasks(conn)
with kb.connect(board="default") as conn:
d = kb.list_tasks(conn)
assert {t.title for t in a} == {"alpha-task-1", "alpha-task-2"}
assert {t.title for t in b} == {"beta-only"}
assert d == []
def test_connect_without_args_uses_current(self, fresh_home):
kb.create_board("curr")
kb.set_current_board("curr")
with kb.connect() as conn:
kb.create_task(conn, title="implicit", assignee="x")
with kb.connect(board="curr") as conn:
tasks = kb.list_tasks(conn)
assert [t.title for t in tasks] == ["implicit"]
def test_connect_env_var_overrides_current(self, fresh_home, monkeypatch):
kb.create_board("persist")
kb.create_board("envwin")
kb.set_current_board("persist")
monkeypatch.setenv("HERMES_KANBAN_BOARD", "envwin")
with kb.connect() as conn:
kb.create_task(conn, title="via-env", assignee="x")
with kb.connect(board="envwin") as conn:
assert [t.title for t in kb.list_tasks(conn)] == ["via-env"]
with kb.connect(board="persist") as conn:
assert kb.list_tasks(conn) == []
# ---------------------------------------------------------------------------
# Worker spawn env injection
# ---------------------------------------------------------------------------
class TestWorkerSpawnEnv:
"""Ensure the dispatcher pins ``HERMES_KANBAN_BOARD`` / DB / workspaces on spawn.
We monkey-patch ``subprocess.Popen`` to capture the child env without
actually spawning anything.
"""
def test_default_spawn_sets_env_vars(self, fresh_home, monkeypatch):
captured = {}
class FakeProc:
pid = 12345
def fake_popen(cmd, *args, **kwargs):
captured["cmd"] = cmd
captured["env"] = kwargs.get("env", {})
return FakeProc()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
kb.create_board("spawntest")
task = kb.Task(
id="t_abc",
title="worker test",
body=None,
assignee="teknium",
status="ready",
priority=0,
created_by="user",
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
claim_lock=None,
claim_expires=None,
tenant=None,
)
kb._default_spawn(task, str(fresh_home / "ws"), board="spawntest")
env = captured["env"]
assert env["HERMES_KANBAN_BOARD"] == "spawntest"
assert env["HERMES_KANBAN_TASK"] == "t_abc"
# DB path should match the per-board DB, not the legacy default.
expected_db = fresh_home / "kanban" / "boards" / "spawntest" / "kanban.db"
assert env["HERMES_KANBAN_DB"] == str(expected_db)
expected_ws = fresh_home / "kanban" / "boards" / "spawntest" / "workspaces"
assert env["HERMES_KANBAN_WORKSPACES_ROOT"] == str(expected_ws)
def test_default_board_spawn_keeps_legacy_paths(self, fresh_home, monkeypatch):
captured = {}
class FakeProc:
pid = 1
def fake_popen(cmd, *args, **kwargs):
captured["env"] = kwargs.get("env", {})
return FakeProc()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
task = kb.Task(
id="t_def",
title="",
body=None,
assignee="teknium",
status="ready",
priority=0,
created_by=None,
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
claim_lock=None,
claim_expires=None,
tenant=None,
)
kb._default_spawn(task, str(fresh_home / "ws"), board=None)
env = captured["env"]
assert env["HERMES_KANBAN_BOARD"] == "default"
assert env["HERMES_KANBAN_DB"] == str(fresh_home / "kanban.db")
# ---------------------------------------------------------------------------
# CLI surface
# ---------------------------------------------------------------------------
def _cli(args: list[str], env_extra: dict | None = None) -> subprocess.CompletedProcess:
"""Run ``hermes kanban …`` with PYTHONPATH pinned to the worktree."""
env = dict(os.environ)
env["PYTHONPATH"] = str(_WORKTREE)
if env_extra:
env.update(env_extra)
return subprocess.run(
[sys.executable, "-m", "hermes_cli.main", "kanban"] + args,
env=env,
capture_output=True,
text=True,
cwd=str(_WORKTREE),
timeout=30,
)
class TestCLI:
def test_boards_list_default_only(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
res = _cli(["boards", "list", "--json"], env_extra=env)
assert res.returncode == 0, res.stderr
data = json.loads(res.stdout)
slugs = [b["slug"] for b in data]
assert slugs == ["default"]
assert data[0]["is_current"] is True
def test_boards_create_and_switch(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
r1 = _cli(
["boards", "create", "myproj", "--name", "My Project", "--switch"],
env_extra=env,
)
assert r1.returncode == 0, r1.stderr
assert "created" in r1.stdout
assert "Switched" in r1.stdout
r2 = _cli(["boards", "list", "--json"], env_extra=env)
data = json.loads(r2.stdout)
cur = [b for b in data if b["is_current"]][0]
assert cur["slug"] == "myproj"
def test_per_board_task_isolation_via_cli(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
assert _cli(["boards", "create", "projA"], env_extra=env).returncode == 0
assert _cli(["boards", "create", "projB"], env_extra=env).returncode == 0
# Create one task on each via --board.
r = _cli(["--board", "projA", "create", "Task A", "--assignee", "dev"], env_extra=env)
assert r.returncode == 0, r.stderr
r = _cli(["--board", "projB", "create", "Task B", "--assignee", "dev"], env_extra=env)
assert r.returncode == 0, r.stderr
# list on each board only shows its own.
listA = _cli(["--board", "projA", "list", "--json"], env_extra=env)
listB = _cli(["--board", "projB", "list", "--json"], env_extra=env)
listD = _cli(["list", "--json"], env_extra=env)
titlesA = [t["title"] for t in json.loads(listA.stdout)]
titlesB = [t["title"] for t in json.loads(listB.stdout)]
titlesD = [t["title"] for t in json.loads(listD.stdout)]
assert titlesA == ["Task A"]
assert titlesB == ["Task B"]
assert titlesD == []
def test_board_flag_rejects_unknown(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
r = _cli(["--board", "ghost", "list"], env_extra=env)
# main.py's dispatcher doesn't propagate return codes today, so we
# assert the user-visible signal: a stderr error message. Whether
# the exit code stays 0 is a separate (pre-existing) issue.
assert "does not exist" in r.stderr
def test_boards_rm_archives(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
_cli(["boards", "create", "rmme"], env_extra=env)
r = _cli(["boards", "rm", "rmme"], env_extra=env)
assert r.returncode == 0, r.stderr
assert "archived" in r.stdout
# Default board list no longer shows it.
res = _cli(["boards", "list", "--json"], env_extra=env)
slugs = [b["slug"] for b in json.loads(res.stdout)]
assert "rmme" not in slugs
@@ -902,12 +902,13 @@ def test_list_profiles_on_disk(tmp_path, monkeypatch):
"""list_profiles_on_disk returns directories under ~/.hermes/profiles/
that contain a config.yaml."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("HERMES_HOME", raising=False)
profiles = tmp_path / ".hermes" / "profiles"
profiles.mkdir(parents=True)
(profiles / "researcher").mkdir()
(profiles / "researcher" / "config.yaml").write_text("model: {}\n")
(profiles / "writer").mkdir()
(profiles / "writer" / "config.yaml").write_text("model: {}\n")
for name in ("researcher", "writer"):
d = profiles / name
d.mkdir()
(d / "config.yaml").write_text("model: {}\n")
(profiles / "empty_dir").mkdir()
# A stray file; should be ignored.
(profiles / "stray.txt").write_text("noise")
@@ -916,6 +917,20 @@ def test_list_profiles_on_disk(tmp_path, monkeypatch):
assert names == ["researcher", "writer"]
def test_list_profiles_on_disk_custom_root(tmp_path, monkeypatch):
"""list_profiles_on_disk respects a custom HERMES_HOME root."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
profiles = tmp_path / "profiles"
profiles.mkdir(parents=True)
for name in ("researcher", "writer"):
d = profiles / name
d.mkdir()
(d / "config.yaml").write_text("model: {}\n")
names = kb.list_profiles_on_disk()
assert names == ["researcher", "writer"]
def test_known_assignees_merges_disk_and_board(tmp_path, monkeypatch):
"""known_assignees unions profiles on disk with currently-assigned
names, and reports per-status counts."""
+292
View File
@@ -252,6 +252,22 @@ def test_assign_reassigns_when_not_running(kanban_home):
assert kb.get_task(conn, t).assignee == "b"
def test_assignee_normalized_to_lowercase_on_create_and_assign(kanban_home):
"""Dashboard/CLI may pass title-cased profile labels; DB + spawn use canonical id."""
with kb.connect() as conn:
tid = kb.create_task(conn, title="cased", assignee="Jules")
assert kb.get_task(conn, tid).assignee == "jules"
assert kb.assign_task(conn, tid, "Librarian")
assert kb.get_task(conn, tid).assignee == "librarian"
def test_list_tasks_assignee_filter_case_insensitive(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="q", assignee="jules")
found = kb.list_tasks(conn, assignee="Jules")
assert len(found) == 1 and found[0].id == tid
def test_archive_hides_from_default_list(kanban_home):
with kb.connect() as conn:
t = kb.create_task(conn, title="x")
@@ -436,3 +452,279 @@ def test_tenant_propagates_to_events(kanban_home):
# The "created" event should have tenant in its payload.
created = [e for e in events if e.kind == "created"]
assert created and created[0].payload.get("tenant") == "biz-a"
# ---------------------------------------------------------------------------
# Shared-board path resolution (issue #19348)
#
# The kanban board is a cross-profile coordination primitive: a worker
# spawned with `hermes -p <profile>` must read/write the same kanban.db
# as the dispatcher that claimed the task. These tests exercise the
# path-resolution layer directly and would have caught the regression
# where `kanban_db_path()` resolved to the active profile's HERMES_HOME.
# ---------------------------------------------------------------------------
class TestSharedBoardPaths:
"""`kanban_home`/`kanban_db_path`/`workspaces_root`/`worker_log_path`
must anchor at the **shared root**, not the active profile's HERMES_HOME."""
def _set_home(self, monkeypatch, tmp_path, hermes_home):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HERMES_KANBAN_HOME", raising=False)
def test_default_install_anchors_at_home_dot_hermes(
self, tmp_path, monkeypatch
):
# Standard install: HERMES_HOME == ~/.hermes, no profile active.
default_home = tmp_path / ".hermes"
default_home.mkdir()
self._set_home(monkeypatch, tmp_path, default_home)
assert kb.kanban_home() == default_home
assert kb.kanban_db_path() == default_home / "kanban.db"
assert kb.workspaces_root() == default_home / "kanban" / "workspaces"
assert (
kb.worker_log_path("t_demo")
== default_home / "kanban" / "logs" / "t_demo.log"
)
def test_profile_worker_resolves_to_shared_root(
self, tmp_path, monkeypatch
):
# Reproduces the bug: dispatcher uses ~/.hermes/kanban.db,
# worker spawned with -p <profile> previously resolved to
# ~/.hermes/profiles/<profile>/kanban.db. After the fix both
# converge on ~/.hermes/kanban.db.
default_home = tmp_path / ".hermes"
default_home.mkdir()
profile_home = default_home / "profiles" / "nehemiahkanban"
profile_home.mkdir(parents=True)
self._set_home(monkeypatch, tmp_path, profile_home)
# All four resolvers must anchor at the shared root, not the
# profile-local HERMES_HOME.
assert kb.kanban_home() == default_home
assert kb.kanban_db_path() == default_home / "kanban.db"
assert kb.workspaces_root() == default_home / "kanban" / "workspaces"
assert (
kb.worker_log_path("t_0d214f19")
== default_home / "kanban" / "logs" / "t_0d214f19.log"
)
# Sanity: the profile-local path that used to be returned is
# explicitly NOT what we resolve to anymore.
assert kb.kanban_db_path() != profile_home / "kanban.db"
def test_dispatcher_and_profile_worker_converge(
self, tmp_path, monkeypatch
):
# End-to-end convergence: resolve the path under each side's
# HERMES_HOME and confirm equality. This is the property the
# dispatcher/worker handoff actually depends on.
default_home = tmp_path / ".hermes"
default_home.mkdir()
profile_home = default_home / "profiles" / "coder"
profile_home.mkdir(parents=True)
# Dispatcher's perspective.
self._set_home(monkeypatch, tmp_path, default_home)
dispatcher_db = kb.kanban_db_path()
dispatcher_ws = kb.workspaces_root()
dispatcher_log = kb.worker_log_path("t_handoff")
# Worker's perspective (profile activated by `hermes -p coder`).
monkeypatch.setenv("HERMES_HOME", str(profile_home))
worker_db = kb.kanban_db_path()
worker_ws = kb.workspaces_root()
worker_log = kb.worker_log_path("t_handoff")
assert dispatcher_db == worker_db
assert dispatcher_ws == worker_ws
assert dispatcher_log == worker_log
def test_docker_custom_hermes_home_uses_env_path_directly(
self, tmp_path, monkeypatch
):
# Docker / custom deployment: HERMES_HOME points outside ~/.hermes.
# `get_default_hermes_root()` returns env_home directly when it
# is not a `<root>/profiles/<name>` shape and not under
# `Path.home() / ".hermes"`.
custom_root = tmp_path / "opt" / "hermes"
custom_root.mkdir(parents=True)
self._set_home(monkeypatch, tmp_path, custom_root)
assert kb.kanban_home() == custom_root
assert kb.kanban_db_path() == custom_root / "kanban.db"
def test_docker_profile_layout_uses_grandparent(
self, tmp_path, monkeypatch
):
# Docker profile shape: HERMES_HOME=/opt/hermes/profiles/coder;
# `get_default_hermes_root()` walks up to /opt/hermes because
# the immediate parent dir is named "profiles".
custom_root = tmp_path / "opt" / "hermes"
profile = custom_root / "profiles" / "coder"
profile.mkdir(parents=True)
self._set_home(monkeypatch, tmp_path, profile)
assert kb.kanban_home() == custom_root
assert kb.kanban_db_path() == custom_root / "kanban.db"
def test_explicit_override_via_hermes_kanban_home(
self, tmp_path, monkeypatch
):
# Explicit override: HERMES_KANBAN_HOME beats every other
# resolution rule.
default_home = tmp_path / ".hermes"
profile_home = default_home / "profiles" / "any"
profile_home.mkdir(parents=True)
override = tmp_path / "shared-board"
override.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(profile_home))
monkeypatch.setenv("HERMES_KANBAN_HOME", str(override))
assert kb.kanban_home() == override
assert kb.kanban_db_path() == override / "kanban.db"
assert kb.workspaces_root() == override / "kanban" / "workspaces"
def test_empty_override_falls_through(self, tmp_path, monkeypatch):
# Empty/whitespace override is treated as unset.
default_home = tmp_path / ".hermes"
default_home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(default_home))
monkeypatch.setenv("HERMES_KANBAN_HOME", " ")
assert kb.kanban_home() == default_home
def test_dispatcher_and_worker_share_a_real_database(
self, tmp_path, monkeypatch
):
# Belt-and-suspenders: round-trip a task across the two
# HERMES_HOME perspectives via a real SQLite file. Without the
# fix the worker would open a different file and see no rows.
default_home = tmp_path / ".hermes"
default_home.mkdir()
profile_home = default_home / "profiles" / "nehemiahkanban"
profile_home.mkdir(parents=True)
# Dispatcher creates the board and a task.
self._set_home(monkeypatch, tmp_path, default_home)
kb.init_db()
with kb.connect() as conn:
task_id = kb.create_task(conn, title="cross-profile")
# Worker switches to the profile HERMES_HOME and reads.
monkeypatch.setenv("HERMES_HOME", str(profile_home))
with kb.connect() as conn:
task = kb.get_task(conn, task_id)
assert task is not None
assert task.title == "cross-profile"
def test_hermes_kanban_db_pin_beats_kanban_home(
self, tmp_path, monkeypatch
):
# HERMES_KANBAN_DB pins the file path directly and beats both
# HERMES_KANBAN_HOME and the `get_default_hermes_root()` path.
# This is the env the dispatcher injects into workers.
default_home = tmp_path / ".hermes"
default_home.mkdir()
umbrella = tmp_path / "umbrella"
umbrella.mkdir()
pinned_db = tmp_path / "pinned" / "board.db"
pinned_db.parent.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(default_home))
monkeypatch.setenv("HERMES_KANBAN_HOME", str(umbrella))
monkeypatch.setenv("HERMES_KANBAN_DB", str(pinned_db))
assert kb.kanban_db_path() == pinned_db
# workspaces_root still follows HERMES_KANBAN_HOME -- the pins
# are independent.
assert kb.workspaces_root() == umbrella / "kanban" / "workspaces"
def test_hermes_kanban_workspaces_root_pin_beats_kanban_home(
self, tmp_path, monkeypatch
):
# HERMES_KANBAN_WORKSPACES_ROOT pins the workspaces root directly.
default_home = tmp_path / ".hermes"
default_home.mkdir()
umbrella = tmp_path / "umbrella"
umbrella.mkdir()
pinned_ws = tmp_path / "pinned-workspaces"
pinned_ws.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(default_home))
monkeypatch.setenv("HERMES_KANBAN_HOME", str(umbrella))
monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", str(pinned_ws))
assert kb.workspaces_root() == pinned_ws
# kanban_db_path still follows HERMES_KANBAN_HOME.
assert kb.kanban_db_path() == umbrella / "kanban.db"
def test_empty_per_path_overrides_fall_through(
self, tmp_path, monkeypatch
):
# Empty/whitespace pins are treated as unset, same as
# HERMES_KANBAN_HOME.
default_home = tmp_path / ".hermes"
default_home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(default_home))
monkeypatch.setenv("HERMES_KANBAN_DB", " ")
monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", "")
assert kb.kanban_db_path() == default_home / "kanban.db"
assert kb.workspaces_root() == default_home / "kanban" / "workspaces"
def test_dispatcher_spawn_injects_kanban_db_and_workspaces_root(
self, tmp_path, monkeypatch
):
# The dispatcher's `_default_spawn` must inject HERMES_KANBAN_DB
# and HERMES_KANBAN_WORKSPACES_ROOT into the worker env so the
# worker converges on the dispatcher's paths even when the
# `-p <profile>` flag rewrites HERMES_HOME.
default_home = tmp_path / ".hermes"
default_home.mkdir()
self._set_home(monkeypatch, tmp_path, default_home)
captured = {}
class _FakePopen:
def __init__(self, cmd, **kwargs):
captured["cmd"] = cmd
captured["env"] = kwargs.get("env", {})
self.pid = 4242
monkeypatch.setattr("subprocess.Popen", _FakePopen)
task = kb.Task(
id="t_dispatch_env",
title="x",
body=None,
assignee="coder",
status="ready",
priority=0,
created_by=None,
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
claim_lock=None,
claim_expires=None,
tenant=None,
)
kb._default_spawn(task, str(tmp_path / "ws"))
env = captured["env"]
assert env["HERMES_KANBAN_DB"] == str(default_home / "kanban.db")
assert env["HERMES_KANBAN_WORKSPACES_ROOT"] == str(
default_home / "kanban" / "workspaces"
)
assert env["HERMES_KANBAN_TASK"] == "t_dispatch_env"
@@ -0,0 +1,55 @@
"""Regression tests for OpenAI Codex model validation when the listing lags behind
actually usable backend model IDs.
The bug: `/model` and `switch_model()` reject `gpt-5.3-codex-spark` because the
OpenAI Codex listing omits it, even though direct runtime calls with
`--provider openai-codex -m gpt-5.3-codex-spark` succeed.
"""
from unittest.mock import patch
from hermes_cli.model_switch import switch_model
from hermes_cli.models import validate_requested_model
def test_openai_codex_unknown_but_plausible_model_is_accepted_with_warning():
"""If the Codex listing is incomplete, `/model` should soft-accept the model
with a warning instead of hard-rejecting it.
"""
with patch(
"hermes_cli.models.provider_model_ids",
return_value=["gpt-5.5", "gpt-5.4", "gpt-5.3-codex"],
):
result = validate_requested_model("gpt-5.3-codex-spark", "openai-codex")
assert result["accepted"] is True
assert result["persist"] is True
assert result["recognized"] is False
assert "gpt-5.3-codex-spark" in result["message"]
assert "OpenAI Codex model listing" in result["message"]
assert "Similar models" in result["message"]
assert "gpt-5.3-codex" in result["message"]
def test_switch_model_allows_openai_codex_model_missing_from_listing():
"""switch_model() should succeed for Codex models that the runtime accepts
even when the listing has not caught up yet.
"""
with patch(
"hermes_cli.models.provider_model_ids",
return_value=["gpt-5.5", "gpt-5.4", "gpt-5.3-codex"],
):
result = switch_model(
"gpt-5.3-codex-spark",
current_provider="openai-codex",
current_model="gpt-5.4",
current_base_url="",
current_api_key="",
user_providers=None,
)
assert result.success is True
assert result.new_model == "gpt-5.3-codex-spark"
assert result.target_provider == "openai-codex"
assert result.warning_message
assert "OpenAI Codex model listing" in result.warning_message
+9 -1
View File
@@ -508,7 +508,7 @@ class TestPromptPluginEnvVars:
class TestCursesRadiolist:
"""Test the curses_radiolist function (non-TTY fallback path)."""
"""Test the curses_radiolist function."""
def test_non_tty_returns_default(self):
from hermes_cli.curses_ui import curses_radiolist
@@ -524,6 +524,14 @@ class TestCursesRadiolist:
result = curses_radiolist("Pick", ["x", "y"], selected=0, cancel_returns=1)
assert result == 1
def test_keyboard_interrupt_returns_cancel_value(self):
from hermes_cli.curses_ui import curses_radiolist
with patch("sys.stdin") as mock_stdin, patch("curses.wrapper", side_effect=KeyboardInterrupt):
mock_stdin.isatty.return_value = True
result = curses_radiolist("Pick", ["x", "y"], selected=0, cancel_returns=-1)
assert result == -1
# ── Provider discovery helpers ───────────────────────────────────────────
+28
View File
@@ -15,6 +15,7 @@ from unittest.mock import patch, MagicMock
import pytest
from hermes_cli.profiles import (
normalize_profile_name,
validate_profile_name,
get_profile_dir,
create_profile,
@@ -58,6 +59,24 @@ def profile_env(tmp_path, monkeypatch):
# TestValidateProfileName
# ===================================================================
class TestNormalizeProfileName:
"""Tests for normalize_profile_name()."""
def test_title_case_normalized(self):
assert normalize_profile_name("Jules") == "jules"
assert normalize_profile_name(" Librarian ") == "librarian"
def test_default_case_insensitive(self):
assert normalize_profile_name("Default") == "default"
assert normalize_profile_name("DEFAULT") == "default"
def test_empty_raises(self):
with pytest.raises(ValueError, match="cannot be empty"):
normalize_profile_name("")
with pytest.raises(ValueError, match="cannot be empty"):
normalize_profile_name(" ")
class TestValidateProfileName:
"""Tests for validate_profile_name()."""
@@ -66,6 +85,11 @@ class TestValidateProfileName:
# Should not raise
validate_profile_name(name)
def test_uppercase_rejected(self):
# validate_profile_name is strict — callers normalize first, then validate.
with pytest.raises(ValueError):
validate_profile_name("Jules")
@pytest.mark.parametrize("name", ["UPPER", "has space", ".hidden", "-leading"])
def test_invalid_names_rejected(self, name):
with pytest.raises(ValueError):
@@ -107,6 +131,10 @@ class TestGetProfileDir:
result = get_profile_dir("coder")
assert result == tmp_path / ".hermes" / "profiles" / "coder"
def test_named_profile_matching_is_case_insensitive(self, profile_env):
tmp_path = profile_env
assert get_profile_dir("Coder") == tmp_path / ".hermes" / "profiles" / "coder"
# ===================================================================
# TestCreateProfile
+32
View File
@@ -613,3 +613,35 @@ def test_offer_launch_chat_falls_back_to_module(monkeypatch):
setup_mod._offer_launch_chat()
assert exec_calls == [(sys.executable, [sys.executable, "-m", "hermes_cli.main", "chat"])]
def test_setup_slack_saves_home_channel(monkeypatch):
"""_setup_slack() saves SLACK_HOME_CHANNEL when the user provides one."""
saved = {}
prompts = iter(["xoxb-test-token", "xapp-test-token", "", "C01ABC2DE3F"])
monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "")
monkeypatch.setattr(setup_mod, "save_env_value", lambda k, v: saved.update({k: v}))
monkeypatch.setattr(setup_mod, "prompt", lambda *_a, **_kw: next(prompts))
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_a, **_kw: False)
monkeypatch.setattr(setup_mod, "_write_slack_manifest_and_instruct", lambda: None)
setup_mod._setup_slack()
assert saved.get("SLACK_HOME_CHANNEL") == "C01ABC2DE3F"
def test_setup_slack_home_channel_empty_not_saved(monkeypatch):
"""_setup_slack() does not save SLACK_HOME_CHANNEL when left blank."""
saved = {}
prompts = iter(["xoxb-test-token", "xapp-test-token", "", ""])
monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "")
monkeypatch.setattr(setup_mod, "save_env_value", lambda k, v: saved.update({k: v}))
monkeypatch.setattr(setup_mod, "prompt", lambda *_a, **_kw: next(prompts))
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_a, **_kw: False)
monkeypatch.setattr(setup_mod, "_write_slack_manifest_and_instruct", lambda: None)
setup_mod._setup_slack()
assert "SLACK_HOME_CHANNEL" not in saved
+27
View File
@@ -2,10 +2,13 @@
from unittest.mock import patch
import pytest
from hermes_cli.tools_config import (
_DEFAULT_OFF_TOOLSETS,
_apply_toolset_change,
_configure_provider,
_reconfigure_provider,
_get_platform_tools,
_platform_toolset_summary,
_reconfigure_tool,
@@ -898,3 +901,27 @@ def test_get_effective_configurable_toolsets_dedupes_bundled_plugins():
assert len(spotify_rows) == 1, spotify_rows
# Built-in label wins over the plugin label.
assert spotify_rows[0][1] == "🎵 Spotify"
@pytest.mark.parametrize("provider,config_key,expected", [
# managed provider → use_gateway True
({"name": "T", "tts_provider": "elevenlabs", "managed_nous_feature": "tts", "env_vars": []}, "tts", True),
({"name": "B", "browser_provider": "browserbase", "managed_nous_feature": "browser", "env_vars": []}, "browser", True),
({"name": "W", "web_backend": "tavily", "managed_nous_feature": "web", "env_vars": []}, "web", True),
# self-hosted provider → use_gateway False
({"name": "T", "tts_provider": "elevenlabs", "env_vars": []}, "tts", False),
({"name": "B", "browser_provider": "browserbase", "env_vars": []}, "browser", False),
({"name": "W", "web_backend": "tavily", "env_vars": []}, "web", False),
])
def test_reconfigure_provider_syncs_use_gateway(provider, config_key, expected):
config = {}
_reconfigure_provider(provider, config)
assert config[config_key]["use_gateway"] is expected
def test_reconfigure_browser_provider_overwrites_stale_use_gateway():
# Switching from managed (use_gateway=True) to self-hosted must clear the stale flag.
config = {"browser": {"cloud_provider": "managed-browser", "use_gateway": True}}
provider = {"name": "Browserbase", "browser_provider": "browserbase", "env_vars": []}
_reconfigure_provider(provider, config)
assert config["browser"]["use_gateway"] is False
+33
View File
@@ -69,6 +69,39 @@ def test_no_install_when_only_optional_peer_package_missing_from_hidden_lock(tmp
assert main_mod._tui_need_npm_install(tmp_path) is False
def test_no_install_when_only_peer_annotation_differs(tmp_path: Path, main_mod) -> None:
"""npm 9 drops the ``peer`` flag from the hidden lock on dev-deps that are
*also* declared as peers. That's a cosmetic difference — the package is
installed at the requested version so it must not trigger a reinstall.
Regression for the TUI-in-Docker failure where 16 such mismatches caused
`Installing TUI dependencies` EACCES on every launch.
"""
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text(
'{"packages":{'
'"node_modules/foo":{"version":"1.0.0","dev":true,"peer":true,"resolved":"https://x/foo.tgz"}'
'}}'
)
(tmp_path / "node_modules" / ".package-lock.json").write_text(
'{"packages":{'
'"node_modules/foo":{"version":"1.0.0","dev":true,"resolved":"https://x/foo.tgz"}'
'}}'
)
assert main_mod._tui_need_npm_install(tmp_path) is False
def test_install_when_version_differs_even_with_peer_drop(tmp_path: Path, main_mod) -> None:
"""The peer-drop tolerance must not mask a real version skew."""
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text(
'{"packages":{"node_modules/foo":{"version":"2.0.0","dev":true,"peer":true}}}'
)
(tmp_path / "node_modules" / ".package-lock.json").write_text(
'{"packages":{"node_modules/foo":{"version":"1.0.0","dev":true}}}'
)
assert main_mod._tui_need_npm_install(tmp_path) is True
def test_no_install_when_lock_older_than_marker(tmp_path: Path, main_mod) -> None:
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text("{}")
@@ -172,6 +172,27 @@ class TestGenerate:
assert result["success"] is False
assert result["error_type"] == "api_error"
def test_api_error_preserves_real_response_status(self):
import requests as req_lib
from plugins.image_gen.xai import XAIImageGenProvider
response = req_lib.Response()
response.status_code = 401
response._content = json.dumps({"error": {"message": "Invalid API key"}}).encode()
response.headers["Content-Type"] = "application/json"
response.raise_for_status = MagicMock(
side_effect=req_lib.HTTPError(response=response)
)
with patch("plugins.image_gen.xai.requests.post", return_value=response):
provider = XAIImageGenProvider()
result = provider.generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "xAI image generation failed (401): Invalid API key" in result["error"]
def test_timeout(self):
import requests as req_lib
@@ -253,6 +253,33 @@ def test_patch_invalid_status(client):
assert r.status_code == 400
def test_patch_status_running_rejected(client):
"""Dashboard PATCH cannot transition a task directly to 'running'.
The only legitimate path into 'running' is through the dispatcher's
``claim_task`` which atomically creates a ``task_runs`` row,
claim_lock, expiry, and worker-PID metadata. Allowing a direct set
creates orphaned 'running' tasks with no run row or claim, which
violate the board's run-history invariants. See issue #19535.
"""
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
r = client.patch(
f"/api/plugins/kanban/tasks/{t['id']}",
json={"status": "running"},
)
assert r.status_code == 400
assert "running" in r.json()["detail"]
# Task's status should still be its pre-request value — the direct-set
# was rejected before any mutation.
board = client.get("/api/plugins/kanban/board").json()
statuses = {
tt["id"]: col["name"]
for col in board["columns"]
for tt in col["tasks"]
}
assert statuses.get(t["id"]) != "running"
# ---------------------------------------------------------------------------
# Comments + Links
# ---------------------------------------------------------------------------
+6
View File
@@ -432,6 +432,8 @@ class TestPreflightCompression:
ok_resp = _mock_response(content="After preflight", finish_reason="stop")
agent.client.chat.completions.create.side_effect = [ok_resp]
status_messages = []
agent.status_callback = lambda ev, msg: status_messages.append((ev, msg))
with (
patch.object(agent, "_compress_context") as mock_compress,
@@ -460,6 +462,10 @@ class TestPreflightCompression:
)
assert result["completed"] is True
assert result["final_response"] == "After preflight"
assert any(
ev == "lifecycle" and "Preflight compression" in msg
for ev, msg in status_messages
)
def test_no_preflight_when_under_threshold(self, agent):
"""When history fits within context, no preflight compression needed."""
+31
View File
@@ -263,3 +263,34 @@ class TestGetToolCallIdStatic:
def test_object_without_id_attr(self):
tc = types.SimpleNamespace()
assert AIAgent._get_tool_call_id_static(tc) == ""
# ---------------------------------------------------------------------------
# _get_tool_call_name_static
# ---------------------------------------------------------------------------
class TestGetToolCallNameStatic:
def test_dict_with_valid_name(self):
assert AIAgent._get_tool_call_name_static(
{"id": "call_1", "function": {"name": "terminal", "arguments": "{}"}}
) == "terminal"
def test_dict_with_missing_function(self):
assert AIAgent._get_tool_call_name_static({"id": "call_1"}) == ""
def test_dict_with_none_function(self):
assert AIAgent._get_tool_call_name_static({"id": "call_1", "function": None}) == ""
def test_dict_with_none_name(self):
assert AIAgent._get_tool_call_name_static(
{"function": {"name": None, "arguments": "{}"}}
) == ""
def test_object_with_valid_name(self):
tc = make_tc("read_file")
assert AIAgent._get_tool_call_name_static(tc) == "read_file"
def test_object_without_function_attr(self):
tc = types.SimpleNamespace(id="call_1")
assert AIAgent._get_tool_call_name_static(tc) == ""
+77
View File
@@ -2181,6 +2181,83 @@ class TestHandleMaxIterations:
kwargs = agent.client.chat.completions.create.call_args.kwargs
assert "reasoning" not in kwargs.get("extra_body", {})
def test_summary_request_removes_orphan_tool_result(self, agent):
"""Regression: max-iterations summary request must NOT contain
orphan tool results (tool_call_id with no matching assistant tool_call)."""
resp = _mock_response(content="Summary of work done.")
agent.client.chat.completions.create.return_value = resp
agent._cached_system_prompt = "You are helpful."
messages = [
{"role": "user", "content": "Analyze finance-data-router"},
{"role": "assistant", "content": "[Session Arc Summary] ..."},
{"role": "tool", "tool_call_id": "call_cfedFhJjGmu1RvRc1OUC38j8", "content": "file content here"},
{"role": "assistant", "tool_calls": [{"id": "call_8fXBXsT592Vpvm7wnW4obPEu", "function": {"name": "patch", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "call_8fXBXsT592Vpvm7wnW4obPEu", "content": "patch result"},
{"role": "assistant", "content": "Done."},
]
result = agent._handle_max_iterations(messages, 120)
assert result == "Summary of work done."
kwargs = agent.client.chat.completions.create.call_args.kwargs
sent_msgs = kwargs.get("messages", [])
orphan_ids = [
m.get("tool_call_id") for m in sent_msgs
if m.get("role") == "tool" and m.get("tool_call_id") == "call_cfedFhJjGmu1RvRc1OUC38j8"
]
assert len(orphan_ids) == 0, f"Orphan tool result still present: {orphan_ids}"
def test_summary_request_inserts_stub_for_missing_tool_result(self, agent):
"""If an assistant tool_call has no matching tool result in the
summary request, a stub must be inserted to satisfy the API contract."""
resp = _mock_response(content="Summary")
agent.client.chat.completions.create.return_value = resp
agent._cached_system_prompt = "You are helpful."
messages = [
{"role": "user", "content": "do stuff"},
{"role": "assistant", "tool_calls": [{"id": "call_no_result", "function": {"name": "terminal", "arguments": "{}"}}]},
{"role": "assistant", "content": "Continuing..."},
]
result = agent._handle_max_iterations(messages, 60)
assert result == "Summary"
kwargs = agent.client.chat.completions.create.call_args.kwargs
sent_msgs = kwargs.get("messages", [])
stub_ids = [
m.get("tool_call_id") for m in sent_msgs
if m.get("role") == "tool" and m.get("tool_call_id") == "call_no_result"
]
assert len(stub_ids) >= 1, f"No stub result for assistant tool_call: {stub_ids}"
def test_summary_omits_provider_preferences_for_non_openrouter(self, agent):
agent.base_url = "https://api.openai.com/v1"
agent._base_url_lower = agent.base_url.lower()
agent.provider = "openai"
agent.providers_allowed = ["Anthropic"]
agent.client.chat.completions.create.return_value = _mock_response(content="Summary")
agent._cached_system_prompt = "You are helpful."
result = agent._handle_max_iterations([{"role": "user", "content": "do stuff"}], 60)
assert result == "Summary"
kwargs = agent.client.chat.completions.create.call_args.kwargs
assert "provider" not in kwargs.get("extra_body", {})
def test_summary_keeps_provider_preferences_for_openrouter(self, agent):
agent.base_url = "https://openrouter.ai/api/v1"
agent._base_url_lower = agent.base_url.lower()
agent.provider = "openrouter"
agent.providers_allowed = ["Anthropic"]
agent.client.chat.completions.create.return_value = _mock_response(content="Summary")
agent._cached_system_prompt = "You are helpful."
result = agent._handle_max_iterations([{"role": "user", "content": "do stuff"}], 60)
assert result == "Summary"
kwargs = agent.client.chat.completions.create.call_args.kwargs
assert kwargs["extra_body"]["provider"]["only"] == ["Anthropic"]
def test_codex_summary_sanitizes_orphan_tool_results(self, agent):
agent.api_mode = "codex_responses"
agent.provider = "openai-codex"
+74 -5
View File
@@ -64,10 +64,23 @@ class TestCoerceNumber:
def test_scientific_notation(self):
assert _coerce_number("1e5") == 100000
def test_inf_stays_string_for_integer_only(self):
"""Infinity should not be converted to int."""
def test_inf_stays_string(self):
"""Infinity is not JSON-serializable, so it should stay as string."""
result = _coerce_number("inf")
assert result == "inf"
assert isinstance(result, str)
def test_negative_inf_stays_string(self):
"""Negative infinity should also stay as string."""
result = _coerce_number("-inf")
assert result == "-inf"
assert isinstance(result, str)
def test_nan_stays_string(self):
"""NaN is not JSON-serializable, so it should stay as string."""
result = _coerce_number("nan")
assert result == "nan"
assert isinstance(result, str)
def test_negative_float(self):
assert _coerce_number("-2.5") == -2.5
@@ -284,13 +297,69 @@ class TestCoerceToolArgs:
result = coerce_tool_args("test_tool", args)
assert result["stages"] is None
def test_invalid_json_array_preserved_as_string(self):
"""If the string isn't valid JSON, pass it through — let the tool decide."""
def test_invalid_json_array_wrapped_in_single_element_list(self):
"""A bare string gets wrapped into ``[value]`` when the schema says array.
Open-weight models (DeepSeek, Qwen, GLM) sometimes emit
``{"urls": "https://a.com"}`` when the tool expects a list.
Wrapping produces a valid dispatch rather than a confusing tool
failure. This supersedes the earlier "pass the string through"
behavior no real tool handles a bare string as an array
gracefully.
"""
schema = self._mock_schema({"items": {"type": "array"}})
with patch("model_tools.registry.get_schema", return_value=schema):
args = {"items": "not-json"}
result = coerce_tool_args("test_tool", args)
assert result["items"] == "not-json"
assert result["items"] == ["not-json"]
def test_bare_string_wrapped_as_array(self):
"""Bare string on array field → single-element list."""
schema = self._mock_schema({"urls": {"type": "array", "items": {"type": "string"}}})
with patch("model_tools.registry.get_schema", return_value=schema):
args = {"urls": "https://a.com"}
result = coerce_tool_args("test_tool", args)
assert result["urls"] == ["https://a.com"]
def test_bare_int_wrapped_as_array(self):
"""Bare non-string scalars (int, bool, float) also get wrapped."""
schema = self._mock_schema({"ids": {"type": "array", "items": {"type": "integer"}}})
with patch("model_tools.registry.get_schema", return_value=schema):
args = {"ids": 5}
result = coerce_tool_args("test_tool", args)
assert result["ids"] == [5]
def test_bare_dict_wrapped_as_array(self):
"""Bare dict on array field → single-element list."""
schema = self._mock_schema({"items": {"type": "array"}})
with patch("model_tools.registry.get_schema", return_value=schema):
args = {"items": {"a": 1}}
result = coerce_tool_args("test_tool", args)
assert result["items"] == [{"a": 1}]
def test_none_on_array_field_preserved(self):
"""``None`` is never wrapped — tools with defaults handle it."""
schema = self._mock_schema({"items": {"type": "array"}})
with patch("model_tools.registry.get_schema", return_value=schema):
args = {"items": None}
result = coerce_tool_args("test_tool", args)
assert result["items"] is None
def test_existing_list_passthrough(self):
"""An already-valid list is not touched."""
schema = self._mock_schema({"items": {"type": "array"}})
with patch("model_tools.registry.get_schema", return_value=schema):
args = {"items": ["a", "b"]}
result = coerce_tool_args("test_tool", args)
assert result["items"] == ["a", "b"]
def test_json_encoded_array_still_parses(self):
"""JSON-encoded strings still parse (not double-wrapped)."""
schema = self._mock_schema({"items": {"type": "array"}})
with patch("model_tools.registry.get_schema", return_value=schema):
args = {"items": '["a","b"]'}
result = coerce_tool_args("test_tool", args)
assert result["items"] == ["a", "b"]
def test_extra_args_without_schema_left_alone(self):
"""Args not in the schema properties are not touched."""
@@ -96,6 +96,7 @@ def test_marker_message_inserted_when_missing():
assert repaired == 1
assert messages[1] == {
"role": "tool",
"name": "read_file",
"tool_call_id": "call_1",
"content": marker,
}
+14 -7
View File
@@ -30,7 +30,7 @@ class TestWrapCommand:
wrapped = env._wrap_command("echo hello", "/tmp")
assert "source" in wrapped
assert "cd /tmp" in wrapped or "cd '/tmp'" in wrapped
assert "cd -- /tmp" in wrapped or "cd -- '/tmp'" in wrapped
assert "eval 'echo hello'" in wrapped
assert "__hermes_ec=$?" in wrapped
assert "export -p >" in wrapped
@@ -57,24 +57,31 @@ class TestWrapCommand:
env._snapshot_ready = True
wrapped = env._wrap_command("ls", "~")
assert "cd ~" in wrapped
assert "cd '~'" not in wrapped
assert "cd -- ~" in wrapped
assert "cd -- '~'" not in wrapped
def test_tilde_subpath_with_spaces_uses_home_and_quotes_suffix(self):
env = _TestableEnv()
env._snapshot_ready = True
wrapped = env._wrap_command("ls", "~/my repo")
assert "cd $HOME/'my repo'" in wrapped
assert "cd ~/my repo" not in wrapped
assert "cd -- $HOME/'my repo'" in wrapped
assert "cd -- ~/my repo" not in wrapped
def test_tilde_slash_maps_to_home(self):
env = _TestableEnv()
env._snapshot_ready = True
wrapped = env._wrap_command("ls", "~/")
assert "cd $HOME" in wrapped
assert "cd ~/" not in wrapped
assert "cd -- $HOME" in wrapped
assert "cd -- ~/" not in wrapped
def test_hyphen_prefixed_workdir_is_passed_after_double_dash(self):
env = _TestableEnv()
env._snapshot_ready = True
wrapped = env._wrap_command("pwd", "-demo")
assert "builtin cd -- -demo || exit 126" in wrapped
def test_cd_failure_exit_126(self):
env = _TestableEnv()
@@ -209,6 +209,13 @@ class TestFindAgentBrowser:
class TestBrowserRequirements:
def test_cdp_override_does_not_require_agent_browser_cli(self, monkeypatch):
monkeypatch.setenv("BROWSER_CDP_URL", "ws://127.0.0.1:9222/devtools/browser/test")
monkeypatch.setattr("tools.browser_tool._is_camofox_mode", lambda: False)
monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: (_ for _ in ()).throw(FileNotFoundError("not found")))
assert check_browser_requirements() is True
def test_termux_requires_real_agent_browser_install_not_npx_fallback(self, monkeypatch):
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
+98 -6
View File
@@ -821,7 +821,9 @@ class TestDelegationCredentialResolution(unittest.TestCase):
self.assertEqual(creds["api_key"], "local-key")
self.assertEqual(creds["api_mode"], "chat_completions")
def test_direct_endpoint_falls_back_to_openai_api_key_env(self):
def test_direct_endpoint_returns_none_api_key_when_not_configured(self):
# When base_url is set without api_key, api_key should be None so
# _build_child_agent inherits the parent's key (effective_api_key = override or parent).
parent = _make_mock_parent(depth=0)
cfg = {
"model": "qwen2.5-coder",
@@ -829,10 +831,11 @@ class TestDelegationCredentialResolution(unittest.TestCase):
}
with patch.dict(os.environ, {"OPENAI_API_KEY": "env-openai-key"}, clear=False):
creds = _resolve_delegation_credentials(cfg, parent)
self.assertEqual(creds["api_key"], "env-openai-key")
self.assertIsNone(creds["api_key"])
self.assertEqual(creds["provider"], "custom")
def test_direct_endpoint_does_not_fall_back_to_openrouter_api_key_env(self):
def test_direct_endpoint_no_raise_when_only_provider_env_key_present(self):
# Even if OPENAI_API_KEY is absent, no ValueError — _build_child_agent uses parent key.
parent = _make_mock_parent(depth=0)
cfg = {
"model": "qwen2.5-coder",
@@ -846,9 +849,9 @@ class TestDelegationCredentialResolution(unittest.TestCase):
},
clear=False,
):
with self.assertRaises(ValueError) as ctx:
_resolve_delegation_credentials(cfg, parent)
self.assertIn("OPENAI_API_KEY", str(ctx.exception))
creds = _resolve_delegation_credentials(cfg, parent)
self.assertIsNone(creds["api_key"])
self.assertEqual(creds["provider"], "custom")
@patch("hermes_cli.runtime_provider.resolve_runtime_provider")
def test_nous_provider_resolves_nous_credentials(self, mock_resolve):
@@ -977,6 +980,48 @@ class TestDelegationProviderIntegration(unittest.TestCase):
self.assertNotEqual(kwargs["base_url"], parent.base_url)
self.assertNotEqual(kwargs["api_key"], parent.api_key)
@patch("tools.delegate_tool._load_config")
@patch("tools.delegate_tool._resolve_delegation_credentials")
def test_provider_override_clears_parent_openrouter_filters(
self, mock_creds, mock_cfg
):
"""Delegated provider should not inherit parent provider-preference filters."""
mock_cfg.return_value = {
"max_iterations": 45,
"model": "google/gemini-3-flash-preview",
"provider": "openrouter",
}
mock_creds.return_value = {
"model": "google/gemini-3-flash-preview",
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "sk-or-key",
"api_mode": "chat_completions",
}
parent = _make_mock_parent(depth=0)
parent.providers_allowed = ["anthropic/claude-3.5-sonnet"]
parent.providers_ignored = ["openai/gpt-4o-mini"]
parent.providers_order = ["google/gemini-2.5-pro"]
parent.provider_sort = "price"
with patch("run_agent.AIAgent") as MockAgent:
mock_child = MagicMock()
mock_child.run_conversation.return_value = {
"final_response": "done",
"completed": True,
"api_calls": 1,
}
MockAgent.return_value = mock_child
delegate_task(goal="Cross-provider test", parent_agent=parent)
_, kwargs = MockAgent.call_args
self.assertEqual(kwargs["provider"], "openrouter")
self.assertIsNone(kwargs["providers_allowed"])
self.assertIsNone(kwargs["providers_ignored"])
self.assertIsNone(kwargs["providers_order"])
self.assertIsNone(kwargs["provider_sort"])
@patch("tools.delegate_tool._load_config")
@patch("tools.delegate_tool._resolve_delegation_credentials")
def test_direct_endpoint_credentials_reach_child_agent(self, mock_creds, mock_cfg):
@@ -2403,5 +2448,52 @@ class TestSubagentApprovalCallback(unittest.TestCase):
self.assertIsNone(_get_approval_callback())
class TestFallbackModelInheritance(unittest.TestCase):
"""Subagents must inherit the parent's fallback provider chain."""
def test_child_inherits_fallback_chain(self):
"""_build_child_agent passes parent._fallback_chain as fallback_model."""
parent = _make_mock_parent(depth=0)
fallback_entry = {"provider": "openrouter", "model": "gpt-4o-mini", "api_key": "sk-or-x"}
parent._fallback_chain = [fallback_entry]
with patch("run_agent.AIAgent") as MockAgent:
MockAgent.return_value = MagicMock()
_build_child_agent(
task_index=0,
goal="test fallback inheritance",
context=None,
toolsets=None,
model=None,
max_iterations=10,
parent_agent=parent,
task_count=1,
)
_, kwargs = MockAgent.call_args
self.assertEqual(kwargs["fallback_model"], [fallback_entry])
def test_child_gets_no_fallback_when_parent_chain_empty(self):
"""When parent._fallback_chain is empty, fallback_model is None."""
parent = _make_mock_parent(depth=0)
parent._fallback_chain = []
with patch("run_agent.AIAgent") as MockAgent:
MockAgent.return_value = MagicMock()
_build_child_agent(
task_index=0,
goal="test no fallback",
context=None,
toolsets=None,
model=None,
max_iterations=10,
parent_agent=parent,
task_count=1,
)
_, kwargs = MockAgent.call_args
self.assertIsNone(kwargs["fallback_model"])
if __name__ == "__main__":
unittest.main()
+52
View File
@@ -271,6 +271,58 @@ class TestShellFileOpsHelpers:
ops = ShellFileOperations(env)
assert ops.cwd == "/"
def test_read_file_strips_leaked_terminal_fence_markers(self, mock_env):
leaked = (
"'\x07__HERMES_FENCE_a9f7b3__\x1b]0;cat "
"'/tmp/test/a.py' 2> /dev/null\x07\n"
"print('ok')\n"
"__HERMES_FENCE_a9f7b3__\x07'\n"
)
def side_effect(command, **kwargs):
if command.startswith("wc -c"):
return {"output": "12\n", "returncode": 0}
if command.startswith("head -c"):
return {"output": "print('ok')\n", "returncode": 0}
if command.startswith("sed -n"):
return {"output": leaked, "returncode": 0}
if command.startswith("wc -l"):
return {"output": "1\n", "returncode": 0}
return {"output": "", "returncode": 0}
mock_env.execute.side_effect = side_effect
ops = ShellFileOperations(mock_env)
result = ops.read_file("/tmp/test/a.py")
assert result.error is None
assert "HERMES_FENCE" not in result.content
assert "\x1b]" not in result.content
assert "\x07" not in result.content
assert " 1|print('ok')" in result.content
def test_read_file_raw_strips_leaked_terminal_fence_markers(self, mock_env):
leaked = (
"__HERMES_FENCE_a9f7b3__\x07'\n"
"alpha\n"
"\x1b]0;cat '/tmp/test/a.txt'\x07__HERMES_FENCE_a9f7b3__\n"
)
def side_effect(command, **kwargs):
if command.startswith("wc -c"):
return {"output": "6\n", "returncode": 0}
if command.startswith("head -c"):
return {"output": "alpha\n", "returncode": 0}
if command.startswith("cat "):
return {"output": leaked, "returncode": 0}
return {"output": "", "returncode": 0}
mock_env.execute.side_effect = side_effect
ops = ShellFileOperations(mock_env)
result = ops.read_file_raw("/tmp/test/a.txt")
assert result.error is None
assert result.content == "alpha\n"
class TestSearchPathValidation:
"""Test that search() returns an error for non-existent paths."""
@@ -0,0 +1,35 @@
"""Tests for delegate heartbeat stale threshold configuration."""
import pytest
class TestHeartbeatStaleThresholds:
"""Verify the heartbeat stale threshold constants are correct."""
def test_idle_cycles_value(self):
"""IDLE stale cycles should be 15 (15 * 30s = 450s)."""
from tools.delegate_tool import _HEARTBEAT_STALE_CYCLES_IDLE
assert _HEARTBEAT_STALE_CYCLES_IDLE == 15
def test_in_tool_cycles_value(self):
"""IN_TOOL stale cycles should be 40 (40 * 30s = 1200s)."""
from tools.delegate_tool import _HEARTBEAT_STALE_CYCLES_IN_TOOL
assert _HEARTBEAT_STALE_CYCLES_IN_TOOL == 40
def test_idle_timeout_seconds(self):
"""Effective idle stale timeout: 15 * 30 = 450s (> typical LLM response time)."""
from tools.delegate_tool import _HEARTBEAT_STALE_CYCLES_IDLE, _HEARTBEAT_INTERVAL
effective = _HEARTBEAT_STALE_CYCLES_IDLE * _HEARTBEAT_INTERVAL
assert effective == 450
assert effective > 300 # Must be > 5 minutes for slow LLM responses
def test_in_tool_timeout_seconds(self):
"""Effective in-tool stale timeout: 40 * 30 = 1200s (= 20 minutes)."""
from tools.delegate_tool import _HEARTBEAT_STALE_CYCLES_IN_TOOL, _HEARTBEAT_INTERVAL
effective = _HEARTBEAT_STALE_CYCLES_IN_TOOL * _HEARTBEAT_INTERVAL
assert effective == 1200
def test_interval_unchanged(self):
"""Heartbeat interval should remain 30s."""
from tools.delegate_tool import _HEARTBEAT_INTERVAL
assert _HEARTBEAT_INTERVAL == 30
+120 -2
View File
@@ -467,8 +467,8 @@ def test_kanban_guidance_in_worker_prompt(monkeypatch, tmp_path):
skip_memory=True,
)
prompt = a._build_system_prompt()
# Header phrase
assert "You are a Kanban worker" in prompt
# Header phrase (identity-free — SOUL.md owns identity, layer 3 is protocol)
assert "Kanban task execution protocol" in prompt
# Lifecycle signals
assert "kanban_show()" in prompt
assert "kanban_complete" in prompt
@@ -492,3 +492,121 @@ def test_kanban_guidance_prompt_size_bounded(monkeypatch, tmp_path):
assert 1_500 < len(KANBAN_GUIDANCE) < 4_096, (
f"KANBAN_GUIDANCE is {len(KANBAN_GUIDANCE)} chars — too short (missing?) or too long"
)
# ---------------------------------------------------------------------------
# Worker task-ownership enforcement (regression tests for #19534)
# ---------------------------------------------------------------------------
#
# A worker process has HERMES_KANBAN_TASK set to its own task id. The
# destructive tools (kanban_complete, kanban_block, kanban_heartbeat)
# must refuse to operate on any OTHER task id, even if the caller
# supplies an explicit `task_id` argument. Workers legitimately call
# kanban_show / kanban_comment / kanban_create / kanban_link on other
# tasks, so those are unrestricted.
#
# Orchestrator profiles (no HERMES_KANBAN_TASK in env) are intentionally
# exempt — their job is routing, and they sometimes close out child
# tasks on behalf of the child.
def test_worker_complete_rejects_foreign_task_id(worker_env):
"""A worker cannot complete a task that isn't its own (#19534)."""
from hermes_cli import kanban_db as kb
conn = kb.connect()
try:
other = kb.create_task(conn, title="sibling")
conn.execute("UPDATE tasks SET status='ready' WHERE id=?", (other,))
conn.commit()
finally:
conn.close()
from tools import kanban_tools as kt
out = kt._handle_complete({"task_id": other, "summary": "HIJACK"})
d = json.loads(out)
assert d.get("ok") is not True
assert "refusing to mutate" in d.get("error", "")
# Sibling task must be untouched.
conn = kb.connect()
try:
assert kb.get_task(conn, other).status == "ready"
finally:
conn.close()
def test_worker_block_rejects_foreign_task_id(worker_env):
"""A worker cannot block a task that isn't its own (#19534)."""
from hermes_cli import kanban_db as kb
conn = kb.connect()
try:
other = kb.create_task(conn, title="sibling")
conn.execute("UPDATE tasks SET status='ready' WHERE id=?", (other,))
conn.commit()
finally:
conn.close()
from tools import kanban_tools as kt
out = kt._handle_block({"task_id": other, "reason": "evil"})
d = json.loads(out)
assert "refusing to mutate" in d.get("error", "")
conn = kb.connect()
try:
assert kb.get_task(conn, other).status == "ready"
finally:
conn.close()
def test_worker_heartbeat_rejects_foreign_task_id(worker_env):
"""A worker cannot heartbeat a task that isn't its own (#19534)."""
from hermes_cli import kanban_db as kb
conn = kb.connect()
try:
other = kb.create_task(conn, title="sibling")
# Put sibling in running state so heartbeat would otherwise succeed.
conn.execute("UPDATE tasks SET status='running' WHERE id=?", (other,))
conn.commit()
finally:
conn.close()
from tools import kanban_tools as kt
out = kt._handle_heartbeat({"task_id": other})
d = json.loads(out)
assert "refusing to mutate" in d.get("error", "")
def test_worker_complete_own_task_still_works(worker_env):
"""The ownership check doesn't break the normal own-task happy path."""
from tools import kanban_tools as kt
# Both implicit (no task_id arg) and explicit (matching env) must work.
out = kt._handle_complete({"task_id": worker_env, "summary": "explicit own"})
d = json.loads(out)
assert d.get("ok") is True and d.get("task_id") == worker_env
def test_orchestrator_complete_any_task_allowed(monkeypatch, tmp_path):
"""Orchestrator profiles (no HERMES_KANBAN_TASK) can still complete
any task via explicit task_id. The check only applies to workers."""
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
from pathlib import Path as _P
monkeypatch.setattr(_P, "home", lambda: tmp_path)
from hermes_cli import kanban_db as kb
kb._INITIALIZED_PATHS.clear()
kb.init_db()
conn = kb.connect()
try:
tid = kb.create_task(conn, title="child to close out")
conn.execute("UPDATE tasks SET status='ready' WHERE id=?", (tid,))
conn.commit()
finally:
conn.close()
from tools import kanban_tools as kt
out = kt._handle_complete({"task_id": tid, "summary": "orchestrator close"})
d = json.loads(out)
assert d.get("ok") is True and d.get("task_id") == tid
+3
View File
@@ -440,6 +440,7 @@ class TestBuildOAuthAuthNonInteractive:
def test_build_client_metadata_basic():
"""_build_client_metadata returns metadata with expected defaults."""
pytest.importorskip("mcp")
from tools.mcp_oauth import _build_client_metadata, _configure_callback_port
cfg = {"client_name": "Test Client"}
@@ -453,6 +454,7 @@ def test_build_client_metadata_basic():
def test_build_client_metadata_without_secret_is_public():
"""Without client_secret, token endpoint auth is 'none' (public client)."""
pytest.importorskip("mcp")
from tools.mcp_oauth import _build_client_metadata, _configure_callback_port
cfg = {}
@@ -463,6 +465,7 @@ def test_build_client_metadata_without_secret_is_public():
def test_build_client_metadata_with_secret_is_confidential():
"""With client_secret, token endpoint auth is 'client_secret_post'."""
pytest.importorskip("mcp")
from tools.mcp_oauth import _build_client_metadata, _configure_callback_port
cfg = {"client_secret": "shh"}
@@ -46,6 +46,13 @@ def test_is_session_expired_detects_session_not_found():
assert _is_session_expired_error(RuntimeError("Unknown session: abc123")) is True
def test_is_session_expired_detects_session_terminated():
"""Remote Playwright MCP reports transport loss as ``Session terminated``."""
from tools.mcp_tool import _is_session_expired_error
assert _is_session_expired_error(RuntimeError("Session terminated")) is True
def test_is_session_expired_is_case_insensitive():
"""Match uses lower-cased comparison so servers that emit the
message in different cases (SDK formatter quirks) still trigger."""
+62
View File
@@ -498,3 +498,65 @@ class TestSessionSearch:
assert result["count"] == 0
assert result["results"] == []
assert result["sessions_searched"] == 0
def test_source_from_resolved_parent_not_fts5_child(self):
"""source in output must reflect the resolved parent session, not the child that matched FTS5.
Regression test for #15909: when a delegation child session (source='telegram')
resolves to a parent (source='api_server'), the result entry must report
'api_server', not 'telegram'.
"""
from unittest.mock import MagicMock, AsyncMock, patch as _patch
from tools.session_search_tool import session_search
mock_db = MagicMock()
# FTS5 hit is in the child delegation session which carries source='telegram'
mock_db.search_messages.return_value = [
{
"session_id": "child_sid",
"content": "hello world",
"source": "telegram", # child session source — wrong value to surface
"session_started": 1709400000,
"model": "gpt-4o-mini",
},
]
def _get_session(session_id):
if session_id == "child_sid":
return {
"id": "child_sid",
"parent_session_id": "parent_sid",
"source": "telegram",
"started_at": 1709400000,
"model": "gpt-4o-mini",
}
if session_id == "parent_sid":
return {
"id": "parent_sid",
"parent_session_id": None,
"source": "api_server", # correct parent source
"started_at": 1709300000,
"model": "gpt-4o-mini",
}
return None
mock_db.get_session.side_effect = _get_session
mock_db.get_messages_as_conversation.return_value = [
{"role": "user", "content": "hello world"},
{"role": "assistant", "content": "hi there"},
]
with _patch(
"tools.session_search_tool.async_call_llm",
new_callable=AsyncMock,
side_effect=RuntimeError("no provider"),
):
result = json.loads(session_search(query="hello world", db=mock_db))
assert result["success"] is True
assert result["count"] == 1
entry = result["results"][0]
assert entry["session_id"] == "parent_sid", "should report resolved parent session ID"
assert entry["source"] == "api_server", (
f"source should be parent's 'api_server', got {entry['source']!r}"
)
+31
View File
@@ -531,10 +531,41 @@ class TestSkillManageDispatcher:
assert result["success"] is False
def test_full_create_via_dispatcher(self, tmp_path):
"""Foreground create does NOT mark the skill as agent-created.
Skills created by user-directed foreground turns belong to the user;
only the background self-improvement review fork should mark its
own sediment as agent-created (so the curator can later consolidate
or prune it).
"""
with _skill_dir(tmp_path):
raw = skill_manage(action="create", name="test-skill", content=VALID_SKILL_CONTENT)
from tools.skill_usage import load_usage
usage = load_usage()
result = json.loads(raw)
assert result["success"] is True
# No provenance marker on a foreground create — record either missing
# entirely (telemetry best-effort) or present with created_by unset.
rec = usage.get("test-skill") or {}
assert rec.get("created_by") in (None, "", False)
def test_create_from_background_review_marks_agent_created(self, tmp_path):
"""Background-review fork creates ARE marked as agent-created."""
from tools.skill_provenance import set_current_write_origin, BACKGROUND_REVIEW
token = set_current_write_origin(BACKGROUND_REVIEW)
try:
with _skill_dir(tmp_path):
raw = skill_manage(
action="create", name="review-sediment", content=VALID_SKILL_CONTENT
)
from tools.skill_usage import load_usage
usage = load_usage()
finally:
from tools.skill_provenance import reset_current_write_origin
reset_current_write_origin(token)
result = json.loads(raw)
assert result["success"] is True
assert usage["review-sediment"]["created_by"] == "agent"
def test_delete_via_dispatcher_threads_absorbed_into(self, tmp_path):
# Dispatcher must plumb absorbed_into through to _delete_skill so the
+102
View File
@@ -0,0 +1,102 @@
"""Tests for tools/skill_provenance.py — write-origin ContextVar."""
import contextvars
import pytest
def test_default_origin_is_foreground():
from tools.skill_provenance import get_current_write_origin
# In a fresh ContextVar context, default kicks in.
ctx = contextvars.copy_context()
origin = ctx.run(get_current_write_origin)
assert origin == "foreground"
def test_set_and_get_origin():
from tools.skill_provenance import (
set_current_write_origin,
reset_current_write_origin,
get_current_write_origin,
)
token = set_current_write_origin("background_review")
try:
assert get_current_write_origin() == "background_review"
finally:
reset_current_write_origin(token)
def test_reset_restores_prior_origin():
from tools.skill_provenance import (
set_current_write_origin,
reset_current_write_origin,
get_current_write_origin,
)
outer = set_current_write_origin("assistant_tool")
try:
inner = set_current_write_origin("background_review")
try:
assert get_current_write_origin() == "background_review"
finally:
reset_current_write_origin(inner)
assert get_current_write_origin() == "assistant_tool"
finally:
reset_current_write_origin(outer)
def test_is_background_review_truthy_only_for_review():
from tools.skill_provenance import (
set_current_write_origin,
reset_current_write_origin,
is_background_review,
BACKGROUND_REVIEW,
)
for origin, expected in (
("foreground", False),
("assistant_tool", False),
("random_other_value", False),
(BACKGROUND_REVIEW, True),
):
token = set_current_write_origin(origin)
try:
assert is_background_review() is expected, (
f"is_background_review() wrong for origin={origin!r}"
)
finally:
reset_current_write_origin(token)
def test_empty_origin_falls_back_to_foreground():
from tools.skill_provenance import (
set_current_write_origin,
reset_current_write_origin,
get_current_write_origin,
)
token = set_current_write_origin("")
try:
# Empty is coerced to "foreground" at the set() boundary.
assert get_current_write_origin() == "foreground"
finally:
reset_current_write_origin(token)
def test_context_isolation_between_copies():
"""ContextVar scoping: modifications in one copy do not leak out."""
from tools.skill_provenance import (
set_current_write_origin,
get_current_write_origin,
BACKGROUND_REVIEW,
)
# Start at the module default.
original = get_current_write_origin()
def _run_in_copy():
set_current_write_origin(BACKGROUND_REVIEW)
return get_current_write_origin()
ctx = contextvars.copy_context()
inside = ctx.run(_run_in_copy)
assert inside == BACKGROUND_REVIEW
# Parent context unaffected.
assert get_current_write_origin() == original
+25 -7
View File
@@ -194,10 +194,11 @@ def test_forget_removes_record(skills_home):
# ---------------------------------------------------------------------------
def test_agent_created_excludes_bundled(skills_home):
from tools.skill_usage import list_agent_created_skill_names
from tools.skill_usage import list_agent_created_skill_names, mark_agent_created
skills_dir = skills_home / "skills"
_write_skill(skills_dir, "bundled-skill", category="github")
_write_skill(skills_dir, "my-skill")
mark_agent_created("my-skill")
# Seed a bundled manifest marking bundled-skill as upstream
(skills_dir / ".bundled_manifest").write_text(
"bundled-skill:abc123\n", encoding="utf-8",
@@ -208,10 +209,11 @@ def test_agent_created_excludes_bundled(skills_home):
def test_agent_created_excludes_hub_installed(skills_home):
from tools.skill_usage import list_agent_created_skill_names
from tools.skill_usage import list_agent_created_skill_names, mark_agent_created
skills_dir = skills_home / "skills"
_write_skill(skills_dir, "hub-skill")
_write_skill(skills_dir, "my-skill")
mark_agent_created("my-skill")
hub_dir = skills_dir / ".hub"
hub_dir.mkdir()
(hub_dir / "lock.json").write_text(
@@ -238,9 +240,10 @@ def test_is_agent_created(skills_home):
def test_agent_created_skips_archive_and_hub_dirs(skills_home):
from tools.skill_usage import list_agent_created_skill_names
from tools.skill_usage import list_agent_created_skill_names, mark_agent_created
skills_dir = skills_home / "skills"
_write_skill(skills_dir, "real-skill")
mark_agent_created("real-skill")
# Dot-prefixed dirs must be ignored even if they contain SKILL.md
archive = skills_dir / ".archive" / "old-skill"
archive.mkdir(parents=True)
@@ -368,27 +371,41 @@ def test_archive_collision_gets_suffix(skills_home):
# Reporting
# ---------------------------------------------------------------------------
def test_agent_created_report_includes_defaults(skills_home):
from tools.skill_usage import agent_created_report, bump_view
def test_agent_created_report_includes_marked_skills_with_defaults(skills_home):
from tools.skill_usage import agent_created_report, bump_view, mark_agent_created
skills_dir = skills_home / "skills"
_write_skill(skills_dir, "a")
_write_skill(skills_dir, "b")
mark_agent_created("a")
mark_agent_created("b")
bump_view("a")
rows = agent_created_report()
by_name = {r["name"]: r for r in rows}
assert "a" in by_name and "b" in by_name
assert by_name["a"]["view_count"] == 1
# b has no usage record yet — must still appear with defaults
# b has only the provenance marker — activity fields still default.
assert by_name["b"]["view_count"] == 0
assert by_name["b"]["state"] == "active"
def test_manual_skill_with_usage_is_not_curator_managed(skills_home):
from tools.skill_usage import agent_created_report, bump_view, list_agent_created_skill_names
skills_dir = skills_home / "skills"
_write_skill(skills_dir, "manual-skill")
bump_view("manual-skill")
assert "manual-skill" not in list_agent_created_skill_names()
assert "manual-skill" not in {r["name"] for r in agent_created_report()}
def test_agent_created_report_excludes_bundled_and_hub(skills_home):
from tools.skill_usage import agent_created_report
from tools.skill_usage import agent_created_report, mark_agent_created
skills_dir = skills_home / "skills"
_write_skill(skills_dir, "mine")
_write_skill(skills_dir, "bundled")
_write_skill(skills_dir, "hubbed")
mark_agent_created("mine")
(skills_dir / ".bundled_manifest").write_text("bundled:abc\n", encoding="utf-8")
hub = skills_dir / ".hub"
hub.mkdir()
@@ -414,6 +431,7 @@ def test_agent_created_report_derives_activity_from_view_and_patch(skills_home,
])
monkeypatch.setattr(skill_usage, "_now_iso", lambda: next(timestamps))
skill_usage.mark_agent_created("mine")
skill_usage.bump_view("mine")
skill_usage.bump_patch("mine")
+63
View File
@@ -901,6 +901,69 @@ class TestCheckForSkillUpdates:
assert bundle_content_hash(bundle) == content_hash(skill_dir)
def test_bundle_content_hash_accepts_binary_files(self):
bundle = SkillBundle(
name="demo-binary-skill",
files={
"SKILL.md": "# Demo\n",
"assets/logo.png": b"\x89PNG\r\n\x1a\nbinary",
},
source="github",
identifier="owner/repo/demo-binary-skill",
trust_level="community",
)
digest = bundle_content_hash(bundle)
assert digest.startswith("sha256:")
def test_bundle_content_hash_bytes_matches_str_equivalent(self):
"""Bytes content must hash identically to its str-decoded form."""
text_bundle = SkillBundle(
name="demo-skill",
files={
"SKILL.md": "same content",
"references/checklist.md": "- [ ] security\n",
},
source="github",
identifier="owner/repo/demo-skill",
trust_level="community",
)
bytes_bundle = SkillBundle(
name="demo-skill",
files={
"SKILL.md": b"same content",
"references/checklist.md": b"- [ ] security\n",
},
source="github",
identifier="owner/repo/demo-skill",
trust_level="community",
)
assert bundle_content_hash(bytes_bundle) == bundle_content_hash(text_bundle)
def test_bundle_content_hash_mixed_matches_on_disk(self, tmp_path):
"""In-memory bundle hash must equal on-disk content_hash for mixed bytes+str."""
from tools.skills_guard import content_hash
bundle = SkillBundle(
name="demo-skill",
files={
"SKILL.md": b"# Demo Skill\n",
"references/checklist.md": "- [ ] security\n",
},
source="github",
identifier="owner/repo/demo-skill",
trust_level="community",
)
skill_dir = tmp_path / "demo-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_bytes(b"# Demo Skill\n")
(skill_dir / "references").mkdir()
(skill_dir / "references" / "checklist.md").write_text("- [ ] security\n")
assert bundle_content_hash(bundle) == content_hash(skill_dir)
def test_reports_update_when_remote_hash_differs(self):
lock = MagicMock()
lock.list_installed.return_value = [{
+15 -2
View File
@@ -516,12 +516,25 @@ class TestPerToolThresholds:
except ImportError:
pytest.skip("terminal_tool not importable in test env")
def test_read_file_never_persisted(self):
def test_read_file_result_size_cap(self):
from tools.registry import registry
try:
import tools.file_tools # noqa: F401
val = registry.get_max_result_size("read_file")
assert val == float("inf")
assert val == 100_000
except ImportError:
pytest.skip("file_tools not importable in test env")
def test_read_file_registry_cap_is_100k(self):
"""Regression test: read_file must have a 100_000 char registry cap (Layer 2 safety net)."""
from tools.registry import registry
try:
import tools.file_tools # noqa: F401
val = registry.get_max_result_size("read_file")
assert val == 100_000, (
f"read_file registry cap must be 100_000, got {val!r}. "
"float('inf') is not allowed — it disables the Layer 2 result-size guard."
)
except ImportError:
pytest.skip("file_tools not importable in test env")
+4
View File
@@ -415,6 +415,10 @@ class TestTranscribeLocalCommand:
# _transcribe_local — additional tests
# ============================================================================
@pytest.mark.skipif(
not __import__("importlib").util.find_spec("faster_whisper"),
reason="faster_whisper not installed",
)
class TestTranscribeLocalExtended:
def test_model_reuse_on_second_call(self, tmp_path):
"""Second call with same model should NOT reload the model."""
+337
View File
@@ -0,0 +1,337 @@
"""Tests for video_analyze tool in tools/vision_tools.py."""
import asyncio
import json
import os
from pathlib import Path
from typing import Awaitable
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from tools.vision_tools import (
_detect_video_mime_type,
_video_to_base64_data_url,
_handle_video_analyze,
_MAX_VIDEO_BASE64_BYTES,
_VIDEO_MIME_TYPES,
_VIDEO_SIZE_WARN_BYTES,
video_analyze_tool,
VIDEO_ANALYZE_SCHEMA,
)
# ---------------------------------------------------------------------------
# _detect_video_mime_type
# ---------------------------------------------------------------------------
class TestDetectVideoMimeType:
"""Extension-based MIME detection for video files."""
def test_mp4(self, tmp_path):
p = tmp_path / "clip.mp4"
p.write_bytes(b"\x00" * 10)
assert _detect_video_mime_type(p) == "video/mp4"
def test_webm(self, tmp_path):
p = tmp_path / "clip.webm"
p.write_bytes(b"\x00" * 10)
assert _detect_video_mime_type(p) == "video/webm"
def test_mov(self, tmp_path):
p = tmp_path / "clip.mov"
p.write_bytes(b"\x00" * 10)
assert _detect_video_mime_type(p) == "video/mov"
def test_avi_fallback_mp4(self, tmp_path):
p = tmp_path / "clip.avi"
p.write_bytes(b"\x00" * 10)
assert _detect_video_mime_type(p) == "video/mp4"
def test_mkv_fallback_mp4(self, tmp_path):
p = tmp_path / "clip.mkv"
p.write_bytes(b"\x00" * 10)
assert _detect_video_mime_type(p) == "video/mp4"
def test_mpeg(self, tmp_path):
p = tmp_path / "clip.mpeg"
p.write_bytes(b"\x00" * 10)
assert _detect_video_mime_type(p) == "video/mpeg"
def test_mpg(self, tmp_path):
p = tmp_path / "clip.mpg"
p.write_bytes(b"\x00" * 10)
assert _detect_video_mime_type(p) == "video/mpeg"
def test_unsupported_extension(self, tmp_path):
p = tmp_path / "clip.flv"
p.write_bytes(b"\x00" * 10)
assert _detect_video_mime_type(p) is None
def test_case_insensitive(self, tmp_path):
p = tmp_path / "clip.MP4"
p.write_bytes(b"\x00" * 10)
assert _detect_video_mime_type(p) == "video/mp4"
# ---------------------------------------------------------------------------
# _video_to_base64_data_url
# ---------------------------------------------------------------------------
class TestVideoToBase64DataUrl:
"""Base64 encoding of video files."""
def test_produces_data_url(self, tmp_path):
p = tmp_path / "test.mp4"
p.write_bytes(b"\x00\x01\x02\x03")
result = _video_to_base64_data_url(p)
assert result.startswith("data:video/mp4;base64,")
def test_custom_mime_type(self, tmp_path):
p = tmp_path / "test.webm"
p.write_bytes(b"\x00\x01\x02\x03")
result = _video_to_base64_data_url(p, mime_type="video/webm")
assert result.startswith("data:video/webm;base64,")
def test_default_mime_for_unknown_ext(self, tmp_path):
p = tmp_path / "test.xyz"
p.write_bytes(b"\x00\x01\x02\x03")
result = _video_to_base64_data_url(p)
# Falls back to video/mp4
assert result.startswith("data:video/mp4;base64,")
# ---------------------------------------------------------------------------
# Schema validation
# ---------------------------------------------------------------------------
class TestVideoAnalyzeSchema:
"""Schema structure is correct."""
def test_schema_name(self):
assert VIDEO_ANALYZE_SCHEMA["name"] == "video_analyze"
def test_schema_has_required_fields(self):
params = VIDEO_ANALYZE_SCHEMA["parameters"]
assert "video_url" in params["properties"]
assert "question" in params["properties"]
assert params["required"] == ["video_url", "question"]
def test_schema_description_mentions_video(self):
assert "video" in VIDEO_ANALYZE_SCHEMA["description"].lower()
# ---------------------------------------------------------------------------
# _handle_video_analyze handler
# ---------------------------------------------------------------------------
class TestHandleVideoAnalyze:
"""Tests for the registry handler wrapper."""
def test_returns_awaitable(self, tmp_path, monkeypatch):
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"\x00" * 100)
monkeypatch.setenv("AUXILIARY_VIDEO_MODEL", "")
monkeypatch.setenv("AUXILIARY_VISION_MODEL", "")
with patch("tools.vision_tools.video_analyze_tool", new_callable=AsyncMock) as mock_tool:
mock_tool.return_value = json.dumps({"success": True, "analysis": "test"})
result = _handle_video_analyze({"video_url": str(video_file), "question": "what is this?"})
# Should return an awaitable (coroutine)
assert asyncio.iscoroutine(result)
# Clean up the unawaited coroutine
result.close()
def test_uses_auxiliary_video_model_env(self, tmp_path, monkeypatch):
monkeypatch.setenv("AUXILIARY_VIDEO_MODEL", "google/gemini-2.5-flash")
monkeypatch.setenv("AUXILIARY_VISION_MODEL", "other-model")
with patch("tools.vision_tools.video_analyze_tool", new_callable=AsyncMock) as mock_tool:
mock_tool.return_value = json.dumps({"success": True, "analysis": "ok"})
asyncio.get_event_loop().run_until_complete(
_handle_video_analyze({"video_url": "/tmp/test.mp4", "question": "test"})
)
args = mock_tool.call_args[0]
assert args[2] == "google/gemini-2.5-flash"
def test_falls_back_to_vision_model_env(self, tmp_path, monkeypatch):
monkeypatch.setenv("AUXILIARY_VIDEO_MODEL", "")
monkeypatch.setenv("AUXILIARY_VISION_MODEL", "google/gemini-flash")
with patch("tools.vision_tools.video_analyze_tool", new_callable=AsyncMock) as mock_tool:
mock_tool.return_value = json.dumps({"success": True, "analysis": "ok"})
asyncio.get_event_loop().run_until_complete(
_handle_video_analyze({"video_url": "/tmp/test.mp4", "question": "test"})
)
args = mock_tool.call_args[0]
assert args[2] == "google/gemini-flash"
# ---------------------------------------------------------------------------
# video_analyze_tool — integration-style tests with mocked LLM
# ---------------------------------------------------------------------------
class TestVideoAnalyzeTool:
"""Core video analysis function tests."""
def _run(self, coro):
return asyncio.get_event_loop().run_until_complete(coro)
def test_local_file_success(self, tmp_path, monkeypatch):
"""Analyze a local video file — happy path."""
video = tmp_path / "demo.mp4"
video.write_bytes(b"\x00" * 1024)
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "A short video showing a demo."
with patch("tools.vision_tools.async_call_llm", new_callable=AsyncMock, return_value=mock_response):
with patch("tools.vision_tools.extract_content_or_reasoning", return_value="A short video showing a demo."):
result = self._run(video_analyze_tool(str(video), "What is this?"))
data = json.loads(result)
assert data["success"] is True
assert "demo" in data["analysis"].lower()
def test_local_file_not_found(self, tmp_path):
"""Non-existent file raises appropriate error."""
result = self._run(video_analyze_tool("/nonexistent/video.mp4", "What?"))
data = json.loads(result)
assert data["success"] is False
assert "invalid video source" in data["analysis"].lower()
def test_unsupported_format(self, tmp_path):
"""Unsupported extension raises error."""
video = tmp_path / "clip.flv"
video.write_bytes(b"\x00" * 100)
result = self._run(video_analyze_tool(str(video), "What is this?"))
data = json.loads(result)
assert data["success"] is False
assert "unsupported video format" in data["analysis"].lower()
def test_video_too_large(self, tmp_path, monkeypatch):
"""Video exceeding max size is rejected."""
video = tmp_path / "huge.mp4"
# Don't actually write 50MB — mock the stat
video.write_bytes(b"\x00" * 100)
# Patch the base64 encoding to return something huge
with patch("tools.vision_tools._video_to_base64_data_url") as mock_encode:
mock_encode.return_value = "data:video/mp4;base64," + "A" * (_MAX_VIDEO_BASE64_BYTES + 1)
result = self._run(video_analyze_tool(str(video), "What?"))
data = json.loads(result)
assert data["success"] is False
assert "too large" in data["analysis"].lower()
def test_interrupt_check(self, tmp_path):
"""Tool respects interrupt flag."""
video = tmp_path / "test.mp4"
video.write_bytes(b"\x00" * 100)
with patch("tools.interrupt.is_interrupted", return_value=True):
result = self._run(video_analyze_tool(str(video), "What?"))
data = json.loads(result)
assert data["success"] is False
def test_empty_response_retries(self, tmp_path):
"""Retries once on empty model response."""
video = tmp_path / "test.mp4"
video.write_bytes(b"\x00" * 100)
call_count = 0
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Video analysis result."
async def fake_llm(**kwargs):
nonlocal call_count
call_count += 1
return mock_response
with patch("tools.vision_tools.async_call_llm", side_effect=fake_llm):
with patch("tools.vision_tools.extract_content_or_reasoning", side_effect=["", "Video analysis result."]):
result = self._run(video_analyze_tool(str(video), "What?"))
data = json.loads(result)
assert data["success"] is True
assert call_count == 2 # Initial call + retry
def test_file_scheme_stripped(self, tmp_path):
"""file:// prefix is stripped correctly."""
video = tmp_path / "test.mp4"
video.write_bytes(b"\x00" * 100)
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "OK"
with patch("tools.vision_tools.async_call_llm", new_callable=AsyncMock, return_value=mock_response):
with patch("tools.vision_tools.extract_content_or_reasoning", return_value="OK"):
result = self._run(video_analyze_tool(f"file://{video}", "What?"))
data = json.loads(result)
assert data["success"] is True
def test_api_message_format(self, tmp_path):
"""Verify the message sent to LLM uses video_url content type."""
video = tmp_path / "test.mp4"
video.write_bytes(b"\x00" * 100)
captured_kwargs = {}
async def capture_llm(**kwargs):
captured_kwargs.update(kwargs)
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "OK"
return mock_response
with patch("tools.vision_tools.async_call_llm", side_effect=capture_llm):
with patch("tools.vision_tools.extract_content_or_reasoning", return_value="OK"):
self._run(video_analyze_tool(str(video), "Describe this"))
messages = captured_kwargs["messages"]
assert len(messages) == 1
content = messages[0]["content"]
assert len(content) == 2
assert content[0]["type"] == "text"
assert content[1]["type"] == "video_url"
assert "video_url" in content[1]
assert content[1]["video_url"]["url"].startswith("data:video/mp4;base64,")
# ---------------------------------------------------------------------------
# Toolset registration
# ---------------------------------------------------------------------------
class TestVideoToolsetRegistration:
"""Verify the tool is registered correctly."""
def test_registered_in_video_toolset(self):
from tools.registry import registry
entry = registry.get_entry("video_analyze")
assert entry is not None
assert entry.toolset == "video"
assert entry.is_async is True
assert entry.emoji == "🎬"
def test_not_in_core_tools(self):
"""video_analyze should NOT be in _HERMES_CORE_TOOLS (default disabled)."""
from toolsets import _HERMES_CORE_TOOLS
assert "video_analyze" not in _HERMES_CORE_TOOLS
def test_in_video_toolset_definition(self):
"""Toolset 'video' should contain video_analyze."""
from toolsets import TOOLSETS
assert "video" in TOOLSETS
assert "video_analyze" in TOOLSETS["video"]["tools"]
+19
View File
@@ -1040,6 +1040,25 @@ class TestDisableVoiceModeReal:
class TestVoiceSpeakResponseReal:
"""Tests _voice_speak_response with real CLI instance."""
def test_async_scheduling_clears_done_before_thread_start(self):
cli = _make_voice_cli(_voice_tts=True)
starts = []
class FakeThread:
def __init__(self, target=None, args=(), daemon=None):
self.target = target
self.args = args
self.daemon = daemon
def start(self):
starts.append(cli._voice_tts_done.is_set())
with patch("cli.threading.Thread", FakeThread):
cli._voice_speak_response_async("Hello")
assert starts == [False]
assert not cli._voice_tts_done.is_set()
@patch("cli._cprint")
def test_early_return_when_tts_off(self, _cp):
cli = _make_voice_cli(_voice_tts=False)
+93
View File
@@ -479,6 +479,99 @@ def test_slash_exec_rejects_skill_commands(server):
assert "skill command" in resp["error"]["message"]
def test_slash_exec_handles_plugin_commands_in_live_gateway(server):
"""Plugin slash commands return normal slash.exec output without using the worker."""
sid = "test-session"
class Worker:
def __init__(self):
self.calls = []
def run(self, cmd):
self.calls.append(cmd)
return f"worker:{cmd}"
worker = Worker()
server._sessions[sid] = {"session_key": sid, "agent": None, "slash_worker": worker}
with patch(
"hermes_cli.plugins.get_plugin_command_handler",
lambda name: (lambda arg: f"plugin:{arg}") if name == "plugin-cmd" else None,
):
resp = server.handle_request({
"id": "r-plugin-slash",
"method": "slash.exec",
"params": {"command": "plugin-cmd hello", "session_id": sid},
})
assert "error" not in resp
assert resp["result"] == {"output": "plugin:hello"}
assert worker.calls == []
def test_slash_exec_plugin_lookup_failure_falls_back_to_worker(server):
"""Plugin discovery failures must not break ordinary slash-worker commands."""
sid = "test-session"
class Worker:
def __init__(self):
self.calls = []
def run(self, cmd):
self.calls.append(cmd)
return f"worker:{cmd}"
worker = Worker()
server._sessions[sid] = {"session_key": sid, "agent": None, "slash_worker": worker}
with patch(
"hermes_cli.plugins.get_plugin_command_handler",
side_effect=RuntimeError("discovery boom"),
):
resp = server.handle_request({
"id": "r-plugin-lookup-failure",
"method": "slash.exec",
"params": {"command": "help", "session_id": sid},
})
assert "error" not in resp
assert resp["result"] == {"output": "worker:help"}
assert worker.calls == ["help"]
def test_slash_exec_plugin_handler_error_returns_output(server):
"""Plugin handler failures return slash output so the TUI does not redispatch."""
sid = "test-session"
class Worker:
def __init__(self):
self.calls = []
def run(self, cmd):
self.calls.append(cmd)
return f"worker:{cmd}"
def handler(arg):
raise RuntimeError(f"handler boom: {arg}")
worker = Worker()
server._sessions[sid] = {"session_key": sid, "agent": None, "slash_worker": worker}
with patch(
"hermes_cli.plugins.get_plugin_command_handler",
lambda name: handler if name == "plugin-cmd" else None,
):
resp = server.handle_request({
"id": "r-plugin-handler-error",
"method": "slash.exec",
"params": {"command": "plugin-cmd hello", "session_id": sid},
})
assert "error" not in resp
assert resp["result"] == {"output": "Plugin command error: handler boom: hello"}
assert worker.calls == []
@pytest.mark.parametrize("cmd", ["retry", "queue hello", "q hello", "steer fix the test", "plan"])
def test_slash_exec_rejects_pending_input_commands(server, cmd):
"""slash.exec must reject commands that use _pending_input in the CLI."""