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