Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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()"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"}})
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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) == ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 = [{
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user