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

# Conflicts:
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson
2026-05-30 13:19:27 -05:00
157 changed files with 10059 additions and 831 deletions
@@ -94,6 +94,47 @@ def test_install_npm_works_without_extras(tmp_path, monkeypatch):
assert install_targets == ["pyright"]
def test_existing_binary_finds_windows_wrapper_in_staging(tmp_path, monkeypatch):
"""Installed Windows shims should satisfy later status/probe calls."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from agent.lsp import install as install_mod
wrapper = install_mod.hermes_lsp_bin_dir() / "pyright-langserver.cmd"
wrapper.write_text("@echo off\n")
wrapper.chmod(0o755)
monkeypatch.setattr(install_mod, "_is_windows", lambda: True)
monkeypatch.setattr(install_mod.shutil, "which", lambda _name: None)
assert install_mod._existing_binary("pyright-langserver") == str(wrapper)
assert install_mod.detect_status("pyright") == "installed"
def test_install_pip_finds_windows_scripts_launcher(tmp_path, monkeypatch):
"""pip console scripts can land in Scripts/ on native Windows."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from agent.lsp import install as install_mod
def fake_run(cmd, **kwargs):
scripts_dir = install_mod.hermes_lsp_bin_dir().parent / "python-packages" / "Scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
launcher = scripts_dir / "fake-language-server.exe"
launcher.write_text("launcher\n")
launcher.chmod(0o755)
return MagicMock(returncode=0, stderr="")
monkeypatch.setattr(install_mod, "_is_windows", lambda: True)
monkeypatch.setattr(install_mod.subprocess, "run", fake_run)
resolved = install_mod._install_pip("fake-lsp", "fake-language-server")
assert resolved is not None
assert resolved.endswith("fake-language-server.exe")
assert (install_mod.hermes_lsp_bin_dir() / "fake-language-server.exe").exists()
# ---------------------------------------------------------------------------
# Fix 2: ``hermes lsp status`` surfaces shellcheck-missing for bash
# ---------------------------------------------------------------------------
+29
View File
@@ -41,6 +41,8 @@ class TestShouldCompress:
class TestUpdateFromResponse:
def test_updates_fields(self, compressor):
compressor.awaiting_real_usage_after_compression = True
compressor.last_compression_rough_tokens = 90_000
compressor.update_from_response({
"prompt_tokens": 5000,
"completion_tokens": 1000,
@@ -48,12 +50,39 @@ class TestUpdateFromResponse:
})
assert compressor.last_prompt_tokens == 5000
assert compressor.last_completion_tokens == 1000
assert compressor.last_real_prompt_tokens == 5000
assert compressor.last_rough_tokens_when_real_prompt_fit == 90_000
assert compressor.awaiting_real_usage_after_compression is False
def test_missing_fields_default_zero(self, compressor):
compressor.update_from_response({})
assert compressor.last_prompt_tokens == 0
class TestPreflightDeferral:
def test_defers_when_recent_real_usage_fit_and_rough_growth_is_small(self, compressor):
compressor.threshold_tokens = 85_000
compressor.last_real_prompt_tokens = 50_000
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
assert compressor.should_defer_preflight_to_real_usage(93_000) is True
assert compressor.last_rough_tokens_when_real_prompt_fit == 93_000
def test_does_not_defer_when_rough_growth_is_large(self, compressor):
compressor.threshold_tokens = 85_000
compressor.last_real_prompt_tokens = 50_000
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
assert compressor.should_defer_preflight_to_real_usage(100_000) is False
def test_does_not_defer_without_recent_real_usage(self, compressor):
compressor.threshold_tokens = 85_000
compressor.last_real_prompt_tokens = 0
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
assert compressor.should_defer_preflight_to_real_usage(93_000) is False
class TestCompress:
def _make_messages(self, n):
-62
View File
@@ -123,55 +123,6 @@ class TestEstimateMessagesTokensRough:
# =========================================================================
class TestDefaultContextLengths:
def test_claude_models_context_lengths(self):
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
if "claude" not in key:
continue
# Claude 4.6+ models (4.6, 4.7, 4.8) have 1M context at standard
# API pricing (no long-context premium). Older Claude 4.x and
# 3.x models cap at 200k.
if any(tag in key for tag in ("4.6", "4-6", "4.7", "4-7", "4.8", "4-8")):
assert value == 1000000, f"{key} should be 1000000"
else:
assert value == 200000, f"{key} should be 200000"
def test_gpt4_models_128k_or_1m(self):
# gpt-4.1 and gpt-4.1-mini have 1M context; other gpt-4* have 128k
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
if "gpt-4" in key and "gpt-4.1" not in key:
assert value == 128000, f"{key} should be 128000"
def test_gpt41_models_1m(self):
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
if "gpt-4.1" in key:
assert value == 1047576, f"{key} should be 1047576"
def test_gemini_models_1m(self):
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
if "gemini" in key:
assert value == 1048576, f"{key} should be 1048576"
def test_grok_models_context_lengths(self):
# xAI /v1/models does not return context_length metadata, so
# DEFAULT_CONTEXT_LENGTHS must cover the Grok family explicitly.
# Values sourced from models.dev (2026-04).
expected = {
"grok-4.20": 2000000,
"grok-4-fast": 2000000,
"grok-4": 256000,
"grok-build": 256000,
"grok-code-fast": 256000,
"grok-3": 131072,
"grok-2": 131072,
"grok-2-vision": 8192,
"grok": 131072,
}
for key, value in expected.items():
assert key in DEFAULT_CONTEXT_LENGTHS, f"{key} missing from DEFAULT_CONTEXT_LENGTHS"
assert DEFAULT_CONTEXT_LENGTHS[key] == value, (
f"{key} should be {value}, got {DEFAULT_CONTEXT_LENGTHS[key]}"
)
def test_grok_substring_matching(self):
# Longest-first substring matching must resolve the real xAI model
# IDs to the correct fallback entries without 128k probe-down.
@@ -268,13 +219,6 @@ class TestDefaultContextLengths:
f"{model_id}: expected {expected_ctx}, got {actual}"
)
def test_all_values_positive(self):
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
assert value > 0, f"{key} has non-positive context length"
def test_dict_is_not_empty(self):
assert len(DEFAULT_CONTEXT_LENGTHS) >= 10
# =========================================================================
# Codex OAuth context-window resolution (provider="openai-codex")
@@ -1141,12 +1085,6 @@ class TestContextProbeTiers:
for i in range(len(CONTEXT_PROBE_TIERS) - 1):
assert CONTEXT_PROBE_TIERS[i] > CONTEXT_PROBE_TIERS[i + 1]
def test_first_tier_is_256k(self):
assert CONTEXT_PROBE_TIERS[0] == 256_000
def test_last_tier_is_8k(self):
assert CONTEXT_PROBE_TIERS[-1] == 8_000
class TestGetNextProbeTier:
def test_from_256k(self):
-11
View File
@@ -82,17 +82,6 @@ SAMPLE_REGISTRY = {
class TestProviderMapping:
def test_all_mapped_providers_are_strings(self):
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
assert isinstance(hermes_id, str)
assert isinstance(mdev_id, str)
def test_known_providers_mapped(self):
assert PROVIDER_TO_MODELS_DEV["anthropic"] == "anthropic"
assert PROVIDER_TO_MODELS_DEV["copilot"] == "github-copilot"
assert PROVIDER_TO_MODELS_DEV["stepfun"] == "stepfun"
assert PROVIDER_TO_MODELS_DEV["kilocode"] == "kilo"
def test_xai_oauth_uses_xai_catalog(self):
assert PROVIDER_TO_MODELS_DEV["xai"] == "xai"
assert PROVIDER_TO_MODELS_DEV["xai-oauth"] == "xai"
@@ -0,0 +1,141 @@
"""Regression coverage for #35344: a resumed session must not let a stale
``## Active Task`` from an inherited compaction handoff hijack the reply to a
new, unrelated user message.
The failure mode (real report): a lineage was compacted, producing a handoff
whose ``## Active Task`` described task A. The lineage was resumed later and
the user asked about an unrelated task B. The model answered with A because
the handoff's resume directive outranked the fresh ask.
The structural fix lives in ``SUMMARY_PREFIX``: the handoff is framed as
reference-only and the latest user message explicitly *wins* on conflict, with
named reverse-signal verbs. Two invariants guard the resume path specifically:
1. A handoff persisted under the OLD (conflicting) prefix is re-normalized to
the CURRENT prefix when it is re-compacted on a resumed lineage — so a
pre-fix stale handoff cannot keep its "resume exactly" directive forever.
2. The current handoff prefix contains an unambiguous "latest message wins /
discard stale Active Task" rule, so an unrelated new ask is privileged over
the inherited ``## Active Task``.
These are content/structural assertions (no live model call) — they pin the
mechanism that makes the stale task historical rather than active.
"""
from agent.context_compressor import (
SUMMARY_PREFIX,
LEGACY_SUMMARY_PREFIX,
ContextCompressor,
)
# The conflicting prefix that shipped before the #35344 fix. A handoff
# persisted in a resumed lineage could carry this verbatim.
_OLD_CONFLICTING_PREFIX = (
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Your current task is identified in the '## Active Task' section of the "
"summary — resume exactly from there. "
"Respond ONLY to the latest user message "
"that appears AFTER this summary. The current session state (files, "
"config, etc.) may reflect work described here — avoid repeating it:"
)
def test_latest_message_wins_over_inherited_active_task():
"""The handoff must explicitly privilege the latest user message over a
stale ``## Active Task`` — the core #35344 contract."""
lower = SUMMARY_PREFIX.lower()
assert "latest user message" in lower
assert "## active task" in lower
# Conflict-resolution must be explicit, not implied.
assert "wins" in lower or "supersede" in lower
assert "discard" in lower
def test_no_resume_exactly_directive_can_hijack():
"""The directive that caused the hijack ("resume exactly from Active
Task") must be gone."""
assert "resume exactly" not in SUMMARY_PREFIX.lower()
def test_resumed_stale_handoff_gets_renormalized_to_current_prefix():
"""A handoff persisted under the OLD conflicting prefix (e.g. saved before
the fix and inherited into a resumed lineage) is upgraded to the CURRENT
prefix when re-normalized on re-compaction — so the "resume exactly"
directive cannot survive into a resumed session."""
stale_body = (
"## Active Task\n"
"User asked: 'Migrate the billing module to Stripe'\n\n"
"## Goal\nMigrate billing.\n"
)
stale_handoff = f"{_OLD_CONFLICTING_PREFIX}\n{stale_body}"
# Sanity: the fixture really does carry the old directive.
assert "resume exactly" in stale_handoff.lower()
renormalized = ContextCompressor._with_summary_prefix(stale_handoff)
# The body is preserved...
assert "Migrate the billing module to Stripe" in renormalized
# ...but the conflicting directive is stripped and replaced with the
# current latest-message-wins framing.
assert "resume exactly" not in renormalized.lower()
assert renormalized.startswith(SUMMARY_PREFIX)
assert "wins" in renormalized.lower()
def test_legacy_prefix_handoff_also_renormalized():
"""The same upgrade applies to the oldest ``[CONTEXT SUMMARY]:`` handoff
format that may sit in a long-lived resumed lineage."""
legacy = f"{LEGACY_SUMMARY_PREFIX} ## Active Task\nUser asked: 'task A'"
renormalized = ContextCompressor._with_summary_prefix(legacy)
assert renormalized.startswith(SUMMARY_PREFIX)
assert LEGACY_SUMMARY_PREFIX not in renormalized
assert "task A" in renormalized
def test_inherited_handoff_detected_in_resumed_protected_head():
"""On a resumed lineage the handoff commonly sits right after the system
prompt (in the protected head). ``_find_latest_context_summary`` must
detect it there so re-compaction rehydrates state from it rather than
serializing it as a fresh user turn (which is what let the stale Active
Task read as live intent)."""
messages = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": f"{SUMMARY_PREFIX}\n## Active Task\nUser asked: 'task A'"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "Unrelated task B: what's the capital of France?"},
]
# Search the whole post-system range.
idx, body = ContextCompressor._find_latest_context_summary(
messages, 1, len(messages)
)
assert idx == 1, "handoff in protected head must be found"
assert "task A" in body
# The detected body is stripped of the prefix (treated as state, not a
# standalone instruction message).
assert not body.startswith(SUMMARY_PREFIX)
def test_historical_prefixed_handoff_detected_and_stripped():
"""A pre-fix handoff (old conflicting prefix) inherited into a resumed
lineage must still be recognized as a context summary AND have its old
directive stripped on detection — otherwise re-compaction serializes the
stale 'resume exactly' text as a fresh turn."""
messages = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": f"{_OLD_CONFLICTING_PREFIX}\n## Active Task\nUser asked: 'task A'"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "Unrelated task B"},
]
idx, body = ContextCompressor._find_latest_context_summary(
messages, 1, len(messages)
)
assert idx == 1
assert "task A" in body
assert "resume exactly" not in body.lower()
@@ -0,0 +1,226 @@
"""Regression test: set_runtime_main() must pass base_url/api_key/api_mode
so that _resolve_auto() can route custom: providers in Step 1.
Fixes https://github.com/NousResearch/hermes-agent/issues/34777
"""
import pytest
from unittest.mock import patch, MagicMock
def _get_globals(mod):
"""Read runtime globals without triggering redaction."""
return {
"provider": mod._RUNTIME_MAIN_PROVIDER,
"model": mod._RUNTIME_MAIN_MODEL,
"base_url": mod._RUNTIME_MAIN_BASE_URL,
"cred": mod._RUNTIME_MAIN_API_KEY, # renamed to avoid redaction
"api_mode": mod._RUNTIME_MAIN_API_MODE,
}
class TestSetRuntimeMainCustomProvider:
"""set_runtime_main must propagate base_url/api_key/api_mode for custom providers."""
def test_globals_stored(self):
"""set_runtime_main stores all five fields in process-local globals."""
import agent.auxiliary_client as mod
mod.clear_runtime_main()
try:
mod.set_runtime_main(
"custom:my-router",
"glm-5.1",
base_url="https://my-server.example.com/v1",
api_key="sk-test-key",
api_mode="chat_completions",
)
g = _get_globals(mod)
assert g["provider"] == "custom:my-router"
assert g["model"] == "glm-5.1"
assert g["base_url"] == "https://my-server.example.com/v1"
assert g["cred"] == "sk-test-key"
assert g["api_mode"] == "chat_completions"
finally:
mod.clear_runtime_main()
def test_clear_resets_all_globals(self):
"""clear_runtime_main resets all five globals to empty."""
import agent.auxiliary_client as mod
mod.set_runtime_main(
"custom:x", "m",
base_url="https://x.example.com",
api_key="sk-abc",
api_mode="chat_completions",
)
mod.clear_runtime_main()
g = _get_globals(mod)
for v in g.values():
assert v == "", f"Expected empty, got {v!r}"
def test_resolve_auto_uses_globals_for_custom_provider(self):
"""_resolve_auto reads base_url/api_key from globals when main_runtime is None."""
import agent.auxiliary_client as mod
mod.clear_runtime_main()
try:
mod.set_runtime_main(
"custom:test-router",
"test-model",
base_url="https://custom-endpoint.example.com/v1",
api_key="sk-test-123",
)
with patch.object(mod, "resolve_provider_client") as mock_resolve:
mock_resolve.return_value = (MagicMock(), "test-model")
client, resolved = mod._resolve_auto(main_runtime=None)
mock_resolve.assert_called_once()
call_args = mock_resolve.call_args
assert call_args[0][0] == "custom"
assert call_args[1]["explicit_base_url"] == "https://custom-endpoint.example.com/v1"
assert call_args[1]["explicit_api_key"] == "sk-test-123"
finally:
mod.clear_runtime_main()
def test_explicit_main_runtime_takes_precedence(self):
"""When main_runtime dict has values, globals are NOT used."""
import agent.auxiliary_client as mod
mod.clear_runtime_main()
try:
mod.set_runtime_main(
"custom:router-a",
"model-a",
base_url="https://from-global.example.com",
api_key="sk-global",
)
with patch.object(mod, "resolve_provider_client") as mock_resolve:
mock_resolve.return_value = (MagicMock(), "model-b")
main_rt = {
"provider": "custom:router-b",
"model": "model-b",
"base_url": "https://from-dict.example.com",
"api_key": "sk-dict",
}
mod._resolve_auto(main_runtime=main_rt)
call_args = mock_resolve.call_args[1]
assert call_args["explicit_base_url"] == "https://from-dict.example.com"
assert call_args["explicit_api_key"] == "sk-dict"
finally:
mod.clear_runtime_main()
def test_backward_compatible_defaults(self):
"""Calling set_runtime_main with only positional args still works."""
import agent.auxiliary_client as mod
mod.clear_runtime_main()
try:
mod.set_runtime_main("openrouter", "gpt-4o")
g = _get_globals(mod)
assert g["provider"] == "openrouter"
assert g["model"] == "gpt-4o"
assert g["base_url"] == ""
assert g["cred"] == ""
assert g["api_mode"] == ""
finally:
mod.clear_runtime_main()
class TestResolveAutoCustomEndToEnd:
"""End-to-end routing assertions — build a *real* client (no mock on
resolve_provider_client) and verify the auxiliary auto-detect chain lands
on the user's custom endpoint instead of falling through to the aggregator
chain. These guard the actual user-visible symptom in #34777 (aux tasks
silently routed to a fallback provider) rather than just the wiring.
"""
@staticmethod
def _client_base_url(client):
for chain in (("base_url",), ("_client", "base_url")):
obj = client
try:
for attr in chain:
obj = getattr(obj, attr)
return str(obj)
except AttributeError:
continue
return None
def test_config_less_custom_endpoint_routes_via_global(self, tmp_path, monkeypatch):
"""custom:<name> with NO config entry: the live base_url carried by
set_runtime_main() must build a real client at that endpoint — not
fall through to Step 2 (the regression in #34777)."""
import agent.auxiliary_client as mod
# Hermetic: no aggregator creds, no stale OPENAI_BASE_URL.
for var in ("OPENROUTER_API_KEY", "NOUS_API_KEY", "OPENAI_API_KEY",
"OPENAI_BASE_URL"):
monkeypatch.delenv(var, raising=False)
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"model:\n"
" default: glm-5.1\n"
" provider: 'custom:ephemeral'\n"
" base_url: ''\n"
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
mod.clear_runtime_main()
try:
mod.set_runtime_main(
"custom:ephemeral",
"glm-5.1",
base_url="https://ephemeral.live/v1",
api_key="sk-live",
)
client, resolved = mod.resolve_provider_client("auto", None)
assert client is not None, (
"config-less custom endpoint fell through to Step 2 — "
"the #34777 bug is back"
)
assert resolved == "glm-5.1"
base = self._client_base_url(client)
assert base and base.rstrip("/") == "https://ephemeral.live/v1"
finally:
mod.clear_runtime_main()
def test_named_custom_with_config_entry_still_routes(self, tmp_path, monkeypatch):
"""Regression guard: custom:<name> WITH a custom_providers entry must
still resolve to that entry's endpoint. An earlier competing fix
collapsed the provider to bare ``custom`` before resolution, which
broke the named-custom branch and returned None here."""
import agent.auxiliary_client as mod
for var in ("OPENROUTER_API_KEY", "NOUS_API_KEY", "OPENAI_API_KEY",
"OPENAI_BASE_URL"):
monkeypatch.delenv(var, raising=False)
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"model:\n"
" default: glm-5.1\n"
" provider: 'custom:openclaw'\n"
" base_url: ''\n"
"custom_providers:\n"
" - name: openclaw\n"
" base_url: 'https://withcfg.example/v1'\n"
" model: glm-5.1\n"
" api_key: cfg-key\n"
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# No live base_url carried — resolution must come from config alone,
# via the named-custom branch in resolve_provider_client.
mod.clear_runtime_main()
try:
mod.set_runtime_main("custom:openclaw", "glm-5.1")
client, resolved = mod.resolve_provider_client("auto", None)
assert client is not None
base = self._client_base_url(client)
assert base and base.rstrip("/") == "https://withcfg.example/v1"
finally:
mod.clear_runtime_main()
@@ -0,0 +1,62 @@
"""Pin the semantics of SUMMARY_PREFIX so the compaction handoff doesn't
re-introduce conflicting instructions.
Background: SUMMARY_PREFIX previously contained two contradictory directives:
1. "treat it as background reference, NOT as active instructions"
"Do NOT answer questions or fulfill requests mentioned in this summary"
"Respond ONLY to the latest user message that appears AFTER this summary"
2. "Your current task is identified in the '## Active Task' section of the
summary — resume exactly from there."
When the latest user message contradicted Active Task (e.g. "stop the
i18n refactor", "never mind, look at grafana"), the model often followed
(2) anyway because "resume exactly" is a strong directive — leading to
the agent repeatedly re-surfacing already-cancelled work across turns.
These tests pin the post-fix invariants so the conflict cannot regress.
"""
from agent.context_compressor import SUMMARY_PREFIX
def test_no_resume_exactly_directive():
"""The prefix must not tell the model to resume Active Task verbatim."""
assert "resume exactly" not in SUMMARY_PREFIX.lower()
def test_latest_message_wins_on_conflict():
"""The prefix must explicitly say latest user message wins on conflict."""
lower = SUMMARY_PREFIX.lower()
assert "latest user message" in lower
# Must have an explicit conflict-resolution rule.
assert "wins" in lower or "supersede" in lower or "discard" in lower
def test_reverse_signals_called_out():
"""Reverse signals (stop/undo/never mind/topic change) must be named so
the model recognizes them as cancellation triggers, not just background."""
lower = SUMMARY_PREFIX.lower()
# At least a few of the canonical reverse-signal verbs should appear.
reverse_terms = ["stop", "undo", "roll back", "never mind", "just verify"]
hits = sum(1 for t in reverse_terms if t in lower)
assert hits >= 3, (
f"Expected ≥3 reverse-signal terms in SUMMARY_PREFIX, found {hits}. "
"Without naming them the model treats reverse signals as ordinary "
"context and keeps pushing the cancelled task."
)
def test_summary_marked_reference_only():
"""The REFERENCE ONLY framing must remain — it's the entire point."""
assert "REFERENCE ONLY" in SUMMARY_PREFIX
assert "background reference" in SUMMARY_PREFIX
assert "NOT as active instructions" in SUMMARY_PREFIX
def test_memory_authority_preserved():
"""The fix must not weaken the MEMORY.md / USER.md authority clause."""
assert "MEMORY.md" in SUMMARY_PREFIX
assert "USER.md" in SUMMARY_PREFIX
assert "authoritative" in SUMMARY_PREFIX
+24 -1
View File
@@ -75,6 +75,27 @@ class TestLightModeDetection:
assert cli_mod._detect_light_mode() is True
class TestOsc11Probe:
"""The OSC 11 background probe must never run where its reply can leak
into prompt_toolkit's input (a late BEL-terminated reply reads as Ctrl+G
= open-editor, trapping the user in a stray editor). Guard the cases we
refuse to probe in.
"""
@pytest.mark.parametrize("var", ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"))
def test_skips_over_ssh(self, cli_mod, monkeypatch, var):
monkeypatch.setattr(cli_mod.sys.stdin, "isatty", lambda: True, raising=False)
monkeypatch.setattr(cli_mod.sys.stdout, "isatty", lambda: True, raising=False)
for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"):
monkeypatch.delenv(v, raising=False)
monkeypatch.setenv(var, "1.2.3.4 5555 22")
assert cli_mod._query_osc11_background() is None
def test_skips_when_not_a_tty(self, cli_mod, monkeypatch):
monkeypatch.setattr(cli_mod.sys.stdin, "isatty", lambda: False, raising=False)
assert cli_mod._query_osc11_background() is None
class TestLightModeRemap:
def test_remap_no_op_in_dark_mode(self, cli_mod, monkeypatch):
monkeypatch.setenv("HERMES_LIGHT", "0")
@@ -133,7 +154,9 @@ class TestSkinConfigHook:
after = SkinConfig.get_color
assert before is after
def test_skin_color_remaps_through_wrapper_in_light_mode(self, cli_mod, monkeypatch):
def test_skin_color_remaps_through_wrapper_in_light_mode(
self, cli_mod, monkeypatch
):
from hermes_cli.skin_engine import SkinConfig
cli_mod._LIGHT_MODE_CACHE = True
@@ -0,0 +1,116 @@
"""Regression guard for issue #34569 — inline /steer (and /model) submit
must repaint the input area after clearing the buffer.
Mechanism of the bug
--------------------
``handle_enter`` dispatches ``/steer`` (and ``/model``) inline on the UI
thread while the agent is running. Those branches called
``buffer.reset(append_to_history=True)`` but — unlike every *other*
early-return branch in the handler — did NOT call ``event.app.invalidate()``.
Because ``process_command()`` prints through ``patch_stdout`` (which scrolls
output above the prompt and never triggers a prompt_toolkit redraw), the
just-cleared input area could keep showing the submitted ``/steer <text>``
until some unrelated redraw fired. The user saw their submitted text as if
it were unsent and could accidentally re-submit it.
This test pins the contract structurally: inside ``handle_enter``, any
inline-command early-return that resets the buffer must be followed by an
``event.app.invalidate()`` before its ``return``. It is an *invariant*
(every reset-then-return repaints), not a snapshot of current source.
"""
from __future__ import annotations
import ast
from pathlib import Path
def _load_handle_enter_node() -> ast.FunctionDef:
"""Extract the ``handle_enter`` nested function node from cli.py."""
cli_path = Path(__file__).resolve().parents[2] / "cli.py"
tree = ast.parse(cli_path.read_text(encoding="utf-8"))
target = None
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "handle_enter":
target = node
break
assert target is not None, "handle_enter closure not found in cli.py"
return target
def _is_buffer_reset(node: ast.stmt) -> bool:
"""True if the statement is ``...current_buffer.reset(...)``."""
if not isinstance(node, ast.Expr):
return False
call = node.value
if not isinstance(call, ast.Call):
return False
func = call.func
return isinstance(func, ast.Attribute) and func.attr == "reset"
def _is_invalidate(node: ast.stmt) -> bool:
"""True if the statement is ``event.app.invalidate()``."""
if not isinstance(node, ast.Expr):
return False
call = node.value
if not isinstance(call, ast.Call):
return False
func = call.func
return isinstance(func, ast.Attribute) and func.attr == "invalidate"
def _collect_reset_blocks(func: ast.FunctionDef) -> list[list[ast.stmt]]:
"""Find every statement sequence (a block body/orelse/finalbody) within
``handle_enter`` that contains a ``buffer.reset()`` call."""
blocks: list[list[ast.stmt]] = []
for node in ast.walk(func):
for attr in ("body", "orelse", "finalbody"):
seq = getattr(node, attr, None)
if not isinstance(seq, list):
continue
if any(isinstance(s, ast.stmt) and _is_buffer_reset(s) for s in seq):
blocks.append(seq)
return blocks
def test_inline_command_reset_branches_invalidate():
"""Every handle_enter branch that resets the buffer and then returns must
invalidate the app first (issue #34569)."""
func = _load_handle_enter_node()
reset_blocks = _collect_reset_blocks(func)
assert reset_blocks, "expected to find buffer.reset() calls in handle_enter"
offenders = []
for seq in reset_blocks:
for i, stmt in enumerate(seq):
if not _is_buffer_reset(stmt):
continue
# Find the next return after this reset in the same block.
ret_idx = None
for j in range(i + 1, len(seq)):
if isinstance(seq[j], ast.Return):
ret_idx = j
break
if ret_idx is None:
# reset not directly followed by a return in this block
# (e.g. the fall-through reset at the end of the handler) —
# the next user input naturally repaints, so skip.
continue
between = seq[i + 1 : ret_idx]
if not any(_is_invalidate(s) for s in between):
offenders.append(ast.dump(stmt))
assert not offenders, (
"handle_enter has reset-then-return branch(es) that never call "
"event.app.invalidate() — the input area can keep showing the "
"submitted text (issue #34569). Offending reset stmts:\n"
+ "\n".join(offenders)
)
if __name__ == "__main__": # pragma: no cover
test_inline_command_reset_branches_invalidate()
print("ok")
+105
View File
@@ -276,6 +276,111 @@ class TestExtractCacheBustingConfig:
assert out["tools.registry_generation"] == 12345
def test_skips_honcho_config_read_when_provider_is_not_honcho(self, monkeypatch):
"""Non-Honcho gateways must not read/parse honcho.json on every message."""
from gateway.run import GatewayRunner
called = False
def _boom():
nonlocal called
called = True
raise AssertionError("should not read Honcho config")
monkeypatch.setattr(GatewayRunner, "_extract_honcho_cache_busting_config", _boom)
out = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "mem0"}})
assert called is False
assert out["honcho.peer_name"] is None
assert out["honcho.user_peer_aliases"] is None
def test_reads_honcho_config_only_when_provider_is_honcho(self, monkeypatch):
from gateway.run import GatewayRunner
calls = []
def _fake():
calls.append(True)
return {
"honcho.peer_name": "eri",
"honcho.ai_peer": "hermes",
"honcho.pin_peer_name": True,
"honcho.runtime_peer_prefix": "tg_",
"honcho.user_peer_aliases": [("123", "eri")],
}
monkeypatch.setattr(GatewayRunner, "_extract_honcho_cache_busting_config", _fake)
out = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
assert calls == [True]
assert out["honcho.peer_name"] == "eri"
assert out["honcho.user_peer_aliases"] == [("123", "eri")]
def test_memory_provider_change_busts_signature(self, monkeypatch):
"""Switching memory.provider must itself change the cache-busting
signature, so the agent is rebuilt when a user swaps providers
mid-gateway (independent of the honcho.json identity keys)."""
from gateway.run import GatewayRunner
# Neutralize honcho.json reads so the only varying input is the
# provider value itself.
monkeypatch.setattr(
GatewayRunner,
"_extract_honcho_cache_busting_config",
classmethod(lambda cls: cls._empty_honcho_cache_busting_config()),
)
sig_honcho = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
sig_mem0 = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "mem0"}})
assert sig_honcho["memory.provider"] == "honcho"
assert sig_mem0["memory.provider"] == "mem0"
assert sig_honcho != sig_mem0
def test_honcho_cache_busting_config_memoized_by_mtime(self, monkeypatch, tmp_path):
"""Repeated Honcho extraction for unchanged honcho.json should reuse parse result."""
from types import SimpleNamespace
from gateway.run import GatewayRunner
config_path = tmp_path / "honcho.json"
config_path.write_text("{}")
parse_calls = []
class FakeConfig:
peer_name = "eri"
ai_peer = "hermes"
pin_peer_name = False
runtime_peer_prefix = "tg_"
user_peer_aliases = {"123": "eri"}
@classmethod
def from_global_config(cls, config_path=None):
parse_calls.append(config_path)
return cls()
fake_client = SimpleNamespace(
HonchoClientConfig=FakeConfig,
resolve_config_path=lambda: config_path,
)
monkeypatch.setitem(__import__("sys").modules, "plugins.memory.honcho.client", fake_client)
monkeypatch.setattr(GatewayRunner, "_HONCHO_CACHE_BUSTING_MEMO", {})
first = GatewayRunner._extract_honcho_cache_busting_config()
second = GatewayRunner._extract_honcho_cache_busting_config()
assert first == second
assert first["honcho.user_peer_aliases"] == [("123", "eri")]
assert parse_calls == [config_path]
config_path.write_text("{\n \"changed\": true\n}")
third = GatewayRunner._extract_honcho_cache_busting_config()
assert third == first
assert parse_calls == [config_path, config_path]
def test_full_round_trip_busts_cache_on_real_edit(self):
"""End-to-end: simulate a config edit on main and verify the
extracted cache_keys change produces a new signature."""
+113
View File
@@ -343,6 +343,56 @@ class TestLoadGatewayConfig:
# Env value preserved, not clobbered by yaml.
assert os.environ.get("DISCORD_THREAD_REQUIRE_MENTION") == "true"
def test_bridges_discord_allow_from_from_config_yaml(self, tmp_path, monkeypatch):
"""discord.allow_from should populate DISCORD_ALLOWED_USERS for auth."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"discord:\n"
" allow_from:\n"
" - \"123456789012345678\"\n"
" - \"999888777666555444\"\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("DISCORD_ALLOWED_USERS", raising=False)
config = load_gateway_config()
assert config.platforms[Platform.DISCORD].extra["allow_from"] == [
"123456789012345678",
"999888777666555444",
]
assert os.environ.get("DISCORD_ALLOWED_USERS") == (
"123456789012345678,999888777666555444"
)
def test_bridges_discord_platform_extra_allow_from_to_env(self, tmp_path, monkeypatch):
"""platforms.discord.extra.allow_from should reach DISCORD_ALLOWED_USERS too."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"platforms:\n"
" discord:\n"
" extra:\n"
" allow_from:\n"
" - \"123456789012345678\"\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("DISCORD_ALLOWED_USERS", raising=False)
config = load_gateway_config()
assert config.platforms[Platform.DISCORD].extra["allow_from"] == [
"123456789012345678",
]
assert os.environ.get("DISCORD_ALLOWED_USERS") == "123456789012345678"
def test_bridges_quoted_false_platform_enabled_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
@@ -361,6 +411,69 @@ class TestLoadGatewayConfig:
assert config.platforms[Platform.API_SERVER].enabled is False
assert Platform.API_SERVER not in config.get_connected_platforms()
def test_bridges_nested_gateway_platforms_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"gateway:\n"
" platforms:\n"
" telegram:\n"
" enabled: true\n"
" token: nested-token\n"
" home_channel:\n"
" platform: telegram\n"
" chat_id: \"123\"\n"
" name: Nested Home\n"
" extra:\n"
" reply_prefix: nested\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
telegram = config.platforms[Platform.TELEGRAM]
assert telegram.enabled is True
assert telegram.token == "nested-token"
assert telegram.home_channel == HomeChannel(
platform=Platform.TELEGRAM,
chat_id="123",
name="Nested Home",
)
assert telegram.extra["reply_prefix"] == "nested"
def test_top_level_platforms_override_nested_gateway_platforms(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"gateway:\n"
" platforms:\n"
" telegram:\n"
" enabled: false\n"
" token: nested-token\n"
" extra:\n"
" reply_prefix: nested\n"
"platforms:\n"
" telegram:\n"
" enabled: true\n"
" token: top-token\n"
" extra:\n"
" reply_prefix: top\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
telegram = config.platforms[Platform.TELEGRAM]
assert telegram.enabled is True
assert telegram.token == "top-token"
assert telegram.extra["reply_prefix"] == "top"
def test_bridges_quoted_false_session_notify_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
@@ -0,0 +1,202 @@
"""Tests for the outbound silence-narration filter (anti-loop control).
See the gateway delivery path: hallucinated "silence" tokens like ``*(silent)*``
are dropped pre-send so bot-to-bot channels can't mirror them into a token-burning
loop that crashes a model with "no content after all retries".
"""
import pytest
from gateway.config import GatewayConfig, Platform
from gateway.delivery import (
DeliveryRouter,
DeliveryTarget,
_is_silence_narration,
)
# --- Truth table -----------------------------------------------------------
POSITIVE_CASES = [
"*(silent)*",
"*Silence.*",
"🔇",
".",
"",
"...",
"(silent)",
"_silent_",
"silent",
" *(silent)* ",
"`silent`",
"~silent~",
"Silence",
"no response",
"No Reply.",
]
NEGATIVE_CASES = [
"Silence is golden — here is the plan...",
"Silent install completed",
"The deployment ran silently in the background",
"ok",
"👍",
"Here is the result:\n\n- item one\n- item two",
"I have nothing to add, but here is why: the build is green.",
"silently", # word boundary — trailing letters mean it isn't a bare token
"no responses were collected from the survey",
# A 64+ char string that opens with a silence token must not be dropped.
"silent " + "x" * 70,
"",
" ",
]
@pytest.mark.parametrize("content", POSITIVE_CASES)
def test_is_silence_narration_positive(content):
assert _is_silence_narration(content) is True
@pytest.mark.parametrize("content", NEGATIVE_CASES)
def test_is_silence_narration_negative(content):
assert _is_silence_narration(content) is False
def test_is_silence_narration_none_safe():
assert _is_silence_narration(None) is False
def test_length_guard_rejects_long_strings():
# Exactly 65 chars of dots — over the 64-char guard, so not treated as narration.
assert _is_silence_narration("." * 65) is False
assert _is_silence_narration("." * 64) is True
# --- Integration through DeliveryRouter ------------------------------------
class RecordingAdapter:
def __init__(self):
self.calls = []
async def send(self, chat_id, content, metadata=None):
self.calls.append({"chat_id": chat_id, "content": content, "metadata": metadata})
return {"success": True}
@pytest.mark.asyncio
async def test_silence_narration_dropped_pre_send(tmp_path, monkeypatch):
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
adapter = RecordingAdapter()
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
target = DeliveryTarget.parse("discord:99887766")
result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
assert adapter.calls == [] # adapter.send never invoked
assert result == {
"success": True,
"filtered": "silence_narration",
"delivered": False,
}
@pytest.mark.asyncio
async def test_real_message_is_delivered(tmp_path, monkeypatch):
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
adapter = RecordingAdapter()
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
target = DeliveryTarget.parse("discord:99887766")
result = await router._deliver_to_platform(
target, "Silence is golden — here is the plan...", metadata=None
)
assert len(adapter.calls) == 1
assert adapter.calls[0]["content"] == "Silence is golden — here is the plan..."
assert result == {"success": True}
@pytest.mark.asyncio
async def test_config_opt_out_lets_silence_through(tmp_path, monkeypatch):
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
adapter = RecordingAdapter()
config = GatewayConfig(filter_silence_narration=False)
router = DeliveryRouter(config, adapters={Platform.DISCORD: adapter})
target = DeliveryTarget.parse("discord:99887766")
result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
assert len(adapter.calls) == 1
assert adapter.calls[0]["content"] == "*(silent)*"
assert result == {"success": True}
@pytest.mark.asyncio
async def test_env_override_disables_filter(tmp_path, monkeypatch):
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
monkeypatch.setenv("HERMES_FILTER_SILENCE_NARRATION", "0")
adapter = RecordingAdapter()
# Config default is True, but env override wins.
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
target = DeliveryTarget.parse("discord:99887766")
result = await router._deliver_to_platform(target, "🔇", metadata=None)
assert len(adapter.calls) == 1
assert result == {"success": True}
@pytest.mark.asyncio
async def test_env_override_enables_filter_over_config(tmp_path, monkeypatch):
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
monkeypatch.setenv("HERMES_FILTER_SILENCE_NARRATION", "1")
adapter = RecordingAdapter()
# Config says off, env override forces on.
config = GatewayConfig(filter_silence_narration=False)
router = DeliveryRouter(config, adapters={Platform.DISCORD: adapter})
target = DeliveryTarget.parse("discord:99887766")
result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
assert adapter.calls == []
assert result["filtered"] == "silence_narration"
@pytest.mark.asyncio
async def test_local_delivery_not_filtered(tmp_path, monkeypatch):
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
router = DeliveryRouter(GatewayConfig(), adapters={})
results = await router.deliver(
content="*(silent)*",
targets=[DeliveryTarget.parse("local")],
job_id="silence-job",
)
# Local path saved the file (no loop risk) and was not filtered.
local_result = results["local"]
assert local_result["success"] is True
saved_path = local_result["result"]["path"]
assert saved_path.endswith(".md")
# --- Config round-trip ------------------------------------------------------
def test_config_flag_defaults_true():
assert GatewayConfig().filter_silence_narration is True
def test_config_from_dict_parses_flag():
cfg = GatewayConfig.from_dict({"filter_silence_narration": False})
assert cfg.filter_silence_narration is False
def test_config_to_dict_roundtrip():
cfg = GatewayConfig(filter_silence_narration=False)
assert cfg.to_dict()["filter_silence_narration"] is False
restored = GatewayConfig.from_dict(cfg.to_dict())
assert restored.filter_silence_narration is False
+147
View File
@@ -0,0 +1,147 @@
"""Regression tests for #35314 — empty model on the post-interrupt recovery turn.
After a ``stream_interrupt_abort`` during an active gateway session, the recovery
turn was sometimes built with ``model=""`` (a transient config-cache miss returned
an empty ``user_config``). Every API call then failed HTTP 400 "No models
provided", "trying fallback..." was logged but never executed (the user had no
fallback configured), and the session went silent until the user re-sent.
These tests pin two fixes:
1. ``_resolve_session_agent_runtime`` caches the last successfully-resolved
model per session and recovers it when a fresh resolution comes back empty.
2. ``_has_pending_fallback`` gates the "trying fallback..." status so it is only
announced when a fallback chain actually exists.
"""
import threading
import gateway.run as gateway_run
def _make_runner():
runner = object.__new__(gateway_run.GatewayRunner)
runner._session_model_overrides = {}
runner._last_resolved_model = {}
runner._service_tier = None
runner._agent_cache = {}
runner._agent_cache_lock = threading.Lock()
return runner
def _patch_resolution(monkeypatch, *, model_from_config: str, provider: str = "openrouter"):
"""Stub gateway model + runtime resolution to a known state."""
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda cfg=None: model_from_config)
monkeypatch.setattr(
gateway_run,
"_resolve_runtime_agent_kwargs",
lambda: {
"provider": provider,
"api_key": "x",
"base_url": "https://openrouter.ai/api/v1",
"api_mode": "chat_completions",
},
)
def test_normal_turn_caches_last_resolved_model(monkeypatch):
_patch_resolution(monkeypatch, model_from_config="deepseek/deepseek-v4-flash")
runner = _make_runner()
sk = "agent:main:discord:dm:123"
model, _ = runner._resolve_session_agent_runtime(session_key=sk, user_config={"model": {"default": "x"}})
assert model == "deepseek/deepseek-v4-flash"
# Cached per-session AND process-wide for first-seen-session recovery.
assert runner._last_resolved_model[sk] == "deepseek/deepseek-v4-flash"
assert runner._last_resolved_model["*"] == "deepseek/deepseek-v4-flash"
def test_empty_model_recovers_session_last_good(monkeypatch):
runner = _make_runner()
sk = "agent:main:discord:dm:123"
# Turn 1: config has the model — cache it.
_patch_resolution(monkeypatch, model_from_config="deepseek/deepseek-v4-flash")
runner._resolve_session_agent_runtime(session_key=sk, user_config={"model": {"default": "x"}})
# Turn 2: simulate the transient empty config read (the #35314 race).
_patch_resolution(monkeypatch, model_from_config="", provider="")
model, _ = runner._resolve_session_agent_runtime(session_key=sk, user_config={})
assert model == "deepseek/deepseek-v4-flash", "recovery turn must reuse last-known-good, not build model=''"
def test_empty_model_new_session_recovers_global_last_good(monkeypatch):
runner = _make_runner()
# Prime a different session so the process-wide "*" slot is populated.
_patch_resolution(monkeypatch, model_from_config="deepseek/deepseek-v4-flash")
runner._resolve_session_agent_runtime(session_key="agent:main:discord:dm:111", user_config={"model": {}})
# A brand-new session that hits an empty config read still recovers via "*".
_patch_resolution(monkeypatch, model_from_config="", provider="")
model, _ = runner._resolve_session_agent_runtime(session_key="agent:main:discord:dm:999", user_config={})
assert model == "deepseek/deepseek-v4-flash"
def test_cold_start_empty_model_does_not_crash(monkeypatch):
"""No last-good anywhere + empty config → returns '' gracefully (no exception)."""
_patch_resolution(monkeypatch, model_from_config="", provider="")
runner = _make_runner()
model, _ = runner._resolve_session_agent_runtime(session_key="agent:main:discord:dm:1", user_config={})
assert model == ""
def test_bare_runner_without_cache_attr_does_not_crash(monkeypatch):
"""object.__new__ runners (test helpers / pitfall #17) lack _last_resolved_model.
The getattr guard must tolerate the missing attribute.
"""
_patch_resolution(monkeypatch, model_from_config="deepseek/deepseek-v4-flash")
runner = object.__new__(gateway_run.GatewayRunner)
runner._session_model_overrides = {}
runner._service_tier = None
# Deliberately omit _last_resolved_model.
model, _ = runner._resolve_session_agent_runtime(session_key="x", user_config={"model": {}})
assert model == "deepseek/deepseek-v4-flash"
# ── _has_pending_fallback gate ──────────────────────────────────────────────
def _bare_agent():
import run_agent
return object.__new__(run_agent.AIAgent)
def test_has_pending_fallback_empty_chain():
agent = _bare_agent()
agent._fallback_chain = []
agent._fallback_index = 0
assert agent._has_pending_fallback() is False
def test_has_pending_fallback_with_chain():
agent = _bare_agent()
agent._fallback_chain = [{"provider": "openai", "model": "gpt-5"}]
agent._fallback_index = 0
assert agent._has_pending_fallback() is True
def test_has_pending_fallback_exhausted_chain():
agent = _bare_agent()
agent._fallback_chain = [{"provider": "openai", "model": "gpt-5"}]
agent._fallback_index = 1
assert agent._has_pending_fallback() is False
def test_has_pending_fallback_missing_attrs():
"""Bare agent with no fallback attributes set must default to False, not crash."""
agent = _bare_agent()
assert agent._has_pending_fallback() is False
+29 -3
View File
@@ -336,9 +336,35 @@ class TestEdgeCases:
paths, _ = _extract("File at /tmp/my file.png here")
assert paths == []
def test_windows_path_not_matched(self):
"""Windows-style paths should not match."""
paths, _ = _extract("See C:\\Users\\test\\image.png")
@pytest.mark.parametrize(
"content,expected",
[
# Backslash separators (native Windows style)
("See C:\\Users\\test\\image.png here", "C:\\Users\\test\\image.png"),
# Forward slashes with drive letter (common in cross-platform code)
("See C:/Users/test/image.png here", "C:/Users/test/image.png"),
# Non-C: drive
("Video at D:/data/clip.mp4 ready", "D:/data/clip.mp4"),
# Lowercase drive letter
("Path e:/audio/track.mp3 done", "e:/audio/track.mp3"),
],
)
def test_windows_drive_letter_paths_matched(self, content, expected):
"""Windows drive-letter paths (C:/..., C:\\...) must be detected (#34632).
Prior behavior anchored on (?:~/|/) only, which silently dropped
Windows absolute paths so the agent's bare-path references were
sent as text instead of native uploads.
"""
paths, cleaned = _extract(content)
assert paths == [expected]
assert expected not in cleaned
def test_relative_windows_path_not_matched(self):
"""A bare Windows-style filename without a drive letter must still
not match (e.g. ``foo\\bar.png`` is treated as relative, like its
Unix sibling ``foo/bar.png``)."""
paths, _ = _extract("File at foo\\bar.png here")
assert paths == []
def test_relative_path_not_matched(self):
+39
View File
@@ -361,6 +361,45 @@ class TestExtractMedia:
assert "[[audio_as_voice]]" not in cleaned
assert "[[as_document]]" not in cleaned
# Windows path support — regression coverage for #34632
def test_media_tag_windows_backslash_path(self):
"""extract_media should recognise Windows backslash paths."""
media, cleaned = BasePlatformAdapter.extract_media(
r"MEDIA:C:\Users\kotsu\file.pdf"
)
assert len(media) == 1
assert media[0][0].endswith("file.pdf")
def test_media_tag_windows_forward_slash_path(self):
"""extract_media should recognise Windows forward-slash paths."""
media, cleaned = BasePlatformAdapter.extract_media(
"MEDIA:C:/Users/kotsu/file.pdf"
)
assert len(media) == 1
assert media[0][0].endswith("file.pdf")
def test_media_tag_windows_drive_root(self):
"""extract_media should recognise a path at the drive root."""
media, cleaned = BasePlatformAdapter.extract_media(
r"MEDIA:D:\report.md"
)
assert len(media) == 1
assert media[0][0].endswith("report.md")
def test_media_tag_unix_paths_still_work(self):
"""Unix absolute and tilde paths must still extract after Windows change."""
for content in ["MEDIA:/tmp/audio.ogg", r"MEDIA:~/docs/notes.md"]:
media, _ = BasePlatformAdapter.extract_media(content)
assert len(media) == 1, f"Failed for: {content}"
def test_relative_path_still_ignored(self):
"""Relative Windows-style paths (no drive letter) must not match."""
media, _ = BasePlatformAdapter.extract_media(
r"MEDIA:Users\kotsu\file.pdf"
)
assert media == []
class TestMediaExtensionAllowlistParity:
"""Regression coverage for issue #34517 — the MEDIA: extension black hole.
+15 -11
View File
@@ -294,19 +294,20 @@ class TestPlatformReconnectWatcher:
assert runner._failed_platforms[Platform.TELEGRAM]["attempts"] == 2
@pytest.mark.asyncio
async def test_reconnect_pauses_after_circuit_breaker_threshold(self):
"""After enough consecutive retryable failures, the watcher should
*pause* the platform (keep it in the queue but stop hammering it),
not drop it. The user resumes via /platform resume.
async def test_reconnect_never_auto_pauses_retryable_failures(self):
"""Retryable failures (network/DNS) must keep retrying indefinitely —
the watcher must NOT auto-pause them. Auto-pausing a transiently-failed
platform left bots silently dead after a DNS blip (#35284). The pause
circuit breaker remains available for manual /platform pause only.
"""
runner = _make_runner()
platform_config = PlatformConfig(enabled=True, token="test")
# 9 prior attempts — the next failure will be the 10th and should
# trip the circuit breaker.
# Far past the old circuit-breaker threshold (10): even after many
# consecutive retryable failures the platform must stay unpaused.
runner._failed_platforms[Platform.TELEGRAM] = {
"config": platform_config,
"attempts": 9,
"attempts": 25,
"next_retry": time.monotonic() - 1,
}
@@ -332,12 +333,15 @@ class TestPlatformReconnectWatcher:
await run_one_iteration()
# Platform stays in queue — paused, not dropped
# Platform stays in queue and keeps retrying — never auto-paused.
assert Platform.TELEGRAM in runner._failed_platforms
info = runner._failed_platforms[Platform.TELEGRAM]
assert info["paused"] is True
assert info["attempts"] == 10
assert "pause_reason" in info
assert info.get("paused") is not True
assert "pause_reason" not in info
assert info["attempts"] == 26
# next_retry is pushed out by the backoff (capped at 300s), not inf.
assert info["next_retry"] != float("inf")
assert info["next_retry"] > time.monotonic()
@pytest.mark.asyncio
async def test_reconnect_skips_paused_platforms(self):
+147
View File
@@ -0,0 +1,147 @@
r"""Tests for _TOOL_MEDIA_RE regex patterns in gateway/run.py.
Issue #34632: The _TOOL_MEDIA_RE patterns in GatewayRunner used (?:/|~\/) to
anchor paths, which only matched Unix-style absolute and home-relative paths.
Windows absolute paths (C:\\Users\\..., D:/...) were silently ignored, causing
MEDIA directive delivery to fail on Windows.
Fix: Add [A-Za-z]:[/\\\\] as a third anchor alternative in both patterns.
Two identical _TOOL_MEDIA_RE patterns exist in run.py:
1. History scanning (~L17223): collects already-seen media paths
2. Result scanning (~L17549): extracts new media tags from agent output
This test file validates that both equivalent regex patterns correctly match
Windows paths while preserving existing Unix path matching behavior.
"""
import re
import pytest
# Reconstruct the exact _TOOL_MEDIA_RE pattern from gateway/run.py
# The pattern is built by concatenating raw string parts:
# r'MEDIA:((?:[A-Za-z]:[/\\]|/|~\/)\S+\.(?:png|...))'
_TOOL_MEDIA_RE = re.compile(
r'MEDIA:((?:[A-Za-z]:[/\\]|/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'
r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'
r'txt|csv|apk|ipa))',
re.IGNORECASE,
)
# Reconstruct the pre-fix pattern (without Windows anchor) for regression proof
_TOOL_MEDIA_RE_PRE_FIX = re.compile(
r'MEDIA:((?:/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'
r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'
r'txt|csv|apk|ipa))',
re.IGNORECASE,
)
class TestToolMediaReWindowsPaths:
"""Issue #34632: _TOOL_MEDIA_RE must match Windows absolute paths."""
# ── Positive: Windows paths now match ──────────────────────────
@pytest.mark.parametrize("media_tag, expected_path", [
# Windows backslash paths
("MEDIA:C:\\Users\\test\\image.png", "C:\\Users\\test\\image.png"),
("MEDIA:D:\\data\\report.pdf", "D:\\data\\report.pdf"),
("MEDIA:E:\\Photos\\vacation.jpg", "E:\\Photos\\vacation.jpg"),
# Windows forward-slash paths
("MEDIA:C:/Users/test/image.png", "C:/Users/test/image.png"),
("MEDIA:D:/data/report.pdf", "D:/data/report.pdf"),
# Mixed separators
("MEDIA:C:\\Users/test\\image.webp", "C:\\Users/test\\image.webp"),
# Various extensions
("MEDIA:F:\\videos\\clip.mp4", "F:\\videos\\clip.mp4"),
("MEDIA:G:\\audio\\song.mp3", "G:\\audio\\song.mp3"),
("MEDIA:H:\\docs\\sheet.xlsx", "H:\\docs\\sheet.xlsx"),
("MEDIA:Z:\\archive\\backup.zip", "Z:\\archive\\backup.zip"),
])
def test_windows_paths_match(self, media_tag, expected_path):
"""Windows absolute paths with drive letters are matched."""
match = _TOOL_MEDIA_RE.search(media_tag)
assert match is not None, f"Should match: {media_tag}"
assert match.group(1) == expected_path
# ── Positive: Unix paths still match ───────────────────────────
@pytest.mark.parametrize("media_tag, expected_path", [
("MEDIA:/tmp/output.png", "/tmp/output.png"),
("MEDIA:/var/log/report.pdf", "/var/log/report.pdf"),
("MEDIA:/home/user/docs/file.txt", "/home/user/docs/file.txt"),
# Home-relative
("MEDIA:~/Downloads/image.jpg", "~/Downloads/image.jpg"),
("MEDIA:~/Documents/report.pdf", "~/Documents/report.pdf"),
])
def test_unix_paths_still_match(self, media_tag, expected_path):
"""Unix-style absolute and home-relative paths still match."""
match = _TOOL_MEDIA_RE.search(media_tag)
assert match is not None, f"Should match: {media_tag}"
assert match.group(1) == expected_path
# ── Negative: invalid paths don't match ────────────────────────
@pytest.mark.parametrize("text", [
"No MEDIA tag here",
"MEDIA:relative/path/file.png", # relative path, no anchor
"MEDIA:file.png", # no directory
"MEDIA:C:file.png", # drive letter but no separator
"MEDIA:/path/to/file.unknown", # unsupported extension
"MEDIA:/path/to/file", # no extension
"MEDIA:", # empty path
])
def test_invalid_paths_dont_match(self, text):
"""Non-MEDIA text, relative paths, and unsupported extensions are ignored."""
match = _TOOL_MEDIA_RE.search(text)
assert match is None, f"Should NOT match: {text}"
# ── Negative/preserved: old pattern rejects Windows paths ──────
@pytest.mark.parametrize("media_tag", [
"MEDIA:C:\\Users\\test\\image.png",
"MEDIA:D:/data/report.pdf",
"MEDIA:C:\\path\\file.jpg",
])
def test_pre_fix_pattern_rejects_windows(self, media_tag):
"""The pre-fix pattern (without Windows anchor) does NOT match Windows paths.
This proves the fix is necessary without it, these paths are silently ignored."""
match = _TOOL_MEDIA_RE_PRE_FIX.search(media_tag)
assert match is None, f"Pre-fix pattern should NOT match: {media_tag}"
# ── Edge cases ─────────────────────────────────────────────────
def test_multiple_media_tags_in_content(self):
"""Multiple MEDIA tags in the same content are all found."""
content = (
"Some text MEDIA:C:\\path\\img.png and more MEDIA:/tmp/out.pdf trailing"
)
matches = list(_TOOL_MEDIA_RE.finditer(content))
assert len(matches) == 2
paths = [m.group(1) for m in matches]
assert "C:\\path\\img.png" in paths
assert "/tmp/out.pdf" in paths
def test_case_insensitive_drive_letter(self):
"""Drive letters are case-insensitive due to re.IGNORECASE."""
match_lower = _TOOL_MEDIA_RE.search("MEDIA:c:\\path\\file.png")
match_upper = _TOOL_MEDIA_RE.search("MEDIA:C:\\path\\file.png")
assert match_lower is not None
assert match_upper is not None
assert match_lower.group(1).lower() == match_upper.group(1).lower()
@pytest.mark.parametrize("media_tag", [
"MEDIA:C:\\path\\file.jpeg",
"MEDIA:C:\\path\\file.JPG",
"MEDIA:C:\\path\\file.GIF",
"MEDIA:C:\\path\\file.MP4",
])
def test_case_insensitive_extensions(self, media_tag):
"""File extensions are matched case-insensitively."""
match = _TOOL_MEDIA_RE.search(media_tag)
assert match is not None, f"Should match: {media_tag}"
+3 -3
View File
@@ -97,7 +97,7 @@ async def test_status_command_reports_running_agent_without_interrupt(monkeypatc
result = await runner._handle_message(_make_event("/status"))
assert "**Session ID:** `sess-1`" in result
assert "**Tokens:** 321" in result
assert "**Cumulative API tokens (re-sent each call):** 321" in result
assert "**Agent Running:** Yes ⚡" in result
assert "**Title:**" not in result
running_agent.interrupt.assert_not_called()
@@ -150,7 +150,7 @@ async def test_status_command_reads_token_totals_from_session_db():
result = await runner._handle_message(_make_event("/status"))
# 1000 + 250 + 500 + 100 + 50 = 1,900
assert "**Tokens:** 1,900" in result
assert "**Cumulative API tokens (re-sent each call):** 1,900" in result
@pytest.mark.asyncio
@@ -171,7 +171,7 @@ async def test_status_command_tokens_zero_when_session_db_row_missing():
result = await runner._handle_message(_make_event("/status"))
assert "**Tokens:** 0" in result
assert "**Cumulative API tokens (re-sent each call):** 0" in result
@pytest.mark.asyncio
@@ -146,6 +146,78 @@ class TestTelegramModelPicker:
# State is cleaned up after a successful switch.
assert "12345" not in adapter._model_picker_state
@pytest.mark.asyncio
async def test_provider_group_folds_and_drills_down(self, monkeypatch):
"""A provider family (e.g. MiniMax) collapses to one mpg: button at
the top level; tapping it expands to its authenticated members as
mp: buttons. A group reduced to a single authenticated member shows
no submenu (direct mp: button).
Inspects callback_data by recording every InlineKeyboardButton built,
which is robust to whether `telegram` is the real SDK or the module
mock (the SDK markup objects don't expose a plain iterable under the
mock)."""
import gateway.platforms.telegram as tg
built: list = []
class _RecordingButton:
def __init__(self, text, callback_data=None, **kw):
self.text = text
self.callback_data = callback_data
built.append(callback_data)
class _RecordingMarkup:
def __init__(self, rows):
self.inline_keyboard = rows
monkeypatch.setattr(tg, "InlineKeyboardButton", _RecordingButton)
monkeypatch.setattr(tg, "InlineKeyboardMarkup", _RecordingMarkup)
adapter = _make_adapter()
async def mock_send_message(**kwargs):
return SimpleNamespace(message_id=101)
adapter._bot.send_message = AsyncMock(side_effect=mock_send_message)
providers = [
{"slug": "minimax", "name": "MiniMax", "total_models": 2},
{"slug": "minimax-cn", "name": "MiniMax (China)", "total_models": 3},
{"slug": "xai", "name": "xAI", "total_models": 1}, # lone group member
]
await adapter.send_model_picker(
chat_id="12345",
providers=providers,
current_model="m",
current_provider="minimax",
session_key="s",
on_model_selected=AsyncMock(),
metadata=None,
)
# Top-level keyboard: MiniMax family folded into one group button;
# xai (lone member) degraded to a direct provider button.
assert "mpg:minimax" in built
assert "mp:xai" in built
assert "mp:minimax" not in built
assert "mp:minimax-cn" not in built
# Drill into the MiniMax group → members appear as mp: buttons + back.
built.clear()
query = AsyncMock()
query.message = MagicMock()
query.message.chat_id = 12345
query.answer = AsyncMock()
query.edit_message_text = AsyncMock()
await adapter._handle_model_picker_callback(query, "mpg:minimax", "12345")
assert "mp:minimax" in built
assert "mp:minimax-cn" in built
assert "mb" in built # back-to-providers button present
@pytest.mark.asyncio
async def test_retries_without_thread_when_thread_not_found(self):
adapter = _make_adapter()
+88 -2
View File
@@ -11,6 +11,7 @@ import pytest
from gateway.config import PlatformConfig
from gateway.config import GatewayConfig, HomeChannel, Platform, _apply_env_overrides
from gateway.platforms.base import SendResult
from gateway.platforms.base import MessageEvent, MessageType
from gateway.platforms import weixin
from gateway.platforms.weixin import ContextTokenStore, WeixinAdapter
from tools.send_message_tool import _parse_target_ref, _send_to_platform
@@ -853,15 +854,27 @@ class TestWeixinContentDedup:
adapter = _make_adapter()
adapter._poll_session = object()
adapter.handle_message = AsyncMock()
# Tighten the text-debounce delay so the flush completes quickly.
adapter._text_batch_delay_seconds = 0.05
adapter._text_batch_split_delay_seconds = 0.05
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"}))
async def _drive():
# Both inbound messages share the same event loop so the debounce
# task created by the first one survives to be flushed.
await adapter._process_message({**base_msg, "message_id": "msg-1"})
await adapter._process_message({**base_msg, "message_id": "msg-2"})
# Wait out the quiet period so the buffered text batch flushes.
await asyncio.sleep(0.2)
asyncio.run(_drive())
# Content-dedup drops the second (duplicate) message before it is even
# enqueued, so only one combined dispatch reaches handle_message.
assert adapter.handle_message.await_count == 1
event = adapter.handle_message.await_args[0][0]
assert event.text == "hello world"
@@ -882,3 +895,76 @@ class TestWeixinContentDedup:
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)
class TestWeixinTextDebounce:
"""Text-debounce batching for rapid multi-message bursts (issue #35301).
Delays are read from ``config.extra`` (config.yaml), not env vars.
"""
def test_batch_delays_default_from_config(self):
adapter = _make_adapter()
assert adapter._text_batch_delay_seconds == 3.0
assert adapter._text_batch_split_delay_seconds == 5.0
def test_batch_delays_overridden_via_config_extra(self):
adapter = WeixinAdapter(
PlatformConfig(
enabled=True,
token="test-token",
extra={
"account_id": "test-account",
"text_batch_delay_seconds": "0.5",
"text_batch_split_delay_seconds": 1.5,
},
)
)
assert adapter._text_batch_delay_seconds == 0.5
assert adapter._text_batch_split_delay_seconds == 1.5
def test_invalid_config_value_falls_back_to_default(self):
adapter = WeixinAdapter(
PlatformConfig(
enabled=True,
token="test-token",
extra={
"account_id": "test-account",
"text_batch_delay_seconds": "not-a-number",
"text_batch_split_delay_seconds": -4,
},
)
)
assert adapter._text_batch_delay_seconds == 3.0
assert adapter._text_batch_split_delay_seconds == 5.0
def test_rapid_texts_collapse_into_single_dispatch(self):
adapter = _make_adapter()
adapter._text_batch_delay_seconds = 0.05
adapter._text_batch_split_delay_seconds = 0.05
dispatched = []
async def _capture(event):
dispatched.append(event.text)
adapter.handle_message = _capture
def _event(text):
return MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=adapter.build_source(
chat_id="wxid_user1", chat_type="dm",
user_id="wxid_user1", user_name="wxid_user1",
),
)
async def _drive():
adapter._enqueue_text_event(_event("one"))
adapter._enqueue_text_event(_event("two"))
adapter._enqueue_text_event(_event("three"))
assert dispatched == [] # nothing flushed during the burst
await asyncio.sleep(0.2)
asyncio.run(_drive())
assert dispatched == ["one\ntwo\nthree"]
@@ -0,0 +1,107 @@
"""Text-debounce batching for the WhatsApp adapter (issue #35301).
WhatsApp delivers rapid multi-message bursts (forwarded batches, paste-splits)
individually. Without debounce each fragment triggers a separate agent
invocation, wasting tokens and flooding the user with reply fragments. This
mirrors the Telegram/WeCom/Feishu pattern.
Batch delays are read from ``config.extra`` (config.yaml), not env vars.
"""
import asyncio
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from gateway.platforms.whatsapp import WhatsAppAdapter
from gateway.session import SessionSource
def _make_adapter(**extra):
base = {"session_name": "test"}
base.update(extra)
return WhatsAppAdapter(PlatformConfig(enabled=True, extra=base))
def _event(text):
src = SessionSource(
platform=Platform.WHATSAPP,
chat_id="chat123",
chat_type="dm",
user_id="user1",
user_name="tester",
)
return MessageEvent(text=text, message_type=MessageType.TEXT, source=src)
def test_batch_delays_default_from_config():
adapter = _make_adapter()
assert adapter._text_batch_delay_seconds == 5.0
assert adapter._text_batch_split_delay_seconds == 10.0
def test_batch_delays_overridden_via_config_extra():
adapter = _make_adapter(
text_batch_delay_seconds="2.5",
text_batch_split_delay_seconds=7,
)
assert adapter._text_batch_delay_seconds == 2.5
assert adapter._text_batch_split_delay_seconds == 7.0
def test_invalid_config_value_falls_back_to_default():
adapter = _make_adapter(
text_batch_delay_seconds="garbage",
text_batch_split_delay_seconds=-3,
)
assert adapter._text_batch_delay_seconds == 5.0
assert adapter._text_batch_split_delay_seconds == 10.0
def test_env_var_is_ignored(monkeypatch):
# Config-only path: the legacy HERMES_* env var must NOT influence delays.
monkeypatch.setenv("HERMES_WHATSAPP_TEXT_BATCH_DELAY_SECONDS", "99")
adapter = _make_adapter()
assert adapter._text_batch_delay_seconds == 5.0
def test_rapid_texts_collapse_into_single_dispatch():
adapter = _make_adapter(
text_batch_delay_seconds=0.05,
text_batch_split_delay_seconds=0.05,
)
dispatched = []
async def _capture(event):
dispatched.append(event.text)
adapter.handle_message = _capture
async def _drive():
adapter._enqueue_text_event(_event("one"))
adapter._enqueue_text_event(_event("two"))
adapter._enqueue_text_event(_event("three"))
assert dispatched == [] # nothing flushed during the burst
await asyncio.sleep(0.2)
asyncio.run(_drive())
assert dispatched == ["one\ntwo\nthree"]
def test_lone_message_dispatched_alone():
adapter = _make_adapter(
text_batch_delay_seconds=0.05,
text_batch_split_delay_seconds=0.05,
)
dispatched = []
async def _capture(event):
dispatched.append(event.text)
adapter.handle_message = _capture
async def _drive():
adapter._enqueue_text_event(_event("solo"))
await asyncio.sleep(0.2)
asyncio.run(_drive())
assert dispatched == ["solo"]
+39
View File
@@ -39,6 +39,45 @@ def mock_args():
return SimpleNamespace()
class TestCmdUpdatePip:
"""Regression tests for pip-install update flows."""
@patch("shutil.which", return_value="/usr/bin/uv")
@patch("subprocess.run")
def test_update_pip_exports_virtualenv_from_sys_prefix(
self, mock_run, _mock_which, mock_args, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/tmp/hermes-launcher-venv")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
hm._cmd_update_pip(mock_args)
assert mock_run.call_count == 1
assert mock_run.call_args.args[0] == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/tmp/hermes-launcher-venv"
@patch("shutil.which", return_value="/usr/bin/uv")
@patch("subprocess.run")
def test_update_pip_does_not_export_virtualenv_for_system_python(
self, mock_run, _mock_which, mock_args, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/usr")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
hm._cmd_update_pip(mock_args)
assert mock_run.call_count == 1
assert "env" not in mock_run.call_args.kwargs
class TestCmdUpdateBranchFallback:
"""cmd_update falls back to main when current branch has no remote counterpart."""
@@ -6,25 +6,6 @@ from unittest.mock import patch
from hermes_cli.model_switch import list_authenticated_providers
@patch.dict(os.environ, {"GH_TOKEN": "test-key"}, clear=False)
def test_copilot_picker_keeps_curated_copilot_models_when_live_catalog_unavailable():
with patch("agent.models_dev.fetch_models_dev", return_value={}), \
patch("hermes_cli.models._resolve_copilot_catalog_api_key", return_value="gh-token"), \
patch("hermes_cli.models._fetch_github_models", return_value=None):
providers = list_authenticated_providers(current_provider="openrouter", max_models=50)
copilot = next((p for p in providers if p["slug"] == "copilot"), None)
assert copilot is not None
assert "gpt-5.4" in copilot["models"]
assert "claude-sonnet-4.6" in copilot["models"]
assert "claude-sonnet-4" in copilot["models"]
assert "claude-sonnet-4.5" in copilot["models"]
assert "claude-haiku-4.5" in copilot["models"]
assert "gemini-3.1-pro-preview" in copilot["models"]
assert "claude-opus-4.6" not in copilot["models"]
@patch.dict(os.environ, {"GH_TOKEN": "test-key"}, clear=False)
def test_copilot_picker_uses_live_catalog_when_available():
live_models = ["gpt-5.4", "claude-sonnet-4.6", "gemini-3.1-pro-preview"]
@@ -80,6 +80,25 @@ def loopback_app():
web_server.app.state.auth_required = prev_required
@pytest.fixture
def insecure_public_app():
"""web_server.app configured for all-interfaces insecure mode."""
_reset_for_tests()
clear_providers()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "0.0.0.0"
web_server.app.state.bound_port = 9120
web_server.app.state.auth_required = False
client = TestClient(web_server.app, base_url="http://192.168.0.222:9120")
yield client
_reset_for_tests()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
def _logged_in(client: TestClient) -> None:
"""Drive the stub OAuth round trip so the client holds session cookies."""
r1 = client.get("/auth/login?provider=stub", follow_redirects=False)
@@ -143,6 +162,30 @@ class TestWsTicketEndpoint:
# ---------------------------------------------------------------------------
@pytest.fixture
def insecure_explicit_host_app():
"""web_server.app bound to an explicit non-loopback host (--insecure).
Models `--host 100.64.0.10 --insecure` (e.g. a Tailscale IP behind
`tailscale serve`) a specific address rather than the all-interfaces
0.0.0.0 wildcard.
"""
_reset_for_tests()
clear_providers()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "100.64.0.10"
web_server.app.state.bound_port = 9119
web_server.app.state.auth_required = False
client = TestClient(web_server.app, base_url="http://100.64.0.10:9119")
yield client
_reset_for_tests()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
def _fake_ws(*, query: dict, client_host: str = "127.0.0.1", path: str = "/api/pty"):
"""Build a stand-in for starlette.WebSocket good enough for _ws_auth_ok."""
@@ -281,6 +324,48 @@ class TestWsRequestIsAllowedGated:
ws.headers = {"host": "127.0.0.1:8080"}
assert web_server._ws_request_is_allowed(ws) is True
def test_non_loopback_peer_allowed_in_insecure_public_mode(self, insecure_public_app):
"""`--host 0.0.0.0 --insecure` is an explicit LAN/public opt-in.
Regression coverage for the dashboard `/chat` breakage where the
HTML shell loaded on 9120 but every WebSocket upgrade was rejected
with 403 because the loopback-only peer guard still ran even though
the operator intentionally exposed the dashboard on all interfaces.
"""
ws = _fake_ws(query={}, client_host="192.168.0.55")
ws.headers = {
"host": "192.168.0.222:9120",
"origin": "http://192.168.0.222:9120",
}
assert web_server._ws_request_is_allowed(ws) is True
def test_peer_allowed_on_explicit_non_loopback_bind(self, insecure_explicit_host_app):
"""`--host 100.64.0.10 --insecure` (Tailscale/LAN IP) is an explicit
non-loopback opt-in too not just the 0.0.0.0 wildcard.
Regression coverage: the merged 0.0.0.0/:: fix did not cover binding
directly to a specific tailnet/LAN address, so `/chat` HTML loaded but
WS upgrades were still rejected by the loopback-only peer guard.
"""
ws = _fake_ws(query={}, client_host="100.64.0.99")
ws.headers = {
"host": "100.64.0.10:9119",
"origin": "http://100.64.0.10:9119",
}
assert web_server._ws_request_is_allowed(ws) is True
def test_rebinding_host_rejected_on_explicit_non_loopback_bind(
self, insecure_explicit_host_app
):
"""Lifting the peer-IP gate for an explicit bind must NOT lift the
DNS-rebinding Host guard: a mismatched Host header is still rejected,
because an explicit non-loopback bind requires an exact Host match in
`_is_accepted_host` (unlike the 0.0.0.0 wildcard, which accepts any).
"""
ws = _fake_ws(query={}, client_host="100.64.0.99")
ws.headers = {"host": "evil.example.com"}
assert web_server._ws_request_is_allowed(ws) is False
def test_host_origin_guard_still_runs_in_gated_mode(self, gated_app):
"""Bypassing the peer-IP check must not bypass the DNS-rebinding
Host header guard that one still protects against attacker
-13
View File
@@ -80,14 +80,6 @@ class TestGmiConfigRegistry:
class TestGmiModelCatalog:
def test_static_model_fallback_exists(self):
assert "gmi" in _PROVIDER_MODELS
models = _PROVIDER_MODELS["gmi"]
assert "zai-org/GLM-5.1-FP8" in models
assert "deepseek-ai/DeepSeek-V3.2" in models
assert "moonshotai/Kimi-K2.5" in models
assert "anthropic/claude-sonnet-4.6" in models
def test_canonical_provider_entry(self):
slugs = [p.slug for p in CANONICAL_PROVIDERS]
assert "gmi" in slugs
@@ -267,11 +259,6 @@ class TestGmiModelMetadata:
class TestGmiAuxiliary:
def test_aux_default_model(self):
from agent.auxiliary_client import _get_aux_model_for_provider
assert _get_aux_model_for_provider("gmi") == "google/gemini-3.1-flash-lite-preview"
def test_resolve_provider_client_uses_gmi_aux_default(self, monkeypatch):
monkeypatch.setenv("GMI_API_KEY", "gmi-test-key")
+21 -10
View File
@@ -106,20 +106,30 @@ def test_worker_block_on_child_with_done_parents_is_still_sticky(kanban_home: Pa
def test_circuit_breaker_block_still_auto_promotes(kanban_home: Path) -> None:
"""A child that was put into ``blocked`` *without* a worker-issued
``kanban_block`` (e.g. circuit-breaker after repeated spawn
failures, manual DB triage) must still get auto-promoted when its
parents complete preserves the pre-#28712 recovery semantics."""
``kanban_block`` (e.g. a transient crash, manual DB triage) and whose
``consecutive_failures`` is still *below* the circuit-breaker limit
must get auto-promoted when its parents complete preserves the
pre-#28712 recovery semantics for genuinely transient failures.
The complementary case a block whose failure count has *reached*
the limit must stay blocked is covered by
``test_kanban_db.py::test_recompute_ready_skips_tasks_at_failure_limit``
(#35072). Together they pin the contract: ``recompute_ready`` defers
the give-up decision to the same effective limit the breaker uses, so
the two never disagree.
"""
with kb.connect() as conn:
parent = kb.create_task(conn, title="parent")
child = kb.create_task(conn, title="child", parents=[parent])
kb.complete_task(conn, parent, result="ok")
# Simulate a circuit-breaker / direct triage that flips status
# without emitting a ``blocked`` event — exactly what
# ``_record_task_failure`` does after a ``gave_up``.
# Simulate a transient circuit-breaker / direct triage that flips
# status without emitting a ``blocked`` event — exactly what
# ``_record_task_failure`` does below the limit. One failure is
# under the default limit (2), so recovery is still correct.
conn.execute(
"UPDATE tasks SET status='blocked', consecutive_failures=5, "
"last_failure_error='persistent error' WHERE id=?",
"UPDATE tasks SET status='blocked', consecutive_failures=1, "
"last_failure_error='transient error' WHERE id=?",
(child,),
)
conn.commit()
@@ -128,8 +138,9 @@ def test_circuit_breaker_block_still_auto_promotes(kanban_home: Path) -> None:
assert promoted == 1
task = kb.get_task(conn, child)
assert task.status == "ready"
assert task.consecutive_failures == 0
assert task.last_failure_error is None
# Counter is preserved across recovery (not reset) so the breaker
# can still accumulate if the task keeps failing (#35072).
assert task.consecutive_failures == 1
def test_gave_up_event_alone_does_not_make_block_sticky(kanban_home: Path) -> None:
+150 -6
View File
@@ -307,7 +307,8 @@ def test_recompute_ready_cascades_through_chain(kanban_home):
def test_recompute_ready_promotes_blocked_with_done_parents(kanban_home):
"""blocked tasks with all parents done should be promoted to ready."""
"""blocked tasks with all parents done should be promoted to ready,
unless the circuit-breaker failure limit has been reached."""
with kb.connect() as conn:
parent = kb.create_task(conn, title="parent", assignee="a")
child = kb.create_task(
@@ -316,16 +317,16 @@ def test_recompute_ready_promotes_blocked_with_done_parents(kanban_home):
# Complete the parent
kb.claim_task(conn, parent)
kb.complete_task(conn, parent, result="ok")
# Manually block the child (simulates a worker that failed
# after the parent finished)
# Manually block the child with zero failures (simulates a
# dependency block, not a circuit-breaker block).
conn.execute(
"UPDATE tasks SET status='blocked', consecutive_failures=5, "
"last_failure_error='persistent error' WHERE id=?",
"UPDATE tasks SET status='blocked', consecutive_failures=0, "
"last_failure_error=NULL WHERE id=?",
(child,),
)
conn.commit()
assert kb.get_task(conn, child).status == "blocked"
# recompute_ready should promote blocked → ready and reset failures
# recompute_ready should promote blocked → ready
promoted = kb.recompute_ready(conn)
assert promoted == 1
task = kb.get_task(conn, child)
@@ -815,6 +816,149 @@ def test_unblock_resets_failure_counters(kanban_home):
assert task.last_failure_error is None
def test_recompute_ready_skips_tasks_at_failure_limit(kanban_home):
"""recompute_ready must not auto-recover tasks whose consecutive_failures
has reached the circuit-breaker limit (#35072).
Without this guard, a task that repeatedly exhausts its iteration
budget would cycle forever: block auto-recover (counter reset)
respawn budget exhausted block
"""
with kb.connect() as conn:
parent = kb.create_task(conn, title="parent", assignee="a")
child = kb.create_task(conn, title="child", assignee="a",
parents=[parent])
# Complete the parent so the child's dependencies are satisfied.
kb.claim_task(conn, parent)
kb.complete_task(conn, parent, summary="done")
# Simulate the child having exhausted its budget twice,
# hitting the default failure limit (2).
kb.claim_task(conn, child)
kb._record_task_failure(
conn, child, error="budget exhausted 1",
outcome="timed_out", release_claim=True, end_run=True,
failure_limit=2,
)
kb._record_task_failure(
conn, child, error="budget exhausted 2",
outcome="timed_out", release_claim=True, end_run=True,
failure_limit=2,
)
task = kb.get_task(conn, child)
assert task.status == "blocked"
assert task.consecutive_failures >= 2
# recompute_ready must NOT promote this task — the circuit
# breaker has tripped and it should stay blocked.
promoted = kb.recompute_ready(conn)
assert promoted == 0
assert kb.get_task(conn, child).status == "blocked"
# Explicit unblock should still work and reset the counter.
assert kb.unblock_task(conn, child)
task = kb.get_task(conn, child)
assert task.status == "ready"
assert task.consecutive_failures == 0
def test_recompute_ready_recovers_below_limit(kanban_home):
"""recompute_ready auto-recovers blocked tasks that haven't hit the
failure limit yet the counter is preserved across recovery."""
with kb.connect() as conn:
t = kb.create_task(conn, title="task", assignee="a")
kb.claim_task(conn, t)
# One failure, below the default limit of 2.
kb._record_task_failure(
conn, t, error="budget exhausted 1",
outcome="timed_out", release_claim=True, end_run=True,
failure_limit=2,
)
task = kb.get_task(conn, t)
assert task.status == "ready"
assert task.consecutive_failures == 1
# Simulate being blocked by something else (not circuit breaker).
conn.execute(
"UPDATE tasks SET status = 'blocked' WHERE id = ?", (t,),
)
conn.commit()
promoted = kb.recompute_ready(conn)
assert promoted == 1
task = kb.get_task(conn, t)
assert task.status == "ready"
# Counter must be preserved, not reset.
assert task.consecutive_failures == 1
def test_recompute_ready_honours_dispatcher_failure_limit(kanban_home):
"""The guard's effective limit must follow the same resolution order
as the circuit breaker (#35072): per-task max_retries → dispatcher
failure_limit DEFAULT_FAILURE_LIMIT.
Without threading the dispatcher's ``kanban.failure_limit`` through,
the guard falls back to DEFAULT_FAILURE_LIMIT and disagrees with the
breaker sticking a task prematurely (config limit > default) or
letting a tripped task escape (config limit < default).
"""
with kb.connect() as conn:
# Config allows MORE retries than the default. A task blocked
# with failures below the configured limit must still recover.
t = kb.create_task(conn, title="lenient", assignee="a")
conn.execute(
"UPDATE tasks SET status='blocked', consecutive_failures=? "
"WHERE id=?",
(kb.DEFAULT_FAILURE_LIMIT, t),
)
conn.commit()
# Default-limit call would stick it (failures >= default).
assert kb.recompute_ready(conn) == 0
assert kb.get_task(conn, t).status == "blocked"
# Dispatcher configured a higher limit → recover, preserve counter.
promoted = kb.recompute_ready(
conn, failure_limit=kb.DEFAULT_FAILURE_LIMIT + 2
)
assert promoted == 1
task = kb.get_task(conn, t)
assert task.status == "ready"
assert task.consecutive_failures == kb.DEFAULT_FAILURE_LIMIT
# Config allows FEWER retries than the default. A task at the
# stricter limit must stay blocked even though it's below default.
t2 = kb.create_task(conn, title="strict", assignee="a")
conn.execute(
"UPDATE tasks SET status='blocked', consecutive_failures=1 "
"WHERE id=?",
(t2,),
)
conn.commit()
# Default-limit (2) would recover it (1 < 2).
# Stricter config limit (1) must keep it blocked (1 >= 1).
assert kb.recompute_ready(conn, failure_limit=1) == 0
assert kb.get_task(conn, t2).status == "blocked"
def test_recompute_ready_per_task_max_retries_overrides_dispatcher(kanban_home):
"""A per-task ``max_retries`` wins over the dispatcher failure_limit,
matching ``_record_task_failure``'s resolution order."""
with kb.connect() as conn:
t = kb.create_task(conn, title="per-task", assignee="a")
# Per-task allows 4 retries; dispatcher config says 2.
conn.execute(
"UPDATE tasks SET status='blocked', consecutive_failures=2, "
"max_retries=4 WHERE id=?",
(t,),
)
conn.commit()
# failures(2) < per-task limit(4) → recover, despite dispatcher=2.
promoted = kb.recompute_ready(conn, failure_limit=2)
assert promoted == 1
task = kb.get_task(conn, t)
assert task.status == "ready"
assert task.consecutive_failures == 2
# ---------------------------------------------------------------------------
# Parent-completion invariant at the claim gate (RCA t_a6acd07d)
# ---------------------------------------------------------------------------
+139
View File
@@ -1,11 +1,74 @@
from __future__ import annotations
import sqlite3
import threading
from pathlib import Path
from hermes_cli import kanban_db as kb
def _make_legacy_db(path: Path) -> None:
"""Write a kanban DB with the pre-AUTOINCREMENT (TEXT PK) schema for the
four tables #35096 affects, keeping every other table current so the
additive-column migration runs cleanly on top.
"""
conn = sqlite3.connect(str(path))
conn.executescript(kb.SCHEMA_SQL)
conn.executescript(
"""
DROP TABLE task_events;
DROP TABLE task_comments;
DROP TABLE task_runs;
DROP TABLE kanban_notify_subs;
CREATE TABLE task_comments (id TEXT PRIMARY KEY, task_id TEXT NOT NULL,
author TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL);
CREATE TABLE task_events (id TEXT PRIMARY KEY, task_id TEXT NOT NULL,
kind TEXT NOT NULL, payload TEXT, created_at INTEGER NOT NULL);
CREATE TABLE task_runs (id TEXT PRIMARY KEY, task_id TEXT NOT NULL,
profile TEXT, status TEXT NOT NULL, started_at INTEGER NOT NULL);
CREATE TABLE kanban_notify_subs (task_id TEXT NOT NULL, platform TEXT NOT NULL,
chat_id TEXT NOT NULL, thread_id TEXT NOT NULL DEFAULT '', user_id TEXT,
created_at INTEGER NOT NULL, last_event_id TEXT,
PRIMARY KEY (task_id, platform, chat_id, thread_id));
"""
)
conn.execute("INSERT INTO tasks (id, title, status, created_at) VALUES ('task-1', 'T', 'done', 1000)")
conn.execute("INSERT INTO task_comments VALUES ('c-1', 'task-1', 'agent', 'hi', 1500)")
conn.execute("INSERT INTO task_events VALUES ('e-1', 'task-1', 'completed', NULL, 2000)")
conn.execute("INSERT INTO task_events VALUES ('e-2', 'task-1', 'blocked', NULL, 2100)")
conn.execute("INSERT INTO task_runs VALUES ('r-1', 'task-1', 'default', 'done', 1000)")
conn.execute(
"INSERT INTO kanban_notify_subs (task_id, platform, chat_id, created_at, last_event_id) "
"VALUES ('task-1', 'telegram', '123', 1000, 'e-1')"
)
conn.commit()
conn.close()
def _setup_home(tmp_path, monkeypatch) -> Path:
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
db_path = kb.kanban_db_path(board="legacy")
db_path.parent.mkdir(parents=True, exist_ok=True)
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
return db_path
def _table_struct(conn: sqlite3.Connection, table: str):
cols = [
(r["name"], (r["type"] or "").upper(), r["notnull"], r["pk"])
for r in conn.execute(f"PRAGMA table_info({table})")
]
idx = sorted(
r["name"]
for r in conn.execute(f"PRAGMA index_list({table})")
if not r["name"].startswith("sqlite_")
)
return cols, idx
def test_connect_initialization_is_thread_safe(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
@@ -36,3 +99,79 @@ def test_connect_initialization_is_thread_safe(tmp_path, monkeypatch):
with kb.connect(board="default") as conn:
cols = {row["name"] for row in conn.execute("PRAGMA table_info(tasks)")}
assert "max_retries" in cols
def test_legacy_text_pk_tables_rebuilt_to_integer_autoincrement(tmp_path, monkeypatch):
"""A pre-AUTOINCREMENT DB is migrated in place: id columns become INTEGER
PKs, ``last_event_id`` becomes INTEGER, data is preserved, and indexes
are recreated (DROP TABLE would otherwise take them down)."""
db_path = _setup_home(tmp_path, monkeypatch)
_make_legacy_db(db_path)
with kb.connect(db_path) as conn:
for table in ("task_events", "task_comments", "task_runs"):
id_col = {r["name"]: r for r in conn.execute(f"PRAGMA table_info({table})")}["id"]
assert id_col["type"].upper() == "INTEGER" and id_col["pk"] == 1
lei = {r["name"]: r for r in conn.execute("PRAGMA table_info(kanban_notify_subs)")}
assert lei["last_event_id"]["type"].upper() == "INTEGER"
# Data preserved across the rebuild.
assert len(conn.execute("SELECT * FROM task_events").fetchall()) == 2
assert conn.execute("SELECT body FROM task_comments").fetchone()["body"] == "hi"
assert len(conn.execute("SELECT * FROM task_runs").fetchall()) == 1
# Non-numeric legacy cursor ("e-1") casts to 0.
assert conn.execute("SELECT last_event_id FROM kanban_notify_subs").fetchone()["last_event_id"] == 0
# Indexes restored, including idx_events_run (added by the additive pass).
indexes = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='index'")}
for name in ("idx_events_task", "idx_events_run", "idx_comments_task",
"idx_runs_task", "idx_runs_status", "idx_notify_task"):
assert name in indexes
# AUTOINCREMENT actually works after the rebuild.
conn.execute("INSERT INTO task_events (task_id, kind, created_at) VALUES ('task-1', 'completed', 3000)")
new_id = conn.execute("SELECT id FROM task_events ORDER BY id DESC LIMIT 1").fetchone()["id"]
assert isinstance(new_id, int) and new_id >= 1
def test_rebuilt_schema_matches_fresh_db(tmp_path, monkeypatch):
"""The rebuilt tables must be structurally identical to a fresh DB, so the
hand-written DDL in ``_REBUILD_SPECS`` can't silently drift from SCHEMA_SQL."""
legacy_path = _setup_home(tmp_path, monkeypatch)
_make_legacy_db(legacy_path)
fresh_path = kb.kanban_db_path(board="fresh")
fresh_path.parent.mkdir(parents=True, exist_ok=True)
kb._INITIALIZED_PATHS.discard(str(fresh_path.resolve()))
with kb.connect(legacy_path) as migrated, kb.connect(fresh_path) as fresh:
for table in ("task_events", "task_comments", "task_runs", "kanban_notify_subs"):
assert _table_struct(migrated, table) == _table_struct(fresh, table)
def test_migration_is_idempotent(tmp_path, monkeypatch):
"""Re-opening an already-migrated DB is a no-op and leaves data intact."""
db_path = _setup_home(tmp_path, monkeypatch)
_make_legacy_db(db_path)
with kb.connect(db_path):
pass
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
with kb.connect(db_path) as conn:
id_col = {r["name"]: r for r in conn.execute("PRAGMA table_info(task_events)")}["id"]
assert id_col["type"].upper() == "INTEGER"
assert len(conn.execute("SELECT * FROM task_events").fetchall()) == 2
def test_unseen_events_for_sub_survives_migrated_db(tmp_path, monkeypatch):
"""The crash that motivated #35096 — ``int(None)`` on a NULL cursor — is
gone after migration; the notifier query returns an integer cursor."""
db_path = _setup_home(tmp_path, monkeypatch)
_make_legacy_db(db_path)
with kb.connect(db_path) as conn:
cursor, events = kb.unseen_events_for_sub(
conn, task_id="task-1", platform="telegram", chat_id="123"
)
assert isinstance(cursor, int)
assert isinstance(events, list)
+166
View File
@@ -0,0 +1,166 @@
"""Regression tests for bounded/lazy CLI MCP startup."""
from __future__ import annotations
from argparse import Namespace
import sys
import threading
import time
import types
import pytest
import cli as cli_mod
from hermes_cli import main as main_mod
from hermes_cli import mcp_startup
@pytest.fixture(autouse=True)
def _reset_mcp_startup_state():
saved_started = mcp_startup._mcp_discovery_started
saved_thread = mcp_startup._mcp_discovery_thread
try:
mcp_startup._mcp_discovery_started = False
mcp_startup._mcp_discovery_thread = None
yield
finally:
thread = mcp_startup._mcp_discovery_thread
if thread is not None and thread.is_alive():
thread.join(timeout=1.0)
mcp_startup._mcp_discovery_started = saved_started
mcp_startup._mcp_discovery_thread = saved_thread
def _agent_args(**overrides) -> Namespace:
base = {
"accept_hooks": False,
"command": "chat",
"cron_command": None,
"gateway_command": None,
"mcp_action": None,
"tui": False,
}
base.update(overrides)
return Namespace(**base)
def test_prepare_agent_startup_backgrounds_blocking_mcp_for_chat(monkeypatch):
stop = threading.Event()
calls = {"mcp": 0}
def _blocking_discover():
calls["mcp"] += 1
stop.wait()
monkeypatch.setitem(
sys.modules,
"hermes_cli.plugins",
types.SimpleNamespace(discover_plugins=lambda: None),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.config",
types.SimpleNamespace(
read_raw_config=lambda: {"mcp_servers": {"demo": {"transport": "stdio"}}},
load_config=lambda: {},
),
)
monkeypatch.setitem(
sys.modules,
"agent.shell_hooks",
types.SimpleNamespace(register_from_config=lambda *_a, **_k: None),
)
monkeypatch.setitem(
sys.modules,
"tools.mcp_tool",
types.SimpleNamespace(discover_mcp_tools=_blocking_discover),
)
try:
start = time.monotonic()
main_mod._prepare_agent_startup(_agent_args())
elapsed = time.monotonic() - start
assert elapsed < 0.2
assert calls["mcp"] == 1
assert mcp_startup._mcp_discovery_thread is not None
assert mcp_startup._mcp_discovery_thread.is_alive()
finally:
stop.set()
def test_prepare_agent_startup_skips_mcp_bootstrap_for_tui_chat(monkeypatch):
calls = {"mcp": 0}
monkeypatch.setitem(
sys.modules,
"hermes_cli.plugins",
types.SimpleNamespace(discover_plugins=lambda: None),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.config",
types.SimpleNamespace(load_config=lambda: {}),
)
monkeypatch.setitem(
sys.modules,
"agent.shell_hooks",
types.SimpleNamespace(register_from_config=lambda *_a, **_k: None),
)
monkeypatch.setitem(
sys.modules,
"tools.mcp_tool",
types.SimpleNamespace(
discover_mcp_tools=lambda: calls.__setitem__("mcp", calls["mcp"] + 1)
),
)
main_mod._prepare_agent_startup(_agent_args(tui=True))
assert calls["mcp"] == 0
assert mcp_startup._mcp_discovery_thread is None
def test_cli_get_tool_definitions_briefly_waits_for_fast_mcp_thread(monkeypatch):
thread = threading.Thread(target=lambda: time.sleep(0.05), daemon=True)
thread.start()
mcp_startup._mcp_discovery_thread = thread
monkeypatch.setitem(
sys.modules,
"model_tools",
types.SimpleNamespace(get_tool_definitions=lambda *_a, **_k: ["ok"]),
)
start = time.monotonic()
result = cli_mod.get_tool_definitions(enabled_toolsets=["web"], quiet_mode=True)
elapsed = time.monotonic() - start
assert result == ["ok"]
assert elapsed >= 0.04
assert not thread.is_alive()
def test_init_agent_waits_for_mcp_discovery_before_agent_build(monkeypatch):
waited = {"done": False}
cli = cli_mod.HermesCLI(compact=True)
cli._session_db = object()
cli._resumed = False
cli.conversation_history = []
cli._install_tool_callbacks = lambda: None
cli._ensure_tirith_security = lambda: None
cli._ensure_runtime_credentials = lambda: True
monkeypatch.setattr(
mcp_startup,
"wait_for_mcp_discovery",
lambda timeout=0.75: waited.__setitem__("done", True),
)
def _fake_agent(*_a, **_k):
assert waited["done"] is True
return types.SimpleNamespace()
monkeypatch.setattr(cli_mod, "AIAgent", _fake_agent)
assert cli._init_agent() is True
@@ -0,0 +1,50 @@
"""Tests for `hermes memory setup [provider]` routing.
The `memory setup` subcommand accepts an optional positional ``provider`` so a
fresh install can configure a specific provider directly (e.g.
``hermes memory setup honcho``) without the interactive picker which matters
because the per-provider ``hermes <provider>`` subcommand is only registered
once that provider is active.
"""
from types import SimpleNamespace
from unittest.mock import patch
from hermes_cli import memory_setup
class TestMemorySetupProviderRouting:
def test_setup_with_provider_arg_skips_picker(self):
"""`memory setup honcho` routes straight to cmd_setup_provider."""
args = SimpleNamespace(memory_command="setup", provider="honcho")
with patch.object(memory_setup, "cmd_setup_provider") as direct, \
patch.object(memory_setup, "cmd_setup") as picker:
memory_setup.memory_command(args)
direct.assert_called_once_with("honcho")
picker.assert_not_called()
def test_setup_without_provider_runs_picker(self):
"""`memory setup` (no provider) runs the interactive picker."""
args = SimpleNamespace(memory_command="setup", provider=None)
with patch.object(memory_setup, "cmd_setup_provider") as direct, \
patch.object(memory_setup, "cmd_setup") as picker:
memory_setup.memory_command(args)
picker.assert_called_once_with(args)
direct.assert_not_called()
def test_setup_with_missing_provider_attr_runs_picker(self):
"""A SimpleNamespace lacking `provider` must not crash — fall back to picker."""
args = SimpleNamespace(memory_command="setup")
with patch.object(memory_setup, "cmd_setup_provider") as direct, \
patch.object(memory_setup, "cmd_setup") as picker:
memory_setup.memory_command(args)
picker.assert_called_once_with(args)
direct.assert_not_called()
def test_unknown_provider_reports_and_returns_early(self, capsys):
"""An unknown provider name surfaces a helpful message and returns
before any config load/save (the not-found guard precedes those imports)."""
memory_setup.cmd_setup_provider("notaprovider")
out = capsys.readouterr().out
assert "not found" in out
assert "hermes memory setup" in out
-32
View File
@@ -142,10 +142,6 @@ class TestCuratedModelsForProvider:
assert len(models) > 0
assert any("claude" in m[0] for m in models)
def test_zai_returns_glm_models(self):
models = curated_models_for_provider("zai")
assert any("glm" in m[0] for m in models)
def test_unknown_provider_returns_empty(self):
assert curated_models_for_provider("totally-unknown") == []
@@ -199,9 +195,6 @@ class TestProviderModelIds:
def test_unknown_provider_returns_empty(self):
assert provider_model_ids("some-unknown-provider") == []
def test_zai_returns_glm_models(self):
assert "glm-5" in provider_model_ids("zai")
def test_stepfun_prefers_live_catalog(self):
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
@@ -222,31 +215,6 @@ class TestProviderModelIds:
patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]):
assert provider_model_ids("copilot-acp") == ["gpt-5.4", "claude-sonnet-4.6"]
def test_copilot_falls_back_to_curated_defaults_without_stale_opus(self):
with patch("hermes_cli.models._resolve_copilot_catalog_api_key", return_value="gh-token"), \
patch("hermes_cli.models._fetch_github_models", return_value=None):
ids = provider_model_ids("copilot")
assert "gpt-5.4" in ids
assert "claude-sonnet-4.6" in ids
assert "claude-sonnet-4" in ids
assert "claude-sonnet-4.5" in ids
assert "claude-haiku-4.5" in ids
assert "gemini-3.1-pro-preview" in ids
assert "claude-opus-4.6" not in ids
def test_copilot_acp_falls_back_to_copilot_defaults(self):
with patch("hermes_cli.models._resolve_copilot_catalog_api_key", return_value="gh-token"), \
patch("hermes_cli.models._fetch_github_models", return_value=None):
ids = provider_model_ids("copilot-acp")
assert "gpt-5.4" in ids
assert "claude-sonnet-4.6" in ids
assert "claude-sonnet-4" in ids
assert "gemini-3.1-pro-preview" in ids
assert "copilot-acp" not in ids
assert "claude-opus-4.6" not in ids
# -- fetch_api_models --------------------------------------------------------
-4
View File
@@ -56,10 +56,6 @@ class TestOpenRouterModels:
assert isinstance(mid, str) and len(mid) > 0
assert isinstance(desc, str)
def test_at_least_5_models(self):
"""Sanity check that the models list hasn't been accidentally truncated."""
assert len(OPENROUTER_MODELS) >= 5
class TestFetchOpenRouterModels:
def test_live_fetch_recomputes_free_tags(self, monkeypatch):
@@ -231,3 +231,93 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
assert "web" in has_direct
assert "web" not in already_managed
assert set(unconfigured) == {"image_gen", "video_gen", "tts", "browser"}
def test_apply_nous_managed_defaults_writes_video_gen_config(monkeypatch):
"""apply_nous_managed_defaults must write video_gen.provider and
video_gen.use_gateway when a Nous subscriber selects video_gen
without a direct FAL_KEY."""
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda **kw: True)
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setattr(ns, "fal_key_is_configured", lambda: False)
monkeypatch.setattr(
ns, "get_nous_portal_account_info",
lambda **kw: _account(logged_in=True, paid=True),
)
config = {"model": {"provider": "nous"}}
changed = ns.apply_nous_managed_defaults(
config, enabled_toolsets=["video_gen"],
)
assert "video_gen" in changed
assert config["video_gen"]["provider"] == "fal"
assert config["video_gen"]["use_gateway"] is True
def test_apply_nous_managed_defaults_writes_image_gen_config(monkeypatch):
"""apply_nous_managed_defaults must write image_gen.use_gateway
when a Nous subscriber selects image_gen without a direct FAL_KEY."""
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda **kw: True)
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setattr(ns, "fal_key_is_configured", lambda: False)
monkeypatch.setattr(
ns, "get_nous_portal_account_info",
lambda **kw: _account(logged_in=True, paid=True),
)
config = {"model": {"provider": "nous"}}
changed = ns.apply_nous_managed_defaults(
config, enabled_toolsets=["image_gen"],
)
assert "image_gen" in changed
assert config["image_gen"]["use_gateway"] is True
def test_apply_nous_managed_defaults_skips_fal_tools_when_key_present(monkeypatch):
"""When FAL_KEY is set, apply_nous_managed_defaults should not touch
image_gen or video_gen config the user's direct key takes precedence."""
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda **kw: True)
monkeypatch.setenv("FAL_KEY", "fal-direct-key")
monkeypatch.setattr(ns, "fal_key_is_configured", lambda: True)
monkeypatch.setattr(
ns, "get_nous_portal_account_info",
lambda **kw: _account(logged_in=True, paid=True),
)
config = {"model": {"provider": "nous"}}
changed = ns.apply_nous_managed_defaults(
config, enabled_toolsets=["image_gen", "video_gen"],
)
assert "image_gen" not in changed
assert "video_gen" not in changed
assert "image_gen" not in config
assert "video_gen" not in config
def test_apply_nous_managed_defaults_preserves_existing_video_gen_section(monkeypatch):
"""When video_gen config already exists as a dict, the function should
update it in-place rather than replacing it."""
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda **kw: True)
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setattr(ns, "fal_key_is_configured", lambda: False)
monkeypatch.setattr(
ns, "get_nous_portal_account_info",
lambda **kw: _account(logged_in=True, paid=True),
)
config = {
"model": {"provider": "nous"},
"video_gen": {"model": "pixverse-v6"},
}
changed = ns.apply_nous_managed_defaults(
config, enabled_toolsets=["video_gen"],
)
assert "video_gen" in changed
assert config["video_gen"]["provider"] == "fal"
assert config["video_gen"]["use_gateway"] is True
# Pre-existing keys should be preserved
assert config["video_gen"]["model"] == "pixverse-v6"
@@ -495,12 +495,3 @@ class TestOllamaCloudSuffixStripping:
assert _strip_ollama_cloud_suffix("qwen3-coder:480b-cloud") == "qwen3-coder:480b"
assert _strip_ollama_cloud_suffix("nemotron-3-nano:30b") == "nemotron-3-nano:30b"
assert _strip_ollama_cloud_suffix("") == ""
# ── Auxiliary Model ──
class TestOllamaCloudAuxiliary:
def test_aux_model_defined(self):
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
assert "ollama-cloud" in _API_KEY_PROVIDER_AUX_MODELS
assert _API_KEY_PROVIDER_AUX_MODELS["ollama-cloud"] == "nemotron-3-nano:30b"
+22 -2
View File
@@ -48,12 +48,32 @@ def test_stamp_file_takes_precedence(tmp_path):
assert detect_install_method(project_root=tmp_path) == "docker"
def test_docker_detected_via_dockerenv(tmp_path):
def test_container_without_stamp_is_not_docker(tmp_path):
"""An unstamped install in a generic container must NOT be flagged as docker.
Regression for issue #34397. The two supported installs both stamp
``.install_method`` (the curl installer -> ``git``, covered by
``test_stamp_file_takes_precedence``; the published image -> ``docker``),
so neither hits this path. An unsupported manual install dropped into a
container has no stamp and was wrongly classified as the published Docker
image, so ``hermes update`` refused to run. With a ``.git`` checkout it
must resolve to ``git``.
"""
(tmp_path / ".git").mkdir()
with patch("hermes_cli.config.get_managed_system", return_value=None), \
patch("hermes_cli.config.get_hermes_home", return_value=tmp_path), \
patch("hermes_constants.is_container", return_value=True):
from hermes_cli.config import detect_install_method
assert detect_install_method(project_root=tmp_path) == "docker"
assert detect_install_method(project_root=tmp_path) == "git"
def test_container_pip_install_without_stamp_is_pip(tmp_path):
"""Container + no .git + no stamp -> pip, not docker (issue #34397)."""
with patch("hermes_cli.config.get_managed_system", return_value=None), \
patch("hermes_cli.config.get_hermes_home", return_value=tmp_path), \
patch("hermes_constants.is_container", return_value=True):
from hermes_cli.config import detect_install_method
assert detect_install_method(project_root=tmp_path) == "pip"
def test_recommended_update_command_docker():
+6 -6
View File
@@ -754,8 +754,8 @@ class TestRenameProfile:
cfg = json.loads(honcho_path.read_text())
assert "hermes.ssi_health" not in cfg["hosts"]
assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "ssi_health"
assert cfg["hosts"]["hermes.heimdall"]["peerName"] == "user-peer"
assert cfg["hosts"]["hermes_heimdall"]["aiPeer"] == "ssi_health"
assert cfg["hosts"]["hermes_heimdall"]["peerName"] == "user-peer"
def test_pins_ai_peer_when_absent_on_honcho_host_rename(self, profile_env):
tmp_path = profile_env
@@ -772,8 +772,8 @@ class TestRenameProfile:
cfg = json.loads(honcho_path.read_text())
assert "hermes.ssi_health" not in cfg["hosts"]
assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "ssi_health"
assert cfg["hosts"]["hermes.heimdall"]["workspace"] == "hermes"
assert cfg["hosts"]["hermes_heimdall"]["aiPeer"] == "ssi_health"
assert cfg["hosts"]["hermes_heimdall"]["workspace"] == "hermes"
def test_does_not_overwrite_existing_honcho_host_on_rename(self, profile_env):
tmp_path = profile_env
@@ -782,7 +782,7 @@ class TestRenameProfile:
honcho_path.write_text(json.dumps({
"hosts": {
"hermes.ssi_health": {"aiPeer": "ssi_health"},
"hermes.heimdall": {"aiPeer": "heimdall"},
"hermes_heimdall": {"aiPeer": "heimdall"},
}
}))
@@ -791,7 +791,7 @@ class TestRenameProfile:
cfg = json.loads(honcho_path.read_text())
assert cfg["hosts"]["hermes.ssi_health"]["aiPeer"] == "ssi_health"
assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "heimdall"
assert cfg["hosts"]["hermes_heimdall"]["aiPeer"] == "heimdall"
def test_default_raises_value_error(self, profile_env):
with pytest.raises(ValueError, match="default"):
+118
View File
@@ -0,0 +1,118 @@
"""Tests for the ``hermes prompt-size`` diagnostic (issue #34667)."""
import json
import pytest
from hermes_cli.prompt_size import (
_SKILLS_BLOCK_RE,
compute_prompt_breakdown,
render_breakdown,
)
def _seed_memory(hermes_home, memory_text="", user_text=""):
mem_dir = hermes_home / "memories"
mem_dir.mkdir(parents=True, exist_ok=True)
if memory_text:
(mem_dir / "MEMORY.md").write_text(memory_text, encoding="utf-8")
if user_text:
(mem_dir / "USER.md").write_text(user_text, encoding="utf-8")
def _seed_skill(hermes_home, name, description):
skill_dir = hermes_home / "skills" / "demo" / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\n---\n# {name}\nbody\n",
encoding="utf-8",
)
@pytest.fixture
def isolated_home(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.chdir(tmp_path) # avoid picking up the repo's AGENTS.md
return hermes_home
def test_breakdown_keys_and_shape(isolated_home):
"""The breakdown exposes every documented key with int byte/char counts."""
data = compute_prompt_breakdown("cli")
assert set(data) >= {
"platform",
"model",
"system_prompt",
"skills_index",
"memory",
"user_profile",
"tools",
"sections",
}
assert data["platform"] == "cli"
for key in ("system_prompt", "skills_index", "memory", "user_profile"):
assert data[key]["bytes"] >= 0
assert data[key]["chars"] >= 0
assert data["tools"]["count"] >= 0
assert data["tools"]["json_bytes"] >= 0
# System prompt is non-trivial even with empty home (identity + guidance).
assert data["system_prompt"]["bytes"] > 0
def test_runs_offline_without_credentials(isolated_home, monkeypatch):
"""No provider credentials configured → still produces a breakdown."""
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "NOUS_API_KEY",
"ANTHROPIC_API_KEY"):
monkeypatch.delenv(var, raising=False)
data = compute_prompt_breakdown("cli")
assert data["system_prompt"]["bytes"] > 0
def test_skills_index_reflects_installed_skills(isolated_home):
"""Installing a skill makes the skills-index block non-empty.
Note: the skills prompt is cached per-process (in-process LRU + disk
snapshot), so we seed the skill BEFORE the first build rather than
comparing before/after within one process.
"""
_seed_skill(isolated_home, "hello", "a demo skill for size testing")
data = compute_prompt_breakdown("cli")
assert data["skills_index"]["bytes"] > 0
def test_memory_and_profile_are_attributed(isolated_home):
"""Memory and user-profile blocks are measured separately."""
_seed_memory(
isolated_home,
memory_text="Project uses pytest.\n",
user_text="User is a developer.\n",
)
data = compute_prompt_breakdown("cli")
assert data["memory"]["bytes"] > 0
assert data["user_profile"]["bytes"] > 0
def test_skills_block_regex_matches_tagged_block():
text = "preamble\n<available_skills>\n cat:\n - a: b\n</available_skills>\ntail"
m = _SKILLS_BLOCK_RE.search(text)
assert m is not None
assert m.group(0).startswith("<available_skills>")
assert m.group(0).endswith("</available_skills>")
def test_render_breakdown_is_plain_text(isolated_home):
data = compute_prompt_breakdown("cli")
out = render_breakdown(data)
assert "System prompt total" in out
assert "skills index" in out
assert "Tool schemas" in out
# Plain text — no JSON braces leaking in.
assert not out.strip().startswith("{")
def test_json_serializable(isolated_home):
data = compute_prompt_breakdown("cli")
# Round-trips cleanly for ``--json`` output.
assert json.loads(json.dumps(data)) == json.loads(json.dumps(data))
+118
View File
@@ -0,0 +1,118 @@
"""Tests for provider-group folding (display-only picker grouping).
These are invariant tests, not catalog snapshots: they assert how
``group_providers`` folds a flat slug list and how member slugs relate to
``PROVIDER_GROUPS`` / ``CANONICAL_PROVIDERS`` not the specific set of
vendors, which is expected to change over time.
"""
from hermes_cli.models import (
CANONICAL_PROVIDERS,
PROVIDER_GROUPS,
group_providers,
provider_group_for_slug,
)
def _slugs(rows):
"""Flatten picker rows back to the concrete slugs they expose."""
out = []
for r in rows:
if r["kind"] == "single":
out.append(r["slug"])
else:
out.extend(r["members"])
return out
def test_groups_reference_real_canonical_slugs():
"""Every group member must be an actual provider slug. Guards typos and
stale group entries after a provider is renamed/removed."""
canonical = {p.slug for p in CANONICAL_PROVIDERS}
for gid, (label, members) in PROVIDER_GROUPS.items():
assert label, f"group {gid} has empty label"
assert len(members) >= 1
for m in members:
assert m in canonical, f"group {gid} member {m!r} is not a canonical slug"
def test_member_slugs_are_unique_across_groups():
"""A slug may belong to at most one group."""
seen = {}
for gid, (_label, members) in PROVIDER_GROUPS.items():
for m in members:
assert m not in seen, f"{m!r} in both {seen[m]!r} and {gid!r}"
seen[m] = gid
def test_reverse_index_matches_groups():
for gid, (_label, members) in PROVIDER_GROUPS.items():
for m in members:
assert provider_group_for_slug(m) == gid
assert provider_group_for_slug("openrouter") == ""
assert provider_group_for_slug("") == ""
def test_ungrouped_providers_pass_through_in_order():
rows = group_providers(["nous", "openrouter", "deepseek"])
assert all(r["kind"] == "single" for r in rows)
assert [r["slug"] for r in rows] == ["nous", "openrouter", "deepseek"]
def test_multi_member_group_folds_to_one_row():
rows = group_providers(["minimax", "minimax-oauth", "minimax-cn"])
assert len(rows) == 1
row = rows[0]
assert row["kind"] == "group"
assert row["group_id"] == "minimax"
assert row["members"] == ["minimax", "minimax-oauth", "minimax-cn"]
def test_group_appears_at_first_member_position():
"""The group row takes the slot of its earliest-listed present member,
and later members do not re-emit."""
rows = group_providers(["nous", "minimax", "deepseek", "minimax-cn"])
kinds = [(r["kind"], r.get("group_id") or r.get("slug")) for r in rows]
assert kinds == [
("single", "nous"),
("group", "minimax"),
("single", "deepseek"),
]
# both minimax members folded into the single group row
assert rows[1]["members"] == ["minimax", "minimax-cn"]
def test_single_present_member_degrades_to_single_row():
"""A group with only one present member shows no submenu."""
rows = group_providers(["xai"]) # xai-oauth absent
assert len(rows) == 1
assert rows[0]["kind"] == "single"
assert rows[0]["slug"] == "xai"
def test_member_order_follows_declaration_not_input():
"""Inside a folded group, members are ordered by PROVIDER_GROUPS, not by
the order they appeared in the input list."""
rows = group_providers(["minimax-cn", "minimax", "minimax-oauth"])
assert rows[0]["members"] == ["minimax", "minimax-oauth", "minimax-cn"]
def test_duplicate_slugs_ignored():
rows = group_providers(["nous", "nous", "minimax", "minimax"])
assert [r.get("slug") or r["group_id"] for r in rows] == ["nous", "minimax"]
def test_fold_is_lossless_for_present_slugs():
"""Every input slug (deduped) must still be reachable through the folded
rows grouping hides nothing."""
flat = [p.slug for p in CANONICAL_PROVIDERS]
rows = group_providers(flat)
assert set(_slugs(rows)) == set(flat)
def test_canonical_fold_row_count_shrinks():
"""Folding the full canonical list produces fewer top-level rows than the
flat list (proves grouping actually consolidates)."""
flat = [p.slug for p in CANONICAL_PROVIDERS]
rows = group_providers(flat)
assert len(rows) < len(flat)
+60
View File
@@ -757,8 +757,68 @@ def test_first_install_nous_auto_configures_managed_defaults(monkeypatch):
assert config["web"]["backend"] == "firecrawl"
assert config["tts"]["provider"] == "openai"
assert config["browser"]["cloud_provider"] == "browser-use"
assert config["image_gen"]["use_gateway"] is True
assert configured == []
def test_first_install_nous_auto_configures_video_gen(monkeypatch):
"""When a Nous subscriber checks video_gen in the toolset checklist,
apply_nous_managed_defaults must write video_gen.provider and
video_gen.use_gateway so the FAL plugin can route through the gateway
at runtime. Regression test for the bug where video_gen was marked as
auto-configured but no config was actually written."""
monkeypatch.setattr("hermes_cli.nous_subscription.managed_nous_tools_enabled", lambda: True)
config = {
"model": {"provider": "nous"},
"platform_toolsets": {"cli": []},
}
for env_var in (
"VOICE_TOOLS_OPENAI_KEY",
"OPENAI_API_KEY",
"ELEVENLABS_API_KEY",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"TAVILY_API_KEY",
"PARALLEL_API_KEY",
"BROWSERBASE_API_KEY",
"BROWSERBASE_PROJECT_ID",
"BROWSER_USE_API_KEY",
"FAL_KEY",
):
monkeypatch.delenv(env_var, raising=False)
monkeypatch.setattr(
"hermes_cli.tools_config._prompt_toolset_checklist",
lambda *args, **kwargs: {"video_gen"},
)
monkeypatch.setattr("hermes_cli.tools_config.save_config", lambda config: None)
monkeypatch.setattr(
"hermes_cli.tools_config._get_enabled_platforms",
lambda: ["cli"],
)
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_portal_account_info",
lambda *args, **kwargs: NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=True,
),
)
configured = []
monkeypatch.setattr(
"hermes_cli.tools_config._configure_toolset",
lambda ts_key, config: configured.append(ts_key),
)
tools_command(first_install=True, config=config)
assert config["video_gen"]["provider"] == "fal"
assert config["video_gen"]["use_gateway"] is True
# video_gen should NOT appear in the manual configure list — it's auto-configured
assert "video_gen" not in configured
# ── Platform / toolset consistency ────────────────────────────────────────────
+54
View File
@@ -638,6 +638,60 @@ def test_oneshot_rejects_invalid_only_toolsets(monkeypatch, capsys):
assert "did not contain any valid toolsets" in err
def test_oneshot_fails_closed_on_empty_final_response(monkeypatch, capsys):
_stub_plugin_discovery(monkeypatch)
import hermes_cli.oneshot as oneshot_mod
monkeypatch.setattr(oneshot_mod, "_run_agent", lambda *_args, **_kwargs: "")
assert oneshot_mod.run_oneshot("hello") == 1
captured = capsys.readouterr()
assert captured.out == ""
assert "no final response" in captured.err
def test_oneshot_prints_nonempty_final_response(monkeypatch, capsys):
_stub_plugin_discovery(monkeypatch)
import hermes_cli.oneshot as oneshot_mod
monkeypatch.setattr(oneshot_mod, "_run_agent", lambda *_args, **_kwargs: "done")
assert oneshot_mod.run_oneshot("hello") == 0
captured = capsys.readouterr()
assert captured.out == "done\n"
assert captured.err == ""
def test_oneshot_fails_closed_on_agent_exception(monkeypatch, capsys):
_stub_plugin_discovery(monkeypatch)
import hermes_cli.oneshot as oneshot_mod
def _boom(*_args, **_kwargs):
raise OSError("not a TTY")
monkeypatch.setattr(oneshot_mod, "_run_agent", _boom)
assert oneshot_mod.run_oneshot("hello") == 1
captured = capsys.readouterr()
assert captured.out == ""
assert "agent failed" in captured.err
assert "not a TTY" in captured.err
def test_oneshot_reraises_keyboard_interrupt(monkeypatch):
_stub_plugin_discovery(monkeypatch)
import hermes_cli.oneshot as oneshot_mod
import pytest as _pytest
def _interrupt(*_args, **_kwargs):
raise KeyboardInterrupt
monkeypatch.setattr(oneshot_mod, "_run_agent", _interrupt)
with _pytest.raises(KeyboardInterrupt):
oneshot_mod.run_oneshot("hello")
def test_oneshot_filters_invalid_toolsets_before_redirect(monkeypatch, capsys):
_stub_plugin_discovery(monkeypatch)
from hermes_cli.oneshot import _validate_explicit_toolsets
@@ -128,24 +128,31 @@ def test_detect_concurrent_is_noop_off_windows(_winp, tmp_path):
def _fake_psutil_with_parent_chain(
parent_chain: list[int],
proc_iter_rows: list,
*,
ancestor_exe: str | None = None,
):
"""Build a psutil stand-in that has Process()/parent() AND process_iter().
"""Build a psutil stand-in that has Process()/parents()/exe() AND process_iter().
``parent_chain`` is the list of PIDs returned by successive ``.parent()``
calls starting from the seed (``os.getpid()``); the last entry's
``.parent()`` returns ``None`` to terminate the walk.
``parent_chain`` is the ordered list of ancestor PIDs (closest first)
returned by ``proc.parents()`` on the seed (``os.getpid()``).
``ancestor_exe`` is the executable path reported by each ancestor's
``.exe()``; when it matches one of our shim paths the ancestor is
excluded (the launcher-shim case). Pass ``None`` to model an ancestor
whose exe can't be read (psutil error) — it stays in the candidate set.
"""
class _FakeProc:
def __init__(self, pid: int, chain: list[int]):
def __init__(self, pid: int, exe_path: str | None):
self.pid = pid
self._chain = chain
self._exe = exe_path
def parent(self):
if not self._chain:
return None
next_pid = self._chain[0]
return _FakeProc(next_pid, self._chain[1:])
def exe(self):
if self._exe is None:
raise OSError("exe unavailable")
return self._exe
def parents(self):
return [_FakeProc(p, ancestor_exe) for p in parent_chain]
class _NoSuchProcess(Exception):
pass
@@ -153,8 +160,8 @@ def _fake_psutil_with_parent_chain(
class _AccessDenied(Exception):
pass
def _process(pid):
return _FakeProc(pid, list(parent_chain))
def _process(pid=None):
return _FakeProc(pid if pid is not None else os.getpid(), ancestor_exe)
return types.SimpleNamespace(
Process=_process,
@@ -185,6 +192,7 @@ def test_detect_concurrent_excludes_parent_chain(_winp, tmp_path):
fake_psutil = _fake_psutil_with_parent_chain(
parent_chain=[launcher_pid],
proc_iter_rows=rows,
ancestor_exe=str(shim),
)
with patch.dict(sys.modules, {"psutil": fake_psutil}):
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
@@ -211,6 +219,7 @@ def test_detect_concurrent_still_finds_unrelated_other_hermes(_winp, tmp_path):
fake_psutil = _fake_psutil_with_parent_chain(
parent_chain=[launcher_pid],
proc_iter_rows=rows,
ancestor_exe=str(shim),
)
with patch.dict(sys.modules, {"psutil": fake_psutil}):
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
@@ -238,6 +247,7 @@ def test_detect_concurrent_parent_chain_walks_deep(_winp, tmp_path):
fake_psutil = _fake_psutil_with_parent_chain(
parent_chain=[parent_pid, grandparent_pid, greatgrandparent_pid],
proc_iter_rows=rows,
ancestor_exe=str(shim),
)
with patch.dict(sys.modules, {"psutil": fake_psutil}):
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
@@ -246,25 +256,38 @@ def test_detect_concurrent_parent_chain_walks_deep(_winp, tmp_path):
@patch.object(cli_main, "_is_windows", return_value=True)
def test_detect_concurrent_parent_walk_handles_cycle(_winp, tmp_path):
"""A PID cycle in the parent chain must not hang the walk."""
def test_detect_concurrent_parents_call_robust_to_one_bad_hop(_winp, tmp_path):
"""The launcher shim is still excluded even when an ancestor exe is unreadable.
Field regression (issues #29341, #34795): the old per-hop ``parent()``
walk bailed on the FIRST psutil error, so an AccessDenied on any hop left
the launcher shim in the candidate set and re-triggered the false
positive. ``parents()`` returns the whole list at once; we evaluate each
ancestor independently, so one unreadable hop never strands the launcher.
"""
scripts_dir = tmp_path
shim = scripts_dir / "hermes.exe"
shim.write_bytes(b"")
me = os.getpid()
bogus_loop_pid = me + 1
launcher_pid = me + 100
rows = [_make_proc(me, str(shim), "python.exe")]
# Chain that points back to ``me`` — the loop-detection branch must break.
rows = [
_make_proc(me, str(shim), "python.exe"),
_make_proc(launcher_pid, str(shim), "hermes.exe"),
]
# ancestor_exe=None → every ancestor's .exe() raises OSError. The helper
# must swallow it per-ancestor and not crash; the launcher won't be
# excluded in this degenerate case, but a real run reads the shim exe.
fake_psutil = _fake_psutil_with_parent_chain(
parent_chain=[bogus_loop_pid, me, bogus_loop_pid],
parent_chain=[launcher_pid],
proc_iter_rows=rows,
ancestor_exe=None,
)
with patch.dict(sys.modules, {"psutil": fake_psutil}):
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
# No crash, no hang; self + bogus_loop_pid excluded; no others reported.
assert result == []
# No crash; helper completes. (Degenerate stub: launcher exe unreadable.)
assert result == [(launcher_pid, "hermes.exe")]
@patch.object(cli_main, "_is_windows", return_value=True)
@@ -310,6 +333,11 @@ def test_format_message_mentions_pids_and_remediation(tmp_path):
assert "--force" in msg
# Mentions the file that would have been overwritten
assert str(tmp_path / "hermes.exe") in msg
# Self-service kill command targets the exact stale PIDs (issue #34795).
assert "taskkill" in msg
assert "/PID 1234" in msg
assert "/PID 5678" in msg
assert "/F" in msg
# ---------------------------------------------------------------------------
+311
View File
@@ -0,0 +1,311 @@
"""Tests for uv-tool install detection in the update path (issue #29700).
``uv tool install hermes-agent`` lives outside any venv, so the previous
``uv pip install --upgrade`` update path failed with ``No virtual
environment found``. ``is_uv_tool_install`` should detect this layout and
both the user-facing recommended command and the actual
``_cmd_update_pip`` subprocess invocation should switch to
``uv tool upgrade hermes-agent``.
Detection is restricted to properties of the running interpreter
(``sys.prefix`` / ``sys.executable``) so a pip/venv install on a machine
that also has ``uv tool install hermes-agent`` does not get misclassified.
"""
from __future__ import annotations
import subprocess
from types import SimpleNamespace
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# is_uv_tool_install
# ---------------------------------------------------------------------------
class TestIsUvToolInstall:
def test_returns_true_when_sys_prefix_matches_uv_tool_layout(self):
from hermes_cli import config
with patch.object(config.sys, "prefix", "/home/user/.local/share/uv/tools/hermes-agent"):
assert config.is_uv_tool_install() is True
def test_returns_true_when_sys_executable_matches_uv_tool_layout(self):
"""Some uv-tool layouts surface the marker on ``sys.executable`` (bin/python)."""
from hermes_cli import config
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
patch.object(
config.sys,
"executable",
"/home/user/.local/share/uv/tools/hermes-agent/bin/python",
):
assert config.is_uv_tool_install() is True
def test_returns_false_when_neither_prefix_nor_executable_matches(self):
from hermes_cli import config
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
patch.object(config.sys, "executable", "/usr/bin/python3"):
assert config.is_uv_tool_install() is False
def test_does_not_consult_uv_tool_list(self):
"""Detection must NOT shell out: ``uv tool list`` would false-positive
when the active install is pip/venv but the machine also has
``uv tool install hermes-agent`` somewhere on disk. Copilot review on
PR #29703 flagged this; the fix is to never call ``uv tool list``
from the detection path."""
from hermes_cli import config
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
patch.object(config.sys, "executable", "/usr/bin/python3"), \
patch("subprocess.run") as mock_run:
assert config.is_uv_tool_install() is False
mock_run.assert_not_called()
def test_case_insensitive_match(self):
"""Match must be case-insensitive — Windows paths preserve case
(e.g. ``...AppData\\Local\\UV\\Tools\\hermes-agent``) and a case-sensitive
check would miss them. We exercise the lower-cased compare path here
without monkey-patching ``os.sep``, which would break the whole suite."""
from hermes_cli import config
with patch.object(
config.sys, "prefix", "/HOME/USER/.local/share/UV/Tools/hermes-agent"
):
assert config.is_uv_tool_install() is True
def test_handles_empty_executable(self):
from hermes_cli import config
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
patch.object(config.sys, "executable", ""):
assert config.is_uv_tool_install() is False
# ---------------------------------------------------------------------------
# recommended_update_command_for_method
# ---------------------------------------------------------------------------
class TestRecommendedUpdateCommandForUvTool:
def test_uv_tool_install_recommends_uv_tool_upgrade(self):
from hermes_cli import config
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch.object(config, "is_uv_tool_install", return_value=True):
cmd = config.recommended_update_command_for_method("pip")
assert cmd == "uv tool upgrade hermes-agent"
def test_uv_tool_install_recommends_uv_tool_upgrade_even_without_uv_on_path(self):
"""Recommendation reflects the *install method*, not whether ``uv`` is
currently on PATH the user needs to know the right command to run."""
from hermes_cli import config
with patch("shutil.which", return_value=None), \
patch.object(config, "is_uv_tool_install", return_value=True):
cmd = config.recommended_update_command_for_method("pip")
assert cmd == "uv tool upgrade hermes-agent"
def test_uv_pip_install_keeps_legacy_recommendation(self):
"""Existing behavior: uv is on PATH but Hermes is a regular pip install."""
from hermes_cli import config
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch.object(config, "is_uv_tool_install", return_value=False):
cmd = config.recommended_update_command_for_method("pip")
assert cmd == "uv pip install --upgrade hermes-agent"
def test_no_uv_falls_back_to_plain_pip(self):
from hermes_cli import config
with patch("shutil.which", return_value=None), \
patch.object(config, "is_uv_tool_install", return_value=False):
cmd = config.recommended_update_command_for_method("pip")
assert cmd == "pip install --upgrade hermes-agent"
def test_recommendation_does_not_spawn_subprocess(self):
"""Computing the recommendation string must be cheap — no ``uv tool list``
spawn. Copilot review on PR #29703 flagged the prior subprocess hop
as adding overhead and a multi-second timeout window for what is
purely a display string."""
from hermes_cli import config
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
patch.object(config.sys, "executable", "/usr/bin/python3"), \
patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch("subprocess.run") as mock_run:
cmd = config.recommended_update_command_for_method("pip")
mock_run.assert_not_called()
assert cmd == "uv pip install --upgrade hermes-agent"
# ---------------------------------------------------------------------------
# _cmd_update_pip subprocess command
# ---------------------------------------------------------------------------
class TestCmdUpdatePipUsesUvTool:
@patch("subprocess.run")
def test_runs_uv_tool_upgrade_when_uv_tool_install(self, mock_run):
"""The actual subprocess invocation must switch to ``uv tool upgrade``."""
from hermes_cli.main import _cmd_update_pip
mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="")
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=True):
_cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == ["/usr/local/bin/uv", "tool", "upgrade", "hermes-agent"]
@patch("subprocess.run")
def test_runs_uv_pip_install_when_not_uv_tool(self, mock_run):
"""Existing behavior preserved when uv is present but Hermes isn't a tool install."""
from hermes_cli.main import _cmd_update_pip
mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="")
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
_cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == [
"/usr/local/bin/uv",
"pip",
"install",
"--upgrade",
"hermes-agent",
]
@patch("subprocess.run")
def test_falls_back_to_pip_when_no_uv(self, mock_run):
from hermes_cli.main import _cmd_update_pip
mock_run.return_value = subprocess.CompletedProcess(["pip"], 0, stdout="", stderr="")
with patch("shutil.which", return_value=None), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
_cmd_update_pip(SimpleNamespace())
cmd = mock_run.call_args[0][0]
assert cmd[1:] == ["-m", "pip", "install", "--upgrade", "hermes-agent"]
@patch("subprocess.run")
def test_exits_nonzero_on_subprocess_failure(self, mock_run):
from hermes_cli.main import _cmd_update_pip
mock_run.return_value = subprocess.CompletedProcess(["uv"], 1, stdout="", stderr="")
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=True):
with pytest.raises(SystemExit) as exc_info:
_cmd_update_pip(SimpleNamespace())
assert exc_info.value.code == 1
@patch("subprocess.run")
def test_uv_tool_install_without_uv_on_path_exits_with_hint(self, mock_run):
"""If the running interpreter looks like a uv-tool install but ``uv`` is
somehow missing from PATH, surface a clear hint instead of silently
falling back to ``python -m pip``, which would either fail (no venv)
or upgrade the wrong copy."""
from hermes_cli.main import _cmd_update_pip
with patch("shutil.which", return_value=None), \
patch("hermes_cli.config.is_uv_tool_install", return_value=True):
with pytest.raises(SystemExit) as exc_info:
_cmd_update_pip(SimpleNamespace())
assert exc_info.value.code == 1
mock_run.assert_not_called()
# ---------------------------------------------------------------------------
# pipx-managed installs, --system fallback, and VIRTUAL_ENV overlay
# (issue #29700 / #35031 family — consolidated update-path handling)
# ---------------------------------------------------------------------------
class TestCmdUpdatePipInstallLayouts:
"""The uv pip path must adapt to where the running interpreter lives:
- inside a venv (launcher shim) -> export VIRTUAL_ENV, no ``--system``
- bare pip outside any venv -> add ``--system``, no overlay
- pipx-managed -> ``pipx upgrade``
"""
@patch("subprocess.run")
def test_pipx_managed_uses_pipx_upgrade(self, mock_run, monkeypatch):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.setattr(hm.sys, "prefix", "/home/u/.local/pipx/venvs/hermes-agent")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
def _which(name):
return {"uv": "/usr/bin/uv", "pipx": "/usr/bin/pipx"}.get(name)
with patch("shutil.which", side_effect=_which), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
hm._cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == ["/usr/bin/pipx", "upgrade", "hermes-agent"]
# pipx upgrade ignores VIRTUAL_ENV; we must not set it.
assert "env" not in mock_run.call_args.kwargs
@patch("subprocess.run")
def test_pipx_layout_without_pipx_binary_treated_as_venv(
self, mock_run, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.setattr(hm.sys, "prefix", "/home/u/.local/pipx/venvs/hermes-agent")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
# pipx layout detected via prefix, but pipx binary missing on PATH.
def _which(name):
return "/usr/bin/uv" if name == "uv" else None
with patch("shutil.which", side_effect=_which), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
hm._cmd_update_pip(SimpleNamespace())
# prefix != base_prefix, so this is treated as a venv -> overlay, no --system.
assert mock_run.call_args[0][0] == [
"/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent",
]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"].endswith("hermes-agent")
@patch("subprocess.run")
def test_bare_pip_outside_venv_adds_system(self, mock_run, monkeypatch):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
# No venv: prefix == base_prefix.
monkeypatch.setattr(hm.sys, "prefix", "/usr")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
with patch("shutil.which", return_value="/usr/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
hm._cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == [
"/usr/bin/uv", "pip", "install", "--system", "--upgrade", "hermes-agent",
]
assert "env" not in mock_run.call_args.kwargs
@patch("subprocess.run")
def test_venv_exports_virtualenv_and_omits_system(self, mock_run, monkeypatch):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/home/u/.hermes/hermes-agent/venv")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
with patch("shutil.which", return_value="/usr/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
hm._cmd_update_pip(SimpleNamespace())
cmd = mock_run.call_args[0][0]
assert "--system" not in cmd
assert cmd == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/home/u/.hermes/hermes-agent/venv"
+4 -1
View File
@@ -249,9 +249,12 @@ class TestFlushAll:
mgr = _make_manager(write_frequency="async")
sess = _make_session()
sess.add_message("user", "pending")
mgr._async_queue.put(sess)
with patch.object(mgr, "_flush_session") as mock_flush:
# Put the item AFTER the mock is installed so the background
# writer thread (if it dequeues before flush_all) still hits
# the mock rather than the real _flush_session.
mgr._async_queue.put(sess)
mgr.flush_all()
# Called at least once for the queued item
assert mock_flush.call_count >= 1
+84 -5
View File
@@ -1,6 +1,7 @@
"""Tests for plugins/memory/honcho/cli.py."""
from types import SimpleNamespace
import json
class TestResolveApiKey:
@@ -100,6 +101,84 @@ class TestResolveApiKey:
f"expected local sentinel for legacy schemeless {legacy!r}"
class TestCmdSetupLocalJwt:
"""Local-deployment setup must allow configuring a JWT for AUTH_JWT_SECRET-backed Honcho servers."""
def _run_setup(self, monkeypatch, tmp_path, initial_cfg, prompt_answers):
import plugins.memory.honcho.cli as honcho_cli
# Avoid touching real config / SDK / filesystem.
cfg_path = tmp_path / "honcho.json"
monkeypatch.setattr(honcho_cli, "_read_config", lambda: dict(initial_cfg))
monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path)
monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path)
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True)
written = {}
def _capture_write(cfg, path=None):
written["cfg"] = cfg
written["path"] = path
monkeypatch.setattr(honcho_cli, "_write_config", _capture_write)
# Feed scripted prompt answers in order.
answers = list(prompt_answers)
def _fake_prompt(label, default=None, secret=False):
if not answers:
# Default-through any remaining prompts to keep the wizard moving.
return default or ""
return answers.pop(0)
monkeypatch.setattr(honcho_cli, "_prompt", _fake_prompt)
honcho_cli.cmd_setup(SimpleNamespace())
return written.get("cfg")
def test_local_setup_stores_jwt_under_host_block(self, monkeypatch, tmp_path):
"""Self-hosted users supplying a JWT must have it written under hosts.<host>.apiKey,
not as the top-level cloud apiKey, so cloud/hybrid switching is preserved and
get_honcho_client treats it as an explicit local auth opt-in."""
cfg = self._run_setup(
monkeypatch,
tmp_path,
initial_cfg={},
prompt_answers=[
"local", # deployment
"http://localhost:8000", # base URL
"my-local-jwt-token", # local JWT
],
)
assert cfg is not None
assert cfg.get("baseUrl") == "http://localhost:8000"
# Top-level apiKey must remain unset (cloud field).
assert not cfg.get("apiKey")
# The new local JWT belongs under the host block.
host_block = (cfg.get("hosts") or {}).get("hermes") or {}
assert host_block.get("apiKey") == "my-local-jwt-token"
def test_local_setup_blank_jwt_keeps_local_no_auth(self, monkeypatch, tmp_path):
"""Blank JWT prompt response on a fresh local config must not introduce an apiKey
anywhere (local no-auth Honcho deployments must still work out of the box)."""
cfg = self._run_setup(
monkeypatch,
tmp_path,
initial_cfg={},
prompt_answers=[
"local",
"http://localhost:8000",
"", # blank JWT
],
)
assert cfg is not None
assert cfg.get("baseUrl") == "http://localhost:8000"
assert not cfg.get("apiKey")
host_block = (cfg.get("hosts") or {}).get("hermes") or {}
assert not host_block.get("apiKey")
class TestCmdStatus:
def test_reports_connection_failure_when_session_setup_fails(self, monkeypatch, capsys, tmp_path):
import plugins.memory.honcho.cli as honcho_cli
@@ -192,7 +271,7 @@ class TestCloneHonchoForProfile:
honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes.coder"]
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert new_block["userPeerAliases"] == {"86701400": "eri", "discord-491827364": "eri"}
def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_path):
@@ -208,7 +287,7 @@ class TestCloneHonchoForProfile:
honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes.coder"]
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert new_block["runtimePeerPrefix"] == "telegram_"
def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path):
@@ -224,7 +303,7 @@ class TestCloneHonchoForProfile:
honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes.coder"]
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert new_block["pinPeerName"] is True
def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, tmp_path):
@@ -235,7 +314,7 @@ class TestCloneHonchoForProfile:
honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes.coder"]
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert "userPeerAliases" not in new_block
assert "runtimePeerPrefix" not in new_block
assert "pinPeerName" not in new_block
@@ -572,5 +651,5 @@ class TestCloneCarriesPinUserPeer:
ok = honcho_cli.clone_honcho_for_profile("partner")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes.partner"]
new_block = written["cfg"]["hosts"]["hermes_partner"]
assert new_block["pinUserPeer"] is True
+206 -12
View File
@@ -13,6 +13,7 @@ import pytest
from plugins.memory.honcho.client import (
HonchoClientConfig,
get_honcho_client,
profile_host_key,
reset_honcho_client,
resolve_active_host,
resolve_config_path,
@@ -430,6 +431,10 @@ class TestResolveConfigPath:
class TestResolveActiveHost:
def test_profile_host_key_uses_honcho_safe_separator(self):
assert profile_host_key("coder") == "hermes_coder"
assert profile_host_key("default") == "hermes"
def test_default_returns_hermes(self):
with patch.dict(os.environ, {}, clear=True):
os.environ.pop("HERMES_HONCHO_HOST", None)
@@ -444,7 +449,7 @@ class TestResolveActiveHost:
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("HERMES_HONCHO_HOST", None)
with patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"):
assert resolve_active_host() == "hermes.coder"
assert resolve_active_host() == "hermes_coder"
def test_default_profile_returns_hermes(self):
with patch.dict(os.environ, {}, clear=False):
@@ -477,10 +482,10 @@ class TestResolveActiveHost:
class TestProfileScopedConfig:
def test_from_env_uses_profile_host(self):
with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}):
config = HonchoClientConfig.from_env(host="hermes.coder")
assert config.host == "hermes.coder"
config = HonchoClientConfig.from_env(host="hermes_coder")
assert config.host == "hermes_coder"
assert config.workspace_id == "hermes" # shared workspace
assert config.ai_peer == "hermes.coder"
assert config.ai_peer == "hermes_coder"
def test_from_env_default_workspace_preserved_for_default_host(self):
with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}):
@@ -494,22 +499,35 @@ class TestProfileScopedConfig:
"apiKey": "shared-key",
"hosts": {
"hermes": {"aiPeer": "hermes", "peerName": "alice"},
"hermes.coder": {
"aiPeer": "hermes.coder",
"hermes_coder": {
"aiPeer": "hermes_coder",
"peerName": "alice-coder",
"workspace": "coder-ws",
},
},
}))
config = HonchoClientConfig.from_global_config(
host="hermes.coder", config_path=config_file,
host="hermes_coder", config_path=config_file,
)
assert config.host == "hermes.coder"
assert config.host == "hermes_coder"
assert config.workspace_id == "coder-ws"
assert config.ai_peer == "hermes.coder"
assert config.ai_peer == "hermes_coder"
assert config.peer_name == "alice-coder"
def test_from_global_config_auto_resolves_host(self, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({
"apiKey": "key",
"hosts": {
"hermes_dreamer": {"peerName": "dreamer-user"},
},
}))
with patch("plugins.memory.honcho.client.resolve_active_host", return_value="hermes_dreamer"):
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.host == "hermes_dreamer"
assert config.peer_name == "dreamer-user"
def test_from_global_config_reads_legacy_dot_profile_host_block(self, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({
"apiKey": "key",
@@ -517,10 +535,13 @@ class TestProfileScopedConfig:
"hermes.dreamer": {"peerName": "dreamer-user"},
},
}))
with patch("plugins.memory.honcho.client.resolve_active_host", return_value="hermes.dreamer"):
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.host == "hermes.dreamer"
config = HonchoClientConfig.from_global_config(
host="hermes_dreamer",
config_path=config_file,
)
assert config.host == "hermes_dreamer"
assert config.peer_name == "dreamer-user"
assert config.workspace_id == "hermes_dreamer"
class TestObservationModeMigration:
@@ -890,3 +911,176 @@ class TestDialecticDepthParsing:
}))
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.dialectic_depth_levels == ["low", "high"]
class TestGetHonchoClientBaseUrlDoublePrefixFix:
"""Regression tests for #20688 — Honcho SDK double-prefixing of /v3 for
self-hosted instances where base_url already contains a version path."""
def teardown_method(self):
reset_honcho_client()
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_local_base_url_with_v3_suffix_stripped(self):
"""base_url 'http://localhost:38000/v3' must become 'http://localhost:38000'
before passing to the Honcho SDK to avoid double '/v3/v3' prefixing."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key=None,
base_url="http://localhost:38000/v3",
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
passed_base_url = mock_honcho.call_args.kwargs.get("base_url")
assert passed_base_url == "http://localhost:38000", (
f"Expected 'http://localhost:38000', got {passed_base_url!r}"
)
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_local_base_url_without_version_unchanged(self):
"""base_url 'http://localhost:38000' (no version) must be passed unchanged."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key=None,
base_url="http://localhost:38000",
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
passed_base_url = mock_honcho.call_args.kwargs.get("base_url")
assert passed_base_url == "http://localhost:38000", (
f"Expected 'http://localhost:38000', got {passed_base_url!r}"
)
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_cloud_base_url_without_version_unchanged(self):
"""A cloud base_url with no version segment must pass through untouched."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key="cloud-key",
base_url="https://api.honcho.dev",
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
passed_base_url = mock_honcho.call_args.kwargs.get("base_url")
assert passed_base_url == "https://api.honcho.dev", (
f"Expected 'https://api.honcho.dev', got {passed_base_url!r}"
)
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_cloud_base_url_with_version_stripped(self):
"""A version segment double-prefixes regardless of host, so a cloud
base_url that ends in '/v3' must also be stripped (the SDK re-adds it)."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key="cloud-key",
base_url="https://api.honcho.dev/v3",
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
passed_base_url = mock_honcho.call_args.kwargs.get("base_url")
assert passed_base_url == "https://api.honcho.dev", (
f"Expected 'https://api.honcho.dev', got {passed_base_url!r}"
)
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
@pytest.mark.parametrize(
"raw_url, expected",
[
# LAN IP self-host
("http://10.0.0.5:8000/v3", "http://10.0.0.5:8000"),
("http://192.168.1.20:38000/v3/", "http://192.168.1.20:38000"),
# Tailscale / custom-domain self-host
("https://honcho.my.ts.net/v3", "https://honcho.my.ts.net"),
("https://honcho.lab.internal/v3", "https://honcho.lab.internal"),
("https://honcho.fly.dev/v3", "https://honcho.fly.dev"),
# higher version segments are also stripped
("https://honcho.lab.internal/v12", "https://honcho.lab.internal"),
# self-host without a version segment is left unchanged
("https://honcho.my.ts.net", "https://honcho.my.ts.net"),
("http://10.0.0.5:8000", "http://10.0.0.5:8000"),
],
)
def test_self_hosted_base_url_version_stripped(self, raw_url, expected):
"""Non-loopback self-hosted instances (LAN IPs, Tailscale, custom
domains) must get the same version-segment stripping as localhost.
Regression for #20688 recurring on any non-loopback self-host."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key="self-host-key",
base_url=raw_url,
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
passed_base_url = mock_honcho.call_args.kwargs.get("base_url")
assert passed_base_url == expected, (
f"Expected {expected!r}, got {passed_base_url!r}"
)
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_local_base_url_with_trailing_slash_stripped(self):
"""base_url 'http://127.0.0.1:38000/v3/' must also be cleaned up."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key=None,
base_url="http://127.0.0.1:38000/v3/",
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
passed_base_url = mock_honcho.call_args.kwargs.get("base_url")
assert passed_base_url == "http://127.0.0.1:38000", (
f"Expected 'http://127.0.0.1:38000', got {passed_base_url!r}"
)
+8 -8
View File
@@ -745,10 +745,10 @@ class TestPinTransition:
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor", "pinPeerName": True}))
sig_pinned = GatewayRunner._extract_cache_busting_config({})
sig_pinned = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor", "pinPeerName": False}))
sig_unpinned = GatewayRunner._extract_cache_busting_config({})
sig_unpinned = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
assert sig_pinned["honcho.pin_peer_name"] != sig_unpinned["honcho.pin_peer_name"]
@@ -759,14 +759,14 @@ class TestPinTransition:
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor"}))
sig_no_aliases = GatewayRunner._extract_cache_busting_config({})
sig_no_aliases = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
cfg_path.write_text(json.dumps({
"apiKey": "k",
"peerName": "Igor",
"userPeerAliases": {"86701400": "Igor"},
}))
sig_with_aliases = GatewayRunner._extract_cache_busting_config({})
sig_with_aliases = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
assert sig_no_aliases["honcho.user_peer_aliases"] != sig_with_aliases["honcho.user_peer_aliases"]
@@ -777,14 +777,14 @@ class TestPinTransition:
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor"}))
sig_no_prefix = GatewayRunner._extract_cache_busting_config({})
sig_no_prefix = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
cfg_path.write_text(json.dumps({
"apiKey": "k",
"peerName": "Igor",
"runtimePeerPrefix": "telegram_",
}))
sig_with_prefix = GatewayRunner._extract_cache_busting_config({})
sig_with_prefix = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
assert sig_no_prefix["honcho.runtime_peer_prefix"] != sig_with_prefix["honcho.runtime_peer_prefix"]
@@ -805,14 +805,14 @@ class TestPinTransition:
"peerName": "Igor",
"aiPeer": "hermes",
}))
sig_before = GatewayRunner._extract_cache_busting_config({})
sig_before = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
cfg_path.write_text(json.dumps({
"apiKey": "k",
"peerName": "Igor",
"aiPeer": "hermetika",
}))
sig_after = GatewayRunner._extract_cache_busting_config({})
sig_after = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
assert sig_before["honcho.ai_peer"] != sig_after["honcho.ai_peer"]
@@ -6,7 +6,9 @@ turn counting, tags), and schema completeness.
"""
import json
import os
import re
import stat
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
@@ -1570,3 +1572,13 @@ class TestShutdown:
assert embedded._client is None
assert provider._client is None
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
def test_save_config_sets_owner_only_permissions(tmp_path):
"""hindsight/config.json must be written with 0o600 so API key is not world-readable."""
provider = HindsightMemoryProvider()
provider.save_config({"api_key": "hd-test-key"}, str(tmp_path))
config_file = tmp_path / "hindsight" / "config.json"
assert config_file.exists()
mode = stat.S_IMODE(config_file.stat().st_mode)
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
+15
View File
@@ -4,6 +4,10 @@ Salvaged from PRs #5301 (qaqcvc) and #5117 (vvvanguards).
"""
import json
import os
import stat
import pytest
from plugins.memory.mem0 import Mem0MemoryProvider
@@ -202,6 +206,17 @@ class TestMem0ResponseUnwrapping:
# ---------------------------------------------------------------------------
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
def test_save_config_sets_owner_only_permissions(tmp_path):
"""mem0.json must be written with 0o600 so API key is not world-readable."""
provider = Mem0MemoryProvider()
provider.save_config({"api_key": "m0-test-key"}, str(tmp_path))
config_file = tmp_path / "mem0.json"
assert config_file.exists()
mode = stat.S_IMODE(config_file.stat().st_mode)
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
class TestMem0Defaults:
"""Ensure we don't break existing users' defaults."""
@@ -1,4 +1,6 @@
import json
import os
import stat
import threading
import pytest
@@ -409,3 +411,13 @@ def test_get_config_schema_minimal():
assert len(schema) == 1
assert schema[0]["key"] == "api_key"
assert schema[0]["secret"] is True
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
def test_save_config_sets_owner_only_permissions(tmp_path):
"""supermemory.json must be written with 0o600 so API key is not world-readable."""
_save_supermemory_config({"api_key": "sm-test-key"}, str(tmp_path))
config_file = tmp_path / "supermemory.json"
assert config_file.exists()
mode = stat.S_IMODE(config_file.stat().st_mode)
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
+291
View File
@@ -0,0 +1,291 @@
"""Tests for Kanban task file attachments (#35338).
Covers three layers:
* ``hermes_cli.kanban_db`` accessors (add/list/get/delete + path helpers)
* the dashboard REST surface (upload / list / download / delete)
* worker-context surfacing so a kanban worker sees the absolute paths
The plugin router is attached to a bare FastAPI app same approach as
``test_kanban_dashboard_plugin.py`` so we exercise the real HTTP path
(multipart upload, streaming download) without the whole dashboard.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from hermes_cli import kanban_db as kb
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _load_plugin_router():
repo_root = Path(__file__).resolve().parents[2]
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
assert plugin_file.exists(), f"plugin file missing: {plugin_file}"
spec = importlib.util.spec_from_file_location(
"hermes_dashboard_plugin_kanban_attach_test", plugin_file,
)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod.router
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
@pytest.fixture
def client(kanban_home):
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
return TestClient(app)
def _make_task(conn, title="t") -> str:
return kb.create_task(conn, title=title)
# ---------------------------------------------------------------------------
# DB-layer accessors
# ---------------------------------------------------------------------------
def test_add_list_get_delete_attachment(kanban_home, tmp_path):
conn = kb.connect()
try:
task_id = _make_task(conn)
# Write a real blob under the per-task dir so delete can unlink it.
dest_dir = kb.task_attachments_dir(task_id)
dest_dir.mkdir(parents=True, exist_ok=True)
blob = dest_dir / "source.pdf"
blob.write_bytes(b"%PDF-1.4 fake")
att_id = kb.add_attachment(
conn,
task_id,
filename="source.pdf",
stored_path=str(blob),
content_type="application/pdf",
size=blob.stat().st_size,
uploaded_by="tester",
)
assert att_id > 0
atts = kb.list_attachments(conn, task_id)
assert len(atts) == 1
a = atts[0]
assert a.filename == "source.pdf"
assert a.content_type == "application/pdf"
assert a.size == len(b"%PDF-1.4 fake")
assert a.uploaded_by == "tester"
assert a.stored_path == str(blob)
got = kb.get_attachment(conn, att_id)
assert got is not None and got.id == att_id
removed = kb.delete_attachment(conn, att_id)
assert removed is not None and removed.id == att_id
assert kb.list_attachments(conn, task_id) == []
assert not blob.exists(), "delete should unlink the on-disk blob"
assert kb.get_attachment(conn, att_id) is None
finally:
conn.close()
def test_add_attachment_rejects_unknown_task(kanban_home):
conn = kb.connect()
try:
with pytest.raises(ValueError):
kb.add_attachment(
conn, "t_doesnotexist", filename="x.txt", stored_path="/tmp/x.txt"
)
finally:
conn.close()
def test_add_attachment_appends_event(kanban_home):
conn = kb.connect()
try:
task_id = _make_task(conn)
kb.add_attachment(
conn, task_id, filename="a.txt", stored_path="/tmp/a.txt", size=3
)
kinds = [e.kind for e in kb.list_events(conn, task_id)]
assert "attached" in kinds
finally:
conn.close()
def test_delete_attachment_missing_returns_none(kanban_home):
conn = kb.connect()
try:
assert kb.delete_attachment(conn, 999999) is None
finally:
conn.close()
def test_attachments_root_is_per_board(kanban_home, monkeypatch):
# default board uses <root>/kanban/attachments
default_root = kb.attachments_root(board="default")
assert default_root.name == "attachments"
# a named board nests under its board dir
monkeypatch.delenv("HERMES_KANBAN_ATTACHMENTS_ROOT", raising=False)
named = kb.attachments_root(board="default")
assert named == default_root
def test_attachments_root_env_override(kanban_home, monkeypatch, tmp_path):
override = tmp_path / "custom-attach"
monkeypatch.setenv("HERMES_KANBAN_ATTACHMENTS_ROOT", str(override))
assert kb.attachments_root() == override
assert kb.task_attachments_dir("t_abc") == override / "t_abc"
# ---------------------------------------------------------------------------
# Worker context surfacing
# ---------------------------------------------------------------------------
def test_worker_context_lists_attachments_with_absolute_path(kanban_home):
conn = kb.connect()
try:
task_id = _make_task(conn, title="translate PDF")
dest_dir = kb.task_attachments_dir(task_id)
dest_dir.mkdir(parents=True, exist_ok=True)
blob = dest_dir / "manual.pdf"
blob.write_bytes(b"data")
kb.add_attachment(
conn,
task_id,
filename="manual.pdf",
stored_path=str(blob.resolve()),
content_type="application/pdf",
size=4,
)
ctx = kb.build_worker_context(conn, task_id)
assert "## Attachments" in ctx
assert "manual.pdf" in ctx
# The absolute path must appear so the worker can read_file it.
assert str(blob.resolve()) in ctx
finally:
conn.close()
def test_worker_context_no_attachments_section_when_empty(kanban_home):
conn = kb.connect()
try:
task_id = _make_task(conn)
ctx = kb.build_worker_context(conn, task_id)
assert "## Attachments" not in ctx
finally:
conn.close()
# ---------------------------------------------------------------------------
# REST surface — upload / list / download / delete round-trip
# ---------------------------------------------------------------------------
def _create_task_via_api(client) -> str:
r = client.post("/api/plugins/kanban/tasks", json={"title": "x"})
assert r.status_code == 200, r.text
return r.json()["task"]["id"]
def test_upload_list_download_delete_roundtrip(client):
task_id = _create_task_via_api(client)
content = b"hello attachment world"
# Upload
r = client.post(
f"/api/plugins/kanban/tasks/{task_id}/attachments",
files={"file": ("notes.txt", content, "text/plain")},
)
assert r.status_code == 200, r.text
att = r.json()["attachment"]
assert att["filename"] == "notes.txt"
assert att["size"] == len(content)
att_id = att["id"]
# List (drawer also embeds it in GET /tasks/:id)
r = client.get(f"/api/plugins/kanban/tasks/{task_id}/attachments")
assert r.status_code == 200
assert [a["filename"] for a in r.json()["attachments"]] == ["notes.txt"]
detail = client.get(f"/api/plugins/kanban/tasks/{task_id}").json()
assert "attachments" in detail
assert len(detail["attachments"]) == 1
# Download streams the exact bytes back
r = client.get(f"/api/plugins/kanban/attachments/{att_id}")
assert r.status_code == 200
assert r.content == content
# Delete removes the row and the file
r = client.delete(f"/api/plugins/kanban/attachments/{att_id}")
assert r.status_code == 200
assert client.get(f"/api/plugins/kanban/attachments/{att_id}").status_code == 404
assert client.get(
f"/api/plugins/kanban/tasks/{task_id}/attachments"
).json()["attachments"] == []
def test_upload_sanitizes_traversal_filename(client):
task_id = _create_task_via_api(client)
r = client.post(
f"/api/plugins/kanban/tasks/{task_id}/attachments",
files={"file": ("../../../../etc/passwd", b"x", "text/plain")},
)
assert r.status_code == 200, r.text
stored_path = r.json()["attachment"]["stored_path"]
# The leaf name only; never escapes the per-task attachments dir.
assert Path(stored_path).name == "passwd"
task_dir = kb.task_attachments_dir(task_id).resolve()
assert Path(stored_path).resolve().is_relative_to(task_dir)
def test_upload_name_collision_gets_suffixed(client):
task_id = _create_task_via_api(client)
for _ in range(2):
r = client.post(
f"/api/plugins/kanban/tasks/{task_id}/attachments",
files={"file": ("dup.txt", b"a", "text/plain")},
)
assert r.status_code == 200, r.text
names = sorted(
a["filename"]
for a in client.get(
f"/api/plugins/kanban/tasks/{task_id}/attachments"
).json()["attachments"]
)
assert names == ["dup (1).txt", "dup.txt"]
def test_upload_unknown_task_404(client):
r = client.post(
"/api/plugins/kanban/tasks/t_nope/attachments",
files={"file": ("x.txt", b"x", "text/plain")},
)
assert r.status_code == 404
def test_download_unknown_attachment_404(client):
assert client.get("/api/plugins/kanban/attachments/424242").status_code == 404
+158
View File
@@ -491,6 +491,96 @@ class TestPreflightCompression:
for ev, msg in status_messages
)
def test_preflight_defers_when_recent_real_usage_fit(self, agent):
"""A noisy rough estimate should not re-compact a recently fitting request."""
agent.compression_enabled = True
agent.context_compressor.context_length = 200_000
agent.context_compressor.threshold_tokens = 100_000
agent.context_compressor.last_prompt_tokens = 58_000
agent.context_compressor.last_real_prompt_tokens = 58_000
agent.context_compressor.last_rough_tokens_when_real_prompt_fit = 113_000
big_history = []
for i in range(20):
big_history.append({"role": "user", "content": f"Message {i} padded"})
big_history.append({"role": "assistant", "content": f"Response {i} padded"})
ok_resp = _mock_response(
content="Used real fit",
finish_reason="stop",
usage={"prompt_tokens": 59_000, "completion_tokens": 100, "total_tokens": 59_100},
)
agent.client.chat.completions.create.side_effect = [ok_resp]
status_messages = []
agent.status_callback = lambda ev, msg: status_messages.append((ev, msg))
with (
patch("agent.conversation_loop.estimate_request_tokens_rough", return_value=114_000),
patch.object(agent, "_compress_context") as mock_compress,
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("hello", conversation_history=big_history)
mock_compress.assert_not_called()
assert result["completed"] is True
assert result["final_response"] == "Used real fit"
assert not any(
ev == "lifecycle" and "Preflight compression" in msg
for ev, msg in status_messages
)
def test_preflight_compresses_when_rough_growth_after_fit_is_large(self, agent):
"""Large rough growth after a fitting request still triggers preflight."""
agent.compression_enabled = True
agent.context_compressor.context_length = 200_000
agent.context_compressor.threshold_tokens = 100_000
agent.context_compressor.last_prompt_tokens = 58_000
agent.context_compressor.last_real_prompt_tokens = 58_000
agent.context_compressor.last_rough_tokens_when_real_prompt_fit = 113_000
big_history = []
for i in range(20):
big_history.append({"role": "user", "content": f"Message {i} padded"})
big_history.append({"role": "assistant", "content": f"Response {i} padded"})
ok_resp = _mock_response(
content="Compressed after growth",
finish_reason="stop",
usage={"prompt_tokens": 50_000, "completion_tokens": 100, "total_tokens": 50_100},
)
agent.client.chat.completions.create.side_effect = [ok_resp]
# First rough estimate must clear the threshold so preflight fires
# (rough growth since the last fitting request is large, so the
# deferral path is NOT taken). Every estimate after compaction is
# sub-threshold. Use a callable side_effect rather than a fixed list
# so we don't have to predict how many times the loop re-estimates —
# the post-response real-token estimate is an extra call that a
# 2-element list would exhaust (StopIteration).
_rough_calls = {"n": 0}
def _rough_estimate(*_args, **_kwargs):
_rough_calls["n"] += 1
return 125_000 if _rough_calls["n"] == 1 else 40_000
with (
patch("agent.conversation_loop.estimate_request_tokens_rough", side_effect=_rough_estimate),
patch.object(agent, "_compress_context") as mock_compress,
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
mock_compress.return_value = (
[{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}],
"new system prompt",
)
result = agent.run_conversation("hello", conversation_history=big_history)
mock_compress.assert_called_once()
assert result["completed"] is True
def test_no_preflight_when_under_threshold(self, agent):
"""When history fits within context, no preflight compression needed."""
agent.compression_enabled = True
@@ -575,6 +665,74 @@ class TestPreflightCompression:
mock_compress.assert_not_called()
assert result["completed"] is True
def test_preflight_seeds_display_tokens_when_compression_aborts(self, agent):
"""Display must reflect the real context size even when compression no-ops.
Regression: the CLI status bar reads ``last_prompt_tokens``, which only
updated from a *successful* API response. When the loaded history was
oversized but compression failed to reduce it (e.g. the auxiliary
summary model timed out), the bar stayed stuck at the old, smaller
value while the preflight estimate reported a much larger number
looking permanently out of sync.
"""
agent.compression_enabled = True
agent.context_compressor.context_length = 200_000
agent.context_compressor.threshold_tokens = 130_000
# Simulate a stale display value from an earlier, smaller turn.
agent.context_compressor.last_prompt_tokens = 74_400
big_history = []
for i in range(20):
big_history.append({"role": "user", "content": f"Message {i} padded text"})
big_history.append({"role": "assistant", "content": f"Response {i} padded text"})
ok_resp = _mock_response(content="After preflight", finish_reason="stop")
agent.client.chat.completions.create.side_effect = [ok_resp]
with (
patch("agent.conversation_loop.estimate_request_tokens_rough", return_value=144_669),
# Compression no-ops (returns input unchanged) — mirrors an aux
# summary-model timeout where the messages can't be reduced.
patch.object(agent, "_compress_context", side_effect=lambda msgs, *a, **k: (msgs, agent._cached_system_prompt)),
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("hello", conversation_history=big_history)
assert result["completed"] is True
# The display token count was revised up to the fresh preflight estimate,
# not left at the stale 74_400.
assert agent.context_compressor.last_prompt_tokens == 144_669
def test_preflight_seed_only_revises_upward(self, agent):
"""A larger tracked value must not be clobbered by a smaller estimate."""
agent.compression_enabled = True
agent.context_compressor.context_length = 200_000
agent.context_compressor.threshold_tokens = 130_000
# A real, larger usage figure is already tracked.
agent.context_compressor.last_prompt_tokens = 160_000
big_history = []
for i in range(20):
big_history.append({"role": "user", "content": f"Message {i} padded text"})
big_history.append({"role": "assistant", "content": f"Response {i} padded text"})
ok_resp = _mock_response(content="After preflight", finish_reason="stop")
agent.client.chat.completions.create.side_effect = [ok_resp]
with (
patch("agent.conversation_loop.estimate_request_tokens_rough", return_value=144_669),
patch.object(agent, "_compress_context", side_effect=lambda msgs, *a, **k: (msgs, agent._cached_system_prompt)),
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
agent.run_conversation("hello", conversation_history=big_history)
# Smaller estimate must not overwrite the larger tracked value.
assert agent.context_compressor.last_prompt_tokens == 160_000
class TestToolResultPreflightCompression:
"""Compression should trigger when tool results push context past the threshold."""
+6 -1
View File
@@ -70,4 +70,9 @@ def test_tool_call_validation_accepts_dict_arguments(monkeypatch):
result = agent.run_conversation("read the file")
assert result["final_response"] == "done"
# The conversation hits max_iterations=3 (3 tool turns then forced summary).
# PR #34470 adds an explainer suffix to abnormal turn endings so users
# understand why the response is short instead of seeing a blank reply.
# The exact suffix wording is owned by conversation_loop; this test only
# cares that the model's actual text ('done') survives at the start.
assert result["final_response"].startswith("done")
+168 -5
View File
@@ -2543,6 +2543,122 @@ class TestConcurrentToolExecution:
assert json.loads(result) == {"error": "Blocked"}
assert agent._turns_since_memory == 5
def test_concurrent_blocked_write_skips_checkpoint(self, agent, monkeypatch):
"""Concurrent path: blocked write_file should not trigger checkpoint."""
tc1 = _mock_tool_call(name="write_file",
arguments='{"path":"test.txt","content":"hello"}',
call_id="c1")
tc2 = _mock_tool_call(name="read_file",
arguments='{"path":"other.py"}',
call_id="c2")
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
messages = []
monkeypatch.setattr(
"hermes_cli.plugins.get_pre_tool_call_block_message",
lambda *args, **kwargs: "Blocked" if args[0] == "write_file" else None,
)
agent._checkpoint_mgr.enabled = True
def fake_handle(name, args, task_id, **kwargs):
return f"result_{name}"
with patch("run_agent.handle_function_call", side_effect=fake_handle):
with patch.object(agent._checkpoint_mgr, "ensure_checkpoint") as cp_mock:
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
cp_mock.assert_not_called()
def test_concurrent_blocked_patch_skips_checkpoint(self, agent, monkeypatch):
"""Concurrent path: blocked patch should not trigger checkpoint."""
tc1 = _mock_tool_call(name="patch",
arguments='{"path":"f.py","old":"a","new":"b"}',
call_id="c1")
tc2 = _mock_tool_call(name="read_file",
arguments='{"path":"other.py"}',
call_id="c2")
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
messages = []
monkeypatch.setattr(
"hermes_cli.plugins.get_pre_tool_call_block_message",
lambda *args, **kwargs: "Blocked" if args[0] == "patch" else None,
)
agent._checkpoint_mgr.enabled = True
def fake_handle(name, args, task_id, **kwargs):
return f"result_{name}"
with patch("run_agent.handle_function_call", side_effect=fake_handle):
with patch.object(agent._checkpoint_mgr, "ensure_checkpoint") as cp_mock:
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
cp_mock.assert_not_called()
def test_concurrent_blocked_terminal_skips_checkpoint(self, agent, monkeypatch):
"""Concurrent path: blocked terminal should not trigger checkpoint."""
tc1 = _mock_tool_call(name="terminal",
arguments='{"command":"rm -rf /tmp/foo"}',
call_id="c1")
tc2 = _mock_tool_call(name="read_file",
arguments='{"path":"other.py"}',
call_id="c2")
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
messages = []
monkeypatch.setattr(
"hermes_cli.plugins.get_pre_tool_call_block_message",
lambda *args, **kwargs: "Blocked" if args[0] == "terminal" else None,
)
agent._checkpoint_mgr.enabled = True
def fake_handle(name, args, task_id, **kwargs):
return f"result_{name}"
with patch("run_agent.handle_function_call", side_effect=fake_handle):
with patch.object(agent._checkpoint_mgr, "ensure_checkpoint") as cp_mock:
with patch("agent.tool_executor._is_destructive_command", return_value=True):
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
cp_mock.assert_not_called()
def test_concurrent_blocked_write_does_not_steal_slot_from_allowed_write(self, agent, monkeypatch):
"""When write_file is blocked, its dedup slot must not be consumed,
so a subsequent allowed write_file for the same path still checkpoints."""
tc1 = _mock_tool_call(name="write_file",
arguments='{"path":"dup.txt","content":"blocked"}',
call_id="c1")
tc2 = _mock_tool_call(name="write_file",
arguments='{"path":"dup.txt","content":"allowed"}',
call_id="c2")
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
messages = []
call_count = {"n": 0}
def block_first_only(*args, **kwargs):
call_count["n"] += 1
return "Blocked" if call_count["n"] == 1 else None
monkeypatch.setattr(
"hermes_cli.plugins.get_pre_tool_call_block_message",
block_first_only,
)
agent._checkpoint_mgr.enabled = True
def fake_handle(name, args, task_id, **kwargs):
return f"result_{name}"
with patch("run_agent.handle_function_call", side_effect=fake_handle):
with patch.object(agent._checkpoint_mgr, "ensure_checkpoint") as cp_mock:
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
# Second (allowed) write must checkpoint even though first was blocked.
cp_mock.assert_called_once()
class TestPathsOverlap:
"""Unit tests for the _paths_overlap helper."""
@@ -2756,6 +2872,40 @@ class TestHandleMaxIterations:
]
assert len(stub_ids) >= 1, f"No stub result for assistant tool_call: {stub_ids}"
def test_summary_strips_strict_schema_foreign_fields(self, agent):
"""Regression: the max-iterations summary request must NOT carry
Chat-Completions-schema-foreign keys tool_name (SQLite FTS
bookkeeping), codex_* reasoning carriers, or internal _-prefixed
scaffolding. Strict gateways (Fireworks-backed OpenCode Go, Mistral,
Kimi) reject these with 'Extra inputs are not permitted, field:
messages[N].tool_name'. The transport's convert_messages() strips
them on the main loop; this hand-built summary path must mirror it."""
agent.client.chat.completions.create.return_value = _mock_response(content="Summary")
agent._cached_system_prompt = "You are helpful."
messages = [
{"role": "user", "content": "do stuff"},
{
"role": "assistant",
"tool_calls": [{"id": "call_1", "function": {"name": "execute_code", "arguments": "{}"}}],
"codex_reasoning_items": [{"id": "rs_1"}],
},
{"role": "tool", "tool_call_id": "call_1", "content": "result", "tool_name": "execute_code"},
{"role": "assistant", "content": "Done.", "_empty_recovery_synthetic": True},
]
result = agent._handle_max_iterations(messages, 60)
assert result == "Summary"
sent_msgs = agent.client.chat.completions.create.call_args.kwargs.get("messages", [])
for m in sent_msgs:
assert "tool_name" not in m, m
assert "codex_reasoning_items" not in m, m
assert "codex_message_items" not in m, m
assert not any(isinstance(k, str) and k.startswith("_") for k in m), m
# Internal history is untouched — the path copies each message.
assert messages[2]["tool_name"] == "execute_code"
assert messages[1]["codex_reasoning_items"] == [{"id": "rs_1"}]
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()
@@ -3046,7 +3196,11 @@ class TestRunConversation:
mock_compress.assert_not_called() # no compression triggered
assert result["completed"] is True
assert result["final_response"] == "(empty)"
# #34452: the bare "(empty)" sentinel is now replaced by a
# user-visible end-of-turn explanation so the failure isn't silent.
assert result["final_response"] != "(empty)"
assert "No reply:" in result["final_response"]
assert result["turn_exit_reason"] == "empty_response_exhausted"
assert result["api_calls"] == 6 # 1 original + 2 prefill + 3 retries
def test_reasoning_only_response_prefill_then_empty(self, agent):
@@ -3066,7 +3220,9 @@ class TestRunConversation:
):
result = agent.run_conversation("answer me")
assert result["completed"] is True
assert result["final_response"] == "(empty)"
# #34452: explanation replaces the bare "(empty)" sentinel.
assert result["final_response"] != "(empty)"
assert "No reply:" in result["final_response"]
assert result["api_calls"] == 6 # 1 original + 2 prefill + 3 retries
def test_reasoning_only_prefill_succeeds_on_continuation(self, agent):
@@ -3113,7 +3269,9 @@ class TestRunConversation:
):
result = agent.run_conversation("answer me")
assert result["completed"] is True
assert result["final_response"] == "(empty)"
# #34452: explanation replaces the bare "(empty)" sentinel.
assert result["final_response"] != "(empty)"
assert "No reply:" in result["final_response"]
assert result["api_calls"] == 4 # 1 original + 3 retries
def test_truly_empty_response_succeeds_on_nudge(self, agent):
@@ -3209,7 +3367,9 @@ class TestRunConversation:
):
result = agent.run_conversation("answer me")
assert result["completed"] is True
assert result["final_response"] == "(empty)"
# #34452: explanation replaces the bare "(empty)" sentinel.
assert result["final_response"] != "(empty)"
assert "No reply:" in result["final_response"]
def test_empty_response_emits_status_for_gateway(self, agent):
"""_emit_status is called during empty retries so gateway users see feedback."""
@@ -3235,7 +3395,10 @@ class TestRunConversation:
):
result = agent.run_conversation("answer me")
assert result["final_response"] == "(empty)"
# #34452: explanation replaces the bare "(empty)" sentinel, but the
# status emissions during retries are unchanged.
assert result["final_response"] != "(empty)"
assert "No reply:" in result["final_response"]
# Should have emitted retry statuses (3 retries) + final failure
retry_msgs = [m for m in status_messages if "retrying" in m.lower()]
assert len(retry_msgs) == 3, f"Expected 3 retry status messages, got {len(retry_msgs)}: {status_messages}"
@@ -0,0 +1,181 @@
"""Tests for the end-of-turn completion explainer (#34452).
When a turn ends abnormally after tools (empty content after retries, a
partial/truncated stream, exhausted retries, or an iteration/budget limit)
the user should get a single user-visible explanation of why the reply
stopped instead of a blank or fragmentary response box. Normal short
replies (e.g. ``Done.``) must stay quiet.
These tests exercise:
1. ``_format_turn_completion_explanation`` the pure reasonmessage map.
2. ``_turn_completion_explainer_enabled`` the env/config seam.
3. An end-to-end ``run_conversation`` turn that exhausts empty-response
retries and verifies the explanation reaches ``final_response``.
All assertions work under the mocked OpenAI SDK used elsewhere in this
suite (we patch ``run_agent.OpenAI`` and drive ``agent.client``), so they
pass identically in CI and locally.
"""
import os
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from run_agent import AIAgent
# --------------------------------------------------------------------------
# Fixtures (mirrors tests/run_agent/test_tool_call_guardrail_runtime.py)
# --------------------------------------------------------------------------
def _mock_response(content="Hello", finish_reason="stop", tool_calls=None):
msg = SimpleNamespace(content=content, tool_calls=tool_calls)
choice = SimpleNamespace(message=msg, finish_reason=finish_reason)
return SimpleNamespace(choices=[choice], model="test/model", usage=None)
def _make_agent(max_iterations: int = 10, config: dict | None = None) -> AIAgent:
with (
patch("run_agent.get_tool_definitions", return_value=[]),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("hermes_cli.config.load_config", return_value=config or {}),
patch("run_agent.OpenAI"),
):
agent = AIAgent(
api_key="test-key-1234567890",
base_url="https://openrouter.ai/api/v1",
max_iterations=max_iterations,
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
agent.client = MagicMock()
agent._cached_system_prompt = "You are helpful."
agent._use_prompt_caching = False
agent.tool_delay = 0
agent.compression_enabled = False
agent.save_trajectories = False
# No fallback chain so empty responses exhaust deterministically.
agent._fallback_chain = []
return agent
# --------------------------------------------------------------------------
# 1. Pure formatter
# --------------------------------------------------------------------------
def test_explanation_quiet_for_normal_text_response():
"""A healthy text_response exit must NOT produce any explanation."""
out = AIAgent._format_turn_completion_explanation(
"text_response(finish_reason=stop)"
)
assert out == ""
def test_explanation_quiet_for_empty_reason():
assert AIAgent._format_turn_completion_explanation("") == ""
assert AIAgent._format_turn_completion_explanation("unknown") == ""
# guardrail_halt surfaces its own message; explainer stays out of the way.
assert AIAgent._format_turn_completion_explanation("guardrail_halt") == ""
def test_explanation_for_empty_response_exhausted():
out = AIAgent._format_turn_completion_explanation("empty_response_exhausted")
assert out # non-empty
assert "empty content" in out
assert "continue" in out.lower()
def test_explanation_for_partial_stream_recovery():
out = AIAgent._format_turn_completion_explanation("partial_stream_recovery")
assert "partial" in out.lower()
assert "continue" in out.lower()
def test_explanation_for_max_iterations_reached_prefix_match():
"""``max_iterations_reached(...)`` carries a parenthetical suffix."""
out = AIAgent._format_turn_completion_explanation(
"max_iterations_reached(10/10)"
)
assert "iteration" in out.lower()
def test_explanation_for_all_retries_exhausted():
out = AIAgent._format_turn_completion_explanation(
"all_retries_exhausted_no_response"
)
assert "retries" in out.lower()
# --------------------------------------------------------------------------
# 2. Enable/disable seam
# --------------------------------------------------------------------------
def test_explainer_enabled_by_default():
agent = _make_agent()
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("HERMES_TURN_COMPLETION_EXPLAINER", None)
with patch("hermes_cli.config.load_config", return_value={}):
assert agent._turn_completion_explainer_enabled() is True
def test_explainer_disabled_via_env():
agent = _make_agent()
with patch.dict(
os.environ, {"HERMES_TURN_COMPLETION_EXPLAINER": "0"}, clear=False
):
assert agent._turn_completion_explainer_enabled() is False
def test_explainer_disabled_via_config():
agent = _make_agent()
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("HERMES_TURN_COMPLETION_EXPLAINER", None)
with patch(
"hermes_cli.config.load_config",
return_value={"display": {"turn_completion_explainer": False}},
):
assert agent._turn_completion_explainer_enabled() is False
# --------------------------------------------------------------------------
# 3. End-to-end: empty-response exhaustion surfaces the explanation
# --------------------------------------------------------------------------
def test_run_conversation_empty_exhausted_surfaces_explanation():
"""Four empty responses in a row should exhaust retries and the final
response should be the actionable explanation, not a bare '(empty)'."""
agent = _make_agent(max_iterations=10)
# 4 empty responses: retries 1..3 then the terminal on the 4th.
agent.client.chat.completions.create.side_effect = [
_mock_response(content="", finish_reason="stop") for _ in range(8)
]
with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("do something")
assert result["turn_exit_reason"] == "empty_response_exhausted"
# The user must NOT be left with a bare sentinel; the explanation wins.
assert result["final_response"] != "(empty)"
assert result["final_response"].strip() != ""
assert "No reply:" in result["final_response"]
def test_run_conversation_normal_reply_stays_quiet():
"""A normal short reply like 'Done.' must NOT get an explainer footer."""
agent = _make_agent(max_iterations=10)
agent.client.chat.completions.create.side_effect = [
_mock_response(content="Done.", finish_reason="stop"),
]
with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("do something")
assert result["turn_exit_reason"].startswith("text_response")
assert result["final_response"] == "Done."
assert "No reply:" not in result["final_response"]
+206
View File
@@ -229,6 +229,212 @@ def test_api_calendar_list_respects_date_range(api_module):
assert params["timeMax"] == "2026-04-07T23:59:59Z"
@pytest.mark.parametrize(
"header_names",
[
("from", "to", "subject", "date"),
("From", "To", "Subject", "Date"),
],
)
def test_api_gmail_get_reads_headers_case_insensitively(api_module, capsys, header_names):
from_name, to_name, subject_name, date_name = header_names
def fake_run_gws(parts, *, params=None, body=None):
assert parts == ["gmail", "users", "messages", "get"]
assert params == {"userId": "me", "id": "msg-1", "format": "full"}
return {
"id": "msg-1",
"threadId": "thread-1",
"labelIds": ["INBOX"],
"payload": {
"headers": [
{"name": from_name, "value": "sender@example.com"},
{"name": to_name, "value": "recipient@example.com"},
{"name": subject_name, "value": "case bug"},
{"name": date_name, "value": "Fri, 29 May 2026 12:00:00 +0000"},
],
"body": {},
},
}
api_module._run_gws = fake_run_gws
args = api_module.argparse.Namespace(message_id="msg-1", func=api_module.gmail_get)
api_module.gmail_get(args)
result = json.loads(capsys.readouterr().out)
assert result["from"] == "sender@example.com"
assert result["to"] == "recipient@example.com"
assert result["subject"] == "case bug"
assert result["date"] == "Fri, 29 May 2026 12:00:00 +0000"
@pytest.mark.parametrize(
"header_names",
[
("from", "to", "subject", "date"),
("From", "To", "Subject", "Date"),
],
)
def test_api_gmail_search_reads_headers_case_insensitively(
api_module,
capsys,
header_names,
):
from_name, to_name, subject_name, date_name = header_names
calls = []
def fake_run_gws(parts, *, params=None, body=None):
calls.append({"parts": parts, "params": params, "body": body})
if parts == ["gmail", "users", "messages", "list"]:
assert params == {"userId": "me", "q": "from:sender", "maxResults": 5}
return {"messages": [{"id": "msg-1"}]}
assert parts == ["gmail", "users", "messages", "get"]
assert params == {
"userId": "me",
"id": "msg-1",
"format": "metadata",
"metadataHeaders": ["From", "To", "Subject", "Date"],
}
return {
"id": "msg-1",
"threadId": "thread-1",
"labelIds": ["INBOX"],
"snippet": "preview",
"payload": {
"headers": [
{"name": from_name, "value": "sender@example.com"},
{"name": to_name, "value": "recipient@example.com"},
{"name": subject_name, "value": "case bug"},
{"name": date_name, "value": "Fri, 29 May 2026 12:00:00 +0000"},
],
},
}
api_module._run_gws = fake_run_gws
args = api_module.argparse.Namespace(
query="from:sender",
max=5,
func=api_module.gmail_search,
)
api_module.gmail_search(args)
assert len(calls) == 2
result = json.loads(capsys.readouterr().out)
assert result == [
{
"id": "msg-1",
"threadId": "thread-1",
"from": "sender@example.com",
"to": "recipient@example.com",
"subject": "case bug",
"date": "Fri, 29 May 2026 12:00:00 +0000",
"snippet": "preview",
"labels": ["INBOX"],
}
]
def test_api_gmail_send_uses_conventional_mime_header_casing(api_module):
captured = {}
def fake_run_gws(parts, *, params=None, body=None):
captured["parts"] = parts
captured["params"] = params
captured["body"] = body
return {"id": "sent-1", "threadId": "thread-1"}
api_module._run_gws = fake_run_gws
args = api_module.argparse.Namespace(
to="recipient@example.com",
subject="hello",
body="body",
html=False,
cc="copy@example.com",
from_header="sender@example.com",
thread_id="thread-1",
func=api_module.gmail_send,
)
api_module.gmail_send(args)
raw = api_module.base64.urlsafe_b64decode(captured["body"]["raw"])
raw_text = raw.decode()
assert "To: recipient@example.com" in raw_text
assert "Subject: hello" in raw_text
assert "Cc: copy@example.com" in raw_text
assert "From: sender@example.com" in raw_text
assert "\nto: " not in raw_text
assert "\nsubject: " not in raw_text
@pytest.mark.parametrize(
"header_names",
[
("from", "subject", "message-id"),
("From", "Subject", "Message-ID"),
],
)
def test_api_gmail_reply_reads_headers_case_insensitively_and_uses_conventional_mime_header_casing(
api_module,
header_names,
):
from_name, subject_name, message_id_name = header_names
calls = []
def fake_run_gws(parts, *, params=None, body=None):
calls.append({"parts": parts, "params": params, "body": body})
if parts == ["gmail", "users", "messages", "get"]:
assert params == {
"userId": "me",
"id": "msg-1",
"format": "metadata",
"metadataHeaders": ["From", "Subject", "Message-ID"],
}
return {
"id": "msg-1",
"threadId": "thread-1",
"payload": {
"headers": [
{"name": from_name, "value": "sender@example.com"},
{"name": subject_name, "value": "case bug"},
{"name": message_id_name, "value": "<msg-1@example.com>"},
],
},
}
assert parts == ["gmail", "users", "messages", "send"]
assert params == {"userId": "me"}
return {"id": "sent-1", "threadId": "thread-1"}
api_module._run_gws = fake_run_gws
args = api_module.argparse.Namespace(
message_id="msg-1",
body="reply body",
from_header="recipient@example.com",
func=api_module.gmail_reply,
)
api_module.gmail_reply(args)
assert len(calls) == 2
body = calls[1]["body"]
assert body["threadId"] == "thread-1"
raw = api_module.base64.urlsafe_b64decode(body["raw"])
raw_text = raw.decode()
assert "To: sender@example.com" in raw_text
assert "Subject: Re: case bug" in raw_text
assert "From: recipient@example.com" in raw_text
assert "In-Reply-To: <msg-1@example.com>" in raw_text
assert "References: <msg-1@example.com>" in raw_text
assert "\nto: " not in raw_text
assert "\nsubject: " not in raw_text
assert "\nin-reply-to: " not in raw_text
assert "\nreferences: " not in raw_text
def test_api_get_credentials_refresh_persists_authorized_user_type(api_module, monkeypatch):
token_path = api_module.TOKEN_PATH
_write_token(token_path, token="ya29.old")
+75
View File
@@ -1,11 +1,35 @@
"""Tests for hermes_state.py — SessionDB SQLite CRUD, FTS5 search, export."""
import sqlite3
import time
import pytest
from hermes_state import SessionDB
class _NoFtsCursor(sqlite3.Cursor):
"""Simulate a SQLite build without the fts5 module."""
def execute(self, sql, parameters=()):
probe = sql.strip()
if probe in (
"SELECT * FROM messages_fts LIMIT 0",
"SELECT * FROM messages_fts_trigram LIMIT 0",
):
raise sqlite3.OperationalError("no such table: " + probe.split()[-3])
return super().execute(sql, parameters)
def executescript(self, sql_script):
if "USING fts5" in sql_script:
raise sqlite3.OperationalError("no such module: fts5")
return super().executescript(sql_script)
class _NoFtsConnection(sqlite3.Connection):
def cursor(self, factory=None):
return super().cursor(factory or _NoFtsCursor)
@pytest.fixture()
def db(tmp_path):
"""Create a SessionDB with a temp database file."""
@@ -128,6 +152,30 @@ class TestSessionLifecycle:
session = db.get_session("s1")
assert session["model"] == "anthropic/claude-opus-4.6"
def test_update_session_model_overwrites_existing(self, db):
"""A mid-session /model switch must overwrite the stored model.
update_token_counts uses COALESCE(model, ?) (first-writer-wins), so
the dashboard kept showing the original model after a switch (#34850).
update_session_model sets the column unconditionally.
"""
db.create_session(session_id="s1", source="telegram",
model="xiaomi/mimo-v2.5-pro")
# Token updates never change the model once set.
db.update_token_counts("s1", input_tokens=10, output_tokens=5,
model="xiaomi/mimo-v2.5-pro")
assert db.get_session("s1")["model"] == "xiaomi/mimo-v2.5-pro"
# Explicit switch overwrites it.
db.update_session_model("s1", "xiaomi/mimo-v2.5")
assert db.get_session("s1")["model"] == "xiaomi/mimo-v2.5"
# And a subsequent token update does NOT revert it (COALESCE no-ops
# because the column is now non-NULL).
db.update_token_counts("s1", input_tokens=10, output_tokens=5,
model="xiaomi/mimo-v2.5-pro")
assert db.get_session("s1")["model"] == "xiaomi/mimo-v2.5"
def test_parent_session(self, db):
db.create_session(session_id="parent", source="cli")
db.create_session(session_id="child", source="cli", parent_session_id="parent")
@@ -135,6 +183,33 @@ class TestSessionLifecycle:
child = db.get_session("child")
assert child["parent_session_id"] == "parent"
def test_db_initializes_without_fts5_module(self, tmp_path, monkeypatch):
real_connect = sqlite3.connect
def connect_without_fts(*args, **kwargs):
kwargs["factory"] = _NoFtsConnection
return real_connect(*args, **kwargs)
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
db = SessionDB(db_path=tmp_path / "state.db")
try:
assert db._fts_enabled is False
# Neither FTS5 virtual table should have been created on a build
# that lacks the fts5 module — both init paths must degrade.
assert db._fts_table_exists("messages_fts") is False
assert db._fts_table_exists("messages_fts_trigram") is False
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="user", content="hello from sqlite without fts")
messages = db.get_messages("s1")
assert len(messages) == 1
assert messages[0]["content"] == "hello from sqlite without fts"
assert db.search_messages("hello") == []
finally:
db.close()
# =========================================================================
# Message storage
+25
View File
@@ -2,9 +2,13 @@
import json
import os
import stat
from pathlib import Path
import pytest
from plugins.memory.honcho.client import HonchoClientConfig
from plugins.memory.honcho import HonchoMemoryProvider
class TestHonchoClientConfigAutoEnable:
@@ -100,3 +104,24 @@ class TestHonchoClientConfigAutoEnable:
assert cfg.api_key == "fallback-key"
assert cfg.enabled is True # from_env() sets enabled=True
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
def test_save_config_sets_owner_only_permissions(tmp_path, monkeypatch):
"""honcho.json is created atomically with 0o600, not chmod-after-write."""
import utils
calls = []
real_atomic = utils.atomic_json_write
def spy(path, data, **kwargs):
calls.append(kwargs.get("mode"))
return real_atomic(path, data, **kwargs)
monkeypatch.setattr(utils, "atomic_json_write", spy)
provider = HonchoMemoryProvider()
provider.save_config({"api_key": "hc-test-key"}, str(tmp_path))
assert calls == [0o600]
config_file = tmp_path / "honcho.json"
assert config_file.exists()
mode = stat.S_IMODE(config_file.stat().st_mode)
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
+85
View File
@@ -115,3 +115,88 @@ def test_bundled_plugin_manifests_ship_in_both_wheel_and_sdist():
assert "recursive-include plugins" in manifest and "plugin.yaml" in manifest, (
"MANIFEST.in must recursive-include plugins plugin.yaml/plugin.yml (sdist)"
)
# Minimum non-vulnerable Starlette: CVE-2026-48710 ("BadHost") was fixed in
# 1.0.1. Anything below that lets a malformed Host header desync
# ``request.url.path`` from the dispatched ASGI path, bypassing path-based
# authz in middleware/endpoints that gate on ``request.url``. Starlette is a
# transitive dep (fastapi in [web]; sse-starlette/mcp in [mcp]/[computer-use]/
# [dev]) so we pin it directly in every extra that exposes a server surface and
# enforce the floor in both pyproject and the committed lockfile.
_STARLETTE_CVE_FLOOR = (1, 0, 1)
def _version_tuple(spec: str) -> tuple[int, ...]:
# "1.0.1" -> (1, 0, 1); tolerant of pre/post suffixes by truncating.
head = spec.split("+", 1)[0]
parts = []
for chunk in head.split("."):
digits = "".join(ch for ch in chunk if ch.isdigit())
if not digits:
break
parts.append(int(digits))
return tuple(parts)
def test_starlette_pinned_above_cve_2026_48710_floor_in_pyproject():
"""Every extra that declares Starlette must pin a patched (>=1.0.1) version.
Regression guard for #35067 / CVE-2026-48710. A future edit that drops the
pin (re-exposing the unbounded transitive ``starlette>=0.27`` from mcp /
``>=0.40.0`` from fastapi) or pins a pre-1.0.1 version fails here instead of
shipping a Host-header auth-bypass to dashboard / MCP-HTTP users.
"""
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
extras = data["project"]["optional-dependencies"]
found = {}
for extra, specs in extras.items():
for spec in specs:
name = spec.split("==", 1)[0].split(">", 1)[0].split("<", 1)[0].split("[", 1)[0].strip()
if name.lower() == "starlette":
assert "==" in spec, f"[{extra}] must exact-pin starlette, got {spec!r}"
ver = spec.split("==", 1)[1].split(";", 1)[0].strip()
found[extra] = ver
# The four server-surface extras must each carry the direct pin.
for extra in ("web", "mcp", "computer-use", "dev"):
assert extra in found, (
f"[{extra}] no longer pins starlette directly — CVE-2026-48710 "
f"regression risk (mcp/fastapi pull it transitively with no upper bound)"
)
for extra, ver in found.items():
assert _version_tuple(ver) >= _STARLETTE_CVE_FLOOR, (
f"[{extra}] pins starlette=={ver}, below the CVE-2026-48710 fix "
f"floor {'.'.join(map(str, _STARLETTE_CVE_FLOOR))}"
)
def test_locked_starlette_is_not_vulnerable_to_cve_2026_48710():
"""The committed uv.lock must resolve starlette to a patched version.
pyproject pins protect the declared extras, but the lockfile is what
hash-verified installs (``uv sync --locked``) actually pull. Assert the
resolved version is >= the CVE-2026-48710 fix floor so a stale-lock
regression can't ship a vulnerable Starlette to users.
"""
lock = (REPO_ROOT / "uv.lock").read_text(encoding="utf-8")
versions = []
in_starlette = False
for line in lock.splitlines():
if line.startswith("[[package]]"):
in_starlette = False
elif line.strip() == 'name = "starlette"':
in_starlette = True
elif in_starlette and line.startswith("version = "):
versions.append(line.split("=", 1)[1].strip().strip('"'))
in_starlette = False
assert versions, "starlette not found in uv.lock"
for ver in versions:
assert _version_tuple(ver) >= _STARLETTE_CVE_FLOOR, (
f"uv.lock resolves starlette=={ver}, below the CVE-2026-48710 fix "
f"floor {'.'.join(map(str, _STARLETTE_CVE_FLOOR))} — regenerate the "
f"lockfile after bumping the pin"
)
@@ -189,6 +189,32 @@ class TestBrowserEvalSupervisorPath:
json.loads(bt._browser_eval("1+1"))
assert called["subprocess"] is True
def test_subprocess_reference_chain_error_becomes_guidance(self, monkeypatch):
"""The CLI subprocess can't retry with returnByValue=False, so the
cryptic 'Object reference chain is too long' CDP error must be turned
into actionable guidance instead of surfaced raw."""
import tools.browser_tool as bt
# No supervisor → subprocess path runs.
_patch_supervisor(monkeypatch, None)
def _fake_subprocess(task_id, cmd, args):
assert cmd == "eval"
return {
"success": False,
"error": "Runtime.evaluate failed: Object reference chain is too long",
}
monkeypatch.setattr(bt, "_run_browser_command", _fake_subprocess)
out = json.loads(bt._browser_eval("document.body"))
assert out["success"] is False
# Raw protocol error must NOT leak through.
assert "reference chain" not in out["error"].lower()
# Actionable guidance instead.
assert "primitive" in out["error"].lower()
assert "DOM node" in out["error"] or "dom node" in out["error"].lower()
# ---------------------------------------------------------------------------
# Response shaping: CDPSupervisor.evaluate_runtime
@@ -361,3 +387,91 @@ class TestEvaluateRuntimeResponseShaping:
finally:
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=2)
def _make_supervisor_with_cdp_fn(cdp_fn):
"""Like ``_make_supervisor_with_cdp`` but lets the test supply a coroutine
function as ``_cdp`` so behaviour can vary by params (e.g. returnByValue).
"""
import asyncio
import threading
from tools.browser_supervisor import CDPSupervisor
sup = object.__new__(CDPSupervisor)
sup._state_lock = threading.Lock()
sup._active = True
sup._page_session_id = "test-session-id"
loop = asyncio.new_event_loop()
def _runner():
asyncio.set_event_loop(loop)
loop.run_forever()
thread = threading.Thread(target=_runner, daemon=True)
thread.start()
sup._cdp = cdp_fn # type: ignore[method-assign]
sup._loop = loop
sup._thread = thread
return sup
class TestEvaluateRuntimeDomNodeCrashRetry:
"""returnByValue=True on a DOM node fails CDP serialization with 'Object
reference chain is too long'. evaluate_runtime must retry with
returnByValue=False and return the node's description instead of crashing.
"""
def test_reference_chain_crash_retries_without_by_value(self):
calls = []
async def _fake_cdp(method, params=None, *, session_id=None, timeout=10.0):
by_value = (params or {}).get("returnByValue")
calls.append(by_value)
if by_value:
# Mirror _read_loop turning a top-level CDP error into a RuntimeError.
raise RuntimeError(
"CDP error on id=7: {'code': -32000, "
"'message': 'Object reference chain is too long'}"
)
# returnByValue=False: Chrome returns the node's description, no value.
return {
"id": 8,
"result": {
"result": {
"type": "object",
"subtype": "node",
"description": "body",
}
},
}
sup = _make_supervisor_with_cdp_fn(_fake_cdp)
try:
out = sup.evaluate_runtime("document.body")
assert out["ok"] is True
assert out["result"] == "body"
assert out["result_type"] == "object"
# First call by_value=True (crashed), retried with by_value=False.
assert calls == [True, False]
finally:
_stop_supervisor(sup)
def test_unrelated_error_does_not_retry(self):
calls = []
async def _fake_cdp(method, params=None, *, session_id=None, timeout=10.0):
calls.append((params or {}).get("returnByValue"))
raise RuntimeError("CDP error on id=3: {'message': 'Target closed'}")
sup = _make_supervisor_with_cdp_fn(_fake_cdp)
try:
out = sup.evaluate_runtime("document.body")
assert out["ok"] is False
assert "Target closed" in out["error"]
# No retry for unrelated failures — exactly one call.
assert calls == [True]
finally:
_stop_supervisor(sup)
+23 -14
View File
@@ -345,15 +345,23 @@ class TestShellFileOpsHelpers:
def test_add_line_numbers(self, file_ops):
content = "line one\nline two\nline three"
result = file_ops._add_line_numbers(content)
assert " 1|line one" in result
assert " 2|line two" in result
assert " 3|line three" in result
# Compact gutter: "<n>|content" (no fixed-width padding).
assert "1|line one" in result
assert "2|line two" in result
assert "3|line three" in result
def test_add_line_numbers_with_offset(self, file_ops):
content = "continued\nmore"
result = file_ops._add_line_numbers(content, start_line=50)
assert " 50|continued" in result
assert " 51|more" in result
assert "50|continued" in result
assert "51|more" in result
def test_add_line_numbers_padded_env_override(self, file_ops, monkeypatch):
# Legacy fixed-width format available via HERMES_READ_GUTTER=padded.
monkeypatch.setenv("HERMES_READ_GUTTER", "padded")
result = file_ops._add_line_numbers("line one\nline two")
assert " 1|line one" in result
assert " 2|line two" in result
def test_add_line_numbers_truncates_long_lines(self, file_ops):
long_line = "x" * (MAX_LINE_LENGTH + 100)
@@ -405,7 +413,7 @@ class TestShellFileOpsHelpers:
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
assert "1|print('ok')" in result.content
def test_read_file_raw_strips_leaked_terminal_fence_markers(self, mock_env):
leaked = (
@@ -638,12 +646,14 @@ class TestPatchReplacePostWriteVerification:
state = {"content": "hello world\n"}
def side_effect(command, stdin_data=None, **kwargs):
# Write is `cat > path` — detect by the `>` redirect, NOT just `cat `
if command.startswith("cat >"):
if stdin_data is not None:
state["content"] = stdin_data
# A write is the only call that pipes content over stdin — key
# on that behavioral signal rather than the exact write command,
# which is an atomic temp-file + mv script (`set -e; ... mv ...`),
# not a bare `cat > path`.
if stdin_data is not None:
state["content"] = stdin_data
return {"output": "", "returncode": 0}
if command.startswith("cat "): # read
if command.startswith("cat "): # read / verify
return {"output": state["content"], "returncode": 0}
if command.startswith("mkdir "):
return {"output": "", "returncode": 0}
@@ -664,9 +674,8 @@ class TestPatchReplacePostWriteVerification:
state = {"content": "hello world\n"}
def side_effect(command, stdin_data=None, **kwargs):
if command.startswith("cat >"): # write
if stdin_data is not None:
state["content"] = stdin_data
if stdin_data is not None: # write (atomic temp-file + mv script)
state["content"] = stdin_data
return {"output": "", "returncode": 0}
if command.startswith("cat "): # read
call_count["cat"] += 1
@@ -292,7 +292,7 @@ class TestPaginationBounds:
result = ops.read_file("notes.txt", offset=0, limit=0)
assert result.error is None
assert " 1|line1" in result.content
assert "1|line1" in result.content
sed_commands = [cmd for cmd in commands if cmd.startswith("sed -n")]
assert sed_commands == ["sed -n '1,1p' 'notes.txt'"]
@@ -0,0 +1,197 @@
"""Regression tests for file-tool path resolution base correctness.
The bug (observed in a worktree dev session, May 2026): when the resolution
base for a relative path is itself RELATIVE e.g. ``TERMINAL_CWD="."`` from a
stale config ``_resolve_path_for_task`` resolved the path against the agent's
PROCESS cwd instead of the intended workspace. In a git-worktree session this
silently routed ``patch``/``write_file`` edits into the *main* checkout: the
write landed, self-verified, and reported success against the wrong file.
The agent then grepped the worktree, saw nothing, and concluded the patch tool
had silently no-op'd. It hadn't; it wrote to the wrong place.
Core invariant these tests pin:
The resolution base for a relative path MUST always be absolute. A relative
``TERMINAL_CWD`` (``.``, ``./sub``, ``..``) must be anchored deterministically,
never left to resolve against whatever the process cwd happens to be.
"""
import os
from pathlib import Path
import pytest
import tools.file_tools as ft
@pytest.fixture
def _isolated_cwd(tmp_path, monkeypatch):
"""Two checkouts: workspace (intended) + decoy (process cwd)."""
workspace = tmp_path / "workspace"
decoy = tmp_path / "decoy"
workspace.mkdir()
decoy.mkdir()
(workspace / "target.py").write_text("WORKSPACE_ORIGINAL\n")
(decoy / "target.py").write_text("DECOY_ORIGINAL\n")
# Process cwd = decoy, analogous to "main repo" while the terminal is in
# the worktree.
monkeypatch.chdir(decoy)
# No live-terminal-cwd tracking recorded yet (fresh-session condition).
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
return workspace, decoy
def test_relative_terminal_cwd_anchors_to_absolute_not_process_cwd(_isolated_cwd, monkeypatch):
"""TERMINAL_CWD='.' must NOT silently mean 'the agent process cwd'.
A relative base is meaningless as a resolution anchor. The resolver must
make it absolute deterministically. We assert the resolved path is
absolute and stable regardless of where os.getcwd() points.
"""
workspace, decoy = _isolated_cwd
# Poison config: literal relative '.'
monkeypatch.setenv("TERMINAL_CWD", ".")
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved.is_absolute(), f"resolution base leaked a relative path: {resolved}"
# The exact anchor for a bare '.' is the process cwd resolved to absolute —
# that is acceptable as long as it is ABSOLUTE and stable. The bug was that
# a relative base produced surprising results; the fix is that the base is
# always absolutised. (We do not require it to point at the workspace here —
# that's what live-cwd tracking is for; see the next test.)
assert str(resolved) == str((Path(os.getcwd()) / "target.py").resolve())
def test_live_tracking_cwd_wins_over_relative_terminal_cwd(_isolated_cwd, monkeypatch):
"""When the terminal reports its absolute cwd, that is authoritative.
This is the real-world fix: the terminal's tracked absolute cwd (the
worktree) must override a stale relative TERMINAL_CWD so edits land where
the agent is actually working.
"""
workspace, decoy = _isolated_cwd
monkeypatch.setenv("TERMINAL_CWD", ".")
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved == (workspace / "target.py")
def test_absolute_terminal_cwd_used_verbatim(_isolated_cwd, monkeypatch):
"""An absolute TERMINAL_CWD is the resolution base (no live tracking)."""
workspace, decoy = _isolated_cwd
monkeypatch.setenv("TERMINAL_CWD", str(workspace))
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved == (workspace / "target.py")
def test_absolute_input_path_ignores_base(_isolated_cwd, monkeypatch):
"""An absolute input path is never re-anchored."""
workspace, decoy = _isolated_cwd
monkeypatch.setenv("TERMINAL_CWD", ".")
abs_target = str(workspace / "target.py")
resolved = ft._resolve_path_for_task(abs_target, task_id="default")
assert resolved == Path(abs_target).resolve()
def test_resolution_base_always_absolute_no_terminal_cwd(_isolated_cwd, monkeypatch):
"""With TERMINAL_CWD unset, the base falls back to an ABSOLUTE process cwd."""
workspace, decoy = _isolated_cwd
monkeypatch.delenv("TERMINAL_CWD", raising=False)
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved.is_absolute()
assert str(resolved) == str((Path(os.getcwd()) / "target.py").resolve())
# ── B-(ii): workspace-divergence warning ────────────────────────────────────
def test_warning_fires_when_relative_path_escapes_workspace(_isolated_cwd, monkeypatch):
"""Relative path resolving outside the live workspace must warn."""
workspace, decoy = _isolated_cwd
# Live cwd = workspace, but the relative path resolves to decoy (process cwd)
# because TERMINAL_CWD is the poison '.'. Simulate by pointing live tracking
# at workspace while the resolved path is under decoy.
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
resolved_in_decoy = decoy / "target.py"
warn = ft._path_resolution_warning("target.py", resolved_in_decoy, task_id="default")
assert warn is not None
assert "OUTSIDE the active workspace" in warn
assert str(decoy) in warn
assert str(workspace) in warn
def test_no_warning_when_relative_path_inside_workspace(_isolated_cwd, monkeypatch):
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
resolved_in_workspace = workspace / "target.py"
warn = ft._path_resolution_warning("target.py", resolved_in_workspace, task_id="default")
assert warn is None
def test_no_warning_for_absolute_input(_isolated_cwd, monkeypatch):
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
warn = ft._path_resolution_warning(str(decoy / "target.py"), decoy / "target.py", task_id="default")
assert warn is None
def test_no_warning_when_no_live_cwd(_isolated_cwd, monkeypatch):
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
warn = ft._path_resolution_warning("target.py", decoy / "target.py", task_id="default")
assert warn is None
# ── Fix A: write_file / patch report the resolved ABSOLUTE path ──────────────
def test_write_file_reports_resolved_absolute_path(_isolated_cwd, monkeypatch):
"""write_file_tool must put the absolute on-disk path in files_modified."""
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
import json
out = json.loads(ft.write_file_tool("newfile.txt", "hello\n", task_id="t1"))
expected = str((workspace / "newfile.txt").resolve())
assert out.get("resolved_path") == expected
assert out.get("files_modified") == [expected]
assert (workspace / "newfile.txt").read_text() == "hello\n"
def test_patch_reports_resolved_absolute_path(_isolated_cwd, monkeypatch):
"""patch_tool (replace mode) must put the absolute on-disk path in files_modified."""
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
import json
out = json.loads(ft.patch_tool(
mode="replace", path="target.py",
old_string="WORKSPACE_ORIGINAL", new_string="WORKSPACE_PATCHED",
task_id="t1",
))
expected = str((workspace / "target.py").resolve())
assert not out.get("error"), out
assert out.get("resolved_path") == expected
assert out.get("files_modified") == [expected]
assert "WORKSPACE_PATCHED" in (workspace / "target.py").read_text()
# And the decoy copy is untouched.
assert (decoy / "target.py").read_text() == "DECOY_ORIGINAL\n"
+172
View File
@@ -107,5 +107,177 @@ class TestCheckSensitivePathMacOSBypass:
assert _check_sensitive_path("/tmp/safe_file.txt") is None
class TestAtomicWrite:
"""write_file / patch land via a temp-file + atomic rename.
The invariant: a write that fails partway NEVER corrupts the existing
file, and the swap is a real rename (so a reader either sees the full
old content or the full new content, never a half-written file). These
run against a real LocalEnvironment so the actual shell script executes.
"""
@pytest.fixture
def ops(self, tmp_path: Path):
from tools.environments.local import LocalEnvironment
from tools.file_operations import ShellFileOperations
env = LocalEnvironment(cwd=str(tmp_path))
return ShellFileOperations(env, cwd=str(tmp_path))
def test_overwrite_changes_inode(self, ops, tmp_path: Path):
# A real rename allocates a new inode for the target; an in-place
# rewrite would keep the same inode. This proves the swap is atomic.
target = tmp_path / "f.txt"
target.write_text("v1")
ino_before = os.stat(target).st_ino
res = ops.write_file(str(target), "v2 content")
assert res.error is None, res.error
assert target.read_text() == "v2 content"
assert os.stat(target).st_ino != ino_before
def test_overwrite_preserves_mode(self, ops, tmp_path: Path):
target = tmp_path / "perms.txt"
target.write_text("old")
os.chmod(target, 0o640)
res = ops.write_file(str(target), "new")
assert res.error is None, res.error
assert (os.stat(target).st_mode & 0o777) == 0o640
def test_failed_write_leaves_original_intact(self, ops, tmp_path: Path):
# A read-only parent directory means the temp file can't be created,
# so the write fails BEFORE any rename. The original must survive
# byte-for-byte and no temp file may be left behind.
if hasattr(os, "geteuid") and os.geteuid() == 0:
pytest.skip("root bypasses directory permission bits")
locked = tmp_path / "locked"
locked.mkdir()
target = locked / "f.txt"
target.write_text("ORIGINAL\n")
os.chmod(locked, 0o500) # r-x: cannot create entries inside
try:
res = ops.write_file(str(target), "SHOULD NOT LAND")
finally:
os.chmod(locked, 0o700) # restore for cleanup
assert res.error is not None
assert target.read_text() == "ORIGINAL\n"
assert [p for p in os.listdir(locked) if ".hermes-tmp" in p] == []
def test_no_temp_file_leaked_on_success(self, ops, tmp_path: Path):
target = tmp_path / "f.txt"
ops.write_file(str(target), "hello\n")
assert [p for p in os.listdir(tmp_path) if ".hermes-tmp" in p] == []
def test_special_chars_roundtrip(self, ops, tmp_path: Path):
target = tmp_path / "special.txt"
tricky = "q 'single' \"double\" $VAR `cmd` \\back\nünïcödé 日本語\n"
res = ops.write_file(str(target), tricky)
assert res.error is None, res.error
assert target.read_text(encoding="utf-8") == tricky
def test_patch_routes_through_atomic_write(self, ops, tmp_path: Path):
target = tmp_path / "edit.py"
target.write_text("a = 1\nb = 2\nc = 3\n")
os.chmod(target, 0o600)
res = ops.patch_replace(str(target), "b = 2", "b = 22")
assert res.success, res.error
assert target.read_text() == "a = 1\nb = 22\nc = 3\n"
assert (os.stat(target).st_mode & 0o777) == 0o600
class TestBomHandling:
"""UTF-8 BOM is stripped on read and preserved across write/patch.
A BOM (U+FEFF, bytes EF BB BF) is an invisible leading marker some
Windows editors prepend. The agent should never see it in read output,
but a file that had one on disk must keep it after an edit so the byte
signature is preserved.
"""
BOM = "\ufeff"
@pytest.fixture
def ops(self, tmp_path: Path):
from tools.environments.local import LocalEnvironment
from tools.file_operations import ShellFileOperations
env = LocalEnvironment(cwd=str(tmp_path))
return ShellFileOperations(env, cwd=str(tmp_path))
def test_helpers(self):
from tools.file_operations import _strip_bom, _has_bom
assert _strip_bom("\ufeffhello") == ("hello", True)
assert _strip_bom("hello") == ("hello", False)
assert _strip_bom("") == ("", False)
# mid-string BOM is data, not a marker — left alone
assert _strip_bom("a\ufeffb") == ("a\ufeffb", False)
assert _has_bom("\ufeffx") is True
assert _has_bom("x") is False
assert _has_bom(None) is False
def test_read_strips_bom(self, ops, tmp_path: Path):
target = tmp_path / "bom.py"
# Write raw bytes with a real UTF-8 BOM prefix.
target.write_bytes(self.BOM.encode("utf-8") + b"import os\nx = 1\n")
res = ops.read_file(str(target))
assert res.error is None, res.error
# Line 1 content must NOT carry the phantom U+FEFF.
first_line = res.content.split("\n", 1)[0]
assert self.BOM not in first_line
assert first_line.endswith("import os")
def test_read_raw_strips_bom(self, ops, tmp_path: Path):
target = tmp_path / "bom.txt"
target.write_bytes(self.BOM.encode("utf-8") + b"hello\nworld\n")
res = ops.read_file_raw(str(target))
assert res.error is None, res.error
assert not res.content.startswith(self.BOM)
assert res.content == "hello\nworld\n"
def test_write_preserves_bom(self, ops, tmp_path: Path):
# Existing file has a BOM; agent rewrites with BOM-less content.
target = tmp_path / "config.txt"
target.write_bytes(self.BOM.encode("utf-8") + b"old\n")
res = ops.write_file(str(target), "new content\n")
assert res.error is None, res.error
raw = target.read_bytes()
assert raw.startswith(self.BOM.encode("utf-8")) # BOM restored
assert raw == self.BOM.encode("utf-8") + b"new content\n"
def test_write_no_bom_when_original_had_none(self, ops, tmp_path: Path):
target = tmp_path / "plain.txt"
target.write_text("old\n")
res = ops.write_file(str(target), "new\n")
assert res.error is None, res.error
assert not target.read_bytes().startswith(self.BOM.encode("utf-8"))
def test_write_does_not_double_bom(self, ops, tmp_path: Path):
# If content already carries a BOM and the file had one, don't add a
# second.
target = tmp_path / "config.txt"
target.write_bytes(self.BOM.encode("utf-8") + b"old\n")
res = ops.write_file(str(target), self.BOM + "new\n")
assert res.error is None, res.error
raw = target.read_bytes()
# exactly one BOM
assert raw == self.BOM.encode("utf-8") + b"new\n"
def test_patch_roundtrip_preserves_bom(self, ops, tmp_path: Path):
target = tmp_path / "edit.py"
target.write_bytes(self.BOM.encode("utf-8") + b"a = 1\nb = 2\nc = 3\n")
res = ops.patch_replace(str(target), "b = 2", "b = 22")
assert res.success, res.error
raw = target.read_bytes()
assert raw.startswith(self.BOM.encode("utf-8")) # marker survived
assert raw == self.BOM.encode("utf-8") + b"a = 1\nb = 22\nc = 3\n"
def test_patch_matches_first_line_through_bom(self, ops, tmp_path: Path):
# The whole point: an edit targeting the BOM-prefixed first line
# must match cleanly (the matcher sees BOM-stripped content).
target = tmp_path / "mod.py"
target.write_bytes(self.BOM.encode("utf-8") + b"import os\nimport sys\n")
res = ops.patch_replace(str(target), "import os", "import os, json")
assert res.success, res.error
raw = target.read_bytes()
assert raw == self.BOM.encode("utf-8") + b"import os, json\nimport sys\n"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+77
View File
@@ -203,6 +203,83 @@ class TestSIGKILLEscalation:
assert "interrupted" in result_holder["value"]["output"].lower()
# ---------------------------------------------------------------------------
# Regression: _run_tool cleanup on BaseException (issue #35309)
# ---------------------------------------------------------------------------
class TestRunToolCleanupOnBaseException:
"""Verify that _run_tool cleans up _interrupted_threads even when
_invoke_tool raises a BaseException (e.g. CancelledError).
Regression test for #35309: without the finally block, a BaseException
bypasses ``except Exception``, leaking the worker tid into
_interrupted_threads. ThreadPoolExecutor recycles tids, so the next
tool scheduled on the same thread is instantly "interrupted".
"""
def test_cleanup_on_base_exception(self):
from unittest.mock import MagicMock, patch
import types
from tools.interrupt import set_interrupt, is_interrupted, _interrupted_threads, _lock
# Clear global state
with _lock:
_interrupted_threads.clear()
# Build a minimal mock agent with the attributes _run_tool needs
agent = MagicMock()
agent._interrupt_requested = False
agent._tool_worker_threads = set()
agent._tool_worker_threads_lock = threading.Lock()
# _set_interrupt delegates to the real module
def _mock_set_interrupt(active, tid=None):
set_interrupt(active, tid)
agent._set_interrupt = _mock_set_interrupt
# _invoke_tool raises BaseException (simulating CancelledError)
agent._invoke_tool = MagicMock(side_effect=BaseException("simulated CancelledError"))
# Bind the real concurrent method so we get _run_tool
from run_agent import AIAgent
agent._execute_tool_calls_concurrent = types.MethodType(
AIAgent._execute_tool_calls_concurrent, agent
)
# Build a single tool call
tc = MagicMock()
tc.id = "tc_base_exc"
tc.function.name = "dummy_tool"
tc.function.arguments = "{}"
assistant_msg = MagicMock()
assistant_msg.tool_calls = [tc]
# _execute_tool_calls_concurrent will submit _run_tool to a
# ThreadPoolExecutor. The BaseException propagates out of the
# worker, but the finally block should still clean up.
try:
agent._execute_tool_calls_concurrent(assistant_msg, [], "default")
except Exception:
pass # ThreadPoolExecutor may re-raise
# After the worker finishes (even with BaseException), the worker
# tid should have been removed from _interrupted_threads and
# _tool_worker_threads.
assert len(agent._tool_worker_threads) == 0, (
f"_tool_worker_threads not cleaned up: {agent._tool_worker_threads}"
)
# Verify no stale tid is left in the global interrupt set. The
# worker thread is recycled by ThreadPoolExecutor, so a leaked tid
# would poison the next task on that thread. We cleared the set at
# the start and never set any interrupt ourselves, so a leak from
# _run_tool is the only way an entry could land here.
with _lock:
leaked = set(_interrupted_threads)
assert leaked == set(), f"leaked tids in _interrupted_threads: {leaked}"
# ---------------------------------------------------------------------------
# Manual smoke test checklist (not automated)
# ---------------------------------------------------------------------------
@@ -234,6 +234,44 @@ def test_browserbase_does_not_use_gateway_only_configuration():
assert provider.is_available() is False
def test_browser_use_availability_skips_refresh_for_expired_cached_gateway_token(tmp_path, monkeypatch):
_install_fake_tools_package()
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
expired_at = "2000-01-01T00:00:00+00:00"
(tmp_path / "auth.json").write_text(
'{"providers":{"nous":{"access_token":"expired-token","refresh_token":"refresh-token","expires_at":"%s"}}}'
% expired_at,
encoding="utf-8",
)
refresh_calls = []
def _record_refresh(*, refresh_skew_seconds=120, **_kwargs):
refresh_calls.append(refresh_skew_seconds)
return "fresh-token"
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_access_token",
_record_refresh,
)
env = os.environ.copy()
env.pop("BROWSER_USE_API_KEY", None)
env.update({
"HERMES_HOME": str(tmp_path),
"BROWSER_USE_GATEWAY_URL": "http://127.0.0.1:3009",
})
with patch.dict(os.environ, env, clear=True):
browser_use_module = _load_plugin_module(
"plugins.browser.browser_use.provider",
"browser/browser_use/provider.py",
)
provider = browser_use_module.BrowserUseBrowserProvider()
assert provider.is_available() is True
assert refresh_calls == []
def test_browser_use_managed_gateway_adds_idempotency_key_and_persists_external_call_id():
_install_fake_tools_package()
env = os.environ.copy()
+35
View File
@@ -12,6 +12,7 @@ assert MODULE_SPEC and MODULE_SPEC.loader
managed_tool_gateway = module_from_spec(MODULE_SPEC)
sys.modules[MODULE_SPEC.name] = managed_tool_gateway
MODULE_SPEC.loader.exec_module(managed_tool_gateway)
is_managed_tool_gateway_ready = managed_tool_gateway.is_managed_tool_gateway_ready
resolve_managed_tool_gateway = managed_tool_gateway.resolve_managed_tool_gateway
@@ -97,3 +98,37 @@ def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkey
)
assert managed_tool_gateway.read_nous_access_token() == "fresh-token"
def test_is_managed_tool_gateway_ready_skips_refresh_for_expired_cached_token(tmp_path, monkeypatch):
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
expired_at = (datetime.now(timezone.utc) - timedelta(seconds=30)).isoformat()
(tmp_path / "auth.json").write_text(json.dumps({
"providers": {
"nous": {
"access_token": "expired-token",
"refresh_token": "refresh-token",
"expires_at": expired_at,
}
}
}))
refresh_calls = []
def _record_refresh(*, refresh_skew_seconds=120, **_kwargs):
refresh_calls.append(refresh_skew_seconds)
return "fresh-token"
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_access_token",
_record_refresh,
)
with patch.dict(
os.environ,
{"TOOL_GATEWAY_DOMAIN": "nousresearch.com"},
clear=False,
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
assert is_managed_tool_gateway_ready("modal") is True
assert refresh_calls == []
+218
View File
@@ -1,9 +1,12 @@
"""Tests for MCP stability fixes — event loop handler, PID tracking, shutdown robustness."""
import asyncio
import os
import signal
from unittest.mock import patch, MagicMock
import pytest
# ---------------------------------------------------------------------------
@@ -171,6 +174,221 @@ class TestStdioPidTracking:
assert fake_pid not in _orphan_stdio_pids
# ---------------------------------------------------------------------------
# Fix 2b: stdio descendant reaping via process group (issue #23799)
# ---------------------------------------------------------------------------
#
# When a stdio MCP wrapper (e.g. ``openclaw mcp serve``) itself spawns a
# helper subprocess (``claude mcp serve``) and then exits, the helper
# reparents to systemd-user and is invisible to the per-pid orphan reaper.
# The fix captures the wrapper's pgid at spawn time and reaps via killpg,
# which reaches same-group descendants whether or not the direct pid is alive.
class TestStdioPgroupReaping:
"""_kill_orphaned_mcp_children reaps via killpg when a pgid is tracked."""
def _reset_state(self):
from tools.mcp_tool import _stdio_pids, _orphan_stdio_pids, _stdio_pgids, _lock
with _lock:
_stdio_pids.clear()
_orphan_stdio_pids.clear()
_stdio_pgids.clear()
def test_killpg_used_when_pgid_tracked(self, monkeypatch):
"""SIGTERM and SIGKILL route through killpg when pgid is known."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pids,
_stdio_pgids,
_lock,
)
self._reset_state()
fake_pid = 525252
fake_pgid = 525252 # session leader: pgid == pid
with _lock:
_orphan_stdio_pids.add(fake_pid)
_stdio_pgids[fake_pid] = fake_pgid
fake_sigkill = 9
monkeypatch.setattr(signal, "SIGKILL", fake_sigkill, raising=False)
# Ensure os.killpg exists on this platform for the test to make sense;
# the production fallback path is covered by the per-pid tests above.
if not hasattr(os, "killpg"):
pytest.skip("os.killpg not available on this platform")
with patch("tools.mcp_tool.os.killpg") as mock_killpg, \
patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("gateway.status._pid_exists", return_value=True), \
patch("time.sleep"):
_kill_orphaned_mcp_children()
# Both phases should have used killpg (pgroup reach), not per-pid kill.
mock_killpg.assert_any_call(fake_pgid, signal.SIGTERM)
mock_killpg.assert_any_call(fake_pgid, fake_sigkill)
assert mock_killpg.call_count == 2
mock_kill.assert_not_called()
with _lock:
assert fake_pid not in _orphan_stdio_pids
assert fake_pid not in _stdio_pgids
def test_killpg_failure_falls_back_to_kill(self, monkeypatch):
"""If killpg raises ProcessLookupError (pgroup gone), try os.kill."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pids,
_stdio_pgids,
_lock,
)
self._reset_state()
fake_pid = 636363
fake_pgid = 636363
with _lock:
_orphan_stdio_pids.add(fake_pid)
_stdio_pgids[fake_pid] = fake_pgid
if not hasattr(os, "killpg"):
pytest.skip("os.killpg not available on this platform")
with patch(
"tools.mcp_tool.os.killpg",
side_effect=ProcessLookupError("no such process group"),
) as mock_killpg, \
patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("gateway.status._pid_exists", return_value=False), \
patch("time.sleep"):
_kill_orphaned_mcp_children()
# killpg was attempted (phase 1 SIGTERM) and fell back to os.kill.
# Phase 3 skips because _pid_exists returns False (direct pid gone).
mock_killpg.assert_called()
mock_kill.assert_any_call(fake_pid, signal.SIGTERM)
with _lock:
assert fake_pid not in _orphan_stdio_pids
assert fake_pid not in _stdio_pgids
def test_no_pgid_uses_per_pid_kill(self, monkeypatch):
"""When no pgid is recorded (e.g. Windows), fall back to os.kill."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pids,
_stdio_pgids,
_lock,
)
self._reset_state()
fake_pid = 747474
with _lock:
_orphan_stdio_pids.add(fake_pid)
# No entry in _stdio_pgids.
with patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("gateway.status._pid_exists", return_value=False), \
patch("time.sleep"):
# killpg may or may not exist; either way the no-pgid path skips it.
_kill_orphaned_mcp_children()
mock_kill.assert_any_call(fake_pid, signal.SIGTERM)
with _lock:
assert fake_pid not in _orphan_stdio_pids
@pytest.mark.live_system_guard_bypass
@pytest.mark.skipif(
not hasattr(os, "killpg") or not hasattr(os, "setsid"),
reason="POSIX-only: requires os.killpg and os.setsid",
)
def test_grandchild_reaped_via_pgroup(self, tmp_path):
"""End-to-end: parent spawns grandchild, parent exits, killpg reaps grandchild.
Mirrors issue #23799: a stdio MCP wrapper (parent) launches a long-lived
helper subprocess (grandchild) in the same process group, then the
wrapper exits while the grandchild keeps running. killpg on the pgid
captured at spawn time must still deliver the signal to the grandchild.
Marked ``live_system_guard_bypass`` because this test genuinely needs
real signal delivery to its own subprocess tree (the conftest guard
only knows the test's *initial* children; the spawned tree here is
outside that allowlist).
"""
import subprocess
import sys
import time as _time
psutil = pytest.importorskip("psutil")
# Grandchild: sleep forever, write its pid then wait.
grandchild_pid_file = tmp_path / "grandchild.pid"
grandchild_script = tmp_path / "grandchild.py"
grandchild_script.write_text(
"import os, sys, time\n"
f"open({str(grandchild_pid_file)!r}, 'w').write(str(os.getpid()))\n"
"while True:\n"
" time.sleep(0.5)\n"
)
# Parent: spawn grandchild, exit immediately (without killing it).
parent_script = tmp_path / "parent.py"
parent_script.write_text(
"import subprocess, sys\n"
f"subprocess.Popen([sys.executable, {str(grandchild_script)!r}])\n"
# Parent exits — grandchild reparents to init.
)
# Spawn parent in its own session (mirrors stdio_client behaviour).
parent = subprocess.Popen(
[sys.executable, str(parent_script)],
start_new_session=True,
)
parent_pgid = os.getpgid(parent.pid)
# Wait for parent to exit and grandchild to spin up.
parent.wait(timeout=5)
deadline = _time.time() + 5
while _time.time() < deadline and not grandchild_pid_file.exists():
_time.sleep(0.05)
assert grandchild_pid_file.exists(), "grandchild did not start"
grandchild_pid = int(grandchild_pid_file.read_text().strip())
# Sanity: grandchild is alive and shares the parent's pgid.
assert psutil.pid_exists(grandchild_pid)
assert os.getpgid(grandchild_pid) == parent_pgid
# Drive the reaper: register the parent pid + pgid as an orphan.
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pids,
_stdio_pgids,
_stdio_pids,
_lock,
)
with _lock:
_stdio_pids.clear()
_orphan_stdio_pids.clear()
_stdio_pgids.clear()
_orphan_stdio_pids.add(parent.pid)
_stdio_pgids[parent.pid] = parent_pgid
try:
_kill_orphaned_mcp_children()
finally:
# Belt-and-suspenders: ensure grandchild is dead even if test fails.
try:
os.kill(grandchild_pid, signal.SIGKILL)
except ProcessLookupError:
pass
# Grandchild should be gone — SIGTERM via killpg in phase 1 reached it.
deadline = _time.time() + 3
while _time.time() < deadline and psutil.pid_exists(grandchild_pid):
_time.sleep(0.05)
assert not psutil.pid_exists(grandchild_pid), (
"grandchild survived killpg-based reaping (issue #23799 regression)"
)
# ---------------------------------------------------------------------------
# Fix 3: MCP reload timeout (cli.py)
# ---------------------------------------------------------------------------
+84
View File
@@ -1220,6 +1220,90 @@ class TestParseTargetRefSlack:
assert _parse_target_ref("telegram", "C0B0QV5434G")[2] is False
class TestParseTargetRefEmail:
"""_parse_target_ref recognizes email addresses as explicit for the email platform."""
def test_standard_email_is_explicit(self):
chat_id, thread_id, is_explicit = _parse_target_ref("email", "user@example.com")
assert chat_id == "user@example.com"
assert thread_id is None
assert is_explicit is True
def test_email_with_dots_in_local_part(self):
chat_id, _, is_explicit = _parse_target_ref("email", "first.last@example.co.uk")
assert chat_id == "first.last@example.co.uk"
assert is_explicit is True
def test_email_with_plus_tag(self):
chat_id, _, is_explicit = _parse_target_ref("email", "user+tag@gmail.com")
assert chat_id == "user+tag@gmail.com"
assert is_explicit is True
def test_email_strips_whitespace(self):
chat_id, _, is_explicit = _parse_target_ref("email", " user@example.com ")
assert chat_id == "user@example.com"
assert is_explicit is True
def test_invalid_email_not_explicit(self):
assert _parse_target_ref("email", "not-an-email")[2] is False
assert _parse_target_ref("email", "@example.com")[2] is False
assert _parse_target_ref("email", "user@")[2] is False
assert _parse_target_ref("email", "user@.com")[2] is False
def test_email_not_explicit_for_other_platforms(self):
assert _parse_target_ref("telegram", "user@example.com")[2] is False
assert _parse_target_ref("discord", "user@example.com")[2] is False
assert _parse_target_ref("slack", "user@example.com")[2] is False
class TestEmailHomeChannelErrorHint:
"""The no-home-channel error for email points at the real env var.
Email reads its home channel from EMAIL_HOME_ADDRESS (gateway/config.py),
not the generic EMAIL_HOME_CHANNEL. The error guidance must name the
variable that is actually consulted so users who follow it succeed.
"""
def test_email_error_names_email_home_address(self):
email_cfg = SimpleNamespace(enabled=True, token="", extra={})
config = SimpleNamespace(
platforms={Platform.EMAIL: email_cfg},
get_home_channel=lambda _platform: None,
)
with patch("gateway.config.load_gateway_config", return_value=config), \
patch("tools.interrupt.is_interrupted", return_value=False):
result = json.loads(
send_message_tool(
{
"action": "send",
"target": "email",
"message": "hi",
}
)
)
assert "EMAIL_HOME_ADDRESS" in result["error"]
assert "EMAIL_HOME_CHANNEL" not in result["error"]
def test_non_email_platform_keeps_generic_home_channel_hint(self):
telegram_cfg = SimpleNamespace(enabled=True, token="***", extra={})
config = SimpleNamespace(
platforms={Platform.TELEGRAM: telegram_cfg},
get_home_channel=lambda _platform: None,
)
with patch("gateway.config.load_gateway_config", return_value=config), \
patch("tools.interrupt.is_interrupted", return_value=False):
result = json.loads(
send_message_tool(
{
"action": "send",
"target": "telegram",
"message": "hi",
}
)
)
assert "TELEGRAM_HOME_CHANNEL" in result["error"]
class TestSendDiscordThreadId:
"""_send_discord uses thread_id when provided."""
+82
View File
@@ -845,3 +845,85 @@ class TestResetBundledSkill:
post_manifest = _read_manifest()
assert "google-workspace" in post_manifest
assert (skills_dir / "productivity" / "google-workspace" / "SKILL.md").exists()
def test_reset_restore_succeeds_on_readonly_nix_tree(self, tmp_path):
"""#34972: --restore must succeed even when the user copy is a fully
read-only tree (r-xr-xr-x dirs + files), as produced by copying a
Nix-store source. The manifest is re-baselined and bundled re-copied."""
import os
import stat
bundled = self._setup_bundled(tmp_path)
skills_dir = tmp_path / "user_skills"
manifest_file = skills_dir / ".bundled_manifest"
dest = skills_dir / "productivity" / "google-workspace"
sub = dest / "references"
sub.mkdir(parents=True)
(dest / "SKILL.md").write_text("# user version\n")
(sub / "ref.md").write_text("# nested ref\n")
manifest_file.write_text(
"google-workspace:STALEHASH000000000000000000000000\n"
)
# Read-only files AND directories — the real Nix-store case.
ro_dir = (
stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP
| stat.S_IROTH | stat.S_IXOTH
)
os.chmod(sub / "ref.md", stat.S_IREAD)
os.chmod(dest / "SKILL.md", stat.S_IREAD)
os.chmod(sub, ro_dir)
os.chmod(dest, ro_dir)
try:
with self._patches(bundled, skills_dir, manifest_file):
result = reset_bundled_skill("google-workspace", restore=True)
assert result["ok"] is True
assert result["action"] == "restored"
# Bundled version was re-copied over the (deleted) user copy.
assert "upstream" in (dest / "SKILL.md").read_text()
# The read-only nested user dir/file was fully removed, not left behind.
assert not (sub / "ref.md").exists()
# sync ran and re-copied the skill (not stuck in limbo).
assert "google-workspace" in result["synced"]["copied"]
finally:
# Restore perms so tmp_path teardown can remove anything left.
for p in (sub, dest):
if p.exists():
os.chmod(p, stat.S_IRWXU)
def test_reset_restore_preserves_manifest_on_rmtree_failure(self, tmp_path):
"""#34972: when the user copy genuinely cannot be removed, the manifest
entry must NOT be deleted otherwise the skill enters a limbo state
where future syncs silently skip it forever."""
bundled = self._setup_bundled(tmp_path)
skills_dir = tmp_path / "user_skills"
manifest_file = skills_dir / ".bundled_manifest"
dest = skills_dir / "productivity" / "google-workspace"
dest.mkdir(parents=True)
(dest / "SKILL.md").write_text("# user version\n")
manifest_file.write_text(
"google-workspace:STALEHASH000000000000000000000000\n"
)
# Simulate an unremovable tree (e.g. a busy mountpoint or a path even
# chmod can't rescue) by making the removal helper raise.
def _boom(_path):
raise PermissionError(13, "Permission denied")
with self._patches(bundled, skills_dir, manifest_file), patch(
"tools.skills_sync._rmtree_writable", side_effect=_boom
):
result = reset_bundled_skill("google-workspace", restore=True)
# Restore failed, and the manifest must be left untouched.
assert result["ok"] is False
assert result["action"] == "not_reset"
assert "Manifest entry preserved" in result["message"]
manifest_after = manifest_file.read_text()
assert "google-workspace" in manifest_after
# User copy is still on disk (we changed nothing).
assert (dest / "SKILL.md").exists()
+81
View File
@@ -917,3 +917,84 @@ class TestIsImageSizeError:
def test_empty_message(self):
assert not _is_image_size_error(Exception(""))
class TestDownloadRetryClassification:
"""Error-class-aware retry: 4xx fail-fast, 429/5xx/transient retried (issue #32296)."""
@staticmethod
def _status_error(status_code):
import httpx
request = httpx.Request("GET", "https://example.com/img.jpg")
response = httpx.Response(status_code, request=request)
return httpx.HTTPStatusError(
f"{status_code}", request=request, response=response
)
def _make_client_raising_status(self, status_code):
"""AsyncClient whose response.raise_for_status() raises HTTPStatusError."""
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock(
side_effect=self._status_error(status_code)
)
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(return_value=mock_response)
return mock_client
def test_is_retryable_classification(self):
from tools.vision_tools import _is_retryable_download_error
# Non-retryable client errors
for code in (400, 403, 404, 410):
assert _is_retryable_download_error(self._status_error(code)) is False
# Retryable: rate limit + server errors
for code in (429, 500, 502, 503):
assert _is_retryable_download_error(self._status_error(code)) is True
# Policy/SSRF/size errors are terminal
assert _is_retryable_download_error(PermissionError("blocked")) is False
assert _is_retryable_download_error(ValueError("too large")) is False
# Unclassified (network blip) is retryable
assert _is_retryable_download_error(ConnectionError("reset")) is True
@pytest.mark.asyncio
async def test_404_fails_fast_without_retry(self, tmp_path):
"""A 404 must raise on the first attempt — no backoff sleep, no extra GETs."""
import httpx
from tools.vision_tools import _download_image
mock_client = self._make_client_raising_status(404)
with (
patch("tools.vision_tools.httpx.AsyncClient", return_value=mock_client),
patch("tools.vision_tools.check_website_access", return_value=None),
patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
pytest.raises(httpx.HTTPStatusError),
):
await _download_image(
"https://example.com/missing.jpg", tmp_path / "x.jpg", max_retries=3
)
# Exactly one attempt, zero backoff sleeps.
assert mock_client.get.await_count == 1
mock_sleep.assert_not_called()
@pytest.mark.asyncio
async def test_503_retries_then_raises(self, tmp_path):
"""A 5xx is retried up to max_retries, sleeping between attempts."""
import httpx
from tools.vision_tools import _download_image
mock_client = self._make_client_raising_status(503)
with (
patch("tools.vision_tools.httpx.AsyncClient", return_value=mock_client),
patch("tools.vision_tools.check_website_access", return_value=None),
patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
pytest.raises(httpx.HTTPStatusError),
):
await _download_image(
"https://example.com/flaky.jpg", tmp_path / "y.jpg", max_retries=3
)
# All three attempts used, two backoff sleeps between them.
assert mock_client.get.await_count == 3
assert mock_sleep.await_count == 2
+41 -2
View File
@@ -623,10 +623,49 @@ class TestCheckWebApiKey:
assert check_web_api_key() is True
def test_tool_gateway_returns_true(self):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
with patch("tools.web_tools._peek_nous_access_token", return_value="nous-token"):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True
def test_tool_gateway_availability_skips_refresh_for_expired_cached_token(
self,
tmp_path,
monkeypatch,
):
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
expired_at = "2000-01-01T00:00:00+00:00"
(tmp_path / "auth.json").write_text(json.dumps({
"providers": {
"nous": {
"access_token": "expired-token",
"refresh_token": "refresh-token",
"expires_at": expired_at,
}
}
}))
refresh_calls = []
def _record_refresh(*, refresh_skew_seconds=120, **_kwargs):
refresh_calls.append(refresh_skew_seconds)
return "fresh-token"
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_access_token",
_record_refresh,
)
with patch.dict(
os.environ,
{"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"},
clear=False,
):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True
assert refresh_calls == []
def test_configured_backend_must_match_available_provider(self):
with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
@@ -636,7 +675,7 @@ class TestCheckWebApiKey:
def test_configured_firecrawl_backend_accepts_managed_gateway(self):
with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
with patch("tools.web_tools._peek_nous_access_token", return_value="nous-token"):
with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True
@@ -0,0 +1,78 @@
"""Tests for tui_gateway.entry.wait_for_mcp_discovery (PR #35245).
MCP tool discovery runs in a background daemon thread so a slow/dead server
can't freeze ``gateway.ready``. The agent snapshots its tool list once at
build time and never re-reads it, so ``_make_agent`` briefly joins the
discovery thread before building bounded, so a dead server can't re-introduce
the startup hang, and a no-op once discovery has finished.
"""
import threading
import time
import tui_gateway.entry as entry
def _restore_thread_slot(saved):
entry._mcp_discovery_thread = saved
def test_no_thread_is_noop():
"""When no discovery thread was started (the common no-MCP case), the
helper returns immediately and never blocks."""
saved = entry._mcp_discovery_thread
try:
entry._mcp_discovery_thread = None
start = time.monotonic()
entry.wait_for_mcp_discovery(timeout=5.0)
assert time.monotonic() - start < 0.1
finally:
_restore_thread_slot(saved)
def test_already_finished_thread_is_noop():
"""A thread that has already finished is not joined-on (dead thread)."""
saved = entry._mcp_discovery_thread
try:
t = threading.Thread(target=lambda: None, daemon=True)
t.start()
t.join() # ensure it's finished
entry._mcp_discovery_thread = t
start = time.monotonic()
entry.wait_for_mcp_discovery(timeout=5.0)
assert time.monotonic() - start < 0.1
finally:
_restore_thread_slot(saved)
def test_fast_thread_is_joined():
"""A reachable-but-still-connecting (fast) server lands before the agent
snapshots tools the helper waits for it to finish."""
saved = entry._mcp_discovery_thread
try:
t = threading.Thread(target=lambda: time.sleep(0.05), daemon=True)
t.start()
entry._mcp_discovery_thread = t
entry.wait_for_mcp_discovery(timeout=1.0)
assert not t.is_alive() # joined to completion
finally:
_restore_thread_slot(saved)
def test_hung_thread_is_bounded_by_timeout():
"""A slow/dead server must NOT re-introduce the startup hang — the join is
bounded by the timeout and returns even though the thread is still alive."""
saved = entry._mcp_discovery_thread
stop = threading.Event()
try:
t = threading.Thread(target=stop.wait, daemon=True) # blocks until set
t.start()
entry._mcp_discovery_thread = t
start = time.monotonic()
entry.wait_for_mcp_discovery(timeout=0.3)
elapsed = time.monotonic() - start
assert 0.25 <= elapsed < 1.0 # bounded near the timeout, not forever
assert t.is_alive() # thread still running; we did not block on it
finally:
stop.set()
_restore_thread_slot(saved)