Merge remote-tracking branch 'origin/main' into bb/gui
# Conflicts: # apps/dashboard/src/i18n/af.ts # apps/dashboard/src/i18n/de.ts # apps/dashboard/src/i18n/es.ts # apps/dashboard/src/i18n/fr.ts # apps/dashboard/src/i18n/ga.ts # apps/dashboard/src/i18n/hu.ts # apps/dashboard/src/i18n/it.ts # apps/dashboard/src/i18n/ja.ts # apps/dashboard/src/i18n/ko.ts # apps/dashboard/src/i18n/pt.ts # apps/dashboard/src/i18n/ru.ts # apps/dashboard/src/i18n/tr.ts # apps/dashboard/src/i18n/uk.ts # apps/dashboard/src/i18n/zh-hant.ts # gateway/config.py # hermes_cli/main.py # plugins/strike-freedom-cockpit/README.md # tui_gateway/server.py
This commit is contained in:
@@ -2123,6 +2123,227 @@ class TestCodexAuxiliaryAdapterTimeout:
|
||||
assert time.monotonic() - started < 0.14
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue #23432 — auxiliary timeout poisons cached client; later aux calls fail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAuxiliaryClientPoisonedCacheEviction:
|
||||
"""Connection/timeout errors must evict the cached aux client.
|
||||
|
||||
Otherwise the next auxiliary call (compression retry, memory flush,
|
||||
background review) reuses the closed httpx transport and fails with
|
||||
``Connection error`` even though the main provider route is healthy.
|
||||
See https://github.com/NousResearch/hermes-agent/issues/23432.
|
||||
"""
|
||||
|
||||
def test_evict_cached_client_instance_drops_direct_match(self):
|
||||
from agent.auxiliary_client import (
|
||||
_client_cache, _client_cache_lock, _evict_cached_client_instance,
|
||||
)
|
||||
|
||||
target = MagicMock(name="target_client")
|
||||
other = MagicMock(name="other_client")
|
||||
with _client_cache_lock:
|
||||
_client_cache.clear()
|
||||
_client_cache[("openrouter", False, None, None, None)] = (target, "x", None)
|
||||
_client_cache[("anthropic", False, None, None, None)] = (other, "y", None)
|
||||
try:
|
||||
assert _evict_cached_client_instance(target) is True
|
||||
assert ("openrouter", False, None, None, None) not in _client_cache
|
||||
assert ("anthropic", False, None, None, None) in _client_cache
|
||||
finally:
|
||||
with _client_cache_lock:
|
||||
_client_cache.clear()
|
||||
|
||||
def test_evict_cached_client_instance_walks_codex_wrapper(self):
|
||||
"""Closing the underlying OpenAI client must evict the Codex shim."""
|
||||
from agent.auxiliary_client import (
|
||||
_client_cache, _client_cache_lock, _evict_cached_client_instance,
|
||||
CodexAuxiliaryClient,
|
||||
)
|
||||
|
||||
real = SimpleNamespace(api_key="k", base_url="https://chatgpt.com/backend-api/codex",
|
||||
responses=SimpleNamespace(stream=lambda **k: None),
|
||||
close=lambda: None)
|
||||
wrapper = CodexAuxiliaryClient(real, "gpt-5.5")
|
||||
with _client_cache_lock:
|
||||
_client_cache.clear()
|
||||
_client_cache[("openai-codex", False, None, None, None)] = (wrapper, "gpt-5.5", None)
|
||||
try:
|
||||
# Eviction by the inner OpenAI client must remove the wrapper entry.
|
||||
assert _evict_cached_client_instance(real) is True
|
||||
assert ("openai-codex", False, None, None, None) not in _client_cache
|
||||
finally:
|
||||
with _client_cache_lock:
|
||||
_client_cache.clear()
|
||||
|
||||
def test_evict_cached_client_instance_handles_none_and_misses(self):
|
||||
from agent.auxiliary_client import _evict_cached_client_instance
|
||||
|
||||
assert _evict_cached_client_instance(None) is False
|
||||
assert _evict_cached_client_instance(MagicMock()) is False
|
||||
|
||||
def test_evict_cached_client_instance_walks_async_wrapper(self):
|
||||
"""async_mode is part of the cache key so sync and async share the same
|
||||
underlying OpenAI client across two distinct cache entries. A single
|
||||
timeout that closes the leaf must evict BOTH — otherwise the async
|
||||
entry survives, keeps reusing the dead transport, and every async
|
||||
aux call (compression, vision, session_search) fails fast with
|
||||
'Connection error' until gateway restart even while the sync route
|
||||
recovers.
|
||||
|
||||
Regression for the async-side gap left by #23482, which fixed the
|
||||
sync wrapper's _real_client walk but missed the async wrappers.
|
||||
"""
|
||||
from agent.auxiliary_client import (
|
||||
_client_cache, _client_cache_lock, _evict_cached_client_instance,
|
||||
CodexAuxiliaryClient, AsyncCodexAuxiliaryClient,
|
||||
)
|
||||
|
||||
real = SimpleNamespace(api_key="k", base_url="https://chatgpt.com/backend-api/codex",
|
||||
responses=SimpleNamespace(stream=lambda **k: None),
|
||||
close=lambda: None)
|
||||
sync_wrapper = CodexAuxiliaryClient(real, "gpt-5.5")
|
||||
async_wrapper = AsyncCodexAuxiliaryClient(sync_wrapper)
|
||||
with _client_cache_lock:
|
||||
_client_cache.clear()
|
||||
_client_cache[("openai-codex", False, None, None, None)] = (sync_wrapper, "gpt-5.5", None)
|
||||
_client_cache[("openai-codex", True, None, None, None)] = (async_wrapper, "gpt-5.5", None)
|
||||
try:
|
||||
assert _evict_cached_client_instance(real) is True
|
||||
assert ("openai-codex", False, None, None, None) not in _client_cache
|
||||
assert ("openai-codex", True, None, None, None) not in _client_cache, (
|
||||
"async cache entry survived eviction — wrapper is missing _real_client"
|
||||
)
|
||||
finally:
|
||||
with _client_cache_lock:
|
||||
_client_cache.clear()
|
||||
|
||||
def test_codex_timeout_evicts_cached_wrapper(self):
|
||||
"""The timeout closer evicts the cache entry that wraps the closed client."""
|
||||
from agent.auxiliary_client import (
|
||||
_client_cache, _client_cache_lock,
|
||||
_CodexCompletionsAdapter, CodexAuxiliaryClient,
|
||||
)
|
||||
|
||||
class SlowAliveStream:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def __iter__(self):
|
||||
for _ in range(20):
|
||||
time.sleep(0.01)
|
||||
yield SimpleNamespace(type="response.in_progress")
|
||||
|
||||
def get_final_response(self): # pragma: no cover — timeout fires first
|
||||
return SimpleNamespace(output=[], usage=None)
|
||||
|
||||
closed = {"flag": False}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.responses = SimpleNamespace(stream=lambda **k: SlowAliveStream())
|
||||
self.api_key = "k"
|
||||
self.base_url = "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
def close(self):
|
||||
closed["flag"] = True
|
||||
|
||||
fake_real = FakeClient()
|
||||
wrapper = CodexAuxiliaryClient(fake_real, "gpt-5.5")
|
||||
cache_key = ("openai-codex", False, None, None, None)
|
||||
with _client_cache_lock:
|
||||
_client_cache.clear()
|
||||
_client_cache[cache_key] = (wrapper, "gpt-5.5", None)
|
||||
try:
|
||||
adapter = _CodexCompletionsAdapter(fake_real, "gpt-5.5")
|
||||
with pytest.raises(TimeoutError):
|
||||
adapter.create(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
timeout=0.05,
|
||||
)
|
||||
assert closed["flag"] is True, "timeout closer must close inner client"
|
||||
assert cache_key not in _client_cache, (
|
||||
"timeout closer must evict cache entry that wraps the closed client"
|
||||
)
|
||||
finally:
|
||||
with _client_cache_lock:
|
||||
_client_cache.clear()
|
||||
|
||||
def test_call_llm_evicts_on_connection_error_with_explicit_provider(self):
|
||||
"""Connection error on an explicit provider must drop the cached client.
|
||||
|
||||
This is the exact reporter scenario: ``auxiliary.compression.provider:
|
||||
main`` (resolves to ``openai-codex``) → no fallback chain runs (not
|
||||
auto), but the cached client was poisoned by a prior timeout and must
|
||||
be evicted so the next call rebuilds.
|
||||
"""
|
||||
from agent.auxiliary_client import _client_cache, _client_cache_lock
|
||||
|
||||
poisoned = MagicMock(name="poisoned_client")
|
||||
poisoned.base_url = "https://chatgpt.com/backend-api/codex"
|
||||
poisoned.chat.completions.create.side_effect = ConnectionError("transport closed")
|
||||
|
||||
cache_key = ("openai-codex", False, None, None, None)
|
||||
with _client_cache_lock:
|
||||
_client_cache.clear()
|
||||
_client_cache[cache_key] = (poisoned, "gpt-5.5", None)
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("openai-codex", "gpt-5.5", None, None, None),
|
||||
), patch(
|
||||
"agent.auxiliary_client._get_cached_client",
|
||||
return_value=(poisoned, "gpt-5.5"),
|
||||
):
|
||||
with pytest.raises(ConnectionError):
|
||||
call_llm(
|
||||
task="compression",
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
)
|
||||
assert cache_key not in _client_cache, (
|
||||
"connection error must evict cached client so the next call rebuilds"
|
||||
)
|
||||
finally:
|
||||
with _client_cache_lock:
|
||||
_client_cache.clear()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_call_llm_evicts_on_connection_error_with_explicit_provider(self):
|
||||
from agent.auxiliary_client import _client_cache, _client_cache_lock
|
||||
|
||||
poisoned = MagicMock(name="poisoned_async_client")
|
||||
poisoned.base_url = "https://chatgpt.com/backend-api/codex"
|
||||
poisoned.chat.completions.create = AsyncMock(side_effect=ConnectionError("transport closed"))
|
||||
|
||||
cache_key = ("openai-codex", True, None, None, None)
|
||||
with _client_cache_lock:
|
||||
_client_cache.clear()
|
||||
_client_cache[cache_key] = (poisoned, "gpt-5.5", None)
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("openai-codex", "gpt-5.5", None, None, None),
|
||||
), patch(
|
||||
"agent.auxiliary_client._get_cached_client",
|
||||
return_value=(poisoned, "gpt-5.5"),
|
||||
):
|
||||
with pytest.raises(ConnectionError):
|
||||
await async_call_llm(
|
||||
task="compression",
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
)
|
||||
assert cache_key not in _client_cache
|
||||
finally:
|
||||
with _client_cache_lock:
|
||||
_client_cache.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_call_kwargs — tool dedup at API boundary
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2311,3 +2532,165 @@ class TestAnthropicExplicitApiKey:
|
||||
assert mock_build.call_args.args[0] == "explicit-fallback-key", (
|
||||
"resolve_provider_client must forward explicit_api_key to _try_anthropic()"
|
||||
)
|
||||
|
||||
|
||||
# ── Auxiliary unhealthy-provider TTL cache (issue #23570) ────────────────
|
||||
|
||||
|
||||
class TestAuxUnhealthyCache:
|
||||
"""Recently-402'd providers are skipped on subsequent aux calls.
|
||||
|
||||
Without this, every compression / title-gen / session-search call on a
|
||||
long session retries a depleted OpenRouter (~1 RTT to 402) before
|
||||
falling back to the next provider. The TTL cache hides the unhealthy
|
||||
provider for ``_AUX_UNHEALTHY_TTL_SECONDS`` so the chain skips it.
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
from agent.auxiliary_client import _reset_aux_unhealthy_cache
|
||||
_reset_aux_unhealthy_cache()
|
||||
|
||||
def teardown_method(self):
|
||||
from agent.auxiliary_client import _reset_aux_unhealthy_cache
|
||||
_reset_aux_unhealthy_cache()
|
||||
|
||||
def test_mark_then_skip(self):
|
||||
from agent.auxiliary_client import (
|
||||
_mark_provider_unhealthy,
|
||||
_is_provider_unhealthy,
|
||||
)
|
||||
assert _is_provider_unhealthy("openrouter") is False
|
||||
_mark_provider_unhealthy("openrouter")
|
||||
assert _is_provider_unhealthy("openrouter") is True
|
||||
|
||||
def test_ttl_expiry_evicts(self):
|
||||
from agent.auxiliary_client import (
|
||||
_mark_provider_unhealthy,
|
||||
_is_provider_unhealthy,
|
||||
_aux_unhealthy_until,
|
||||
)
|
||||
_mark_provider_unhealthy("openrouter", ttl=0.01)
|
||||
assert _is_provider_unhealthy("openrouter") is True
|
||||
import time
|
||||
time.sleep(0.02)
|
||||
# Lazy eviction: first lookup after expiry returns False AND removes the entry.
|
||||
assert _is_provider_unhealthy("openrouter") is False
|
||||
assert "openrouter" not in _aux_unhealthy_until
|
||||
|
||||
def test_alias_normalization(self):
|
||||
"""'codex' should normalize to 'openai-codex' so the cache lookup
|
||||
matches the chain label."""
|
||||
from agent.auxiliary_client import (
|
||||
_mark_provider_unhealthy,
|
||||
_is_provider_unhealthy,
|
||||
)
|
||||
_mark_provider_unhealthy("codex")
|
||||
assert _is_provider_unhealthy("openai-codex") is True
|
||||
|
||||
def test_resolve_auto_skips_unhealthy_step2(self):
|
||||
"""_resolve_auto Step-2 chain skips unhealthy providers."""
|
||||
from agent.auxiliary_client import (
|
||||
_resolve_auto,
|
||||
_mark_provider_unhealthy,
|
||||
)
|
||||
nous_client = MagicMock()
|
||||
# Mark OpenRouter unhealthy → chain should skip it and pick nous.
|
||||
_mark_provider_unhealthy("openrouter")
|
||||
with patch("agent.auxiliary_client._read_main_provider", return_value=""), \
|
||||
patch("agent.auxiliary_client._read_main_model", return_value=""), \
|
||||
patch("agent.auxiliary_client._try_openrouter") as or_try, \
|
||||
patch("agent.auxiliary_client._try_nous", return_value=(nous_client, "nous-model")), \
|
||||
patch("agent.auxiliary_client._try_custom_endpoint", return_value=(None, None)), \
|
||||
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
|
||||
client, model = _resolve_auto()
|
||||
assert client is nous_client
|
||||
assert model == "nous-model"
|
||||
# The skipped provider's _try_* should NOT have been called at all.
|
||||
or_try.assert_not_called()
|
||||
|
||||
def test_resolve_auto_skips_unhealthy_main_in_step1(self):
|
||||
"""Step-1 also consults the unhealthy cache so a depleted main
|
||||
provider doesn't burn a 402 RTT every aux call. Falls through to
|
||||
Step-2 chain (which also respects the cache)."""
|
||||
from agent.auxiliary_client import (
|
||||
_resolve_auto,
|
||||
_mark_provider_unhealthy,
|
||||
)
|
||||
nous_client = MagicMock()
|
||||
_mark_provider_unhealthy("openrouter")
|
||||
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
|
||||
patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4.6"), \
|
||||
patch("agent.auxiliary_client.resolve_provider_client") as step1, \
|
||||
patch("agent.auxiliary_client._try_openrouter") as or_try, \
|
||||
patch("agent.auxiliary_client._try_nous", return_value=(nous_client, "n-model")), \
|
||||
patch("agent.auxiliary_client._try_custom_endpoint", return_value=(None, None)), \
|
||||
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
|
||||
client, model = _resolve_auto()
|
||||
# Step-1 was bypassed — resolve_provider_client never invoked
|
||||
step1.assert_not_called()
|
||||
# Step-2 also skipped openrouter and landed on nous
|
||||
or_try.assert_not_called()
|
||||
assert client is nous_client
|
||||
|
||||
def test_payment_fallback_skips_unhealthy(self):
|
||||
"""_try_payment_fallback also consults the unhealthy cache so a 402
|
||||
on OpenRouter doesn't cause a second OR call within the same chain
|
||||
iteration if it gets re-entered."""
|
||||
from agent.auxiliary_client import (
|
||||
_try_payment_fallback,
|
||||
_mark_provider_unhealthy,
|
||||
)
|
||||
nous_client = MagicMock()
|
||||
# Mark BOTH the failed provider (openrouter) and a sibling (custom)
|
||||
# unhealthy. The chain should still find nous.
|
||||
_mark_provider_unhealthy("local/custom")
|
||||
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
|
||||
patch("agent.auxiliary_client._try_openrouter") as or_try, \
|
||||
patch("agent.auxiliary_client._try_nous", return_value=(nous_client, "n-model")), \
|
||||
patch("agent.auxiliary_client._try_custom_endpoint") as custom_try, \
|
||||
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
|
||||
client, model, label = _try_payment_fallback("openrouter", task="compression")
|
||||
assert client is nous_client
|
||||
assert label == "nous"
|
||||
# OR is skipped via skip_chain_labels (failed provider), custom via unhealthy cache.
|
||||
or_try.assert_not_called()
|
||||
custom_try.assert_not_called()
|
||||
|
||||
def test_call_llm_marks_provider_unhealthy_on_402(self, monkeypatch):
|
||||
"""A 402 from call_llm causes the provider to be marked unhealthy
|
||||
so the next call skips it instead of re-trying the same depleted
|
||||
endpoint."""
|
||||
from agent.auxiliary_client import (
|
||||
call_llm,
|
||||
_is_provider_unhealthy,
|
||||
)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
# base_url tells _recoverable_pool_provider() that this is OpenRouter
|
||||
# (resolved_provider="auto" doesn't carry that information by itself).
|
||||
primary_client.base_url = "https://openrouter.ai/api/v1/"
|
||||
err = Exception("Payment Required: insufficient credits")
|
||||
err.status_code = 402
|
||||
primary_client.chat.completions.create.side_effect = err
|
||||
|
||||
nous_client = MagicMock()
|
||||
nous_resp = MagicMock()
|
||||
nous_resp.choices = [MagicMock(message=MagicMock(content="ok"))]
|
||||
nous_client.chat.completions.create.return_value = nous_resp
|
||||
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "google/gemini-3-flash-preview")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", "google/gemini-3-flash-preview", None, None, None)), \
|
||||
patch("agent.auxiliary_client._try_payment_fallback",
|
||||
return_value=(nous_client, "n-model", "nous")), \
|
||||
patch("agent.auxiliary_client._build_call_kwargs",
|
||||
return_value={"model": "n-model", "messages": [{"role": "user", "content": "hi"}]}):
|
||||
assert _is_provider_unhealthy("openrouter") is False
|
||||
call_llm(
|
||||
task="compression",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
# After the 402, OpenRouter is in the unhealthy cache.
|
||||
assert _is_provider_unhealthy("openrouter") is True
|
||||
|
||||
@@ -1020,3 +1020,106 @@ def test_rename_summary_mixed_consolidation_and_pruning(curator_env):
|
||||
assert merge_idx < drop_idx, "consolidated should render before pruned"
|
||||
assert "merge-me → umbrella" in lines[merge_idx]
|
||||
assert "drop-me — pruned (stale)" in lines[drop_idx]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pin hint — surfaces `hermes curator pin <umbrella>` in the rename block so
|
||||
# users learn the command exists at the moment they care (a consolidation
|
||||
# just landed against their library). The hint is gated on having at least
|
||||
# one umbrella destination — pruned-only runs skip it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rename_summary_pin_hint_appears_when_consolidation_produced_umbrella(curator_env):
|
||||
"""When at least one skill was absorbed into an umbrella, hint at pinning it."""
|
||||
result = curator_env._build_rename_summary(
|
||||
before_names={"pdf-extraction", "docx-extraction", "document-tools"},
|
||||
after_report=[{"name": "document-tools", "state": "active"}],
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "skill_manage",
|
||||
"arguments": json.dumps({
|
||||
"action": "delete",
|
||||
"name": "pdf-extraction",
|
||||
"absorbed_into": "document-tools",
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "skill_manage",
|
||||
"arguments": json.dumps({
|
||||
"action": "delete",
|
||||
"name": "docx-extraction",
|
||||
"absorbed_into": "document-tools",
|
||||
}),
|
||||
},
|
||||
],
|
||||
model_final="",
|
||||
)
|
||||
assert "hermes curator pin document-tools" in result
|
||||
assert "keep an umbrella stable" in result
|
||||
|
||||
|
||||
def test_rename_summary_pin_hint_skipped_for_pruned_only_runs(curator_env):
|
||||
"""Pruned-only runs have nothing surviving to pin — hint should not appear."""
|
||||
result = curator_env._build_rename_summary(
|
||||
before_names={"old-flaky-thing", "another-stale", "keeper"},
|
||||
after_report=[{"name": "keeper", "state": "active"}],
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "skill_manage",
|
||||
"arguments": json.dumps({
|
||||
"action": "delete",
|
||||
"name": "old-flaky-thing",
|
||||
"absorbed_into": "",
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "skill_manage",
|
||||
"arguments": json.dumps({
|
||||
"action": "delete",
|
||||
"name": "another-stale",
|
||||
"absorbed_into": "",
|
||||
}),
|
||||
},
|
||||
],
|
||||
model_final="",
|
||||
)
|
||||
# Block still renders (skills were archived) but no pin hint.
|
||||
assert "archived 2 skill(s):" in result
|
||||
assert "hermes curator pin" not in result
|
||||
assert "keep an umbrella stable" not in result
|
||||
|
||||
|
||||
def test_rename_summary_pin_hint_picks_one_umbrella_when_multiple_absorbed(curator_env):
|
||||
"""Multiple umbrellas → hint shows one example (alphabetically first), not a list."""
|
||||
result = curator_env._build_rename_summary(
|
||||
before_names={"a-skill", "b-skill", "umbrella-zeta", "umbrella-alpha"},
|
||||
after_report=[
|
||||
{"name": "umbrella-zeta", "state": "active"},
|
||||
{"name": "umbrella-alpha", "state": "active"},
|
||||
],
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "skill_manage",
|
||||
"arguments": json.dumps({
|
||||
"action": "delete",
|
||||
"name": "a-skill",
|
||||
"absorbed_into": "umbrella-zeta",
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "skill_manage",
|
||||
"arguments": json.dumps({
|
||||
"action": "delete",
|
||||
"name": "b-skill",
|
||||
"absorbed_into": "umbrella-alpha",
|
||||
}),
|
||||
},
|
||||
],
|
||||
model_final="",
|
||||
)
|
||||
# Sorted picks alphabetically first.
|
||||
assert "hermes curator pin umbrella-alpha" in result
|
||||
# Exactly one hint line, not one per umbrella.
|
||||
pin_lines = [ln for ln in result.splitlines() if "hermes curator pin" in ln]
|
||||
assert len(pin_lines) == 1
|
||||
|
||||
@@ -156,7 +156,12 @@ def test_t_missing_key_in_non_english_falls_back_to_english(tmp_path, monkeypatc
|
||||
(fake_locales / "zh.yaml").write_text("# intentionally empty\n", encoding="utf-8")
|
||||
monkeypatch.setattr(i18n, "_locales_dir", lambda: fake_locales)
|
||||
i18n.reset_language_cache()
|
||||
assert i18n.t("foo", lang="zh") == "English Foo"
|
||||
try:
|
||||
assert i18n.t("foo", lang="zh") == "English Foo"
|
||||
finally:
|
||||
# Clear the cache on teardown so subsequent tests don't see the
|
||||
# fake "foo: English Foo" catalog instead of the real locales/*.yaml.
|
||||
i18n.reset_language_cache()
|
||||
|
||||
|
||||
def test_t_unknown_language_uses_english():
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Tests for `agent.markdown_tables.realign_markdown_tables`.
|
||||
|
||||
These cover the alignment guarantee on CJK / wide-character tables and
|
||||
the conservative no-op behaviour on non-table input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
from wcwidth import wcswidth
|
||||
|
||||
from agent.markdown_tables import (
|
||||
is_table_divider,
|
||||
looks_like_table_row,
|
||||
realign_markdown_tables,
|
||||
split_table_row,
|
||||
)
|
||||
|
||||
|
||||
def _column_offsets(line: str) -> list[int]:
|
||||
"""Return the display-cell index of every ``|`` in ``line``."""
|
||||
|
||||
cells: list[int] = []
|
||||
width = 0
|
||||
for ch in line:
|
||||
if ch == "|":
|
||||
cells.append(width)
|
||||
# wcswidth on a single char; clamp negatives.
|
||||
w = wcswidth(ch)
|
||||
width += w if w > 0 else 1
|
||||
return cells
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# split_table_row / is_table_divider / looks_like_table_row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_split_strips_outer_pipes_and_trims():
|
||||
assert split_table_row("| a | b | c |") == ["a", "b", "c"]
|
||||
assert split_table_row("|配置|状态|") == ["配置", "状态"]
|
||||
assert split_table_row("a | b | c") == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_is_table_divider_handles_alignment_colons():
|
||||
assert is_table_divider("|---|---|")
|
||||
assert is_table_divider("| :--- | ---: | :---: |")
|
||||
assert not is_table_divider("| - | - |") # 1 dash is not a divider
|
||||
assert not is_table_divider("| a | b |")
|
||||
assert not is_table_divider("---") # single column, no pipes
|
||||
|
||||
|
||||
def test_looks_like_table_row():
|
||||
assert looks_like_table_row("| a | b |")
|
||||
assert looks_like_table_row("a | b | c") # no leading pipe, ≥2 pipes
|
||||
assert not looks_like_table_row("not a table")
|
||||
assert not looks_like_table_row("a | b") # one pipe, no leading pipe
|
||||
assert not looks_like_table_row("")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# realign_markdown_tables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_op_on_text_without_tables():
|
||||
text = "Hello world\nThis has no | pipes table.\n"
|
||||
assert realign_markdown_tables(text) == text
|
||||
|
||||
|
||||
def test_no_op_when_pipes_but_no_divider():
|
||||
text = "echo a | grep b\necho c | wc -l\n"
|
||||
assert realign_markdown_tables(text) == text
|
||||
|
||||
|
||||
def test_cjk_table_pipes_align_across_rows():
|
||||
# Model-emitted (under-padded for CJK) input.
|
||||
src = dedent(
|
||||
"""\
|
||||
| 配置 | Config | 论文 (%) | 复现 (%) | 差值 | 状态 |
|
||||
|------|--------|---------|---------|------|------|
|
||||
| Vicuna (report) | dense | 79.30 | 未完成 | - | × |
|
||||
| ChatGLM | chat | 37.60 | 37.82 | +0.22 | ✓ |
|
||||
| 通义千问 | qwen | (无) | 报错 | - | × |
|
||||
"""
|
||||
)
|
||||
|
||||
out = realign_markdown_tables(src).rstrip("\n").split("\n")
|
||||
|
||||
# All rows in the rebuilt block must have pipes at identical display
|
||||
# columns — that's the alignment guarantee.
|
||||
offsets = [_column_offsets(row) for row in out]
|
||||
assert all(o == offsets[0] for o in offsets), (
|
||||
"rebuilt table rows do not share pipe column offsets:\n"
|
||||
+ "\n".join(out)
|
||||
)
|
||||
# And we expect 7 pipes per row (6 columns + outer borders).
|
||||
assert len(offsets[0]) == 7
|
||||
|
||||
|
||||
def test_emoji_with_cjk_table_aligns():
|
||||
src = dedent(
|
||||
"""\
|
||||
| 模型 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| 千问 | ✅ | 通过 |
|
||||
| Claude | ✅ | 推理强 |
|
||||
| 文心一言 | ❌ | 报错 |
|
||||
"""
|
||||
)
|
||||
|
||||
out = realign_markdown_tables(src).rstrip("\n").split("\n")
|
||||
offsets = [_column_offsets(row) for row in out]
|
||||
# The emoji-with-variation-selector case (⚠️) intentionally tolerates
|
||||
# 1-cell drift; bare emoji like ✅ / ❌ have stable wcwidth and must
|
||||
# align. Use bare emoji here so the assertion is hard.
|
||||
assert all(o == offsets[0] for o in offsets), (
|
||||
"emoji+CJK rows do not share pipe column offsets:\n" + "\n".join(out)
|
||||
)
|
||||
|
||||
|
||||
def test_already_aligned_ascii_table_remains_aligned():
|
||||
src = dedent(
|
||||
"""\
|
||||
| a | b |
|
||||
|-----|-----|
|
||||
| 1 | 2 |
|
||||
| foo | bar |
|
||||
"""
|
||||
)
|
||||
out = realign_markdown_tables(src).rstrip("\n").split("\n")
|
||||
offsets = [_column_offsets(row) for row in out]
|
||||
assert all(o == offsets[0] for o in offsets)
|
||||
|
||||
|
||||
def test_passes_non_table_lines_through_around_a_table():
|
||||
src = dedent(
|
||||
"""\
|
||||
Here is a comparison:
|
||||
|
||||
| 模型 | 状态 |
|
||||
|------|------|
|
||||
| 千问 | 通过 |
|
||||
|
||||
And some prose after.
|
||||
"""
|
||||
)
|
||||
|
||||
out = realign_markdown_tables(src)
|
||||
assert out.startswith("Here is a comparison:\n")
|
||||
assert out.endswith("And some prose after.\n")
|
||||
# And the table lines are aligned.
|
||||
block = [ln for ln in out.split("\n") if "|" in ln]
|
||||
offsets = [_column_offsets(row) for row in block]
|
||||
assert all(o == offsets[0] for o in offsets)
|
||||
|
||||
|
||||
def test_handles_ragged_rows_by_padding_short_rows():
|
||||
src = dedent(
|
||||
"""\
|
||||
| a | b | c |
|
||||
|---|---|---|
|
||||
| 1 | 2 |
|
||||
| x | y | z |
|
||||
"""
|
||||
)
|
||||
out = realign_markdown_tables(src).rstrip("\n").split("\n")
|
||||
offsets = [_column_offsets(row) for row in out]
|
||||
# Short rows must be padded out so they have the same pipe count
|
||||
# and column positions as the header.
|
||||
assert all(len(o) == len(offsets[0]) for o in offsets)
|
||||
assert all(o == offsets[0] for o in offsets)
|
||||
|
||||
|
||||
def test_multiple_tables_in_one_text():
|
||||
src = dedent(
|
||||
"""\
|
||||
First:
|
||||
|
||||
| 配置 | 值 |
|
||||
|------|----|
|
||||
| 通义 | 1 |
|
||||
|
||||
Second:
|
||||
|
||||
| model | n |
|
||||
|-------|---|
|
||||
| gpt | 2 |
|
||||
"""
|
||||
)
|
||||
out = realign_markdown_tables(src)
|
||||
# Each table block individually aligns.
|
||||
blocks: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
for line in out.split("\n"):
|
||||
if "|" in line:
|
||||
current.append(line)
|
||||
elif current:
|
||||
blocks.append(current)
|
||||
current = []
|
||||
if current:
|
||||
blocks.append(current)
|
||||
|
||||
assert len(blocks) == 2
|
||||
for block in blocks:
|
||||
offsets = [_column_offsets(row) for row in block]
|
||||
assert all(o == offsets[0] for o in offsets), (
|
||||
f"block did not align:\n" + "\n".join(block)
|
||||
)
|
||||
@@ -0,0 +1,991 @@
|
||||
"""Unit tests for the plugin LLM facade (``agent.plugin_llm``).
|
||||
|
||||
These tests exercise the trust gate, JSON parsing, schema validation,
|
||||
image input encoding, and the auxiliary-client invocation contract.
|
||||
The auxiliary client itself is stubbed via ``make_plugin_llm_for_test``
|
||||
so we don't hit real providers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.plugin_llm import (
|
||||
PluginLlm,
|
||||
PluginLlmCompleteResult,
|
||||
PluginLlmImageInput,
|
||||
PluginLlmStructuredResult,
|
||||
PluginLlmTextInput,
|
||||
PluginLlmTrustError,
|
||||
_build_structured_messages,
|
||||
_check_overrides,
|
||||
_coerce_allowlist,
|
||||
_parse_structured_text,
|
||||
_strip_code_fences,
|
||||
_TrustPolicy,
|
||||
make_plugin_llm_for_test,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_response(text: str, *, prompt: int = 4, completion: int = 6) -> SimpleNamespace:
|
||||
"""Build an OpenAI-shaped response with the given text + token usage."""
|
||||
return SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(content=text, role="assistant"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(
|
||||
prompt_tokens=prompt,
|
||||
completion_tokens=completion,
|
||||
total_tokens=prompt + completion,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _trusted_policy(plugin_id: str = "trusted-plugin", **overrides: Any) -> _TrustPolicy:
|
||||
defaults = dict(
|
||||
allow_provider_override=True,
|
||||
allowed_providers=None,
|
||||
allow_any_provider=True,
|
||||
allow_model_override=True,
|
||||
allowed_models=None,
|
||||
allow_any_model=True,
|
||||
allow_agent_id_override=True,
|
||||
allow_profile_override=True,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return _TrustPolicy(plugin_id=plugin_id, **defaults)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trust gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTrustGate:
|
||||
def test_default_policy_blocks_provider_override(self):
|
||||
policy = _TrustPolicy(plugin_id="locked")
|
||||
with pytest.raises(PluginLlmTrustError, match="cannot override the provider"):
|
||||
_check_overrides(
|
||||
policy,
|
||||
requested_provider="anthropic",
|
||||
requested_model=None,
|
||||
requested_agent_id=None,
|
||||
requested_profile=None,
|
||||
)
|
||||
|
||||
def test_default_policy_blocks_model_override(self):
|
||||
policy = _TrustPolicy(plugin_id="locked")
|
||||
with pytest.raises(PluginLlmTrustError, match="cannot override the model"):
|
||||
_check_overrides(
|
||||
policy,
|
||||
requested_provider=None,
|
||||
requested_model="claude-3-5-sonnet",
|
||||
requested_agent_id=None,
|
||||
requested_profile=None,
|
||||
)
|
||||
|
||||
def test_default_policy_blocks_agent_override(self):
|
||||
policy = _TrustPolicy(plugin_id="locked")
|
||||
with pytest.raises(PluginLlmTrustError, match="non-default agent id"):
|
||||
_check_overrides(
|
||||
policy,
|
||||
requested_provider=None,
|
||||
requested_model=None,
|
||||
requested_agent_id="ada",
|
||||
requested_profile=None,
|
||||
)
|
||||
|
||||
def test_default_policy_blocks_profile_override(self):
|
||||
policy = _TrustPolicy(plugin_id="locked")
|
||||
with pytest.raises(PluginLlmTrustError, match="cannot override the auth profile"):
|
||||
_check_overrides(
|
||||
policy,
|
||||
requested_provider=None,
|
||||
requested_model=None,
|
||||
requested_agent_id=None,
|
||||
requested_profile="work",
|
||||
)
|
||||
|
||||
def test_overrides_independent(self):
|
||||
"""Each override is gated independently — turning on
|
||||
``allow_model_override`` does NOT also grant provider override."""
|
||||
policy = _TrustPolicy(
|
||||
plugin_id="model-only",
|
||||
allow_model_override=True,
|
||||
allow_any_model=True,
|
||||
)
|
||||
# model alone passes
|
||||
_, m, _, _ = _check_overrides(
|
||||
policy,
|
||||
requested_provider=None,
|
||||
requested_model="gpt-4o",
|
||||
requested_agent_id=None,
|
||||
requested_profile=None,
|
||||
)
|
||||
assert m == "gpt-4o"
|
||||
# provider alone is still denied
|
||||
with pytest.raises(PluginLlmTrustError, match="cannot override the provider"):
|
||||
_check_overrides(
|
||||
policy,
|
||||
requested_provider="anthropic",
|
||||
requested_model=None,
|
||||
requested_agent_id=None,
|
||||
requested_profile=None,
|
||||
)
|
||||
|
||||
def test_provider_allowlist_rejects_non_listed(self):
|
||||
policy = _TrustPolicy(
|
||||
plugin_id="restricted",
|
||||
allow_provider_override=True,
|
||||
allowed_providers=frozenset({"openrouter", "anthropic"}),
|
||||
allow_any_provider=False,
|
||||
)
|
||||
with pytest.raises(PluginLlmTrustError, match="not in plugins.entries"):
|
||||
_check_overrides(
|
||||
policy,
|
||||
requested_provider="openai",
|
||||
requested_model=None,
|
||||
requested_agent_id=None,
|
||||
requested_profile=None,
|
||||
)
|
||||
|
||||
def test_provider_allowlist_accepts_listed_case_insensitively(self):
|
||||
policy = _TrustPolicy(
|
||||
plugin_id="restricted",
|
||||
allow_provider_override=True,
|
||||
allowed_providers=frozenset({"openrouter"}),
|
||||
allow_any_provider=False,
|
||||
)
|
||||
p, _, _, _ = _check_overrides(
|
||||
policy,
|
||||
requested_provider="OpenRouter",
|
||||
requested_model=None,
|
||||
requested_agent_id=None,
|
||||
requested_profile=None,
|
||||
)
|
||||
assert p == "OpenRouter"
|
||||
|
||||
def test_model_allowlist_rejects_non_listed(self):
|
||||
policy = _TrustPolicy(
|
||||
plugin_id="restricted",
|
||||
allow_model_override=True,
|
||||
allowed_models=frozenset({"openai/gpt-4o-mini"}),
|
||||
allow_any_model=False,
|
||||
)
|
||||
with pytest.raises(PluginLlmTrustError, match="not in plugins.entries"):
|
||||
_check_overrides(
|
||||
policy,
|
||||
requested_provider=None,
|
||||
requested_model="anthropic/claude-3-opus",
|
||||
requested_agent_id=None,
|
||||
requested_profile=None,
|
||||
)
|
||||
|
||||
def test_model_allowlist_accepts_listed_case_insensitively(self):
|
||||
policy = _TrustPolicy(
|
||||
plugin_id="restricted",
|
||||
allow_model_override=True,
|
||||
allowed_models=frozenset({"openai/gpt-4o-mini"}),
|
||||
allow_any_model=False,
|
||||
)
|
||||
_, m, _, _ = _check_overrides(
|
||||
policy,
|
||||
requested_provider=None,
|
||||
requested_model="OpenAI/GPT-4o-mini",
|
||||
requested_agent_id=None,
|
||||
requested_profile=None,
|
||||
)
|
||||
assert m == "OpenAI/GPT-4o-mini"
|
||||
|
||||
def test_no_overrides_passes_through(self):
|
||||
policy = _TrustPolicy(plugin_id="locked")
|
||||
result = _check_overrides(
|
||||
policy,
|
||||
requested_provider=None,
|
||||
requested_model=None,
|
||||
requested_agent_id=None,
|
||||
requested_profile=None,
|
||||
)
|
||||
assert result == (None, None, None, None)
|
||||
|
||||
def test_all_overrides_when_fully_trusted(self):
|
||||
policy = _trusted_policy()
|
||||
result = _check_overrides(
|
||||
policy,
|
||||
requested_provider="openrouter",
|
||||
requested_model="anthropic/claude-3-5-sonnet",
|
||||
requested_agent_id="ada",
|
||||
requested_profile="work",
|
||||
)
|
||||
assert result == ("openrouter", "anthropic/claude-3-5-sonnet", "ada", "work")
|
||||
|
||||
|
||||
class TestAllowlistCoercion:
|
||||
def test_missing_yields_none(self):
|
||||
ranges, allow_any = _coerce_allowlist(None)
|
||||
assert ranges is None
|
||||
assert allow_any is False
|
||||
|
||||
def test_list_of_strings(self):
|
||||
ranges, allow_any = _coerce_allowlist(["A", "B"])
|
||||
assert ranges == frozenset({"a", "b"})
|
||||
assert allow_any is False
|
||||
|
||||
def test_star_alone_means_any(self):
|
||||
ranges, allow_any = _coerce_allowlist(["*"])
|
||||
assert ranges == frozenset()
|
||||
assert allow_any is True
|
||||
|
||||
def test_star_plus_specific_keeps_specifics(self):
|
||||
ranges, allow_any = _coerce_allowlist(["*", "openrouter"])
|
||||
assert ranges == frozenset({"openrouter"})
|
||||
assert allow_any is True
|
||||
|
||||
def test_non_list_yields_none(self):
|
||||
ranges, allow_any = _coerce_allowlist("openrouter")
|
||||
assert ranges is None
|
||||
assert allow_any is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structured message building
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStructuredMessageBuilding:
|
||||
def test_text_only_input(self):
|
||||
messages = _build_structured_messages(
|
||||
instructions="Extract the action items",
|
||||
inputs=[PluginLlmTextInput(text="meeting notes go here")],
|
||||
json_mode=False,
|
||||
json_schema=None,
|
||||
schema_name=None,
|
||||
system_prompt=None,
|
||||
)
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "user"
|
||||
parts = messages[0]["content"]
|
||||
assert parts[0]["type"] == "text"
|
||||
assert "Extract the action items" in parts[0]["text"]
|
||||
assert parts[1] == {"type": "text", "text": "meeting notes go here"}
|
||||
|
||||
def test_json_mode_adds_system_directive(self):
|
||||
messages = _build_structured_messages(
|
||||
instructions="Summarise",
|
||||
inputs=[PluginLlmTextInput(text="content")],
|
||||
json_mode=True,
|
||||
json_schema=None,
|
||||
schema_name=None,
|
||||
system_prompt=None,
|
||||
)
|
||||
assert messages[0]["role"] == "system"
|
||||
assert "JSON object" in messages[0]["content"]
|
||||
|
||||
def test_schema_name_appended_to_header(self):
|
||||
messages = _build_structured_messages(
|
||||
instructions="Extract fields",
|
||||
inputs=[PluginLlmTextInput(text="data")],
|
||||
json_mode=False,
|
||||
json_schema=None,
|
||||
schema_name="action.items",
|
||||
system_prompt=None,
|
||||
)
|
||||
header = messages[0]["content"][0]["text"]
|
||||
assert "Schema name: action.items" in header
|
||||
|
||||
def test_image_bytes_encoded_as_data_url(self):
|
||||
png_bytes = b"\x89PNG\r\n\x1a\nfake"
|
||||
messages = _build_structured_messages(
|
||||
instructions="Read the image",
|
||||
inputs=[
|
||||
PluginLlmImageInput(data=png_bytes, mime_type="image/png"),
|
||||
PluginLlmTextInput(text="prefer printed text"),
|
||||
],
|
||||
json_mode=False,
|
||||
json_schema=None,
|
||||
schema_name=None,
|
||||
system_prompt=None,
|
||||
)
|
||||
parts = messages[0]["content"]
|
||||
assert parts[1]["type"] == "image_url"
|
||||
url = parts[1]["image_url"]["url"]
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
decoded = base64.b64decode(url.split(",", 1)[1])
|
||||
assert decoded == png_bytes
|
||||
assert parts[2] == {"type": "text", "text": "prefer printed text"}
|
||||
|
||||
def test_image_url_passed_through(self):
|
||||
messages = _build_structured_messages(
|
||||
instructions="Caption this",
|
||||
inputs=[PluginLlmImageInput(url="https://example.com/cat.jpg")],
|
||||
json_mode=False,
|
||||
json_schema=None,
|
||||
schema_name=None,
|
||||
system_prompt=None,
|
||||
)
|
||||
img_part = messages[0]["content"][1]
|
||||
assert img_part["type"] == "image_url"
|
||||
assert img_part["image_url"]["url"] == "https://example.com/cat.jpg"
|
||||
|
||||
def test_dict_inputs_normalized(self):
|
||||
messages = _build_structured_messages(
|
||||
instructions="Test",
|
||||
inputs=[
|
||||
{"type": "text", "text": "hello"},
|
||||
{"type": "image", "url": "https://x.example/y.png"},
|
||||
],
|
||||
json_mode=False,
|
||||
json_schema=None,
|
||||
schema_name=None,
|
||||
system_prompt=None,
|
||||
)
|
||||
parts = messages[0]["content"]
|
||||
assert parts[1]["text"] == "hello"
|
||||
assert parts[2]["image_url"]["url"] == "https://x.example/y.png"
|
||||
|
||||
def test_invalid_input_block_rejected(self):
|
||||
with pytest.raises(ValueError, match="Unknown input block"):
|
||||
_build_structured_messages(
|
||||
instructions="Test",
|
||||
inputs=[{"type": "audio", "data": b""}],
|
||||
json_mode=False,
|
||||
json_schema=None,
|
||||
schema_name=None,
|
||||
system_prompt=None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestJsonParsing:
|
||||
def test_strip_code_fences_with_json_label(self):
|
||||
assert _strip_code_fences('```json\n{"a":1}\n```') == '{"a":1}'
|
||||
|
||||
def test_strip_code_fences_without_label(self):
|
||||
assert _strip_code_fences("```\nfoo\n```") == "foo"
|
||||
|
||||
def test_strip_code_fences_no_fence(self):
|
||||
assert _strip_code_fences('{"a":1}') == '{"a":1}'
|
||||
|
||||
def test_parse_returns_text_when_not_json_mode(self):
|
||||
parsed, ct = _parse_structured_text(
|
||||
text='{"a": 1}', json_mode=False, json_schema=None
|
||||
)
|
||||
assert parsed is None
|
||||
assert ct == "text"
|
||||
|
||||
def test_parse_valid_json_with_json_mode(self):
|
||||
parsed, ct = _parse_structured_text(
|
||||
text='{"language": "French", "is_question": true}',
|
||||
json_mode=True,
|
||||
json_schema=None,
|
||||
)
|
||||
assert parsed == {"language": "French", "is_question": True}
|
||||
assert ct == "json"
|
||||
|
||||
def test_parse_strips_code_fences_before_loading(self):
|
||||
parsed, ct = _parse_structured_text(
|
||||
text='Here you go:\n```json\n{"ok": true}\n```',
|
||||
json_mode=True,
|
||||
json_schema=None,
|
||||
)
|
||||
assert parsed == {"ok": True}
|
||||
assert ct == "json"
|
||||
|
||||
def test_parse_returns_text_on_invalid_json(self):
|
||||
parsed, ct = _parse_structured_text(
|
||||
text="not even close to json",
|
||||
json_mode=True,
|
||||
json_schema=None,
|
||||
)
|
||||
assert parsed is None
|
||||
assert ct == "text"
|
||||
|
||||
def test_schema_validation_rejects_mismatch(self):
|
||||
pytest.importorskip("jsonschema")
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"language": {"type": "string"}},
|
||||
"required": ["language"],
|
||||
}
|
||||
with pytest.raises(ValueError, match="did not match schema"):
|
||||
_parse_structured_text(
|
||||
text='{"is_question": true}',
|
||||
json_mode=False,
|
||||
json_schema=schema,
|
||||
)
|
||||
|
||||
def test_schema_validation_accepts_match(self):
|
||||
pytest.importorskip("jsonschema")
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"language": {"type": "string"}},
|
||||
"required": ["language"],
|
||||
}
|
||||
parsed, ct = _parse_structured_text(
|
||||
text='{"language": "French"}',
|
||||
json_mode=False,
|
||||
json_schema=schema,
|
||||
)
|
||||
assert parsed == {"language": "French"}
|
||||
assert ct == "json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end facade
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPluginLlmFacade:
|
||||
def test_complete_uses_active_model_by_default(self):
|
||||
captured: dict = {}
|
||||
|
||||
def fake_caller(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return "auto", "default", _fake_response("Hello world.")
|
||||
|
||||
llm = make_plugin_llm_for_test(
|
||||
plugin_id="my-plugin",
|
||||
policy=_TrustPolicy(plugin_id="my-plugin"),
|
||||
sync_caller=fake_caller,
|
||||
)
|
||||
result = llm.complete([{"role": "user", "content": "hi"}])
|
||||
assert isinstance(result, PluginLlmCompleteResult)
|
||||
assert result.text == "Hello world."
|
||||
assert captured["provider_override"] is None
|
||||
assert captured["model_override"] is None
|
||||
assert captured["profile_override"] is None
|
||||
assert result.usage.input_tokens == 4
|
||||
assert result.usage.total_tokens == 10
|
||||
|
||||
def test_complete_rejects_provider_override_without_trust(self):
|
||||
llm = make_plugin_llm_for_test(
|
||||
plugin_id="my-plugin",
|
||||
policy=_TrustPolicy(plugin_id="my-plugin"),
|
||||
sync_caller=lambda **_: ("x", "y", _fake_response("")),
|
||||
)
|
||||
with pytest.raises(PluginLlmTrustError, match="cannot override the provider"):
|
||||
llm.complete(
|
||||
[{"role": "user", "content": "hi"}],
|
||||
provider="openrouter",
|
||||
)
|
||||
|
||||
def test_complete_rejects_model_override_without_trust(self):
|
||||
llm = make_plugin_llm_for_test(
|
||||
plugin_id="my-plugin",
|
||||
policy=_TrustPolicy(plugin_id="my-plugin"),
|
||||
sync_caller=lambda **_: ("x", "y", _fake_response("")),
|
||||
)
|
||||
with pytest.raises(PluginLlmTrustError, match="cannot override the model"):
|
||||
llm.complete(
|
||||
[{"role": "user", "content": "hi"}],
|
||||
model="anthropic/claude-3-opus",
|
||||
)
|
||||
|
||||
def test_complete_passes_through_trusted_overrides(self):
|
||||
captured: dict = {}
|
||||
|
||||
def fake_caller(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return "anthropic", "claude-3-opus", _fake_response("ok")
|
||||
|
||||
llm = make_plugin_llm_for_test(
|
||||
plugin_id="my-plugin",
|
||||
policy=_trusted_policy("my-plugin"),
|
||||
sync_caller=fake_caller,
|
||||
)
|
||||
result = llm.complete(
|
||||
[{"role": "user", "content": "hi"}],
|
||||
provider="anthropic",
|
||||
model="claude-3-opus",
|
||||
profile="work",
|
||||
agent_id="ada",
|
||||
temperature=0.0,
|
||||
max_tokens=128,
|
||||
timeout=10.0,
|
||||
purpose="extract",
|
||||
)
|
||||
# The recorded provider/model in the result come from the override,
|
||||
# since the stub caller echoed those values.
|
||||
assert result.provider == "anthropic"
|
||||
assert result.model == "claude-3-opus"
|
||||
assert captured["provider_override"] == "anthropic"
|
||||
assert captured["model_override"] == "claude-3-opus"
|
||||
assert captured["profile_override"] == "work"
|
||||
assert captured["temperature"] == 0.0
|
||||
assert captured["max_tokens"] == 128
|
||||
assert captured["timeout"] == 10.0
|
||||
|
||||
def test_complete_structured_returns_parsed_json(self):
|
||||
def fake_caller(**_kwargs):
|
||||
return "openai", "gpt-4o", _fake_response(
|
||||
'{"language": "French", "is_question": true, "confidence": 0.99}'
|
||||
)
|
||||
|
||||
llm = make_plugin_llm_for_test(
|
||||
plugin_id="my-plugin",
|
||||
policy=_TrustPolicy(plugin_id="my-plugin"),
|
||||
sync_caller=fake_caller,
|
||||
)
|
||||
result = llm.complete_structured(
|
||||
instructions="Detect language",
|
||||
input=[PluginLlmTextInput(text="Comment ça va?")],
|
||||
json_mode=True,
|
||||
)
|
||||
assert isinstance(result, PluginLlmStructuredResult)
|
||||
assert result.parsed == {
|
||||
"language": "French",
|
||||
"is_question": True,
|
||||
"confidence": 0.99,
|
||||
}
|
||||
assert result.content_type == "json"
|
||||
|
||||
def test_complete_structured_returns_text_on_unparseable_response(self):
|
||||
def fake_caller(**_kwargs):
|
||||
return "openai", "gpt-4o", _fake_response("Sorry, I can't help with that.")
|
||||
|
||||
llm = make_plugin_llm_for_test(
|
||||
plugin_id="my-plugin",
|
||||
policy=_TrustPolicy(plugin_id="my-plugin"),
|
||||
sync_caller=fake_caller,
|
||||
)
|
||||
result = llm.complete_structured(
|
||||
instructions="Detect language",
|
||||
input=[PluginLlmTextInput(text="x")],
|
||||
json_mode=True,
|
||||
)
|
||||
assert result.parsed is None
|
||||
assert result.content_type == "text"
|
||||
assert result.text.startswith("Sorry")
|
||||
|
||||
def test_complete_structured_validates_against_schema(self):
|
||||
pytest.importorskip("jsonschema")
|
||||
|
||||
def fake_caller(**_kwargs):
|
||||
return "openai", "gpt-4o", _fake_response('{"unrelated": "field"}')
|
||||
|
||||
llm = make_plugin_llm_for_test(
|
||||
plugin_id="my-plugin",
|
||||
policy=_TrustPolicy(plugin_id="my-plugin"),
|
||||
sync_caller=fake_caller,
|
||||
)
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"language": {"type": "string"}},
|
||||
"required": ["language"],
|
||||
}
|
||||
with pytest.raises(ValueError, match="did not match schema"):
|
||||
llm.complete_structured(
|
||||
instructions="Detect language",
|
||||
input=[PluginLlmTextInput(text="x")],
|
||||
json_schema=schema,
|
||||
)
|
||||
|
||||
def test_complete_structured_requires_instructions(self):
|
||||
llm = make_plugin_llm_for_test(
|
||||
plugin_id="my-plugin",
|
||||
policy=_TrustPolicy(plugin_id="my-plugin"),
|
||||
sync_caller=MagicMock(),
|
||||
)
|
||||
with pytest.raises(ValueError, match="non-empty instructions"):
|
||||
llm.complete_structured(
|
||||
instructions=" ",
|
||||
input=[PluginLlmTextInput(text="x")],
|
||||
)
|
||||
|
||||
def test_complete_structured_requires_at_least_one_input(self):
|
||||
llm = make_plugin_llm_for_test(
|
||||
plugin_id="my-plugin",
|
||||
policy=_TrustPolicy(plugin_id="my-plugin"),
|
||||
sync_caller=MagicMock(),
|
||||
)
|
||||
with pytest.raises(ValueError, match="at least one input"):
|
||||
llm.complete_structured(
|
||||
instructions="Extract",
|
||||
input=[],
|
||||
)
|
||||
|
||||
def test_complete_structured_emits_response_format_extra_body(self):
|
||||
captured: dict = {}
|
||||
|
||||
def fake_caller(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return "openai", "gpt-4o", _fake_response('{"a": 1}')
|
||||
|
||||
llm = make_plugin_llm_for_test(
|
||||
plugin_id="my-plugin",
|
||||
policy=_TrustPolicy(plugin_id="my-plugin"),
|
||||
sync_caller=fake_caller,
|
||||
)
|
||||
schema = {"type": "object"}
|
||||
llm.complete_structured(
|
||||
instructions="Test",
|
||||
input=[PluginLlmTextInput(text="x")],
|
||||
json_schema=schema,
|
||||
)
|
||||
rf = captured["extra_body"]["response_format"]
|
||||
assert rf["type"] == "json_schema"
|
||||
assert rf["json_schema"]["schema"] == schema
|
||||
|
||||
def test_complete_structured_with_image_passes_image_url_part(self):
|
||||
captured: dict = {}
|
||||
|
||||
def fake_caller(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return "openai", "gpt-4o", _fake_response('{"caption": "ok"}')
|
||||
|
||||
llm = make_plugin_llm_for_test(
|
||||
plugin_id="my-plugin",
|
||||
policy=_TrustPolicy(plugin_id="my-plugin"),
|
||||
sync_caller=fake_caller,
|
||||
)
|
||||
png = b"fake-bytes"
|
||||
llm.complete_structured(
|
||||
instructions="Caption this",
|
||||
input=[PluginLlmImageInput(data=png, mime_type="image/png")],
|
||||
json_mode=True,
|
||||
)
|
||||
msgs = captured["messages"]
|
||||
user_msg = next(m for m in msgs if m["role"] == "user")
|
||||
image_parts = [p for p in user_msg["content"] if p.get("type") == "image_url"]
|
||||
assert len(image_parts) == 1
|
||||
assert image_parts[0]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAsyncSurface:
|
||||
def test_acomplete_uses_async_caller(self):
|
||||
async def fake_async(**_kwargs):
|
||||
return "openai", "gpt-4o", _fake_response("async hello")
|
||||
|
||||
llm = make_plugin_llm_for_test(
|
||||
plugin_id="my-plugin",
|
||||
policy=_TrustPolicy(plugin_id="my-plugin"),
|
||||
async_caller=fake_async,
|
||||
)
|
||||
|
||||
async def _run() -> PluginLlmCompleteResult:
|
||||
return await llm.acomplete([{"role": "user", "content": "hi"}])
|
||||
|
||||
result = asyncio.run(_run())
|
||||
assert result.text == "async hello"
|
||||
assert result.provider == "openai"
|
||||
|
||||
def test_acomplete_structured_parses_json(self):
|
||||
async def fake_async(**_kwargs):
|
||||
return "openai", "gpt-4o", _fake_response('{"x": 42}')
|
||||
|
||||
llm = make_plugin_llm_for_test(
|
||||
plugin_id="my-plugin",
|
||||
policy=_TrustPolicy(plugin_id="my-plugin"),
|
||||
async_caller=fake_async,
|
||||
)
|
||||
|
||||
async def _run() -> PluginLlmStructuredResult:
|
||||
return await llm.acomplete_structured(
|
||||
instructions="Extract x",
|
||||
input=[PluginLlmTextInput(text="data")],
|
||||
json_mode=True,
|
||||
)
|
||||
|
||||
result = asyncio.run(_run())
|
||||
assert result.parsed == {"x": 42}
|
||||
assert result.content_type == "json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config-driven trust gate (round-trip via plugins.entries.<id>.llm)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigDrivenPolicy:
|
||||
def test_policy_loaded_from_yaml(self, tmp_path, monkeypatch):
|
||||
from agent.plugin_llm import _resolve_trust_policy
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"""
|
||||
plugins:
|
||||
entries:
|
||||
my-plugin:
|
||||
llm:
|
||||
allow_provider_override: true
|
||||
allowed_providers: [openrouter, anthropic]
|
||||
allow_model_override: true
|
||||
allowed_models:
|
||||
- openai/gpt-4o-mini
|
||||
- anthropic/claude-3-5-haiku
|
||||
allow_profile_override: false
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
from hermes_cli import config as _config_mod
|
||||
_config_mod._config_cache = None # type: ignore[attr-defined]
|
||||
|
||||
policy = _resolve_trust_policy("my-plugin")
|
||||
assert policy.allow_provider_override is True
|
||||
assert policy.allow_model_override is True
|
||||
assert policy.allow_profile_override is False
|
||||
assert policy.allowed_providers == frozenset({"openrouter", "anthropic"})
|
||||
assert policy.allowed_models == frozenset({
|
||||
"openai/gpt-4o-mini", "anthropic/claude-3-5-haiku",
|
||||
})
|
||||
|
||||
def test_missing_plugin_entry_yields_default_deny(self, tmp_path, monkeypatch):
|
||||
from agent.plugin_llm import _resolve_trust_policy
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text("plugins: {}\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
from hermes_cli import config as _config_mod
|
||||
_config_mod._config_cache = None # type: ignore[attr-defined]
|
||||
|
||||
policy = _resolve_trust_policy("never-configured")
|
||||
assert policy.allow_provider_override is False
|
||||
assert policy.allow_model_override is False
|
||||
assert policy.allow_profile_override is False
|
||||
assert policy.allow_agent_id_override is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin context wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPluginContextIntegration:
|
||||
def test_ctx_llm_is_lazy_singleton(self):
|
||||
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
|
||||
|
||||
manifest = PluginManifest(name="test-plugin", source="test", key="test-plugin")
|
||||
manager = PluginManager()
|
||||
ctx = PluginContext(manifest, manager)
|
||||
first = ctx.llm
|
||||
second = ctx.llm
|
||||
assert first is second
|
||||
assert isinstance(first, PluginLlm)
|
||||
assert first._plugin_id == "test-plugin" # type: ignore[attr-defined]
|
||||
|
||||
def test_ctx_llm_uses_manifest_key_for_policy(self):
|
||||
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
|
||||
|
||||
manifest = PluginManifest(
|
||||
name="bare-name", source="test", key="image_gen/openai"
|
||||
)
|
||||
manager = PluginManager()
|
||||
ctx = PluginContext(manifest, manager)
|
||||
assert ctx.llm._plugin_id == "image_gen/openai" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attribution (result.provider / result.model / audit log)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAttribution:
|
||||
"""Verifies that the result object and the audit log carry the real
|
||||
provider/model that ``call_llm`` ended up using, NOT the placeholder
|
||||
fallbacks ('auto', 'default') from earlier drafts."""
|
||||
|
||||
def test_explicit_overrides_recorded_when_no_response_model(self):
|
||||
from agent.plugin_llm import _resolve_attribution
|
||||
|
||||
# Response with no .model attribute — overrides win.
|
||||
response = SimpleNamespace(choices=[], usage=None)
|
||||
provider, model = _resolve_attribution(
|
||||
provider_override="openrouter",
|
||||
model_override="anthropic/claude-3-5-sonnet",
|
||||
response=response,
|
||||
)
|
||||
assert provider == "openrouter"
|
||||
assert model == "anthropic/claude-3-5-sonnet"
|
||||
|
||||
def test_response_model_wins_over_model_override(self):
|
||||
"""Providers often canonicalise the model name (e.g. ``gpt-4o``
|
||||
→ ``gpt-4o-2024-08-06``). Whatever they actually returned wins
|
||||
for the recorded model so the audit log reflects reality."""
|
||||
from agent.plugin_llm import _resolve_attribution
|
||||
|
||||
response = SimpleNamespace(model="gpt-4o-2024-08-06", choices=[])
|
||||
provider, model = _resolve_attribution(
|
||||
provider_override="openrouter",
|
||||
model_override="openai/gpt-4o",
|
||||
response=response,
|
||||
)
|
||||
assert model == "gpt-4o-2024-08-06"
|
||||
# Provider override is unaffected by response.model.
|
||||
assert provider == "openrouter"
|
||||
|
||||
def test_falls_back_to_main_provider_and_model_when_no_overrides(self, monkeypatch):
|
||||
"""When the plugin doesn't override anything, attribution
|
||||
reflects the user's active main provider/model rather than
|
||||
misleading placeholders."""
|
||||
from agent import plugin_llm
|
||||
import agent.auxiliary_client as ac
|
||||
|
||||
monkeypatch.setattr(ac, "_read_main_provider", lambda: "openrouter")
|
||||
monkeypatch.setattr(ac, "_read_main_model", lambda: "anthropic/claude-3-5-sonnet")
|
||||
|
||||
response = SimpleNamespace(choices=[]) # no .model attribute
|
||||
provider, model = plugin_llm._resolve_attribution(
|
||||
provider_override=None,
|
||||
model_override=None,
|
||||
response=response,
|
||||
)
|
||||
assert provider == "openrouter"
|
||||
assert model == "anthropic/claude-3-5-sonnet"
|
||||
|
||||
def test_response_model_used_even_when_no_overrides(self, monkeypatch):
|
||||
"""The provider's canonical model name should still flow through
|
||||
when no overrides are set."""
|
||||
from agent import plugin_llm
|
||||
import agent.auxiliary_client as ac
|
||||
|
||||
monkeypatch.setattr(ac, "_read_main_provider", lambda: "openrouter")
|
||||
monkeypatch.setattr(ac, "_read_main_model", lambda: "openai/gpt-4o")
|
||||
|
||||
response = SimpleNamespace(model="openai/gpt-4o-2024-08-06", choices=[])
|
||||
provider, model = plugin_llm._resolve_attribution(
|
||||
provider_override=None,
|
||||
model_override=None,
|
||||
response=response,
|
||||
)
|
||||
assert provider == "openrouter"
|
||||
assert model == "openai/gpt-4o-2024-08-06"
|
||||
|
||||
def test_placeholder_fallback_only_when_everything_is_empty(self, monkeypatch):
|
||||
"""If main_provider/main_model are unset AND there's no override
|
||||
AND the response has no .model, fall through to the safety
|
||||
placeholders so the result object never has empty strings."""
|
||||
from agent import plugin_llm
|
||||
import agent.auxiliary_client as ac
|
||||
|
||||
monkeypatch.setattr(ac, "_read_main_provider", lambda: "")
|
||||
monkeypatch.setattr(ac, "_read_main_model", lambda: "")
|
||||
|
||||
response = SimpleNamespace(choices=[])
|
||||
provider, model = plugin_llm._resolve_attribution(
|
||||
provider_override=None,
|
||||
model_override=None,
|
||||
response=response,
|
||||
)
|
||||
assert provider == "auto"
|
||||
assert model == "default"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hook-mode integration (ctx.llm called from a post_tool_call callback)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHookMode:
|
||||
"""The docs page promises ``ctx.llm`` works from inside lifecycle
|
||||
hooks. This exercises that path: register a ``post_tool_call``
|
||||
callback that calls ``ctx.llm.complete``, fire the hook through
|
||||
the real ``invoke_hook`` machinery, and check the call landed."""
|
||||
|
||||
def test_complete_works_from_post_tool_call_hook(self):
|
||||
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
|
||||
|
||||
manifest = PluginManifest(name="hook-plugin", source="test", key="hook-plugin")
|
||||
manager = PluginManager()
|
||||
ctx = PluginContext(manifest, manager)
|
||||
|
||||
# Replace ctx.llm with a stub that records what the hook called.
|
||||
captured: list = []
|
||||
|
||||
def fake_caller(**kwargs):
|
||||
captured.append(kwargs)
|
||||
return "openrouter", "openai/gpt-4o", _fake_response("rewrote it")
|
||||
|
||||
ctx._llm = make_plugin_llm_for_test( # type: ignore[attr-defined]
|
||||
plugin_id="hook-plugin",
|
||||
policy=_TrustPolicy(plugin_id="hook-plugin"),
|
||||
sync_caller=fake_caller,
|
||||
)
|
||||
|
||||
# Plugin registers a hook that runs ctx.llm.complete on every tool call.
|
||||
def rewrite_error_hook(*, tool_name, args, result, **_):
|
||||
if "Traceback" in (result or ""):
|
||||
rewritten = ctx.llm.complete(
|
||||
messages=[
|
||||
{"role": "system", "content": "Rewrite errors plainly."},
|
||||
{"role": "user", "content": result},
|
||||
],
|
||||
max_tokens=64,
|
||||
purpose="hook-plugin.rewrite",
|
||||
)
|
||||
# Real hook would return the rewritten text via
|
||||
# transform_tool_result; here we just capture for the assert.
|
||||
captured.append({"hook_returned": rewritten.text})
|
||||
|
||||
ctx.register_hook("post_tool_call", rewrite_error_hook)
|
||||
|
||||
# Fire the hook the same way the agent core does it.
|
||||
manager.invoke_hook(
|
||||
"post_tool_call",
|
||||
tool_name="terminal",
|
||||
args={"command": "boom"},
|
||||
result="Traceback (most recent call last):\n RuntimeError",
|
||||
)
|
||||
|
||||
# Verify ctx.llm.complete fired through the hook.
|
||||
assert len(captured) == 2 # one llm call + one hook return record
|
||||
llm_call = captured[0]
|
||||
assert "messages" in llm_call
|
||||
assert any("rewrite" in m.get("content", "").lower()
|
||||
for m in llm_call["messages"] if isinstance(m, dict))
|
||||
hook_record = captured[1]
|
||||
assert hook_record["hook_returned"] == "rewrote it"
|
||||
|
||||
def test_complete_works_from_post_tool_call_hook_when_async_caller_set(self):
|
||||
"""Hooks fired synchronously should still work with sync
|
||||
ctx.llm.complete even if other callsites use async."""
|
||||
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
|
||||
|
||||
manifest = PluginManifest(name="hook-async", source="test", key="hook-async")
|
||||
manager = PluginManager()
|
||||
ctx = PluginContext(manifest, manager)
|
||||
|
||||
def fake_caller(**_):
|
||||
return "openrouter", "model-x", _fake_response("ok")
|
||||
|
||||
ctx._llm = make_plugin_llm_for_test( # type: ignore[attr-defined]
|
||||
plugin_id="hook-async",
|
||||
policy=_TrustPolicy(plugin_id="hook-async"),
|
||||
sync_caller=fake_caller,
|
||||
)
|
||||
|
||||
called: list = []
|
||||
|
||||
def hook(**kwargs):
|
||||
r = ctx.llm.complete(messages=[{"role": "user", "content": "x"}])
|
||||
called.append(r.text)
|
||||
|
||||
ctx.register_hook("post_tool_call", hook)
|
||||
manager.invoke_hook("post_tool_call", tool_name="x", args={}, result="y")
|
||||
assert called == ["ok"]
|
||||
@@ -6,6 +6,8 @@ import pytest
|
||||
from agent.prompt_caching import (
|
||||
_apply_cache_marker,
|
||||
apply_anthropic_cache_control,
|
||||
apply_anthropic_cache_control_long_lived,
|
||||
mark_tools_for_long_lived_cache,
|
||||
)
|
||||
|
||||
|
||||
@@ -141,3 +143,132 @@ class TestApplyAnthropicCacheControl:
|
||||
elif "cache_control" in msg:
|
||||
count += 1
|
||||
assert count <= 4
|
||||
|
||||
|
||||
class TestMarkToolsForLongLivedCache:
|
||||
def test_returns_unchanged_for_empty_tools(self):
|
||||
assert mark_tools_for_long_lived_cache(None) is None
|
||||
assert mark_tools_for_long_lived_cache([]) == []
|
||||
|
||||
def test_marks_only_last_tool(self):
|
||||
tools = [
|
||||
{"type": "function", "function": {"name": "a"}},
|
||||
{"type": "function", "function": {"name": "b"}},
|
||||
{"type": "function", "function": {"name": "c"}},
|
||||
]
|
||||
out = mark_tools_for_long_lived_cache(tools)
|
||||
assert "cache_control" not in out[0]
|
||||
assert "cache_control" not in out[1]
|
||||
assert out[2]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
tools = [{"type": "function", "function": {"name": "a"}}]
|
||||
mark_tools_for_long_lived_cache(tools)
|
||||
assert "cache_control" not in tools[0]
|
||||
|
||||
def test_5m_ttl_drops_ttl_field(self):
|
||||
tools = [{"type": "function", "function": {"name": "a"}}]
|
||||
out = mark_tools_for_long_lived_cache(tools, long_lived_ttl="5m")
|
||||
assert out[0]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
|
||||
class TestApplyAnthropicCacheControlLongLived:
|
||||
def test_empty_messages(self):
|
||||
assert apply_anthropic_cache_control_long_lived([]) == []
|
||||
|
||||
def test_marks_first_block_of_split_system(self):
|
||||
msgs = [
|
||||
{"role": "system", "content": [
|
||||
{"type": "text", "text": "STABLE"},
|
||||
{"type": "text", "text": "CONTEXT"},
|
||||
{"type": "text", "text": "VOLATILE"},
|
||||
]},
|
||||
{"role": "user", "content": "msg1"},
|
||||
{"role": "assistant", "content": "msg2"},
|
||||
]
|
||||
out = apply_anthropic_cache_control_long_lived(msgs)
|
||||
sys_blocks = out[0]["content"]
|
||||
assert sys_blocks[0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}
|
||||
assert "cache_control" not in sys_blocks[1]
|
||||
assert "cache_control" not in sys_blocks[2]
|
||||
|
||||
def test_rolling_marker_on_last_2_messages(self):
|
||||
msgs = [
|
||||
{"role": "system", "content": [{"type": "text", "text": "S"}]},
|
||||
{"role": "user", "content": "u1"},
|
||||
{"role": "assistant", "content": "a1"},
|
||||
{"role": "user", "content": "u2"},
|
||||
{"role": "assistant", "content": "a2"},
|
||||
]
|
||||
out = apply_anthropic_cache_control_long_lived(msgs)
|
||||
|
||||
def has_marker(m):
|
||||
c = m.get("content")
|
||||
if isinstance(c, list) and c and isinstance(c[-1], dict):
|
||||
return "cache_control" in c[-1]
|
||||
return "cache_control" in m
|
||||
|
||||
# u1 and a1 (older messages) should NOT be marked
|
||||
assert not has_marker(out[1])
|
||||
assert not has_marker(out[2])
|
||||
# u2 and a2 (last 2) SHOULD be marked
|
||||
assert has_marker(out[3])
|
||||
assert has_marker(out[4])
|
||||
|
||||
def test_rolling_marker_uses_5m_ttl(self):
|
||||
msgs = [
|
||||
{"role": "system", "content": [{"type": "text", "text": "S"}]},
|
||||
{"role": "user", "content": "u1"},
|
||||
{"role": "assistant", "content": "a1"},
|
||||
]
|
||||
out = apply_anthropic_cache_control_long_lived(
|
||||
msgs, long_lived_ttl="1h", rolling_ttl="5m",
|
||||
)
|
||||
# Last user message: cache_control on the wrapped text part should be 5m
|
||||
last = out[-1]
|
||||
c = last["content"]
|
||||
assert isinstance(c, list)
|
||||
assert c[-1]["cache_control"] == {"type": "ephemeral"} # 5m has no ttl key
|
||||
|
||||
def test_string_system_falls_back_to_envelope_marker(self):
|
||||
"""When the caller didn't split the system message, we still place a marker."""
|
||||
msgs = [
|
||||
{"role": "system", "content": "Single string system"},
|
||||
{"role": "user", "content": "u1"},
|
||||
]
|
||||
out = apply_anthropic_cache_control_long_lived(msgs)
|
||||
sys_content = out[0]["content"]
|
||||
# Wrapped into a list and the (now sole) block gets the 1h marker
|
||||
assert isinstance(sys_content, list)
|
||||
assert sys_content[0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
msgs = [
|
||||
{"role": "system", "content": [{"type": "text", "text": "S"}]},
|
||||
{"role": "user", "content": "u1"},
|
||||
]
|
||||
before = copy.deepcopy(msgs)
|
||||
apply_anthropic_cache_control_long_lived(msgs)
|
||||
assert msgs == before
|
||||
|
||||
def test_max_4_breakpoints_with_split_system(self):
|
||||
msgs = [
|
||||
{"role": "system", "content": [{"type": "text", "text": "S"}, {"type": "text", "text": "V"}]},
|
||||
] + [
|
||||
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg{i}"}
|
||||
for i in range(10)
|
||||
]
|
||||
out = apply_anthropic_cache_control_long_lived(msgs)
|
||||
count = 0
|
||||
for m in out:
|
||||
c = m.get("content")
|
||||
if isinstance(c, list):
|
||||
for item in c:
|
||||
if isinstance(item, dict) and "cache_control" in item:
|
||||
count += 1
|
||||
elif "cache_control" in m:
|
||||
count += 1
|
||||
# 1 system block + last 2 messages = 3 breakpoints from this function.
|
||||
# tools[-1] is marked separately (not via this function), so a 4th
|
||||
# breakpoint can be added at API-call time.
|
||||
assert count == 3
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Live E2E: long-lived prefix caching on Claude via OpenRouter.
|
||||
|
||||
Run only when LIVE_OR_KEY env var is set. Skipped under the normal hermetic
|
||||
test suite (which unsets credentials).
|
||||
"""
|
||||
import os, sys, tempfile, time, shutil, pytest
|
||||
|
||||
|
||||
# Probe for the key BEFORE conftest unsets it
|
||||
_LIVE_KEY = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("LIVE_OR_KEY")
|
||||
if not _LIVE_KEY:
|
||||
# Try to read directly from .env
|
||||
env_path = os.path.expanduser("~/.hermes/.env")
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path) as f:
|
||||
for line in f:
|
||||
if line.startswith("OPENROUTER_API_KEY="):
|
||||
_LIVE_KEY = line.strip().split("=", 1)[1].strip().strip('"').strip("'")
|
||||
break
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _LIVE_KEY,
|
||||
reason="set OPENROUTER_API_KEY (or LIVE_OR_KEY) to run live cache test",
|
||||
)
|
||||
|
||||
|
||||
def test_long_lived_prefix_cache_e2e_openrouter(tmp_path, monkeypatch):
|
||||
"""Two AIAgent runs in fresh sessions: call 1 writes cache, call 2 reads it."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# The hermetic conftest unsets OPENROUTER_API_KEY — restore for this test
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", _LIVE_KEY)
|
||||
|
||||
# Minimal config — but with enough toolset/guidance to exceed Anthropic's
|
||||
# ~1024-token minimum-cacheable-prefix threshold. Anthropic silently
|
||||
# ignores cache_control markers on small blocks.
|
||||
import yaml
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
cfg_path.write_text(yaml.safe_dump({
|
||||
"model": {"provider": "openrouter", "default": "anthropic/claude-haiku-4.5"},
|
||||
"prompt_caching": {"long_lived_prefix": True, "long_lived_ttl": "1h", "cache_ttl": "5m"},
|
||||
"agent": {"tool_use_enforcement": True}, # adds substantial guidance text
|
||||
"memory": {"provider": ""},
|
||||
"compression": {"enabled": False},
|
||||
}))
|
||||
|
||||
from run_agent import AIAgent
|
||||
|
||||
def make_agent():
|
||||
return AIAgent(
|
||||
api_key=_LIVE_KEY,
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
provider="openrouter",
|
||||
model="anthropic/claude-haiku-4.5",
|
||||
api_mode="chat_completions",
|
||||
# Use the default toolset roster — the tools array (~13k tokens
|
||||
# for ~35 tools) is what carries the bulk of the cross-session
|
||||
# cache value. With a tiny toolset the cached prefix can fall
|
||||
# below Anthropic Haiku's 2048-token minimum cacheable size and
|
||||
# the marker is silently ignored.
|
||||
enabled_toolsets=None,
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
save_trajectories=False,
|
||||
)
|
||||
|
||||
a1 = make_agent()
|
||||
assert a1._use_prompt_caching is True, "policy should enable caching for Claude on OR"
|
||||
assert a1._use_long_lived_prefix_cache is True, "long-lived path should activate"
|
||||
parts = a1._build_system_prompt_parts()
|
||||
print(f"\nstable={len(parts['stable']):,} ctx={len(parts['context']):,} volatile={len(parts['volatile']):,} chars")
|
||||
print(f"tool count: {len(a1.tools or [])}")
|
||||
|
||||
# Use distinct user messages each call so OpenRouter's response cache
|
||||
# doesn't short-circuit the upstream Anthropic call (we need real
|
||||
# Anthropic billing visibility to verify cache_creation/cache_read).
|
||||
USER_1 = "Reply with the single word ALPHA."
|
||||
USER_2 = "Reply with the single word BRAVO."
|
||||
|
||||
print("\n--- Call 1 (cold) ---")
|
||||
r1 = a1.run_conversation(USER_1, conversation_history=[])
|
||||
print(f"final_response[:80]: {(r1.get('final_response') or '')[:80]!r}")
|
||||
cr1 = a1.session_cache_read_tokens
|
||||
cw1 = a1.session_cache_write_tokens
|
||||
print(f"call1: cache_read={cr1} cache_write={cw1}")
|
||||
|
||||
# Wait so cache settles, then fresh agent (NEW SESSION) for cross-session read
|
||||
time.sleep(2)
|
||||
a2 = make_agent()
|
||||
assert a2.session_id != a1.session_id, "second agent must have a new session"
|
||||
|
||||
print("\n--- Call 2 (warm, NEW session, different user msg) ---")
|
||||
r2 = a2.run_conversation(USER_2, conversation_history=[])
|
||||
print(f"final_response[:80]: {(r2.get('final_response') or '')[:80]!r}")
|
||||
cr2 = a2.session_cache_read_tokens
|
||||
cw2 = a2.session_cache_write_tokens
|
||||
print(f"call2: cache_read={cr2} cache_write={cw2}")
|
||||
|
||||
print(f"\n=== VERDICT ===")
|
||||
print(f" call1 wrote {cw1:,} cache tokens, read {cr1:,}")
|
||||
print(f" call2 wrote {cw2:,} cache tokens, read {cr2:,}")
|
||||
if cw1:
|
||||
print(f" cross-session read fraction: cr2/cw1 = {cr2/cw1:.2%}")
|
||||
|
||||
# Assertions
|
||||
assert cw1 > 0, f"call 1 must write cache (got {cw1}); long-lived layout not reaching wire"
|
||||
assert cr2 > 0, (
|
||||
f"call 2 must read cache cross-session (got {cr2}); "
|
||||
f"stable prefix is not byte-stable across sessions"
|
||||
)
|
||||
assert cr2 >= 1000, f"cache_read on call 2 ({cr2}) too small to indicate real reuse"
|
||||
@@ -180,6 +180,119 @@ class TestCodexBuildKwargs:
|
||||
# "minimal" should be clamped to "low" for xAI as well
|
||||
assert kw.get("reasoning", {}).get("effort") == "low"
|
||||
|
||||
# --- Grok reasoning-effort capability allowlist ---
|
||||
# api.x.ai 400s with "Model X does not support parameter reasoningEffort"
|
||||
# on grok-4 / grok-4-fast / grok-3 / grok-code-fast / grok-4.20-0309-*.
|
||||
# Those models reason natively but don't expose the dial. The transport
|
||||
# must omit the `reasoning` key for them while keeping the encrypted
|
||||
# reasoning content include so we can capture native reasoning tokens.
|
||||
|
||||
def test_xai_grok_4_omits_reasoning_effort(self, transport):
|
||||
"""grok-4 / grok-4-0709 reject reasoning.effort with HTTP 400."""
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
for model in ("grok-4", "grok-4-0709"):
|
||||
kw = transport.build_kwargs(
|
||||
model=model, messages=messages, tools=[],
|
||||
is_xai_responses=True,
|
||||
reasoning_config={"effort": "high"},
|
||||
)
|
||||
assert "reasoning" not in kw, (
|
||||
f"{model} must not receive a reasoning key (xAI rejects it)"
|
||||
)
|
||||
# Still capture native reasoning tokens
|
||||
assert "reasoning.encrypted_content" in kw.get("include", [])
|
||||
|
||||
def test_xai_grok_4_fast_omits_reasoning_effort(self, transport):
|
||||
"""grok-4-fast and grok-4-1-fast variants reject reasoning.effort."""
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
for model in (
|
||||
"grok-4-fast-reasoning",
|
||||
"grok-4-fast-non-reasoning",
|
||||
"grok-4-1-fast-reasoning",
|
||||
"grok-4-1-fast-non-reasoning",
|
||||
):
|
||||
kw = transport.build_kwargs(
|
||||
model=model, messages=messages, tools=[],
|
||||
is_xai_responses=True,
|
||||
reasoning_config={"effort": "low"},
|
||||
)
|
||||
assert "reasoning" not in kw, (
|
||||
f"{model} must not receive a reasoning key (xAI rejects it)"
|
||||
)
|
||||
|
||||
def test_xai_grok_3_non_mini_omits_reasoning_effort(self, transport):
|
||||
"""Plain grok-3 rejects reasoning.effort — only grok-3-mini accepts it."""
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
kw = transport.build_kwargs(
|
||||
model="grok-3", messages=messages, tools=[],
|
||||
is_xai_responses=True,
|
||||
reasoning_config={"effort": "medium"},
|
||||
)
|
||||
assert "reasoning" not in kw
|
||||
|
||||
def test_xai_grok_3_mini_keeps_reasoning_effort(self, transport):
|
||||
"""grok-3-mini and -fast variants do accept the effort dial."""
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
for model in ("grok-3-mini", "grok-3-mini-fast"):
|
||||
kw = transport.build_kwargs(
|
||||
model=model, messages=messages, tools=[],
|
||||
is_xai_responses=True,
|
||||
reasoning_config={"effort": "high"},
|
||||
)
|
||||
assert kw.get("reasoning") == {"effort": "high"}
|
||||
|
||||
def test_xai_grok_4_20_0309_variants_omit_reasoning_effort(self, transport):
|
||||
"""grok-4.20-0309-(non-)reasoning reject the effort dial.
|
||||
|
||||
Counterintuitively, only grok-4.20-multi-agent-0309 accepts it.
|
||||
"""
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
for model in ("grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning"):
|
||||
kw = transport.build_kwargs(
|
||||
model=model, messages=messages, tools=[],
|
||||
is_xai_responses=True,
|
||||
reasoning_config={"effort": "high"},
|
||||
)
|
||||
assert "reasoning" not in kw, f"{model} must not receive reasoning"
|
||||
|
||||
def test_xai_grok_4_20_multi_agent_keeps_reasoning_effort(self, transport):
|
||||
"""grok-4.20-multi-agent-0309 is the one grok-4.20 variant that accepts effort."""
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
kw = transport.build_kwargs(
|
||||
model="grok-4.20-multi-agent-0309", messages=messages, tools=[],
|
||||
is_xai_responses=True,
|
||||
reasoning_config={"effort": "low"},
|
||||
)
|
||||
assert kw.get("reasoning") == {"effort": "low"}
|
||||
|
||||
def test_xai_grok_code_fast_omits_reasoning_effort(self, transport):
|
||||
"""grok-code-fast-1 rejects reasoning.effort."""
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
kw = transport.build_kwargs(
|
||||
model="grok-code-fast-1", messages=messages, tools=[],
|
||||
is_xai_responses=True,
|
||||
reasoning_config={"effort": "high"},
|
||||
)
|
||||
assert "reasoning" not in kw
|
||||
|
||||
def test_xai_aggregator_prefix_stripped(self, transport):
|
||||
"""`x-ai/grok-3-mini` (OpenRouter-style slug) still resolves correctly."""
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
# Effort-capable
|
||||
kw = transport.build_kwargs(
|
||||
model="x-ai/grok-3-mini", messages=messages, tools=[],
|
||||
is_xai_responses=True,
|
||||
reasoning_config={"effort": "high"},
|
||||
)
|
||||
assert kw.get("reasoning") == {"effort": "high"}
|
||||
# Effort-incapable
|
||||
kw = transport.build_kwargs(
|
||||
model="x-ai/grok-4-0709", messages=messages, tools=[],
|
||||
is_xai_responses=True,
|
||||
reasoning_config={"effort": "high"},
|
||||
)
|
||||
assert "reasoning" not in kw
|
||||
|
||||
|
||||
class TestCodexValidateResponse:
|
||||
|
||||
|
||||
@@ -118,14 +118,37 @@ def test_strip_mode_preserves_table_structure_while_cleaning_cell_markdown():
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "| Syntax | Example |" in output
|
||||
assert "|---|---|" in output
|
||||
assert "| Bold | bold |" in output
|
||||
assert "| Strike | strike |" in output
|
||||
|
||||
# Inline cell markdown is stripped (the contract this test enforces).
|
||||
assert "**" not in output
|
||||
assert "~~" not in output
|
||||
assert "`" not in output
|
||||
|
||||
# Cell *content* survives, even if the surrounding whitespace was
|
||||
# rewritten by the wcwidth-aware re-aligner. Asserting on bare
|
||||
# cell text keeps this test focused on the strip behaviour rather
|
||||
# than snapshotting incidental column padding (which is what the
|
||||
# CJK-alignment fix changes).
|
||||
assert "Syntax" in output
|
||||
assert "Example" in output
|
||||
assert "Bold" in output and "bold" in output
|
||||
assert "Strike" in output and "strike" in output
|
||||
|
||||
# Structural sanity: the table still renders as pipe-bordered rows
|
||||
# (header + divider + 2 body rows).
|
||||
body_rows = [ln for ln in output.splitlines() if ln.strip().startswith("|")]
|
||||
assert len(body_rows) == 4
|
||||
|
||||
# Every rendered table row shares the same pipe column offsets — the
|
||||
# alignment guarantee from realign_markdown_tables.
|
||||
pipe_cols = [
|
||||
[i for i, ch in enumerate(row) if ch == "|"] for row in body_rows
|
||||
]
|
||||
assert all(p == pipe_cols[0] for p in pipe_cols), (
|
||||
"table rows misaligned after strip-mode rendering:\n"
|
||||
+ "\n".join(body_rows)
|
||||
)
|
||||
|
||||
|
||||
def test_final_assistant_content_can_leave_markdown_raw():
|
||||
renderable = _render_final_assistant_content("***Bold italic***", mode="raw")
|
||||
|
||||
@@ -6,6 +6,7 @@ don't have to construct a full HermesCLI (which requires extensive setup).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -17,10 +18,17 @@ def _bound(fn, instance):
|
||||
|
||||
def _make_self(prompt_response):
|
||||
"""Build a minimal stand-in 'self' for _confirm_destructive_slash."""
|
||||
return SimpleNamespace(
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = SimpleNamespace(
|
||||
_app=None,
|
||||
_prompt_text_input=lambda _prompt: prompt_response,
|
||||
_prompt_text_input_modal=lambda **_kw: prompt_response,
|
||||
)
|
||||
self_._normalize_slash_confirm_choice = _bound(
|
||||
HermesCLI._normalize_slash_confirm_choice, self_,
|
||||
)
|
||||
return self_
|
||||
|
||||
|
||||
def test_gate_off_returns_once_without_prompting():
|
||||
@@ -117,7 +125,6 @@ def test_gate_on_choice_always_persists_and_returns_always():
|
||||
self_ = _make_self(prompt_response="2")
|
||||
|
||||
saves = []
|
||||
|
||||
def _fake_save(key, value):
|
||||
saves.append((key, value))
|
||||
return True
|
||||
@@ -150,3 +157,55 @@ def test_gate_default_true_when_config_missing():
|
||||
# treated as on despite the config error. If the gate had been off
|
||||
# this would have returned 'once' without consulting the prompt.
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_slash_confirm_modal_number_selection_submits_without_raw_input():
|
||||
"""Pressing 2 in the TUI modal should resolve to Always Approve directly."""
|
||||
from cli import HermesCLI
|
||||
|
||||
q = queue.Queue()
|
||||
self_ = SimpleNamespace(
|
||||
_slash_confirm_state={
|
||||
"choices": [
|
||||
("once", "Approve Once", "proceed once"),
|
||||
("always", "Always Approve", "persist opt-out"),
|
||||
("cancel", "Cancel", "abort"),
|
||||
],
|
||||
"selected": 0,
|
||||
"response_queue": q,
|
||||
},
|
||||
_slash_confirm_deadline=123,
|
||||
_invalidate=lambda: None,
|
||||
)
|
||||
|
||||
_bound(HermesCLI._submit_slash_confirm_response, self_)("always")
|
||||
|
||||
assert q.get_nowait() == "always"
|
||||
assert self_._slash_confirm_state is None
|
||||
assert self_._slash_confirm_deadline == 0
|
||||
|
||||
|
||||
def test_slash_confirm_display_fragments_include_choice_mapping():
|
||||
"""The modal itself must show what 1/2/3 mean, not only 'Choice [1/2/3]'."""
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = SimpleNamespace(
|
||||
_slash_confirm_state={
|
||||
"title": "⚠️ /new — destroys conversation state",
|
||||
"detail": "This starts a fresh session.",
|
||||
"choices": [
|
||||
("once", "Approve Once", "proceed once"),
|
||||
("always", "Always Approve", "persist opt-out"),
|
||||
("cancel", "Cancel", "abort"),
|
||||
],
|
||||
"selected": 1,
|
||||
},
|
||||
)
|
||||
|
||||
fragments = _bound(HermesCLI._get_slash_confirm_display_fragments, self_)()
|
||||
rendered = "".join(fragment for _style, fragment in fragments)
|
||||
|
||||
assert "[1] Approve Once" in rendered
|
||||
assert "[2] Always Approve" in rendered
|
||||
assert "[3] Cancel" in rendered
|
||||
assert "Type 1/2/3" in rendered
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for ``HermesCLI._prompt_text_input`` thread-safe input dispatch.
|
||||
|
||||
Raw ``input()`` prompts can race with prompt_toolkit when called from the TUI.
|
||||
The normal slash confirmations now use a prompt_toolkit-native modal, but
|
||||
``_prompt_text_input`` remains as a fallback for non-interactive calls and edge
|
||||
cases.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _make_cli():
|
||||
"""Minimal HermesCLI shell exposing prompt fallback helpers."""
|
||||
import cli as cli_mod
|
||||
|
||||
obj = object.__new__(cli_mod.HermesCLI)
|
||||
obj._app = MagicMock()
|
||||
obj._status_bar_visible = True
|
||||
return obj
|
||||
|
||||
|
||||
class TestPromptTextInputThreadSafety:
|
||||
def test_main_thread_uses_run_in_terminal(self):
|
||||
"""On the main thread with an active app, route through run_in_terminal."""
|
||||
cli = _make_cli()
|
||||
|
||||
with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
|
||||
patch("builtins.input", return_value="2"):
|
||||
cli._prompt_text_input("Choice: ")
|
||||
|
||||
# run_in_terminal was invoked; the _ask closure passed to it would
|
||||
# call input() when driven by the event loop. We assert dispatch path,
|
||||
# not the orphaned-coroutine result.
|
||||
assert mock_rit.called
|
||||
|
||||
def test_background_thread_falls_back_to_direct_input(self):
|
||||
"""On a daemon thread, skip run_in_terminal and call input() directly.
|
||||
|
||||
This preserves the fallback for any prompt that still runs off the main
|
||||
UI thread: run_in_terminal's coroutine would otherwise be orphaned.
|
||||
"""
|
||||
cli = _make_cli()
|
||||
captured = {}
|
||||
|
||||
def fake_input(prompt):
|
||||
captured["prompt"] = prompt
|
||||
return "1"
|
||||
|
||||
result_holder = {}
|
||||
|
||||
def run_on_daemon():
|
||||
with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
|
||||
patch("builtins.input", side_effect=fake_input):
|
||||
result_holder["value"] = cli._prompt_text_input("Choice [1/2/3]: ")
|
||||
result_holder["rit_called"] = mock_rit.called
|
||||
|
||||
t = threading.Thread(target=run_on_daemon, daemon=True)
|
||||
t.start()
|
||||
t.join(timeout=2.0)
|
||||
assert not t.is_alive(), "daemon thread hung — input() was not driven"
|
||||
|
||||
# run_in_terminal was bypassed entirely on the background thread.
|
||||
assert result_holder["rit_called"] is False
|
||||
# input() was invoked with the prompt and its return value was captured.
|
||||
assert captured.get("prompt") == "Choice [1/2/3]: "
|
||||
assert result_holder["value"] == "1"
|
||||
|
||||
def test_no_app_uses_direct_input(self):
|
||||
"""Without an active prompt_toolkit app, always call input() directly."""
|
||||
cli = _make_cli()
|
||||
cli._app = None
|
||||
|
||||
with patch("builtins.input", return_value="cancel") as mock_input:
|
||||
result = cli._prompt_text_input("Choice: ")
|
||||
|
||||
assert mock_input.called
|
||||
assert result == "cancel"
|
||||
|
||||
def test_run_in_terminal_exception_falls_back(self):
|
||||
"""If run_in_terminal raises (WSL / Warp edge cases), fall back to input()."""
|
||||
cli = _make_cli()
|
||||
|
||||
with patch(
|
||||
"prompt_toolkit.application.run_in_terminal",
|
||||
side_effect=RuntimeError("event loop dropped the coroutine"),
|
||||
), patch("builtins.input", return_value="3") as mock_input:
|
||||
result = cli._prompt_text_input("Choice: ")
|
||||
|
||||
assert mock_input.called
|
||||
assert result == "3"
|
||||
|
||||
def test_eof_returns_none(self):
|
||||
"""EOFError from input() yields None, not an unhandled exception."""
|
||||
cli = _make_cli()
|
||||
cli._app = None
|
||||
|
||||
with patch("builtins.input", side_effect=EOFError()):
|
||||
result = cli._prompt_text_input("Choice: ")
|
||||
|
||||
assert result is None
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for user-defined quick commands that bypass the agent loop."""
|
||||
import os
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from rich.text import Text
|
||||
@@ -159,6 +160,46 @@ class TestGatewayQuickCommands:
|
||||
result = await runner._handle_message(event)
|
||||
assert result == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_does_not_leak_credentials(self):
|
||||
"""Quick command exec must sanitize env — API keys must not appear in output."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner.config = {"quick_commands": {"leak": {"type": "exec", "command": "env"}}}
|
||||
runner._running_agents = {}
|
||||
runner._pending_messages = {}
|
||||
runner._is_user_authorized = MagicMock(return_value=True)
|
||||
|
||||
event = self._make_event("leak")
|
||||
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "sk-or-secret-12345"}):
|
||||
result = await runner._handle_message(event)
|
||||
|
||||
assert "sk-or-secret-12345" not in result, \
|
||||
"Quick command leaked OPENROUTER_API_KEY — exec runs without env sanitization"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_output_is_redacted(self, monkeypatch):
|
||||
"""Quick command output must redact sensitive patterns before returning."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
# Ensure redaction is active regardless of host HERMES_REDACT_SECRETS state
|
||||
# or test ordering (the module snapshots env at import time, so other
|
||||
# tests in the same xdist worker can flip the flag).
|
||||
monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner.config = {"quick_commands": {"token": {"type": "exec", "command": "echo sk-ant-api03-supersecretkey1234567890"}}}
|
||||
runner._running_agents = {}
|
||||
runner._pending_messages = {}
|
||||
runner._is_user_authorized = MagicMock(return_value=True)
|
||||
|
||||
event = self._make_event("token")
|
||||
result = await runner._handle_message(event)
|
||||
|
||||
assert "supersecretkey1234567890" not in result, \
|
||||
"Quick command output not redacted — raw API key returned to user"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_type_returns_error(self):
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
@@ -188,6 +188,16 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
|
||||
"HERMES_BACKGROUND_NOTIFICATIONS",
|
||||
"HERMES_EXEC_ASK",
|
||||
"HERMES_HOME_MODE",
|
||||
# Kanban path/board pins must never leak from a developer shell or
|
||||
# dispatched worker into tests; otherwise tests can write fake tasks to
|
||||
# the real ~/.hermes/kanban.db instead of the per-test HERMES_HOME.
|
||||
"HERMES_KANBAN_DB",
|
||||
"HERMES_KANBAN_BOARD",
|
||||
"HERMES_KANBAN_WORKSPACES_ROOT",
|
||||
"HERMES_KANBAN_LOGS_ROOT",
|
||||
"HERMES_KANBAN_TASK",
|
||||
"HERMES_KANBAN_WORKSPACE",
|
||||
"HERMES_TENANT",
|
||||
"TERMINAL_CWD",
|
||||
"TERMINAL_ENV",
|
||||
"TERMINAL_VERCEL_RUNTIME",
|
||||
@@ -223,6 +233,45 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
|
||||
"SIGNAL_ALLOW_ALL_USERS",
|
||||
"EMAIL_ALLOW_ALL_USERS",
|
||||
"SMS_ALLOW_ALL_USERS",
|
||||
# Gateway home channels are set by /sethome in real profiles. Tests that
|
||||
# exercise dashboard notification toggles must opt in explicitly or they
|
||||
# can accidentally subscribe against a developer's real home channel.
|
||||
"TELEGRAM_HOME_CHANNEL",
|
||||
"TELEGRAM_HOME_CHANNEL_THREAD_ID",
|
||||
"TELEGRAM_HOME_CHANNEL_NAME",
|
||||
"DISCORD_HOME_CHANNEL",
|
||||
"DISCORD_HOME_CHANNEL_THREAD_ID",
|
||||
"DISCORD_HOME_CHANNEL_NAME",
|
||||
"SLACK_HOME_CHANNEL",
|
||||
"SLACK_HOME_CHANNEL_THREAD_ID",
|
||||
"SLACK_HOME_CHANNEL_NAME",
|
||||
"WHATSAPP_HOME_CHANNEL",
|
||||
"WHATSAPP_HOME_CHANNEL_THREAD_ID",
|
||||
"WHATSAPP_HOME_CHANNEL_NAME",
|
||||
"SIGNAL_HOME_CHANNEL",
|
||||
"SIGNAL_HOME_CHANNEL_THREAD_ID",
|
||||
"SIGNAL_HOME_CHANNEL_NAME",
|
||||
"EMAIL_HOME_CHANNEL",
|
||||
"EMAIL_HOME_CHANNEL_THREAD_ID",
|
||||
"EMAIL_HOME_CHANNEL_NAME",
|
||||
"SMS_HOME_CHANNEL",
|
||||
"SMS_HOME_CHANNEL_THREAD_ID",
|
||||
"SMS_HOME_CHANNEL_NAME",
|
||||
"MATTERMOST_HOME_CHANNEL",
|
||||
"MATTERMOST_HOME_CHANNEL_THREAD_ID",
|
||||
"MATTERMOST_HOME_CHANNEL_NAME",
|
||||
"MATRIX_HOME_CHANNEL",
|
||||
"MATRIX_HOME_CHANNEL_THREAD_ID",
|
||||
"MATRIX_HOME_CHANNEL_NAME",
|
||||
"DINGTALK_HOME_CHANNEL",
|
||||
"DINGTALK_HOME_CHANNEL_THREAD_ID",
|
||||
"DINGTALK_HOME_CHANNEL_NAME",
|
||||
"FEISHU_HOME_CHANNEL",
|
||||
"FEISHU_HOME_CHANNEL_THREAD_ID",
|
||||
"FEISHU_HOME_CHANNEL_NAME",
|
||||
"WECOM_HOME_CHANNEL",
|
||||
"WECOM_HOME_CHANNEL_THREAD_ID",
|
||||
"WECOM_HOME_CHANNEL_NAME",
|
||||
# Platform gating — set by load_gateway_config() as a side effect when
|
||||
# a config.yaml is present, so individual test bodies that call the
|
||||
# loader leak these values into later tests on the same xdist worker.
|
||||
@@ -565,4 +614,352 @@ def _reset_tool_registry_caches():
|
||||
_clear_tool_defs_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
# ── Live-system guard ──────────────────────────────────────────────────────
|
||||
#
|
||||
# Several test files exercise the gateway-restart / kill code paths
|
||||
# (``cmd_update``, ``kill_gateway_processes``, ``stop_profile_gateway``).
|
||||
# When a single test forgets to mock either ``os.kill`` or the global
|
||||
# ``find_gateway_pids`` helper, the real call leaks out of the hermetic
|
||||
# environment and finds the developer's live ``hermes-gateway`` process
|
||||
# via ``psutil`` — sending it SIGTERM mid-test. The shutdown forensics in
|
||||
# PR #23285 caught this happening 5+ times in 3 days, every time
|
||||
# correlated with a ``tests/hermes_cli/`` pytest run starting up.
|
||||
#
|
||||
# This fixture makes the leak impossible by intercepting the two
|
||||
# primitives that actually do damage:
|
||||
#
|
||||
# • ``os.kill`` rejects any PID outside the test process subtree with
|
||||
# a hard ``RuntimeError`` so the offending test gets a stack trace
|
||||
# instead of silently murdering the real gateway.
|
||||
# • ``subprocess.run`` / ``subprocess.Popen`` / ``call`` / ``check_call`` /
|
||||
# ``check_output`` reject any ``systemctl ... <verb> hermes-gateway``
|
||||
# invocation that would mutate the live unit. Read-only systemctl
|
||||
# calls (``status``, ``show``, ``list-units``) still pass through.
|
||||
#
|
||||
# We intentionally do NOT stub ``find_gateway_pids`` / ``_scan_gateway_pids``
|
||||
# here — tests of those functions themselves need the real implementation.
|
||||
# Even if a test gets the live gateway PID back from a real scan, the
|
||||
# ``os.kill`` guard above catches the actual signal call, and the
|
||||
# ``systemctl`` guard catches the systemd path. Discovery without
|
||||
# delivery is harmless.
|
||||
|
||||
_LIVE_SYSTEM_GUARD_BYPASS_MARK = "live_system_guard_bypass"
|
||||
|
||||
|
||||
def pytest_configure(config): # noqa: D401 — pytest hook
|
||||
"""Register markers used by hermetic conftest."""
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
f"{_LIVE_SYSTEM_GUARD_BYPASS_MARK}: bypass the live-system guard "
|
||||
"(only for tests that genuinely need real os.kill / subprocess "
|
||||
"behaviour — e.g. PTY tests that signal their own child).",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _live_system_guard(request, monkeypatch):
|
||||
"""Block real os.kill / systemctl / gateway-pid scans during tests.
|
||||
|
||||
See block comment above for the why. Tests that genuinely need
|
||||
real signal delivery (e.g. PTY tests that SIGINT their own child)
|
||||
can opt out with ``@pytest.mark.live_system_guard_bypass``.
|
||||
|
||||
Coverage (every primitive that can deliver a signal to or otherwise
|
||||
terminate a foreign process):
|
||||
• os.kill, os.killpg (POSIX)
|
||||
• subprocess.run / Popen / call / check_call / check_output
|
||||
• subprocess.getoutput / getstatusoutput
|
||||
• os.system / os.popen
|
||||
• pty.spawn
|
||||
• asyncio.create_subprocess_exec / create_subprocess_shell
|
||||
Subprocess inspection looks at the WHOLE command string (not just
|
||||
tokens[0]), so ``bash -c "systemctl restart hermes-gateway"``,
|
||||
``sudo systemctl ...``, ``env systemctl ...``, ``setsid systemctl ...``
|
||||
are all caught. ``pkill``/``killall``/``taskkill`` invocations
|
||||
targeting hermes/python patterns are also blocked.
|
||||
"""
|
||||
if request.node.get_closest_marker(_LIVE_SYSTEM_GUARD_BYPASS_MARK):
|
||||
yield
|
||||
return
|
||||
|
||||
import os as _os
|
||||
import shlex as _shlex
|
||||
import subprocess as _subprocess
|
||||
|
||||
test_pid = _os.getpid()
|
||||
# Capture the test process's existing children at fixture start —
|
||||
# any *new* children spawned by the test are also allowlisted via
|
||||
# the live psutil walk below. Static set keeps the fast path cheap.
|
||||
try:
|
||||
import psutil as _psutil
|
||||
_initial_children = {
|
||||
c.pid for c in _psutil.Process(test_pid).children(recursive=True)
|
||||
}
|
||||
except Exception:
|
||||
_psutil = None
|
||||
_initial_children = set()
|
||||
|
||||
def _is_own_subtree(pid: int) -> bool:
|
||||
# PID 0 means "our own process group"; -1 means "every process we
|
||||
# can signal". Both are dangerous when paired with SIGTERM/SIGKILL,
|
||||
# but pid 0 is technically scoped to our group so allow it; pid -1
|
||||
# is treated as foreign (refuse).
|
||||
if pid == 0:
|
||||
return True
|
||||
if pid < 0:
|
||||
return False
|
||||
if pid == test_pid or pid in _initial_children:
|
||||
return True
|
||||
if _psutil is None:
|
||||
return False
|
||||
try:
|
||||
walker = _psutil.Process(pid)
|
||||
except Exception:
|
||||
# Stale PID — kill would be a no-op anyway, allow it.
|
||||
return True
|
||||
try:
|
||||
for parent in walker.parents():
|
||||
if parent.pid == test_pid:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
real_kill = _os.kill
|
||||
|
||||
def _guarded_kill(pid, sig, *args, **kwargs):
|
||||
if _is_own_subtree(int(pid)):
|
||||
return real_kill(pid, sig, *args, **kwargs)
|
||||
raise RuntimeError(
|
||||
f"tests/conftest.py live-system guard: blocked os.kill("
|
||||
f"{pid}, {sig}) — PID is outside the test process subtree. "
|
||||
"If this fired in CI it means the test reached a real "
|
||||
"kill_gateway_processes / stop_profile_gateway / cmd_update "
|
||||
"code path without mocking find_gateway_pids and os.kill. "
|
||||
"Mock both, or mark the test with "
|
||||
"@pytest.mark.live_system_guard_bypass if real signal "
|
||||
"delivery is genuinely required."
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_os, "kill", _guarded_kill)
|
||||
|
||||
# ``os.killpg`` is the same risk class — sends a signal to every
|
||||
# process in a group. The gateway is a session leader (its own
|
||||
# PGID == its PID), so killpg(gateway_pid, SIGTERM) is a one-shot
|
||||
# kill of the live process. Allow it only when the target PGID is
|
||||
# the test process's own group.
|
||||
if hasattr(_os, "killpg"):
|
||||
real_killpg = _os.killpg
|
||||
own_pgid = _os.getpgrp()
|
||||
|
||||
def _guarded_killpg(pgid, sig, *args, **kwargs):
|
||||
if int(pgid) == own_pgid or _is_own_subtree(int(pgid)):
|
||||
return real_killpg(pgid, sig, *args, **kwargs)
|
||||
raise RuntimeError(
|
||||
f"tests/conftest.py live-system guard: blocked "
|
||||
f"os.killpg({pgid}, {sig}) — PGID is outside the test "
|
||||
"process group. See _live_system_guard for the why."
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_os, "killpg", _guarded_killpg)
|
||||
|
||||
# ── Subprocess command-string inspection (whole-line) ──────────
|
||||
_HERMES_TOKENS = (
|
||||
"hermes-gateway",
|
||||
"hermes.service",
|
||||
"hermes_cli.main gateway",
|
||||
"hermes_cli/main.py gateway",
|
||||
"gateway/run.py",
|
||||
"hermes gateway",
|
||||
)
|
||||
_MUTATING_VERBS = (
|
||||
"restart", "start", "stop", "kill", "reload",
|
||||
"reset-failed", "enable", "disable", "mask", "unmask",
|
||||
"daemon-reload", "try-restart", "reload-or-restart",
|
||||
)
|
||||
_PROCESS_KILLERS = ("pkill", "killall", "taskkill", "skill", "fuser")
|
||||
|
||||
def _cmd_to_string(cmd) -> str:
|
||||
if cmd is None:
|
||||
return ""
|
||||
if isinstance(cmd, (bytes, bytearray)):
|
||||
try:
|
||||
return bytes(cmd).decode(errors="replace")
|
||||
except Exception:
|
||||
return ""
|
||||
if isinstance(cmd, str):
|
||||
return cmd
|
||||
if isinstance(cmd, (list, tuple)):
|
||||
try:
|
||||
return " ".join(str(t) for t in cmd)
|
||||
except Exception:
|
||||
return ""
|
||||
return str(cmd)
|
||||
|
||||
def _matches_hermes_gateway(cmd_str: str) -> bool:
|
||||
low = cmd_str.lower()
|
||||
return any(tok in low for tok in _HERMES_TOKENS)
|
||||
|
||||
def _is_blocked_systemctl(cmd) -> bool:
|
||||
cmd_str = _cmd_to_string(cmd)
|
||||
if "systemctl" not in cmd_str:
|
||||
return False
|
||||
if not _matches_hermes_gateway(cmd_str):
|
||||
return False
|
||||
try:
|
||||
tokens = _shlex.split(cmd_str)
|
||||
except ValueError:
|
||||
tokens = cmd_str.split()
|
||||
return any(verb in tokens for verb in _MUTATING_VERBS)
|
||||
|
||||
def _is_process_killer(cmd) -> bool:
|
||||
cmd_str = _cmd_to_string(cmd)
|
||||
try:
|
||||
tokens = _shlex.split(cmd_str)
|
||||
except ValueError:
|
||||
tokens = cmd_str.split()
|
||||
if not tokens:
|
||||
return False
|
||||
for tok in tokens:
|
||||
head = tok.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
if head in _PROCESS_KILLERS:
|
||||
low = cmd_str.lower()
|
||||
# pkill -f pattern: catch hermes-themed patterns + a
|
||||
# plain "python" -f which would catch the live gateway
|
||||
# whose cmdline contains "python -m hermes_cli.main".
|
||||
if (
|
||||
"hermes" in low
|
||||
or "gateway" in low
|
||||
or ("python" in low and "-f" in tokens)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _check_subprocess_cmd(name, cmd):
|
||||
if _is_blocked_systemctl(cmd):
|
||||
raise RuntimeError(
|
||||
f"tests/conftest.py live-system guard: blocked "
|
||||
f"subprocess.{name}({cmd!r}) — would mutate the "
|
||||
"live hermes-gateway systemd unit. Mock "
|
||||
"subprocess.run / _run_systemctl in the test, or "
|
||||
"mark with @pytest.mark.live_system_guard_bypass."
|
||||
)
|
||||
if _is_process_killer(cmd):
|
||||
raise RuntimeError(
|
||||
f"tests/conftest.py live-system guard: blocked "
|
||||
f"subprocess.{name}({cmd!r}) — process-killer command "
|
||||
"targeting hermes/python could hit the live gateway. "
|
||||
"Mark with @pytest.mark.live_system_guard_bypass if "
|
||||
"intentional."
|
||||
)
|
||||
|
||||
def _wrap_subprocess(name, real):
|
||||
def _guarded(cmd, *args, **kwargs):
|
||||
_check_subprocess_cmd(name, cmd)
|
||||
return real(cmd, *args, **kwargs)
|
||||
_guarded.__name__ = f"_guarded_{name}"
|
||||
# Make the wrapper subscriptable like the wrapped callable when
|
||||
# the wrapped object is. ``subprocess.Popen[bytes]`` is used as
|
||||
# a type annotation in third-party packages (mcp, etc.); replacing
|
||||
# ``Popen`` with a plain function breaks ``Popen[bytes]`` at
|
||||
# import time. Defer ``__class_getitem__`` to the original.
|
||||
if hasattr(real, "__class_getitem__"):
|
||||
_guarded.__class_getitem__ = real.__class_getitem__
|
||||
return _guarded
|
||||
|
||||
def _wrap_popen():
|
||||
"""Subclass Popen so isinstance checks AND Popen[bytes] still work."""
|
||||
real = _subprocess.Popen
|
||||
|
||||
class _GuardedPopen(real): # type: ignore[misc, valid-type]
|
||||
def __init__(self, cmd, *args, **kwargs):
|
||||
_check_subprocess_cmd("Popen", cmd)
|
||||
super().__init__(cmd, *args, **kwargs)
|
||||
|
||||
_GuardedPopen.__name__ = "Popen"
|
||||
_GuardedPopen.__qualname__ = "Popen"
|
||||
return _GuardedPopen
|
||||
|
||||
real_run = _subprocess.run
|
||||
real_popen = _subprocess.Popen
|
||||
real_call = _subprocess.call
|
||||
real_check_call = _subprocess.check_call
|
||||
real_check_output = _subprocess.check_output
|
||||
real_getoutput = _subprocess.getoutput
|
||||
real_getstatusoutput = _subprocess.getstatusoutput
|
||||
|
||||
monkeypatch.setattr(_subprocess, "run", _wrap_subprocess("run", real_run))
|
||||
monkeypatch.setattr(_subprocess, "Popen", _wrap_popen())
|
||||
monkeypatch.setattr(_subprocess, "call", _wrap_subprocess("call", real_call))
|
||||
monkeypatch.setattr(
|
||||
_subprocess, "check_call", _wrap_subprocess("check_call", real_check_call)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_subprocess,
|
||||
"check_output",
|
||||
_wrap_subprocess("check_output", real_check_output),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_subprocess, "getoutput", _wrap_subprocess("getoutput", real_getoutput)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_subprocess,
|
||||
"getstatusoutput",
|
||||
_wrap_subprocess("getstatusoutput", real_getstatusoutput),
|
||||
)
|
||||
|
||||
# os.system / os.popen — same risk class, completely unwrapped before.
|
||||
real_os_system = _os.system
|
||||
real_os_popen = _os.popen
|
||||
|
||||
def _guarded_os_system(command):
|
||||
_check_subprocess_cmd("os.system", command)
|
||||
return real_os_system(command)
|
||||
|
||||
def _guarded_os_popen(cmd, *args, **kwargs):
|
||||
_check_subprocess_cmd("os.popen", cmd)
|
||||
return real_os_popen(cmd, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_os, "system", _guarded_os_system)
|
||||
monkeypatch.setattr(_os, "popen", _guarded_os_popen)
|
||||
|
||||
# pty.spawn — POSIX-only.
|
||||
try:
|
||||
import pty as _pty
|
||||
if hasattr(_pty, "spawn"):
|
||||
real_pty_spawn = _pty.spawn
|
||||
|
||||
def _guarded_pty_spawn(argv, *args, **kwargs):
|
||||
_check_subprocess_cmd("pty.spawn", argv)
|
||||
return real_pty_spawn(argv, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_pty, "spawn", _guarded_pty_spawn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# asyncio.create_subprocess_* — bypasses subprocess module entirely.
|
||||
try:
|
||||
import asyncio as _asyncio
|
||||
real_async_exec = _asyncio.create_subprocess_exec
|
||||
real_async_shell = _asyncio.create_subprocess_shell
|
||||
|
||||
async def _guarded_async_exec(program, *args, **kwargs):
|
||||
_check_subprocess_cmd(
|
||||
"asyncio.create_subprocess_exec", [program, *args]
|
||||
)
|
||||
return await real_async_exec(program, *args, **kwargs)
|
||||
|
||||
async def _guarded_async_shell(cmd, *args, **kwargs):
|
||||
_check_subprocess_cmd("asyncio.create_subprocess_shell", cmd)
|
||||
return await real_async_shell(cmd, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_asyncio, "create_subprocess_exec", _guarded_async_exec)
|
||||
monkeypatch.setattr(
|
||||
_asyncio, "create_subprocess_shell", _guarded_async_shell
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
yield
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import sys
|
||||
@@ -386,3 +387,61 @@ async def test_forum_post_file_creation_failure():
|
||||
|
||||
assert result.success is False
|
||||
assert "missing perms" in (result.error or "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Typing indicator task lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_task_removed_after_api_error():
|
||||
"""When typing API call fails, stale task must be removed so typing can restart."""
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
adapter._client = MagicMock()
|
||||
adapter._client.http = MagicMock()
|
||||
adapter._client.http.request = AsyncMock(side_effect=Exception("rate limited"))
|
||||
adapter._typing_tasks = {}
|
||||
|
||||
await adapter.send_typing("12345")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert "12345" not in adapter._typing_tasks, \
|
||||
"Stale task should be removed after API error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_restartable_after_error():
|
||||
"""After a typing error, send_typing should start a new task (not blocked by stale entry)."""
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
adapter._client = MagicMock()
|
||||
adapter._client.http = MagicMock()
|
||||
adapter._typing_tasks = {}
|
||||
|
||||
# First call fails
|
||||
adapter._client.http.request = AsyncMock(side_effect=Exception("503"))
|
||||
await adapter.send_typing("12345")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Second call should work
|
||||
adapter._client.http.request = AsyncMock()
|
||||
await adapter.send_typing("12345")
|
||||
|
||||
assert "12345" in adapter._typing_tasks, \
|
||||
"Should restart typing after previous failure"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_stop_cleans_up():
|
||||
"""stop_typing should remove the task from _typing_tasks."""
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
adapter._client = MagicMock()
|
||||
adapter._client.http = MagicMock()
|
||||
adapter._client.http.request = AsyncMock()
|
||||
adapter._typing_tasks = {}
|
||||
|
||||
await adapter.send_typing("12345")
|
||||
assert "12345" in adapter._typing_tasks
|
||||
|
||||
await adapter.stop_typing("12345")
|
||||
assert "12345" not in adapter._typing_tasks
|
||||
|
||||
@@ -41,8 +41,9 @@ class TestResolveDisplaySetting:
|
||||
|
||||
# Empty config — should get built-in defaults
|
||||
config = {}
|
||||
# Telegram defaults to tier_high → "all"
|
||||
assert resolve_display_setting(config, "telegram", "tool_progress") == "all"
|
||||
# Telegram tier_high override: "new" (not "all") to reduce edit
|
||||
# pressure during streaming on Telegram's ~1 edit/s flood envelope.
|
||||
assert resolve_display_setting(config, "telegram", "tool_progress") == "new"
|
||||
# Email defaults to tier_minimal → "off"
|
||||
assert resolve_display_setting(config, "email", "tool_progress") == "off"
|
||||
|
||||
@@ -179,11 +180,14 @@ class TestPlatformDefaults:
|
||||
"""Built-in defaults reflect platform capability tiers."""
|
||||
|
||||
def test_high_tier_platforms(self):
|
||||
"""Telegram and Discord default to 'all' tool progress."""
|
||||
"""Discord defaults to 'all' tool progress; Telegram is in tier_high
|
||||
but overrides tool_progress to 'new' (less edit pressure)."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
for plat in ("telegram", "discord"):
|
||||
assert resolve_display_setting({}, plat, "tool_progress") == "all", plat
|
||||
# Telegram: tier_high member with tool_progress="new" override.
|
||||
assert resolve_display_setting({}, "telegram", "tool_progress") == "new"
|
||||
# Discord: pure tier_high.
|
||||
assert resolve_display_setting({}, "discord", "tool_progress") == "all"
|
||||
|
||||
def test_medium_tier_platforms(self):
|
||||
"""Mattermost, Matrix, Feishu, WhatsApp default to 'new' tool progress."""
|
||||
|
||||
@@ -448,7 +448,8 @@ def test_cache_dm_topic_from_message_no_overwrite():
|
||||
|
||||
|
||||
def _make_mock_message(chat_id=111, chat_type="private", text="hello", thread_id=None,
|
||||
user_id=42, user_name="Test User", forum_topic_created=None):
|
||||
user_id=42, user_name="Test User", forum_topic_created=None,
|
||||
is_topic_message=None):
|
||||
"""Create a mock Telegram Message for _build_message_event tests."""
|
||||
chat = SimpleNamespace(
|
||||
id=chat_id,
|
||||
@@ -464,11 +465,15 @@ def _make_mock_message(chat_id=111, chat_type="private", text="hello", thread_id
|
||||
full_name=user_name,
|
||||
)
|
||||
|
||||
if is_topic_message is None:
|
||||
is_topic_message = bool(thread_id) if chat_type == "private" else None
|
||||
|
||||
msg = SimpleNamespace(
|
||||
chat=chat,
|
||||
from_user=user,
|
||||
text=text,
|
||||
message_thread_id=thread_id,
|
||||
is_topic_message=is_topic_message,
|
||||
message_id=1001,
|
||||
reply_to_message=None,
|
||||
date=None,
|
||||
@@ -531,6 +536,40 @@ def test_build_message_event_no_auto_skill_without_thread():
|
||||
assert event.auto_skill is None
|
||||
|
||||
|
||||
def test_build_message_event_filters_non_topic_dm_thread_id():
|
||||
"""A DM reply-thread id should not be persisted unless Telegram marks it as a topic message."""
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
adapter = _make_adapter()
|
||||
msg = _make_mock_message(chat_id=111, thread_id=777, is_topic_message=False)
|
||||
event = adapter._build_message_event(msg, MessageType.TEXT)
|
||||
|
||||
assert event.source.thread_id is None
|
||||
assert event.source.chat_topic is None
|
||||
assert event.auto_skill is None
|
||||
|
||||
|
||||
def test_build_message_event_preserves_true_dm_topic_thread_id():
|
||||
"""True DM topic messages should keep their thread id for routing."""
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
adapter = _make_adapter([
|
||||
{
|
||||
"chat_id": 111,
|
||||
"topics": [
|
||||
{"name": "General", "thread_id": 200},
|
||||
],
|
||||
}
|
||||
])
|
||||
adapter._dm_topics["111:General"] = 200
|
||||
|
||||
msg = _make_mock_message(chat_id=111, thread_id=200, is_topic_message=True)
|
||||
event = adapter._build_message_event(msg, MessageType.TEXT)
|
||||
|
||||
assert event.source.thread_id == "200"
|
||||
assert event.source.chat_topic == "General"
|
||||
|
||||
|
||||
# ── _build_message_event: group_topics skill binding ──
|
||||
|
||||
# The telegram mock sets sys.modules["telegram.constants"] = telegram_mod (root mock),
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.run import GatewayRunner
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
|
||||
class RecordingAdapter:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
async def send(self, chat_id, text, metadata=None):
|
||||
self.sent.append({"chat_id": chat_id, "text": text, "metadata": metadata or {}})
|
||||
|
||||
|
||||
class DisconnectedAdapters(dict):
|
||||
"""Expose a platform during collection, then simulate disconnect on get()."""
|
||||
|
||||
def get(self, key, default=None):
|
||||
return None
|
||||
|
||||
|
||||
async def _run_one_notifier_tick(monkeypatch, runner):
|
||||
real_sleep = asyncio.sleep
|
||||
|
||||
async def fake_sleep(delay):
|
||||
if delay == 5:
|
||||
return None
|
||||
runner._running = False
|
||||
await real_sleep(0)
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
await runner._kanban_notifier_watcher(interval=1)
|
||||
|
||||
|
||||
def _make_runner(adapter):
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._running = True
|
||||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||||
runner._kanban_sub_fail_counts = {}
|
||||
return runner
|
||||
|
||||
|
||||
def _create_completed_subscription(summary="done once"):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="notify once", assignee="worker")
|
||||
kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1")
|
||||
kb.complete_task(conn, tid, summary=summary)
|
||||
return tid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _unseen_terminal_events(tid):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
_, events = kb.unseen_events_for_sub(
|
||||
conn,
|
||||
task_id=tid,
|
||||
platform="telegram",
|
||||
chat_id="chat-1",
|
||||
kinds=["completed", "blocked", "gave_up", "crashed", "timed_out"],
|
||||
)
|
||||
return events
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_kanban_notifier_dedupes_board_slugs_pointing_to_same_db(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "shared-kanban.db"
|
||||
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
|
||||
kb.init_db()
|
||||
kb.write_board_metadata("alias-a", name="Alias A")
|
||||
kb.write_board_metadata("alias-b", name="Alias B")
|
||||
|
||||
tid = _create_completed_subscription()
|
||||
|
||||
adapter = RecordingAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
|
||||
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
|
||||
|
||||
assert len(adapter.sent) == 1
|
||||
assert "Kanban" in adapter.sent[0]["text"]
|
||||
assert tid in adapter.sent[0]["text"]
|
||||
|
||||
|
||||
def test_kanban_notifier_claim_prevents_second_watcher_send(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "single-owner.db"
|
||||
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
|
||||
kb.init_db()
|
||||
|
||||
tid = _create_completed_subscription()
|
||||
|
||||
adapter1 = RecordingAdapter()
|
||||
adapter2 = RecordingAdapter()
|
||||
|
||||
asyncio.run(_run_one_notifier_tick(monkeypatch, _make_runner(adapter1)))
|
||||
asyncio.run(_run_one_notifier_tick(monkeypatch, _make_runner(adapter2)))
|
||||
|
||||
assert len(adapter1.sent) == 1
|
||||
assert adapter2.sent == []
|
||||
|
||||
|
||||
def test_kanban_notifier_rewinds_claim_if_adapter_disconnects(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "adapter-disconnect.db"
|
||||
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
|
||||
kb.init_db()
|
||||
tid = _create_completed_subscription()
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._running = True
|
||||
runner.adapters = DisconnectedAdapters({Platform.TELEGRAM: RecordingAdapter()})
|
||||
runner._kanban_sub_fail_counts = {}
|
||||
|
||||
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
|
||||
|
||||
assert [ev.kind for ev in _unseen_terminal_events(tid)] == ["completed"]
|
||||
|
||||
|
||||
def test_kanban_db_path_is_test_isolated_from_real_home():
|
||||
hermes_home = Path(kb.kanban_home())
|
||||
production_db = Path.home() / ".hermes" / "kanban.db"
|
||||
assert kb.kanban_db_path().resolve() != production_db.resolve()
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="x", assignee="worker")
|
||||
kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert kb.kanban_db_path().resolve().is_relative_to(hermes_home.resolve())
|
||||
assert kb.kanban_db_path().resolve() != production_db.resolve()
|
||||
|
||||
|
||||
class FailingAdapter:
|
||||
"""Adapter whose send() always raises, simulating a transient send error."""
|
||||
|
||||
def __init__(self):
|
||||
self.attempts = 0
|
||||
|
||||
async def send(self, chat_id, text, metadata=None):
|
||||
self.attempts += 1
|
||||
raise RuntimeError("simulated send failure")
|
||||
|
||||
|
||||
def test_kanban_notifier_rewinds_claim_on_send_exception(tmp_path, monkeypatch):
|
||||
"""A raising adapter rewinds the claim so the next tick can retry.
|
||||
|
||||
This is the second rewind path (distinct from the adapter-disconnect path
|
||||
in test_kanban_notifier_rewinds_claim_if_adapter_disconnects). Here the
|
||||
adapter is connected and the send call actually fires; the claim must
|
||||
still rewind so the event isn't lost when send() raises mid-tick.
|
||||
"""
|
||||
db_path = tmp_path / "send-failure.db"
|
||||
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
|
||||
kb.init_db()
|
||||
tid = _create_completed_subscription()
|
||||
|
||||
adapter = FailingAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
|
||||
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
|
||||
|
||||
# Send was attempted (so we exercised the failure path, not just the
|
||||
# disconnect path) and the claim was rewound — the unseen-events query
|
||||
# still returns the event for retry on the next tick.
|
||||
assert adapter.attempts >= 1, "send should have been attempted at least once"
|
||||
assert [ev.kind for ev in _unseen_terminal_events(tid)] == ["completed"]
|
||||
|
||||
|
||||
def test_notifier_redelivers_same_kind_on_dispatch_cycle(tmp_path, monkeypatch):
|
||||
"""A retry cycle (crashed → reclaimed → crashed) notifies the user twice.
|
||||
|
||||
Before #21398 the notifier auto-unsubscribed on any terminal event kind
|
||||
(gave_up / crashed / timed_out), so the second crash in a respawn cycle
|
||||
silently dropped — the subscription was already gone. This test pins the
|
||||
new contract: subscription survives non-final terminal events; the
|
||||
cursor handles dedup.
|
||||
|
||||
Two crashes ten seconds apart on the same task — both should land on
|
||||
the adapter.
|
||||
"""
|
||||
db_path = tmp_path / "redeliver-cycle.db"
|
||||
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
|
||||
kb.init_db()
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="cycle test", assignee="worker")
|
||||
kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1")
|
||||
# First crash — fired by the dispatcher when the worker PID dies.
|
||||
kb._append_event(conn, tid, kind="crashed")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
adapter = RecordingAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
|
||||
|
||||
# First crash delivered.
|
||||
assert len(adapter.sent) == 1
|
||||
assert "crashed" in adapter.sent[0]["text"].lower()
|
||||
|
||||
# Subscription survives — the cursor advanced past event #1, but the
|
||||
# row is still there.
|
||||
conn = kb.connect()
|
||||
try:
|
||||
subs = kb.list_notify_subs(conn, tid)
|
||||
assert len(subs) == 1, (
|
||||
"Subscription must survive a crashed event so a respawn-cycle "
|
||||
"second crash also notifies the user (issue #21398)."
|
||||
)
|
||||
|
||||
# Second crash — same task, same dispatcher (or a respawn). Append
|
||||
# another event to simulate the dispatcher firing crashed a second
|
||||
# time during retry.
|
||||
kb._append_event(conn, tid, kind="crashed")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# New tick: the second event has a fresh id past the cursor advance,
|
||||
# so it gets claimed and delivered.
|
||||
runner = _make_runner(adapter)
|
||||
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
|
||||
|
||||
assert len(adapter.sent) == 2, (
|
||||
f"Second crashed event should also notify; got {len(adapter.sent)} "
|
||||
f"deliveries (texts: {[d['text'] for d in adapter.sent]})"
|
||||
)
|
||||
assert "crashed" in adapter.sent[1]["text"].lower()
|
||||
@@ -0,0 +1,644 @@
|
||||
"""Tests for the LINE platform adapter plugin.
|
||||
|
||||
Covers the seven synthesis areas from the PR review:
|
||||
|
||||
1. webhook signature verification (HMAC-SHA256, base64) + tampering rejection
|
||||
2. inbound chat-id resolution for user / group / room sources
|
||||
3. three-allowlist gating (users / groups / rooms / allow_all)
|
||||
4. inbound dedup via webhookEventId
|
||||
5. RequestCache state machine (PENDING → READY → DELIVERED, ERROR)
|
||||
6. Markdown stripping with URL preservation + LINE-sized chunking
|
||||
7. send routing: reply token preferred → push fallback → batched at 5/call
|
||||
8. register() metadata + standalone_send shape
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.gateway._plugin_adapter_loader import load_plugin_adapter
|
||||
|
||||
# Load plugins/platforms/line/adapter.py under plugin_adapter_line so it
|
||||
# cannot collide with sibling platform-plugin tests in the same xdist worker.
|
||||
_line = load_plugin_adapter("line")
|
||||
|
||||
verify_line_signature = _line.verify_line_signature
|
||||
strip_markdown_preserving_urls = _line.strip_markdown_preserving_urls
|
||||
split_for_line = _line.split_for_line
|
||||
build_postback_button_message = _line.build_postback_button_message
|
||||
_resolve_chat = _line._resolve_chat
|
||||
_allowed_for_source = _line._allowed_for_source
|
||||
_is_system_bypass = _line._is_system_bypass
|
||||
RequestCache = _line.RequestCache
|
||||
State = _line.State
|
||||
LineAdapter = _line.LineAdapter
|
||||
register = _line.register
|
||||
check_requirements = _line.check_requirements
|
||||
validate_config = _line.validate_config
|
||||
_standalone_send = _line._standalone_send
|
||||
_env_enablement = _line._env_enablement
|
||||
_MessageDeduplicator = _line._MessageDeduplicator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Signature verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignature:
|
||||
|
||||
def _sign(self, body: bytes, secret: str) -> str:
|
||||
digest = hmac.new(secret.encode(), body, hashlib.sha256).digest()
|
||||
return base64.b64encode(digest).decode()
|
||||
|
||||
def test_valid_signature_passes(self):
|
||||
body = b'{"events": []}'
|
||||
sig = self._sign(body, "secret")
|
||||
assert verify_line_signature(body, sig, "secret")
|
||||
|
||||
def test_tampered_body_rejected(self):
|
||||
body = b'{"events": []}'
|
||||
sig = self._sign(body, "secret")
|
||||
assert not verify_line_signature(body + b" ", sig, "secret")
|
||||
|
||||
def test_wrong_secret_rejected(self):
|
||||
body = b'{"events": []}'
|
||||
sig = self._sign(body, "secret")
|
||||
assert not verify_line_signature(body, sig, "different")
|
||||
|
||||
def test_empty_signature_rejected(self):
|
||||
assert not verify_line_signature(b"x", "", "secret")
|
||||
|
||||
def test_empty_secret_rejected(self):
|
||||
assert not verify_line_signature(b"x", "AAAA", "")
|
||||
|
||||
def test_garbage_signature_rejected(self):
|
||||
assert not verify_line_signature(b"hello", "not base64 at all!!", "s")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Chat-id / source resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSourceResolution:
|
||||
|
||||
def test_user_source(self):
|
||||
chat_id, ctype = _resolve_chat({"type": "user", "userId": "U123"})
|
||||
assert chat_id == "U123"
|
||||
assert ctype == "dm"
|
||||
|
||||
def test_group_source(self):
|
||||
chat_id, ctype = _resolve_chat({"type": "group", "groupId": "C456", "userId": "U123"})
|
||||
assert chat_id == "C456"
|
||||
assert ctype == "group"
|
||||
|
||||
def test_room_source(self):
|
||||
chat_id, ctype = _resolve_chat({"type": "room", "roomId": "R789", "userId": "U123"})
|
||||
assert chat_id == "R789"
|
||||
assert ctype == "room"
|
||||
|
||||
def test_unknown_source_falls_back_to_dm(self):
|
||||
chat_id, ctype = _resolve_chat({"type": "weird"})
|
||||
assert chat_id == ""
|
||||
assert ctype == "dm"
|
||||
|
||||
def test_empty_source(self):
|
||||
chat_id, ctype = _resolve_chat({})
|
||||
assert chat_id == ""
|
||||
assert ctype == "dm"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Three-allowlist gating
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAllowlist:
|
||||
|
||||
def test_allow_all_short_circuits(self):
|
||||
for src in [
|
||||
{"type": "user", "userId": "Ufoo"},
|
||||
{"type": "group", "groupId": "Cfoo"},
|
||||
{"type": "room", "roomId": "Rfoo"},
|
||||
]:
|
||||
assert _allowed_for_source(src, allow_all=True, user_ids=set(), group_ids=set(), room_ids=set())
|
||||
|
||||
def test_user_in_allowlist_passes(self):
|
||||
src = {"type": "user", "userId": "Uok"}
|
||||
assert _allowed_for_source(src, allow_all=False, user_ids={"Uok"}, group_ids=set(), room_ids=set())
|
||||
|
||||
def test_user_not_in_allowlist_rejected(self):
|
||||
src = {"type": "user", "userId": "Uother"}
|
||||
assert not _allowed_for_source(src, allow_all=False, user_ids={"Uok"}, group_ids=set(), room_ids=set())
|
||||
|
||||
def test_group_uses_group_list_not_user_list(self):
|
||||
src = {"type": "group", "groupId": "Cok", "userId": "Uany"}
|
||||
assert _allowed_for_source(src, allow_all=False, user_ids={"Uany"}, group_ids={"Cok"}, room_ids=set())
|
||||
assert not _allowed_for_source(src, allow_all=False, user_ids={"Uany"}, group_ids=set(), room_ids=set())
|
||||
|
||||
def test_room_uses_room_list(self):
|
||||
src = {"type": "room", "roomId": "Rok"}
|
||||
assert _allowed_for_source(src, allow_all=False, user_ids=set(), group_ids=set(), room_ids={"Rok"})
|
||||
assert not _allowed_for_source(src, allow_all=False, user_ids=set(), group_ids=set(), room_ids=set())
|
||||
|
||||
def test_unknown_type_rejected(self):
|
||||
src = {"type": "weird"}
|
||||
assert not _allowed_for_source(src, allow_all=False, user_ids=set(), group_ids=set(), room_ids=set())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Inbound dedup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDedup:
|
||||
|
||||
def test_first_event_not_duplicate(self):
|
||||
d = _MessageDeduplicator()
|
||||
assert not d.is_duplicate("evt1")
|
||||
|
||||
def test_repeat_event_marked_duplicate(self):
|
||||
d = _MessageDeduplicator()
|
||||
d.is_duplicate("evt1")
|
||||
assert d.is_duplicate("evt1")
|
||||
|
||||
def test_blank_id_not_treated_as_duplicate(self):
|
||||
d = _MessageDeduplicator()
|
||||
# Blank IDs should always pass through (don't lock out unidentifiable events).
|
||||
assert not d.is_duplicate("")
|
||||
assert not d.is_duplicate("")
|
||||
|
||||
def test_lru_eviction_under_pressure(self):
|
||||
d = _MessageDeduplicator(max_size=10)
|
||||
for i in range(20):
|
||||
d.is_duplicate(f"evt{i}")
|
||||
# Exact eviction order isn't specified, but the cap must be enforced.
|
||||
# Insert one more and assert the bookkeeping doesn't grow without bound.
|
||||
d.is_duplicate("evt20")
|
||||
assert len(d._seen) <= 20 # bounded — exact cap depends on eviction policy
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. RequestCache state machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRequestCache:
|
||||
|
||||
def test_register_pending_is_pending(self):
|
||||
c = RequestCache()
|
||||
rid = c.register_pending("Uchat")
|
||||
assert c.get(rid).state is State.PENDING
|
||||
assert c.get(rid).chat_id == "Uchat"
|
||||
|
||||
def test_set_ready_transitions(self):
|
||||
c = RequestCache()
|
||||
rid = c.register_pending("Uchat")
|
||||
c.set_ready(rid, "the answer")
|
||||
assert c.get(rid).state is State.READY
|
||||
assert c.get(rid).payload == "the answer"
|
||||
|
||||
def test_set_error_transitions(self):
|
||||
c = RequestCache()
|
||||
rid = c.register_pending("Uchat")
|
||||
c.set_error(rid, "boom")
|
||||
assert c.get(rid).state is State.ERROR
|
||||
assert c.get(rid).payload == "boom"
|
||||
|
||||
def test_mark_delivered_from_ready(self):
|
||||
c = RequestCache()
|
||||
rid = c.register_pending("Uchat")
|
||||
c.set_ready(rid, "x")
|
||||
c.mark_delivered(rid)
|
||||
assert c.get(rid).state is State.DELIVERED
|
||||
|
||||
def test_mark_delivered_from_error(self):
|
||||
c = RequestCache()
|
||||
rid = c.register_pending("Uchat")
|
||||
c.set_error(rid, "x")
|
||||
c.mark_delivered(rid)
|
||||
assert c.get(rid).state is State.DELIVERED
|
||||
|
||||
def test_set_ready_on_delivered_is_noop(self):
|
||||
c = RequestCache()
|
||||
rid = c.register_pending("Uchat")
|
||||
c.set_ready(rid, "first")
|
||||
c.mark_delivered(rid)
|
||||
c.set_ready(rid, "second")
|
||||
# DELIVERED is terminal — no further mutation
|
||||
assert c.get(rid).payload == "first"
|
||||
assert c.get(rid).state is State.DELIVERED
|
||||
|
||||
def test_find_pending_for_chat(self):
|
||||
c = RequestCache()
|
||||
rid_a = c.register_pending("Ua")
|
||||
rid_b = c.register_pending("Ub")
|
||||
assert c.find_pending_for_chat("Ua") == rid_a
|
||||
assert c.find_pending_for_chat("Ub") == rid_b
|
||||
assert c.find_pending_for_chat("Uc") is None
|
||||
c.set_ready(rid_a, "x")
|
||||
# No longer PENDING — should not be found
|
||||
assert c.find_pending_for_chat("Ua") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Markdown stripping + chunking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMarkdownAndChunking:
|
||||
|
||||
def test_bold_stripped(self):
|
||||
assert strip_markdown_preserving_urls("**hello**") == "hello"
|
||||
|
||||
def test_italic_stripped(self):
|
||||
assert strip_markdown_preserving_urls("*hello*") == "hello"
|
||||
|
||||
def test_inline_code_unfenced(self):
|
||||
assert strip_markdown_preserving_urls("run `ls -la`") == "run ls -la"
|
||||
|
||||
def test_link_preserved_with_url(self):
|
||||
out = strip_markdown_preserving_urls("see [here](https://x.com)")
|
||||
assert "https://x.com" in out
|
||||
assert "here (https://x.com)" in out
|
||||
|
||||
def test_heading_prefix_stripped(self):
|
||||
out = strip_markdown_preserving_urls("# Title\n## Sub")
|
||||
assert out == "Title\nSub"
|
||||
|
||||
def test_bullet_marker_replaced(self):
|
||||
out = strip_markdown_preserving_urls("- a\n- b")
|
||||
assert out == "• a\n• b"
|
||||
|
||||
def test_code_fence_content_kept(self):
|
||||
# Source files often contain code snippets — the agent should still
|
||||
# see the content as plain text, just without backticks.
|
||||
md = "```python\nprint('hi')\n```"
|
||||
out = strip_markdown_preserving_urls(md)
|
||||
assert "print('hi')" in out
|
||||
assert "```" not in out
|
||||
|
||||
def test_split_short_returns_single_chunk(self):
|
||||
assert split_for_line("hi") == ["hi"]
|
||||
|
||||
def test_split_long_chunks_at_paragraph_boundary(self):
|
||||
text = "para1\n\npara2\n\npara3"
|
||||
chunks = split_for_line(text, max_chars=8)
|
||||
assert all(len(c) <= 8 for c in chunks), chunks
|
||||
assert len(chunks) >= 2
|
||||
|
||||
def test_split_caps_at_five_chunks(self):
|
||||
# 1000 paragraphs of 100 chars each — must cap at 5 LINE bubbles.
|
||||
text = "\n\n".join(["x" * 100 for _ in range(1000)])
|
||||
chunks = split_for_line(text)
|
||||
assert len(chunks) <= 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Send routing (reply -> push fallback, batching, system-bypass)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSendRouting:
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self, monkeypatch):
|
||||
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False)
|
||||
from gateway.config import PlatformConfig
|
||||
cfg = PlatformConfig(enabled=True, extra={
|
||||
"channel_access_token": "tok",
|
||||
"channel_secret": "sec",
|
||||
})
|
||||
ad = LineAdapter(cfg)
|
||||
ad._client = MagicMock()
|
||||
ad._client.reply = AsyncMock()
|
||||
ad._client.push = AsyncMock()
|
||||
return ad
|
||||
|
||||
def test_system_bypass_recognized(self):
|
||||
assert _is_system_bypass("⚡ Interrupting current run")
|
||||
assert _is_system_bypass("⏳ Queued — agent is busy")
|
||||
assert _is_system_bypass("⏩ Steered toward new task")
|
||||
assert not _is_system_bypass("Hello world")
|
||||
assert not _is_system_bypass("")
|
||||
|
||||
def test_send_uses_reply_when_token_present(self, adapter):
|
||||
import time as _time
|
||||
adapter._reply_tokens["Uchat"] = ("rt-token", _time.time() + 30)
|
||||
result = asyncio.run(adapter.send("Uchat", "hello"))
|
||||
assert result.success
|
||||
adapter._client.reply.assert_called_once()
|
||||
adapter._client.push.assert_not_called()
|
||||
# Token consumed (single-use)
|
||||
assert "Uchat" not in adapter._reply_tokens
|
||||
|
||||
def test_send_falls_back_to_push_when_no_token(self, adapter):
|
||||
result = asyncio.run(adapter.send("Uchat", "hello"))
|
||||
assert result.success
|
||||
adapter._client.push.assert_called_once()
|
||||
adapter._client.reply.assert_not_called()
|
||||
|
||||
def test_send_falls_back_to_push_when_reply_fails(self, adapter):
|
||||
import time as _time
|
||||
adapter._reply_tokens["Uchat"] = ("rt-token", _time.time() + 30)
|
||||
adapter._client.reply.side_effect = RuntimeError("expired")
|
||||
result = asyncio.run(adapter.send("Uchat", "hello"))
|
||||
assert result.success
|
||||
adapter._client.reply.assert_called_once()
|
||||
adapter._client.push.assert_called_once()
|
||||
|
||||
def test_send_returns_failure_when_push_fails(self, adapter):
|
||||
adapter._client.push.side_effect = RuntimeError("network")
|
||||
result = asyncio.run(adapter.send("Uchat", "hello"))
|
||||
assert not result.success
|
||||
assert "network" in result.error
|
||||
|
||||
def test_send_pending_button_caches_response(self, adapter):
|
||||
# Simulate that the slow-LLM postback button has fired.
|
||||
rid = adapter._cache.register_pending("Uchat")
|
||||
adapter._pending_buttons["Uchat"] = rid
|
||||
result = asyncio.run(adapter.send("Uchat", "the answer"))
|
||||
assert result.success
|
||||
# Response must have been cached, not pushed/replied.
|
||||
adapter._client.reply.assert_not_called()
|
||||
adapter._client.push.assert_not_called()
|
||||
assert adapter._cache.get(rid).state is State.READY
|
||||
assert adapter._cache.get(rid).payload == "the answer"
|
||||
|
||||
def test_send_system_bypass_skips_postback_cache(self, adapter):
|
||||
# Even with a pending button, system busy-acks must surface visibly.
|
||||
rid = adapter._cache.register_pending("Uchat")
|
||||
adapter._pending_buttons["Uchat"] = rid
|
||||
result = asyncio.run(adapter.send("Uchat", "⚡ Interrupting current run"))
|
||||
assert result.success
|
||||
# Bypass goes through push (no reply token stored)
|
||||
adapter._client.push.assert_called_once()
|
||||
# And the cache entry is unchanged (still PENDING for the eventual answer)
|
||||
assert adapter._cache.get(rid).state is State.PENDING
|
||||
|
||||
def test_send_caps_messages_per_call_at_five(self, adapter):
|
||||
# Build a payload that would naturally split into more than 5 LINE
|
||||
# bubbles; the chunker should cap at 5 + truncate.
|
||||
big = "\n\n".join(["x" * 4500 for _ in range(20)])
|
||||
result = asyncio.run(adapter.send("Uchat", big))
|
||||
assert result.success
|
||||
call_kwargs = adapter._client.push.call_args
|
||||
# call_args is (args, kwargs); for our send the messages are the 2nd positional
|
||||
sent_messages = call_kwargs.args[1] if call_kwargs.args else call_kwargs.kwargs.get("messages")
|
||||
# Without args, fall back to inspecting the call shape
|
||||
if sent_messages is None:
|
||||
# We invoked client.push(chat_id, messages) — check first batch
|
||||
sent_messages = adapter._client.push.call_args.args[1]
|
||||
assert len(sent_messages) <= 5
|
||||
|
||||
def test_format_message_strips_markdown(self, adapter):
|
||||
out = adapter.format_message("**bold** [link](https://x.com)")
|
||||
assert "**" not in out
|
||||
assert "https://x.com" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Register() metadata + plugin entry points
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRegister:
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self):
|
||||
self.kwargs = None
|
||||
|
||||
def register_platform(self, **kw):
|
||||
self.kwargs = kw
|
||||
|
||||
def test_register_calls_register_platform(self):
|
||||
ctx = self._FakeCtx()
|
||||
register(ctx)
|
||||
assert ctx.kwargs is not None
|
||||
assert ctx.kwargs["name"] == "line"
|
||||
assert ctx.kwargs["label"] == "LINE"
|
||||
|
||||
def test_register_advertises_required_env(self):
|
||||
ctx = self._FakeCtx()
|
||||
register(ctx)
|
||||
assert set(ctx.kwargs["required_env"]) == {
|
||||
"LINE_CHANNEL_ACCESS_TOKEN",
|
||||
"LINE_CHANNEL_SECRET",
|
||||
}
|
||||
|
||||
def test_register_wires_allowlist_envs(self):
|
||||
ctx = self._FakeCtx()
|
||||
register(ctx)
|
||||
assert ctx.kwargs["allowed_users_env"] == "LINE_ALLOWED_USERS"
|
||||
assert ctx.kwargs["allow_all_env"] == "LINE_ALLOW_ALL_USERS"
|
||||
|
||||
def test_register_wires_cron_home_channel(self):
|
||||
ctx = self._FakeCtx()
|
||||
register(ctx)
|
||||
assert ctx.kwargs["cron_deliver_env_var"] == "LINE_HOME_CHANNEL"
|
||||
|
||||
def test_register_provides_standalone_sender(self):
|
||||
ctx = self._FakeCtx()
|
||||
register(ctx)
|
||||
assert callable(ctx.kwargs["standalone_sender_fn"])
|
||||
|
||||
def test_register_provides_env_enablement(self):
|
||||
ctx = self._FakeCtx()
|
||||
register(ctx)
|
||||
assert callable(ctx.kwargs["env_enablement_fn"])
|
||||
|
||||
def test_register_factory_yields_line_adapter(self):
|
||||
ctx = self._FakeCtx()
|
||||
register(ctx)
|
||||
from gateway.config import PlatformConfig
|
||||
cfg = PlatformConfig(enabled=True, extra={
|
||||
"channel_access_token": "tok",
|
||||
"channel_secret": "sec",
|
||||
})
|
||||
ad = ctx.kwargs["adapter_factory"](cfg)
|
||||
assert isinstance(ad, LineAdapter)
|
||||
|
||||
def test_max_message_length_below_line_per_bubble_limit(self):
|
||||
ctx = self._FakeCtx()
|
||||
register(ctx)
|
||||
# LINE per-bubble limit is 5000; we register 4500 to leave headroom.
|
||||
assert ctx.kwargs["max_message_length"] <= 5000
|
||||
|
||||
|
||||
class TestEnvEnablement:
|
||||
|
||||
def test_returns_none_without_credentials(self, monkeypatch):
|
||||
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False)
|
||||
assert _env_enablement() is None
|
||||
|
||||
def test_returns_dict_with_credentials(self, monkeypatch):
|
||||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok")
|
||||
monkeypatch.setenv("LINE_CHANNEL_SECRET", "sec")
|
||||
assert _env_enablement() == {}
|
||||
|
||||
def test_seeds_port_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok")
|
||||
monkeypatch.setenv("LINE_CHANNEL_SECRET", "sec")
|
||||
monkeypatch.setenv("LINE_PORT", "8080")
|
||||
assert _env_enablement() == {"port": 8080}
|
||||
|
||||
def test_seeds_public_url(self, monkeypatch):
|
||||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok")
|
||||
monkeypatch.setenv("LINE_CHANNEL_SECRET", "sec")
|
||||
monkeypatch.setenv("LINE_PUBLIC_URL", "https://my-tunnel.example.com")
|
||||
result = _env_enablement()
|
||||
assert result["public_url"] == "https://my-tunnel.example.com"
|
||||
|
||||
|
||||
class TestStandaloneSend:
|
||||
|
||||
def test_missing_token_returns_error(self, monkeypatch):
|
||||
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
|
||||
from gateway.config import PlatformConfig
|
||||
cfg = PlatformConfig(enabled=True, extra={})
|
||||
result = asyncio.run(_standalone_send(cfg, "Uchat", "hi"))
|
||||
assert "error" in result
|
||||
|
||||
def test_missing_chat_id_returns_error(self, monkeypatch):
|
||||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok")
|
||||
from gateway.config import PlatformConfig
|
||||
cfg = PlatformConfig(enabled=True, extra={})
|
||||
result = asyncio.run(_standalone_send(cfg, "", "hi"))
|
||||
assert "error" in result
|
||||
|
||||
def test_pushes_via_client_when_credentials_present(self, monkeypatch):
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
push_calls = []
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **kw):
|
||||
pass
|
||||
|
||||
async def push(self, chat_id, messages):
|
||||
push_calls.append((chat_id, messages))
|
||||
|
||||
monkeypatch.setattr(_line, "_LineClient", _FakeClient)
|
||||
cfg = PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"channel_access_token": "tok"},
|
||||
)
|
||||
result = asyncio.run(_standalone_send(cfg, "Uchat", "hello"))
|
||||
assert result.get("success") is True
|
||||
assert len(push_calls) == 1
|
||||
assert push_calls[0][0] == "Uchat"
|
||||
# Message wraps as text bubble
|
||||
assert push_calls[0][1][0]["type"] == "text"
|
||||
|
||||
|
||||
class TestPostbackButtonShape:
|
||||
|
||||
def test_template_buttons_structure(self):
|
||||
msg = build_postback_button_message("hi", "Tap me", "rid-1")
|
||||
assert msg["type"] == "template"
|
||||
assert msg["template"]["type"] == "buttons"
|
||||
assert msg["template"]["text"] == "hi"
|
||||
actions = msg["template"]["actions"]
|
||||
assert len(actions) == 1
|
||||
assert actions[0]["type"] == "postback"
|
||||
data = json.loads(actions[0]["data"])
|
||||
assert data == {"action": "show_response", "request_id": "rid-1"}
|
||||
|
||||
def test_text_truncated_to_160(self):
|
||||
long = "x" * 200
|
||||
msg = build_postback_button_message(long, "Tap", "rid")
|
||||
assert len(msg["template"]["text"]) <= 160
|
||||
|
||||
def test_alt_text_truncated_to_400(self):
|
||||
long = "x" * 500
|
||||
msg = build_postback_button_message(long, "Tap", "rid")
|
||||
assert len(msg["altText"]) <= 400
|
||||
|
||||
|
||||
class TestCheckRequirements:
|
||||
|
||||
def test_rejects_without_token(self, monkeypatch):
|
||||
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.setenv("LINE_CHANNEL_SECRET", "s")
|
||||
assert not check_requirements()
|
||||
|
||||
def test_rejects_without_secret(self, monkeypatch):
|
||||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "t")
|
||||
monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False)
|
||||
assert not check_requirements()
|
||||
|
||||
|
||||
class TestValidateConfig:
|
||||
|
||||
def test_validates_from_extra(self):
|
||||
from gateway.config import PlatformConfig
|
||||
cfg = PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"channel_access_token": "t", "channel_secret": "s"},
|
||||
)
|
||||
assert validate_config(cfg)
|
||||
|
||||
def test_rejects_empty_config(self, monkeypatch):
|
||||
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False)
|
||||
from gateway.config import PlatformConfig
|
||||
cfg = PlatformConfig(enabled=True, extra={})
|
||||
assert not validate_config(cfg)
|
||||
|
||||
|
||||
class TestAdapterInit:
|
||||
|
||||
def test_init_from_config_extra(self, monkeypatch):
|
||||
for k in ("LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET", "LINE_PORT"):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
from gateway.config import PlatformConfig
|
||||
cfg = PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"channel_access_token": "tok",
|
||||
"channel_secret": "sec",
|
||||
"port": 7777,
|
||||
"public_url": "https://x.example.com",
|
||||
"allowed_users": ["U1", "U2"],
|
||||
},
|
||||
)
|
||||
ad = LineAdapter(cfg)
|
||||
assert ad.channel_access_token == "tok"
|
||||
assert ad.channel_secret == "sec"
|
||||
assert ad.webhook_port == 7777
|
||||
assert ad.public_base_url == "https://x.example.com"
|
||||
assert ad.allowed_users == {"U1", "U2"}
|
||||
|
||||
def test_env_overrides_extra(self, monkeypatch):
|
||||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "env-tok")
|
||||
monkeypatch.setenv("LINE_PORT", "1234")
|
||||
from gateway.config import PlatformConfig
|
||||
cfg = PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"channel_access_token": "extra-tok", "channel_secret": "s", "port": 5555},
|
||||
)
|
||||
ad = LineAdapter(cfg)
|
||||
assert ad.channel_access_token == "env-tok"
|
||||
assert ad.webhook_port == 1234
|
||||
|
||||
def test_csv_allowlist_parsed(self, monkeypatch):
|
||||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "t")
|
||||
monkeypatch.setenv("LINE_CHANNEL_SECRET", "s")
|
||||
monkeypatch.setenv("LINE_ALLOWED_USERS", "U1, U2,U3")
|
||||
monkeypatch.setenv("LINE_ALLOWED_GROUPS", "C1")
|
||||
from gateway.config import PlatformConfig
|
||||
ad = LineAdapter(PlatformConfig(enabled=True))
|
||||
assert ad.allowed_users == {"U1", "U2", "U3"}
|
||||
assert ad.allowed_groups == {"C1"}
|
||||
|
||||
def test_get_chat_info_infers_type_from_prefix(self, monkeypatch):
|
||||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "t")
|
||||
monkeypatch.setenv("LINE_CHANNEL_SECRET", "s")
|
||||
from gateway.config import PlatformConfig
|
||||
ad = LineAdapter(PlatformConfig(enabled=True))
|
||||
assert asyncio.run(ad.get_chat_info("U123"))["type"] == "dm"
|
||||
assert asyncio.run(ad.get_chat_info("C123"))["type"] == "group"
|
||||
assert asyncio.run(ad.get_chat_info("R123"))["type"] == "channel"
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Tests for gateway.shutdown_forensics — fast snapshot + async diag spawn."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway import shutdown_forensics as sf
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _signal_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalName:
|
||||
def test_known_signals_resolve_to_names(self):
|
||||
assert sf._signal_name(signal.SIGTERM) == "SIGTERM"
|
||||
assert sf._signal_name(signal.SIGINT) == "SIGINT"
|
||||
|
||||
def test_unknown_int_returns_signal_num_token(self):
|
||||
# Pick an integer extremely unlikely to ever be a real signal alias
|
||||
assert sf._signal_name(9999) == "signal#9999"
|
||||
|
||||
def test_none_returns_unknown(self):
|
||||
assert sf._signal_name(None) == "UNKNOWN"
|
||||
|
||||
def test_non_integer_falls_back_to_str(self):
|
||||
assert sf._signal_name("SIGTERM") == "SIGTERM"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# snapshot_shutdown_context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSnapshotShutdownContext:
|
||||
def test_includes_self_pid_and_signal(self):
|
||||
ctx = sf.snapshot_shutdown_context(signal.SIGTERM)
|
||||
assert ctx["pid"] == os.getpid()
|
||||
assert ctx["signal"] == "SIGTERM"
|
||||
assert ctx["signal_num"] == int(signal.SIGTERM)
|
||||
|
||||
def test_handles_none_signal(self):
|
||||
ctx = sf.snapshot_shutdown_context(None)
|
||||
assert ctx["signal"] == "UNKNOWN"
|
||||
assert ctx["signal_num"] is None
|
||||
|
||||
def test_includes_timestamps(self):
|
||||
before = time.time()
|
||||
ctx = sf.snapshot_shutdown_context(signal.SIGTERM)
|
||||
after = time.time()
|
||||
assert before <= ctx["ts"] <= after
|
||||
assert isinstance(ctx["ts_monotonic"], float)
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Linux /proc not present")
|
||||
def test_includes_parent_summary_on_linux(self):
|
||||
ctx = sf.snapshot_shutdown_context(signal.SIGTERM)
|
||||
assert "parent" in ctx
|
||||
assert ctx["parent"]["pid"] == os.getppid()
|
||||
|
||||
def test_under_systemd_flag_uses_invocation_id(self, monkeypatch):
|
||||
monkeypatch.setenv("INVOCATION_ID", "abc123")
|
||||
ctx = sf.snapshot_shutdown_context(signal.SIGTERM)
|
||||
assert ctx["under_systemd"] is True
|
||||
assert ctx["systemd_invocation_id"] == "abc123"
|
||||
|
||||
def test_under_systemd_false_without_invocation_id_and_normal_ppid(
|
||||
self, monkeypatch
|
||||
):
|
||||
monkeypatch.delenv("INVOCATION_ID", raising=False)
|
||||
# We can't actually change ppid; skip if we happen to be reaped
|
||||
# by init (e.g. running under tini).
|
||||
if os.getppid() == 1:
|
||||
pytest.skip("test process is reaped by init")
|
||||
ctx = sf.snapshot_shutdown_context(signal.SIGTERM)
|
||||
assert ctx["under_systemd"] is False
|
||||
|
||||
def test_completes_quickly(self):
|
||||
"""Snapshot must NOT block — it runs inside the asyncio signal handler."""
|
||||
start = time.monotonic()
|
||||
sf.snapshot_shutdown_context(signal.SIGTERM)
|
||||
elapsed = time.monotonic() - start
|
||||
# Generous bound; the function should be sub-millisecond in practice.
|
||||
assert elapsed < 0.5, f"snapshot took {elapsed:.3f}s — too slow"
|
||||
|
||||
def test_detects_takeover_marker_for_self(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
marker = tmp_path / ".gateway-takeover.json"
|
||||
marker.write_text(
|
||||
f'{{"target_pid": {os.getpid()}, "replacer_pid": 99999}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
ctx = sf.snapshot_shutdown_context(signal.SIGTERM)
|
||||
assert "takeover_marker" in ctx
|
||||
assert ctx["takeover_marker_for_self"] is True
|
||||
|
||||
def test_detects_takeover_marker_for_other(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
marker = tmp_path / ".gateway-takeover.json"
|
||||
marker.write_text(
|
||||
'{"target_pid": 1, "replacer_pid": 99999}', encoding="utf-8"
|
||||
)
|
||||
ctx = sf.snapshot_shutdown_context(signal.SIGTERM)
|
||||
assert ctx["takeover_marker_for_self"] is False
|
||||
|
||||
def test_detects_planned_stop_marker(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
marker.write_text(
|
||||
f'{{"target_pid": {os.getpid()}}}', encoding="utf-8"
|
||||
)
|
||||
ctx = sf.snapshot_shutdown_context(signal.SIGTERM)
|
||||
assert "planned_stop_marker" in ctx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_context_for_log / context_as_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFormatters:
|
||||
def test_format_context_for_log_includes_signal_and_parent(self):
|
||||
ctx = sf.snapshot_shutdown_context(signal.SIGTERM)
|
||||
line = sf.format_context_for_log(ctx)
|
||||
assert "signal=SIGTERM" in line
|
||||
assert "parent_pid=" in line
|
||||
assert "parent_cmdline=" in line
|
||||
|
||||
def test_context_as_json_round_trips(self):
|
||||
ctx = sf.snapshot_shutdown_context(signal.SIGTERM)
|
||||
payload = sf.context_as_json(ctx)
|
||||
decoded = json.loads(payload)
|
||||
assert decoded["pid"] == os.getpid()
|
||||
assert decoded["signal"] == "SIGTERM"
|
||||
|
||||
def test_context_as_json_handles_unserialisable_values(self):
|
||||
ctx = {"signal": "SIGTERM", "weird": object()}
|
||||
payload = sf.context_as_json(ctx)
|
||||
# default=str means objects get repr'd, JSON stays valid
|
||||
decoded = json.loads(payload)
|
||||
assert decoded["signal"] == "SIGTERM"
|
||||
assert "weird" in decoded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# spawn_async_diagnostic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSpawnAsyncDiagnostic:
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only diagnostic")
|
||||
def test_spawns_subprocess_and_writes_output(self, tmp_path):
|
||||
log_path = tmp_path / "diag.log"
|
||||
pid = sf.spawn_async_diagnostic(log_path, "SIGTERM", timeout_seconds=3.0)
|
||||
assert pid is not None and pid > 0
|
||||
|
||||
# Wait briefly for the subprocess to write — bounded by its own timeout.
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
if log_path.exists() and log_path.stat().st_size > 0:
|
||||
# Wait a touch longer for the script to finish writing
|
||||
time.sleep(0.5)
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
# Reap the subprocess so it doesn't show up as a zombie.
|
||||
try:
|
||||
os.waitpid(pid, 0)
|
||||
except (ChildProcessError, OSError):
|
||||
pass
|
||||
|
||||
assert log_path.exists()
|
||||
contents = log_path.read_text(encoding="utf-8", errors="replace")
|
||||
assert "shutdown diagnostic" in contents
|
||||
assert "SIGTERM" in contents
|
||||
|
||||
def test_returns_none_on_windows(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(sf, "sys", type("M", (), {"platform": "win32"})())
|
||||
result = sf.spawn_async_diagnostic(
|
||||
tmp_path / "diag.log", "SIGTERM", timeout_seconds=1.0
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only diagnostic")
|
||||
def test_handles_unwritable_log_path_gracefully(self, tmp_path):
|
||||
# Point at a nonexistent parent that we can't create
|
||||
log_path = Path("/proc/cant-write-here/diag.log")
|
||||
result = sf.spawn_async_diagnostic(log_path, "SIGTERM", timeout_seconds=1.0)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only diagnostic")
|
||||
def test_does_not_block_caller(self, tmp_path):
|
||||
"""The spawn must return immediately even if ``ps`` takes seconds."""
|
||||
log_path = tmp_path / "diag.log"
|
||||
start = time.monotonic()
|
||||
sf.spawn_async_diagnostic(log_path, "SIGTERM", timeout_seconds=10.0)
|
||||
elapsed = time.monotonic() - start
|
||||
# Spawning bash in detached mode takes a few ms; anything under 1s
|
||||
# is plenty of headroom and proves we're not waiting on it.
|
||||
assert elapsed < 1.0, f"spawn blocked for {elapsed:.2f}s"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_systemd_duration_to_us
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseSystemdDuration:
|
||||
def test_seconds(self):
|
||||
assert sf._parse_systemd_duration_to_us("90s") == 90 * 1_000_000
|
||||
|
||||
def test_minutes(self):
|
||||
assert sf._parse_systemd_duration_to_us("3min") == 180 * 1_000_000
|
||||
|
||||
def test_combined_min_sec(self):
|
||||
assert sf._parse_systemd_duration_to_us("1min 30s") == 90 * 1_000_000
|
||||
|
||||
def test_hours(self):
|
||||
assert sf._parse_systemd_duration_to_us("1h") == 3600 * 1_000_000
|
||||
|
||||
def test_milliseconds(self):
|
||||
assert sf._parse_systemd_duration_to_us("500ms") == 500_000
|
||||
|
||||
def test_empty_returns_none(self):
|
||||
assert sf._parse_systemd_duration_to_us("") is None
|
||||
|
||||
def test_unknown_unit_returns_none(self):
|
||||
assert sf._parse_systemd_duration_to_us("90weeks") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_systemd_timing_alignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCheckSystemdTimingAlignment:
|
||||
def test_returns_none_when_not_under_systemd(self, monkeypatch):
|
||||
monkeypatch.delenv("INVOCATION_ID", raising=False)
|
||||
result = sf.check_systemd_timing_alignment(180.0)
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_unit_undeterminable(self, monkeypatch):
|
||||
monkeypatch.setenv("INVOCATION_ID", "abc")
|
||||
# /proc/self/cgroup likely doesn't end in .service for the test runner
|
||||
result = sf.check_systemd_timing_alignment(180.0)
|
||||
# Either None (we couldn't find a unit) or a dict with mismatch info
|
||||
# for whatever unit pytest IS in. Both are valid; we just ensure
|
||||
# the function doesn't raise.
|
||||
assert result is None or isinstance(result, dict)
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Unit tests for gateway.slash_access — per-platform slash command access control.
|
||||
|
||||
Tests the pure policy resolver (no gateway plumbing). Integration tests that
|
||||
exercise the dispatch site live in test_slash_access_dispatch.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.session import SessionSource
|
||||
from gateway.slash_access import (
|
||||
SlashAccessPolicy,
|
||||
policy_for_source,
|
||||
policy_from_extra,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# policy_from_extra — input normalization + scope resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPolicyFromExtra:
|
||||
def test_empty_extra_is_disabled(self):
|
||||
p = policy_from_extra({}, "dm")
|
||||
assert p.enabled is False
|
||||
assert p.admin_user_ids == frozenset()
|
||||
assert p.user_allowed_commands == frozenset()
|
||||
|
||||
def test_disabled_policy_treats_anyone_as_admin(self):
|
||||
# When gating is off, downstream code uses is_admin/can_run uniformly.
|
||||
# Both must short-circuit to True so existing behavior is preserved.
|
||||
p = policy_from_extra({}, "dm")
|
||||
assert p.is_admin("anyone") is True
|
||||
assert p.can_run("anyone", "stop") is True
|
||||
|
||||
def test_dm_admin_list_only(self):
|
||||
p = policy_from_extra({"allow_admin_from": ["111", "222"]}, "dm")
|
||||
assert p.enabled is True
|
||||
assert p.admin_user_ids == frozenset({"111", "222"})
|
||||
assert p.user_allowed_commands == frozenset()
|
||||
|
||||
def test_admin_runs_anything(self):
|
||||
p = policy_from_extra(
|
||||
{"allow_admin_from": [111], "user_allowed_commands": ["help"]},
|
||||
"dm",
|
||||
)
|
||||
assert p.is_admin("111") is True
|
||||
assert p.can_run("111", "stop") is True
|
||||
assert p.can_run("111", "kanban") is True
|
||||
|
||||
def test_non_admin_runs_only_listed_commands(self):
|
||||
p = policy_from_extra(
|
||||
{
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": ["status", "model"],
|
||||
},
|
||||
"dm",
|
||||
)
|
||||
assert p.is_admin("999") is False
|
||||
assert p.can_run("999", "status") is True
|
||||
assert p.can_run("999", "model") is True
|
||||
assert p.can_run("999", "stop") is False
|
||||
assert p.can_run("999", "kanban") is False
|
||||
|
||||
def test_always_allowed_floor_for_non_admin(self):
|
||||
# /help and /whoami always reachable so users can see what they can do.
|
||||
p = policy_from_extra(
|
||||
{"allow_admin_from": ["111"], "user_allowed_commands": []},
|
||||
"dm",
|
||||
)
|
||||
assert p.can_run("999", "help") is True
|
||||
assert p.can_run("999", "whoami") is True
|
||||
assert p.can_run("999", "stop") is False
|
||||
|
||||
def test_unknown_user_id_blocked(self):
|
||||
# Empty/None user_id → no admin status, no command access (except floor).
|
||||
p = policy_from_extra(
|
||||
{"allow_admin_from": ["111"], "user_allowed_commands": ["status"]},
|
||||
"dm",
|
||||
)
|
||||
assert p.is_admin(None) is False
|
||||
assert p.can_run(None, "status") is True # listed command works
|
||||
assert p.can_run(None, "stop") is False
|
||||
assert p.can_run("", "stop") is False
|
||||
|
||||
def test_id_coercion_ints_become_strings(self):
|
||||
# YAML often loads numeric IDs as ints; we stringify on ingest.
|
||||
p = policy_from_extra({"allow_admin_from": [12345, 67890]}, "dm")
|
||||
assert p.admin_user_ids == frozenset({"12345", "67890"})
|
||||
assert p.is_admin("12345") is True
|
||||
assert p.is_admin(12345) is True # is_admin also stringifies
|
||||
|
||||
def test_id_coercion_csv_string(self):
|
||||
p = policy_from_extra({"allow_admin_from": "111, 222 ,333"}, "dm")
|
||||
assert p.admin_user_ids == frozenset({"111", "222", "333"})
|
||||
|
||||
def test_command_coercion_strips_leading_slash_and_lowercases(self):
|
||||
p = policy_from_extra(
|
||||
{
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": ["/Status", "MODEL", "/help"],
|
||||
},
|
||||
"dm",
|
||||
)
|
||||
assert p.user_allowed_commands == frozenset({"status", "model", "help"})
|
||||
|
||||
def test_command_coercion_csv_string(self):
|
||||
p = policy_from_extra(
|
||||
{
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": "status, model , /help",
|
||||
},
|
||||
"dm",
|
||||
)
|
||||
assert p.user_allowed_commands == frozenset({"status", "model", "help"})
|
||||
|
||||
def test_group_scope_uses_group_keys(self):
|
||||
extra = {
|
||||
"allow_admin_from": ["111"], # DM admins
|
||||
"user_allowed_commands": ["status"], # DM commands
|
||||
"group_allow_admin_from": ["222"],
|
||||
"group_user_allowed_commands": ["help"],
|
||||
}
|
||||
dm = policy_from_extra(extra, "dm")
|
||||
gp = policy_from_extra(extra, "group")
|
||||
assert dm.admin_user_ids == frozenset({"111"})
|
||||
assert gp.admin_user_ids == frozenset({"222"})
|
||||
assert dm.user_allowed_commands == frozenset({"status"})
|
||||
# group's user_allowed_commands does not leak into DM's allowed list
|
||||
# except via the explicit fallback rule (only when DM list is unset).
|
||||
assert "help" in gp.user_allowed_commands
|
||||
|
||||
def test_dm_falls_back_to_group_user_commands_when_dm_unset(self):
|
||||
# Common case: operator wants the same command set DM and group;
|
||||
# they should only have to list it once on the group keys.
|
||||
extra = {
|
||||
"allow_admin_from": ["111"],
|
||||
"group_user_allowed_commands": ["status", "model"],
|
||||
}
|
||||
dm = policy_from_extra(extra, "dm")
|
||||
assert dm.user_allowed_commands == frozenset({"status", "model"})
|
||||
|
||||
def test_dm_admin_does_not_imply_group_admin(self):
|
||||
# Admin lists are scope-specific. DM admin must not auto-promote in groups.
|
||||
extra = {"allow_admin_from": ["111"]}
|
||||
dm = policy_from_extra(extra, "dm")
|
||||
gp = policy_from_extra(extra, "group")
|
||||
assert dm.is_admin("111") is True
|
||||
# Group has no admin list set → gating disabled in groups → "111"
|
||||
# gets unrestricted access, but that's the backward-compat fallback,
|
||||
# not implicit admin promotion. The distinction matters when the
|
||||
# group DOES have an admin list set:
|
||||
extra2 = {
|
||||
"allow_admin_from": ["111"],
|
||||
"group_allow_admin_from": ["222"],
|
||||
}
|
||||
gp2 = policy_from_extra(extra2, "group")
|
||||
assert gp2.is_admin("111") is False
|
||||
assert gp2.is_admin("222") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# policy_for_source — wires GatewayConfig + SessionSource together
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPolicyForSource:
|
||||
def test_no_config_returns_disabled(self):
|
||||
p = policy_for_source(None, None)
|
||||
assert p.enabled is False
|
||||
assert p.is_admin("anyone") is True
|
||||
|
||||
def test_no_platform_config_returns_disabled(self):
|
||||
cfg = GatewayConfig(platforms={})
|
||||
src = SessionSource(
|
||||
platform=Platform.DISCORD, chat_id="42", chat_type="dm", user_id="7"
|
||||
)
|
||||
p = policy_for_source(cfg, src)
|
||||
assert p.enabled is False
|
||||
|
||||
def test_dm_chat_type_resolves_to_dm_scope(self):
|
||||
cfg = GatewayConfig(
|
||||
platforms={
|
||||
Platform.DISCORD: PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": ["status"],
|
||||
"group_allow_admin_from": ["222"],
|
||||
"group_user_allowed_commands": ["help"],
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
dm_src = SessionSource(
|
||||
platform=Platform.DISCORD, chat_id="A", chat_type="dm", user_id="111"
|
||||
)
|
||||
p = policy_for_source(cfg, dm_src)
|
||||
assert p.is_admin("111") is True
|
||||
assert p.can_run("999", "status") is True
|
||||
assert p.can_run("999", "help") is True # always-allowed floor
|
||||
assert p.can_run("999", "kanban") is False
|
||||
|
||||
def test_group_chat_type_resolves_to_group_scope(self):
|
||||
cfg = GatewayConfig(
|
||||
platforms={
|
||||
Platform.DISCORD: PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": ["status"],
|
||||
"group_allow_admin_from": ["222"],
|
||||
"group_user_allowed_commands": ["help"],
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
grp_src = SessionSource(
|
||||
platform=Platform.DISCORD, chat_id="G", chat_type="group", user_id="222"
|
||||
)
|
||||
p = policy_for_source(cfg, grp_src)
|
||||
assert p.is_admin("222") is True
|
||||
assert p.is_admin("111") is False # DM admin, not group admin
|
||||
# In group scope, the only listed user command is "help"; "status"
|
||||
# is not in the group list and should be denied for non-admins.
|
||||
assert p.can_run("999", "help") is True
|
||||
assert p.can_run("999", "status") is False
|
||||
|
||||
def test_channel_thread_chat_types_treated_as_group_scope(self):
|
||||
# Discord channels and threads are group-scoped, not DM-scoped.
|
||||
cfg = GatewayConfig(
|
||||
platforms={
|
||||
Platform.DISCORD: PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"group_allow_admin_from": ["222"],
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
for ct in ("group", "channel", "thread", "supergroup"):
|
||||
src = SessionSource(
|
||||
platform=Platform.DISCORD, chat_id="X", chat_type=ct, user_id="222"
|
||||
)
|
||||
p = policy_for_source(cfg, src)
|
||||
assert p.is_admin("222") is True, f"chat_type={ct} should map to group scope"
|
||||
assert p.is_admin("111") is False, f"chat_type={ct} should not see DM admins"
|
||||
|
||||
def test_no_admin_list_for_dm_means_unrestricted_in_dm(self):
|
||||
# Group has admin list, DM does not → DM gating disabled, group active.
|
||||
cfg = GatewayConfig(
|
||||
platforms={
|
||||
Platform.DISCORD: PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"group_allow_admin_from": ["222"]},
|
||||
)
|
||||
}
|
||||
)
|
||||
dm_src = SessionSource(
|
||||
platform=Platform.DISCORD, chat_id="A", chat_type="dm", user_id="999"
|
||||
)
|
||||
grp_src = SessionSource(
|
||||
platform=Platform.DISCORD, chat_id="G", chat_type="group", user_id="999"
|
||||
)
|
||||
dm_p = policy_for_source(cfg, dm_src)
|
||||
grp_p = policy_for_source(cfg, grp_src)
|
||||
assert dm_p.enabled is False
|
||||
assert dm_p.can_run("999", "stop") is True # backward compat
|
||||
assert grp_p.enabled is True
|
||||
assert grp_p.can_run("999", "stop") is False # gated
|
||||
|
||||
def test_per_platform_isolation(self):
|
||||
# Discord has gating, Telegram doesn't → Telegram is unaffected.
|
||||
cfg = GatewayConfig(
|
||||
platforms={
|
||||
Platform.DISCORD: PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"allow_admin_from": ["111"]},
|
||||
),
|
||||
Platform.TELEGRAM: PlatformConfig(enabled=True, extra={}),
|
||||
}
|
||||
)
|
||||
tg_src = SessionSource(
|
||||
platform=Platform.TELEGRAM, chat_id="T", chat_type="dm", user_id="999"
|
||||
)
|
||||
p = policy_for_source(cfg, tg_src)
|
||||
assert p.enabled is False
|
||||
assert p.can_run("999", "stop") is True
|
||||
@@ -0,0 +1,558 @@
|
||||
"""Integration tests for slash command access control gating in gateway/run.py.
|
||||
|
||||
Drives the real ``GatewayRunner._handle_message`` path with a stub session
|
||||
store so we exercise the actual gate inserted at the dispatch site (not a
|
||||
re-implementation in the test). Uses the same ``object.__new__`` runner
|
||||
construction pattern as test_status_command.py.
|
||||
|
||||
Coverage targets:
|
||||
- Backward compat: no ``allow_admin_from`` set → behaves exactly as before
|
||||
(no denial messages, dispatch reaches the real handler).
|
||||
- Admin path: user in ``allow_admin_from`` runs anything.
|
||||
- User path: user not in admin list, but command in
|
||||
``user_allowed_commands`` → allowed.
|
||||
- User denied: command not in either list → returns the ⛔ denial.
|
||||
- Always-allowed floor: /help and /whoami reachable for non-admins
|
||||
even with empty user_allowed_commands.
|
||||
- DM vs group scope isolation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||
|
||||
|
||||
def _make_source(
|
||||
*,
|
||||
platform: Platform = Platform.DISCORD,
|
||||
user_id: str = "user1",
|
||||
chat_type: str = "dm",
|
||||
chat_id: str = "c1",
|
||||
) -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=platform,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
user_name=f"name-{user_id}",
|
||||
chat_type=chat_type,
|
||||
)
|
||||
|
||||
|
||||
def _make_event(text: str, source: SessionSource) -> MessageEvent:
|
||||
return MessageEvent(text=text, source=source, message_id="m1")
|
||||
|
||||
|
||||
def _make_runner(*, platform_extra: dict | None = None,
|
||||
platform: Platform = Platform.DISCORD):
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={
|
||||
platform: PlatformConfig(
|
||||
enabled=True,
|
||||
token="***",
|
||||
extra=platform_extra or {},
|
||||
)
|
||||
}
|
||||
)
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock()
|
||||
runner.adapters = {platform: adapter}
|
||||
runner._voice_mode = {}
|
||||
runner.hooks = SimpleNamespace(
|
||||
emit=AsyncMock(),
|
||||
emit_collect=AsyncMock(return_value=[]),
|
||||
loaded_hooks=False,
|
||||
)
|
||||
runner.session_store = MagicMock()
|
||||
session_entry = SessionEntry(
|
||||
session_key="agent:main:discord:dm:c1",
|
||||
session_id="sess-1",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=platform,
|
||||
chat_type="dm",
|
||||
total_tokens=0,
|
||||
)
|
||||
runner.session_store.get_or_create_session.return_value = session_entry
|
||||
runner.session_store.load_transcript.return_value = []
|
||||
runner.session_store.has_any_sessions.return_value = True
|
||||
runner.session_store.append_to_transcript = MagicMock()
|
||||
runner.session_store.rewrite_transcript = MagicMock()
|
||||
runner.session_store.update_session = MagicMock()
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._session_run_generation = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._session_sources = {}
|
||||
runner._session_db = MagicMock()
|
||||
runner._session_db.get_session_title.return_value = None
|
||||
runner._session_db.get_session.return_value = None
|
||||
runner._reasoning_config = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._show_reasoning = False
|
||||
runner._is_user_authorized = lambda _source: True
|
||||
runner._set_session_env = lambda _context: None
|
||||
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
|
||||
runner._send_voice_reply = AsyncMock()
|
||||
runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None
|
||||
runner._emit_gateway_run_progress = AsyncMock()
|
||||
return runner
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /whoami response shape — proves the handler is reachable AND uses the
|
||||
# resolver. We use /whoami because it's deterministic and short-circuits
|
||||
# before any session/agent setup.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whoami_unrestricted_when_no_admin_list():
|
||||
runner = _make_runner(platform_extra={}) # no admin list
|
||||
result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="999")))
|
||||
assert "Tier: unrestricted" in result
|
||||
assert "no admin list configured" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whoami_admin_user():
|
||||
runner = _make_runner(platform_extra={"allow_admin_from": ["111"]})
|
||||
result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="111")))
|
||||
assert "**admin**" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whoami_non_admin_lists_runnable_commands():
|
||||
runner = _make_runner(
|
||||
platform_extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": ["status", "model"],
|
||||
}
|
||||
)
|
||||
result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="999")))
|
||||
assert "Tier: user" in result
|
||||
assert "/help" in result # always-allowed floor
|
||||
assert "/whoami" in result # always-allowed floor
|
||||
assert "/status" in result
|
||||
assert "/model" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate denial — admin-only command attempted by non-admin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_denied_for_unlisted_command():
|
||||
runner = _make_runner(
|
||||
platform_extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": ["status"],
|
||||
}
|
||||
)
|
||||
# /stop is NOT in user_allowed_commands and not in the always-allowed floor.
|
||||
result = await runner._handle_message(_make_event("/stop", _make_source(user_id="999")))
|
||||
assert result is not None
|
||||
assert "⛔" in result
|
||||
assert "/stop is admin-only here" in result
|
||||
assert "/status" in result # denial preview shows what they CAN run
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_with_empty_user_commands_gets_floor_only():
|
||||
runner = _make_runner(
|
||||
platform_extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": [], # explicitly empty
|
||||
}
|
||||
)
|
||||
# /stop denied
|
||||
result = await runner._handle_message(_make_event("/stop", _make_source(user_id="999")))
|
||||
assert "⛔" in result
|
||||
assert "No slash commands are enabled" in result
|
||||
# /whoami still works (always-allowed floor)
|
||||
whoami_result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="999")))
|
||||
assert "Tier: user" in whoami_result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate ALLOW — admin and listed user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_runs_unlisted_command():
|
||||
runner = _make_runner(
|
||||
platform_extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": [], # users can run nothing
|
||||
}
|
||||
)
|
||||
# Admin runs /whoami (proxy for "any command works"); the gate must NOT
|
||||
# return the ⛔ denial. The /whoami handler is deterministic and doesn't
|
||||
# need a real agent, so we can assert against its content.
|
||||
result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="111")))
|
||||
assert "⛔" not in result
|
||||
assert "**admin**" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_runs_listed_command():
|
||||
runner = _make_runner(
|
||||
platform_extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": ["whoami"], # explicit
|
||||
}
|
||||
)
|
||||
result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="999")))
|
||||
assert "⛔" not in result
|
||||
assert "Tier: user" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backward compatibility — no admin list set means no gating at all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backward_compat_no_admin_list_means_no_gate():
|
||||
runner = _make_runner(platform_extra={}) # nothing configured
|
||||
# Random non-listed user runs /whoami; should return unrestricted profile,
|
||||
# never a denial.
|
||||
result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="anyone")))
|
||||
assert "⛔" not in result
|
||||
assert "Tier: unrestricted" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scope isolation — DM vs group
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_admin_is_not_group_admin():
|
||||
runner = _make_runner(
|
||||
platform_extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"group_allow_admin_from": ["222"],
|
||||
"group_user_allowed_commands": [],
|
||||
}
|
||||
)
|
||||
# User 111 is DM admin. In group context they're a non-admin with no
|
||||
# listed commands → /stop denied.
|
||||
result = await runner._handle_message(
|
||||
_make_event("/stop", _make_source(user_id="111", chat_type="group"))
|
||||
)
|
||||
assert "⛔" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_only_gating_leaves_dm_unrestricted():
|
||||
runner = _make_runner(
|
||||
platform_extra={
|
||||
# Only group has an admin list → DM scope stays in backward-compat mode
|
||||
"group_allow_admin_from": ["222"],
|
||||
}
|
||||
)
|
||||
result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="anyone", chat_type="dm")))
|
||||
assert "Tier: unrestricted" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin-registered slash commands are gated through the same path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_registered_command_is_gated(monkeypatch):
|
||||
"""The gate must recognize plugin-registered slash commands, not just
|
||||
built-in COMMAND_REGISTRY entries. We verify by stubbing
|
||||
is_gateway_known_command and resolve_command so a fictitious /myplugin
|
||||
command is treated as a known plugin command.
|
||||
"""
|
||||
runner = _make_runner(
|
||||
platform_extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": [],
|
||||
}
|
||||
)
|
||||
|
||||
from hermes_cli import commands as cmd_mod
|
||||
|
||||
real_resolve = cmd_mod.resolve_command
|
||||
real_is_known = cmd_mod.is_gateway_known_command
|
||||
|
||||
def fake_resolve(name):
|
||||
if name == "myplugin":
|
||||
# Return a CommandDef-like duck so canonical resolution succeeds
|
||||
return SimpleNamespace(name="myplugin")
|
||||
return real_resolve(name)
|
||||
|
||||
def fake_is_known(name):
|
||||
if name == "myplugin":
|
||||
return True
|
||||
return real_is_known(name)
|
||||
|
||||
monkeypatch.setattr(cmd_mod, "resolve_command", fake_resolve)
|
||||
monkeypatch.setattr(cmd_mod, "is_gateway_known_command", fake_is_known)
|
||||
|
||||
# Non-admin tries to run the plugin command → must be denied by the gate.
|
||||
result = await runner._handle_message(
|
||||
_make_event("/myplugin foo bar", _make_source(user_id="999"))
|
||||
)
|
||||
assert "⛔" in result
|
||||
assert "/myplugin is admin-only here" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Running-agent fast-path gating — admin/user split must hold even when an
|
||||
# agent is already running. The fast-path block in _handle_message dispatches
|
||||
# /stop, /restart, /new, /steer, /model, /approve, /deny, /agents,
|
||||
# /background, /kanban, /goal, /yolo, /verbose, /footer, /help, /commands,
|
||||
# /profile, /update directly without going through the cold dispatch site.
|
||||
# We must apply the gate there too — otherwise non-admins could bypass
|
||||
# gating just because an agent happens to be busy.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_agent_fastpath_blocks_non_admin_command():
|
||||
"""When an agent is running, /restart from a non-admin must be denied."""
|
||||
runner = _make_runner(
|
||||
platform_extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": [],
|
||||
}
|
||||
)
|
||||
src = _make_source(user_id="999")
|
||||
# Mark the session as having an in-flight agent so the fast-path runs.
|
||||
from gateway.session import build_session_key
|
||||
sk = build_session_key(src)
|
||||
runner._running_agents[sk] = MagicMock()
|
||||
runner._running_agents_ts[sk] = 0 # not stale (epoch + small delta on this machine)
|
||||
|
||||
result = await runner._handle_message(_make_event("/restart", src))
|
||||
assert result is not None
|
||||
assert "⛔" in result
|
||||
assert "/restart is admin-only here" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_agent_fastpath_allows_admin_command():
|
||||
"""Admins must still be able to run privileged commands like /restart
|
||||
through the running-agent fast-path. We check that we don't get the
|
||||
denial message; the actual /restart handler is mocked out via the
|
||||
runner's MagicMock."""
|
||||
runner = _make_runner(
|
||||
platform_extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": [],
|
||||
}
|
||||
)
|
||||
src = _make_source(user_id="111") # admin
|
||||
from gateway.session import build_session_key
|
||||
sk = build_session_key(src)
|
||||
runner._running_agents[sk] = MagicMock()
|
||||
runner._running_agents_ts[sk] = 0
|
||||
# Mock the restart handler so it doesn't actually try to restart anything.
|
||||
runner._handle_restart_command = AsyncMock(return_value="restart-handled")
|
||||
|
||||
result = await runner._handle_message(_make_event("/restart", src))
|
||||
assert result == "restart-handled"
|
||||
assert "⛔" not in (result or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_agent_fastpath_status_always_works():
|
||||
"""/status is intentionally pre-gate on the fast-path so users can
|
||||
always see session state, even non-admins."""
|
||||
runner = _make_runner(
|
||||
platform_extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": [],
|
||||
}
|
||||
)
|
||||
src = _make_source(user_id="999") # non-admin
|
||||
from gateway.session import build_session_key
|
||||
sk = build_session_key(src)
|
||||
runner._running_agents[sk] = MagicMock()
|
||||
runner._running_agents_ts[sk] = 0
|
||||
runner._handle_status_command = AsyncMock(return_value="status-handled")
|
||||
|
||||
result = await runner._handle_message(_make_event("/status", src))
|
||||
assert result == "status-handled"
|
||||
assert "⛔" not in (result or "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alias resolution — /h aliases to /help; the gate must canonicalize before
|
||||
# checking access. /hist (history alias) is a real one to exercise.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gate_uses_canonical_name_not_alias():
|
||||
"""If /hist resolves to canonical 'history' and history is in
|
||||
user_allowed_commands, the alias must be allowed too."""
|
||||
runner = _make_runner(
|
||||
platform_extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": ["history"],
|
||||
}
|
||||
)
|
||||
# Find a real alias in the registry to use.
|
||||
from hermes_cli.commands import COMMAND_REGISTRY
|
||||
history_def = next(c for c in COMMAND_REGISTRY if c.name == "history")
|
||||
# If /history has aliases, use one. Otherwise just use /history.
|
||||
alias = history_def.aliases[0] if history_def.aliases else "history"
|
||||
# Mock the history handler so we don't need real session state.
|
||||
runner._handle_history_command = AsyncMock(return_value="history-handled")
|
||||
result = await runner._handle_message(_make_event(f"/{alias}", _make_source(user_id="999")))
|
||||
assert "⛔" not in (result or "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unknown / unregistered command — gate must NOT intercept (let the existing
|
||||
# unknown-command path handle it normally).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gate_does_not_intercept_unknown_command():
|
||||
"""Random non-command text like /xyzzy is not in the registry. The gate
|
||||
must not produce a denial message — the existing unknown-command path
|
||||
will handle it (or the agent will see it as plain text)."""
|
||||
runner = _make_runner(
|
||||
platform_extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": [],
|
||||
}
|
||||
)
|
||||
# /xyzzy is not in COMMAND_REGISTRY and not a plugin command.
|
||||
# The gate should pass through (no ⛔) since canonical resolution
|
||||
# returns the raw command and is_gateway_known_command returns False.
|
||||
# We can only verify the gate didn't fire — downstream behavior may
|
||||
# vary (returns None, agent processes it, etc.). What matters: no denial.
|
||||
runner._handle_unknown_command = AsyncMock(return_value=None)
|
||||
# Stub out the rest of the cold path to short-circuit
|
||||
runner.session_store.get_or_create_session.side_effect = RuntimeError("would have proceeded past gate")
|
||||
try:
|
||||
await runner._handle_message(_make_event("/xyzzy", _make_source(user_id="999")))
|
||||
except RuntimeError as e:
|
||||
# Reaching session creation means we got past the gate without a denial.
|
||||
assert "would have proceeded past gate" in str(e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scope independence — admin in DM scope is NOT auto-admin in group when
|
||||
# group has its own admin list (regression guard for the "admin lists are
|
||||
# scope-specific" rule).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_admin_blocked_in_group_with_separate_admin_list():
|
||||
runner = _make_runner(
|
||||
platform_extra={
|
||||
"allow_admin_from": ["111"], # DM admin
|
||||
"group_allow_admin_from": ["222"], # group admin
|
||||
"group_user_allowed_commands": ["status"],
|
||||
}
|
||||
)
|
||||
# User 111 is DM admin. In a group, they're a non-admin and can only
|
||||
# run group_user_allowed_commands. /restart is not in that list → denied.
|
||||
grp_src = _make_source(user_id="111", chat_type="group", chat_id="g1")
|
||||
result = await runner._handle_message(_make_event("/restart", grp_src))
|
||||
assert "⛔" in result
|
||||
assert "/restart is admin-only here" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-platform isolation — gating on Discord doesn't leak to Telegram.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gating_isolated_per_platform():
|
||||
"""When Discord is gated and Telegram isn't, the same user_id on
|
||||
Telegram must be unrestricted."""
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.DISCORD: PlatformConfig(
|
||||
enabled=True,
|
||||
token="***",
|
||||
extra={
|
||||
"allow_admin_from": ["111"],
|
||||
"user_allowed_commands": [],
|
||||
},
|
||||
),
|
||||
Platform.TELEGRAM: PlatformConfig(
|
||||
enabled=True, token="***", extra={}
|
||||
),
|
||||
}
|
||||
)
|
||||
runner.adapters = {
|
||||
Platform.DISCORD: MagicMock(send=AsyncMock()),
|
||||
Platform.TELEGRAM: MagicMock(send=AsyncMock()),
|
||||
}
|
||||
runner._voice_mode = {}
|
||||
runner.hooks = SimpleNamespace(
|
||||
emit=AsyncMock(),
|
||||
emit_collect=AsyncMock(return_value=[]),
|
||||
loaded_hooks=False,
|
||||
)
|
||||
runner.session_store = MagicMock()
|
||||
session_entry = SessionEntry(
|
||||
session_key="agent:main:telegram:dm:c1",
|
||||
session_id="sess-1",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
total_tokens=0,
|
||||
)
|
||||
runner.session_store.get_or_create_session.return_value = session_entry
|
||||
runner.session_store.load_transcript.return_value = []
|
||||
runner.session_store.has_any_sessions.return_value = True
|
||||
runner.session_store.append_to_transcript = MagicMock()
|
||||
runner.session_store.rewrite_transcript = MagicMock()
|
||||
runner.session_store.update_session = MagicMock()
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._session_run_generation = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._session_sources = {}
|
||||
runner._session_db = MagicMock()
|
||||
runner._session_db.get_session_title.return_value = None
|
||||
runner._session_db.get_session.return_value = None
|
||||
runner._reasoning_config = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._show_reasoning = False
|
||||
runner._is_user_authorized = lambda _source: True
|
||||
runner._set_session_env = lambda _context: None
|
||||
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
|
||||
runner._send_voice_reply = AsyncMock()
|
||||
runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None
|
||||
runner._emit_gateway_run_progress = AsyncMock()
|
||||
|
||||
# Same user_id on Telegram → must be unrestricted (Telegram has no admin list).
|
||||
tg_src = _make_source(platform=Platform.TELEGRAM, user_id="999", chat_id="t1")
|
||||
result = await runner._handle_message(_make_event("/whoami", tg_src))
|
||||
assert "Tier: unrestricted" in result
|
||||
@@ -793,6 +793,201 @@ class TestSegmentBreakOnToolBoundary:
|
||||
"_send_fallback_final — the #10807 fix should prevent this"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_final_deletes_partial_after_chunks_succeed(self):
|
||||
"""After fallback chunks land, the frozen partial must be deleted so
|
||||
the user sees only the complete response (#16668)."""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True, message_id="msg_new"),
|
||||
)
|
||||
adapter.edit_message = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True),
|
||||
)
|
||||
adapter.delete_message = AsyncMock(return_value=None)
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
# Seed the consumer as if it already edited a partial message that
|
||||
# later got stuck (flood control etc.) — _message_id is the stale id.
|
||||
consumer._message_id = "msg_partial"
|
||||
consumer._last_sent_text = "Working on i"
|
||||
|
||||
await consumer._send_fallback_final("Working on it. Done!")
|
||||
|
||||
adapter.delete_message.assert_awaited_once_with("chat_123", "msg_partial")
|
||||
assert consumer._final_response_sent is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_final_does_not_delete_when_no_chunks_reach_user(self):
|
||||
"""If every fallback send fails, the partial is the only thing the
|
||||
user has — must NOT be deleted."""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(
|
||||
return_value=SimpleNamespace(success=False, error="network down"),
|
||||
)
|
||||
adapter.edit_message = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True),
|
||||
)
|
||||
adapter.delete_message = AsyncMock(return_value=None)
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
consumer._message_id = "msg_partial"
|
||||
consumer._last_sent_text = "Working on i"
|
||||
|
||||
await consumer._send_fallback_final("Working on it. Done!")
|
||||
|
||||
adapter.delete_message.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_final_skips_delete_when_adapter_lacks_method(self):
|
||||
"""Platforms without delete_message must not crash the fallback path."""
|
||||
adapter = MagicMock(spec=["send", "edit_message", "MAX_MESSAGE_LENGTH"])
|
||||
adapter.send = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True, message_id="msg_new"),
|
||||
)
|
||||
adapter.edit_message = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True),
|
||||
)
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
consumer._message_id = "msg_partial"
|
||||
consumer._last_sent_text = "Working on i"
|
||||
|
||||
# Should not raise even though the adapter has no delete_message.
|
||||
await consumer._send_fallback_final("Working on it. Done!")
|
||||
assert consumer._final_response_sent is True
|
||||
|
||||
|
||||
class TestFinalResponseDeliveryGuard:
|
||||
"""Regression coverage for #10748 — _final_response_sent must reflect
|
||||
actual delivery of the *current* chunked send, not the cumulative
|
||||
`_already_sent` flag (which earlier tool-progress edits or fallback-mode
|
||||
promotion can taint)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_overflow_failed_send_does_not_mark_final_sent(self):
|
||||
"""Split-overflow path: if every chunk send fails on done frame,
|
||||
_final_response_sent must stay False so the gateway falls back."""
|
||||
adapter = MagicMock()
|
||||
# Every send fails — _send_new_chunk returns the passed-in reply_to.
|
||||
adapter.send = AsyncMock(
|
||||
return_value=SimpleNamespace(success=False, error="network down"),
|
||||
)
|
||||
adapter.edit_message = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True),
|
||||
)
|
||||
adapter.MAX_MESSAGE_LENGTH = 100
|
||||
adapter.truncate_message = MagicMock(
|
||||
side_effect=lambda text, limit: [text[:limit], text[limit:]],
|
||||
)
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
# Simulate prior tool-progress edits that set _already_sent
|
||||
consumer._already_sent = True
|
||||
|
||||
# Long text > MAX_MESSAGE_LENGTH, no existing message id (fresh send path)
|
||||
long_text = "x" * 200
|
||||
consumer.on_delta(long_text)
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
assert consumer._final_response_sent is False, (
|
||||
"_already_sent leaked into _final_response_sent — gateway will "
|
||||
"wrongly suppress its fallback delivery (#10748)"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_overflow_partial_send_marks_final_sent(self):
|
||||
"""Split-overflow path: if at least one chunk lands on done frame,
|
||||
we did deliver the final answer — _final_response_sent must be True."""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(side_effect=[
|
||||
SimpleNamespace(success=True, message_id="msg_1"),
|
||||
SimpleNamespace(success=True, message_id="msg_2"),
|
||||
])
|
||||
adapter.edit_message = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True),
|
||||
)
|
||||
adapter.MAX_MESSAGE_LENGTH = 100
|
||||
adapter.truncate_message = MagicMock(
|
||||
side_effect=lambda text, limit: [text[:limit], text[limit:]],
|
||||
)
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
long_text = "x" * 200
|
||||
consumer.on_delta(long_text)
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
assert consumer._final_response_sent is True
|
||||
|
||||
|
||||
class TestEditOverflowSplitAndDeliver:
|
||||
"""When edit_message split-and-delivers an oversized payload across the
|
||||
original message + N continuations (Telegram >4096 UTF-16), the consumer
|
||||
must update _message_id to the latest continuation, reset _last_sent_text,
|
||||
and fire on_new_message so subsequent tool-progress bubbles linearize
|
||||
below the new visible message."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consumer_advances_message_id_on_split_and_deliver(self):
|
||||
adapter = MagicMock()
|
||||
# Simulate edit_message split-and-deliver: success=True with the
|
||||
# final continuation's id and a populated continuation_message_ids
|
||||
# tuple (the new SendResult contract).
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(
|
||||
success=True,
|
||||
message_id="msg_continuation_2",
|
||||
continuation_message_ids=("msg_continuation_1", "msg_continuation_2"),
|
||||
))
|
||||
adapter.send = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True, message_id="msg_initial"),
|
||||
)
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
config = StreamConsumerConfig(
|
||||
edit_interval=0.01, buffer_threshold=5, cursor="",
|
||||
)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_999", config)
|
||||
|
||||
# Track on_new_message firings.
|
||||
new_msg_count = [0]
|
||||
consumer._on_new_message = lambda: new_msg_count.__setitem__(0, new_msg_count[0] + 1)
|
||||
|
||||
# Seed the consumer as if a first send succeeded already.
|
||||
consumer._message_id = "msg_initial"
|
||||
consumer._last_sent_text = "old"
|
||||
consumer._already_sent = True
|
||||
|
||||
# Drive an edit that the adapter "split and delivers".
|
||||
ok = await consumer._send_or_edit("new full text after overflow")
|
||||
|
||||
assert ok is True
|
||||
# Consumer advanced to the latest continuation id.
|
||||
assert consumer._message_id == "msg_continuation_2"
|
||||
# Skip-if-same cache reset so the next edit doesn't false-positive.
|
||||
assert consumer._last_sent_text == ""
|
||||
# on_new_message fired so the tool-progress bubble breaks below
|
||||
# the new continuation (per the openclaw #32535 lesson).
|
||||
assert new_msg_count[0] == 1
|
||||
|
||||
|
||||
class TestInterimCommentaryMessages:
|
||||
@pytest.mark.asyncio
|
||||
@@ -1493,3 +1688,96 @@ class TestOnNewMessageCallback:
|
||||
await consumer.run()
|
||||
|
||||
assert consumer.already_sent is True
|
||||
|
||||
|
||||
class TestUtf16OverflowDetection:
|
||||
"""Regression coverage for #11170 — Telegram counts message length in
|
||||
UTF-16 code units, not Python codepoints. A response with supplementary
|
||||
characters (emoji, CJK in some ranges) can have len()=3000 codepoints
|
||||
but utf16_len()=5000+ units, blowing past Telegram's 4096 limit."""
|
||||
|
||||
def _make_telegram_like_adapter(self):
|
||||
"""Construct a minimal BasePlatformAdapter subclass that overrides
|
||||
message_len_fn like Telegram does."""
|
||||
from gateway.platforms.base import utf16_len, BasePlatformAdapter
|
||||
|
||||
TelegramLikeAdapter = type(
|
||||
"TelegramLikeAdapter",
|
||||
(BasePlatformAdapter,),
|
||||
{
|
||||
"MAX_MESSAGE_LENGTH": 4096,
|
||||
"message_len_fn": property(lambda self: utf16_len),
|
||||
},
|
||||
)
|
||||
# Defeat ABCMeta abstract-instantiation guard by clearing the cached
|
||||
# abstract methods set after class creation.
|
||||
TelegramLikeAdapter.__abstractmethods__ = frozenset()
|
||||
adapter = TelegramLikeAdapter.__new__(TelegramLikeAdapter)
|
||||
adapter._typing_paused = set()
|
||||
adapter._fatal_error_message = None
|
||||
return adapter
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emoji_text_exceeding_utf16_limit_triggers_overflow_split(self):
|
||||
"""A response that is under 4096 codepoints but over 4096 UTF-16
|
||||
units must trigger the overflow-split path."""
|
||||
from gateway.platforms.base import utf16_len
|
||||
|
||||
adapter = self._make_telegram_like_adapter()
|
||||
# Mock the send/edit methods we actually call
|
||||
adapter.send = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True, message_id="msg_1"),
|
||||
)
|
||||
adapter.edit_message = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True),
|
||||
)
|
||||
# truncate_message: emit two halves so we can assert the split fired
|
||||
adapter.truncate_message = MagicMock(
|
||||
side_effect=lambda text, limit, **kw: [text[:len(text)//2], text[len(text)//2:]],
|
||||
)
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
# 🚀 is 1 codepoint = 2 UTF-16 units. 2200 of them = 2200 codepoints,
|
||||
# 4400 UTF-16 units. Under the codepoint-equivalent limit (would not
|
||||
# trigger split with len()) but over Telegram's UTF-16 4096 limit.
|
||||
emoji_text = "🚀" * 2200
|
||||
assert len(emoji_text) < adapter.MAX_MESSAGE_LENGTH, (
|
||||
"Test setup invariant: codepoint count under limit"
|
||||
)
|
||||
assert utf16_len(emoji_text) > adapter.MAX_MESSAGE_LENGTH, (
|
||||
"Test setup invariant: UTF-16 count over limit"
|
||||
)
|
||||
|
||||
consumer.on_delta(emoji_text)
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
# The fix: stream consumer detects UTF-16 overflow and calls
|
||||
# truncate_message to split. Without the fix, len() would return
|
||||
# 2200 (under 4096) and no split would fire — Telegram would then
|
||||
# reject the send or render \x00 artifacts.
|
||||
adapter.truncate_message.assert_called(), (
|
||||
"UTF-16 overflow not detected — emoji text bypassed split path"
|
||||
)
|
||||
# truncate_message must have been called with len_fn=utf16_len
|
||||
call_kwargs = adapter.truncate_message.call_args[1]
|
||||
assert call_kwargs.get("len_fn") is utf16_len, (
|
||||
f"truncate_message called without utf16_len: {call_kwargs}"
|
||||
)
|
||||
|
||||
def test_codepoint_only_adapter_falls_back_to_len(self):
|
||||
"""Adapters without message_len_fn override (or test MagicMocks)
|
||||
must use plain len for backwards compatibility."""
|
||||
adapter = MagicMock()
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
config = StreamConsumerConfig(cursor=" ▉")
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
# The isinstance guard means MagicMock adapters get len, not the
|
||||
# auto-attr mock. Verified indirectly by all the other tests in
|
||||
# this file passing — they all use MagicMock adapters.
|
||||
assert consumer is not None
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Tests for native draft streaming in GatewayStreamConsumer.
|
||||
|
||||
Telegram Bot API 9.5 (March 2026) introduced sendMessageDraft for native
|
||||
animated streaming previews in private chats. This test suite covers the
|
||||
consumer's transport-selection, fallback, and tool-boundary handling for
|
||||
that path.
|
||||
|
||||
Adapter under test is a runtime subclass of BasePlatformAdapter that
|
||||
overrides supports_draft_streaming + send_draft, since the consumer's
|
||||
isinstance(BasePlatformAdapter) gate excludes plain MagicMocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.stream_consumer import (
|
||||
GatewayStreamConsumer,
|
||||
StreamConsumerConfig,
|
||||
)
|
||||
|
||||
|
||||
def _make_draft_capable_adapter(
|
||||
*, supports_draft: bool = True, draft_succeeds: bool = True,
|
||||
):
|
||||
"""Build a minimal BasePlatformAdapter subclass with draft support.
|
||||
|
||||
The runtime subclass + cleared __abstractmethods__ pattern lets us
|
||||
construct an adapter without hauling in any platform's heavy state
|
||||
(Telegram bot, Discord client, etc.) while still satisfying the
|
||||
consumer's isinstance(BasePlatformAdapter) gate.
|
||||
"""
|
||||
from gateway.platforms.base import BasePlatformAdapter, SendResult
|
||||
|
||||
DraftCapableAdapter = type(
|
||||
"DraftCapableAdapter",
|
||||
(BasePlatformAdapter,),
|
||||
{"MAX_MESSAGE_LENGTH": 4096},
|
||||
)
|
||||
DraftCapableAdapter.__abstractmethods__ = frozenset()
|
||||
adapter = DraftCapableAdapter.__new__(DraftCapableAdapter)
|
||||
adapter._typing_paused = set()
|
||||
adapter._fatal_error_message = None
|
||||
|
||||
# Track every send_draft call for assertions.
|
||||
adapter.draft_calls = []
|
||||
|
||||
def _supports(chat_type=None, metadata=None):
|
||||
return bool(supports_draft) and (chat_type or "").lower() == "dm"
|
||||
adapter.supports_draft_streaming = _supports
|
||||
|
||||
async def _send_draft(*, chat_id, draft_id, content, metadata=None):
|
||||
adapter.draft_calls.append({
|
||||
"chat_id": chat_id,
|
||||
"draft_id": draft_id,
|
||||
"content": content,
|
||||
"metadata": metadata,
|
||||
})
|
||||
if draft_succeeds:
|
||||
return SendResult(success=True, message_id=None)
|
||||
return SendResult(success=False, error="draft_rejected")
|
||||
adapter.send_draft = _send_draft
|
||||
|
||||
# send / edit_message: count and return canned successes so the
|
||||
# consumer's first-send + finalize paths work when drafts fall back
|
||||
# or when delivering the final message.
|
||||
adapter.send = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True, message_id="msg_real"),
|
||||
)
|
||||
adapter.edit_message = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True),
|
||||
)
|
||||
return adapter
|
||||
|
||||
|
||||
class TestDraftTransportSelection:
|
||||
"""Verify _resolve_draft_streaming picks the right transport."""
|
||||
|
||||
def test_auto_dm_with_draft_capable_adapter_picks_draft(self):
|
||||
adapter = _make_draft_capable_adapter()
|
||||
cfg = StreamConsumerConfig(transport="auto", chat_type="dm")
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", cfg)
|
||||
assert consumer._resolve_draft_streaming() is True
|
||||
|
||||
def test_auto_group_falls_back_to_edit(self):
|
||||
adapter = _make_draft_capable_adapter()
|
||||
cfg = StreamConsumerConfig(transport="auto", chat_type="group")
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", cfg)
|
||||
assert consumer._resolve_draft_streaming() is False
|
||||
|
||||
def test_explicit_edit_never_uses_drafts(self):
|
||||
adapter = _make_draft_capable_adapter()
|
||||
cfg = StreamConsumerConfig(transport="edit", chat_type="dm")
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", cfg)
|
||||
assert consumer._resolve_draft_streaming() is False
|
||||
|
||||
def test_explicit_draft_unsupported_falls_back(self):
|
||||
adapter = _make_draft_capable_adapter(supports_draft=False)
|
||||
cfg = StreamConsumerConfig(transport="draft", chat_type="dm")
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", cfg)
|
||||
assert consumer._resolve_draft_streaming() is False
|
||||
|
||||
def test_magicmock_adapter_falls_back_to_edit(self):
|
||||
"""MagicMock adapters (used in many existing tests) must default to
|
||||
edit-based since their auto-attributes aren't real callables."""
|
||||
adapter = MagicMock()
|
||||
cfg = StreamConsumerConfig(transport="auto", chat_type="dm")
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", cfg)
|
||||
assert consumer._resolve_draft_streaming() is False
|
||||
|
||||
|
||||
class TestDraftStreamingHappyPath:
|
||||
"""End-to-end: stream a few deltas in a DM, verify drafts animated and
|
||||
the final message was delivered as a real sendMessage."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_stream_animates_draft_then_finalizes_with_send(self):
|
||||
adapter = _make_draft_capable_adapter()
|
||||
cfg = StreamConsumerConfig(
|
||||
transport="auto", chat_type="dm",
|
||||
edit_interval=0.01, buffer_threshold=5, cursor="",
|
||||
)
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", cfg)
|
||||
|
||||
consumer.on_delta("Hello ")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.on_delta("world!")
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
# At least one draft frame landed.
|
||||
assert len(adapter.draft_calls) >= 1, (
|
||||
"expected at least one send_draft frame"
|
||||
)
|
||||
# Final draft frame held the full accumulated text.
|
||||
assert adapter.draft_calls[-1]["content"] == "Hello world!"
|
||||
# All draft frames in this run shared a single draft_id (animation).
|
||||
draft_ids = {c["draft_id"] for c in adapter.draft_calls}
|
||||
assert len(draft_ids) == 1
|
||||
# Final answer was delivered as a regular sendMessage so the user
|
||||
# sees a real message in their history (drafts have no message_id).
|
||||
adapter.send.assert_awaited()
|
||||
# And the final send carried the complete reply.
|
||||
final_call = adapter.send.call_args
|
||||
sent_content = (
|
||||
final_call.kwargs.get("content")
|
||||
if "content" in final_call.kwargs
|
||||
else final_call.args[1] if len(final_call.args) > 1 else None
|
||||
)
|
||||
assert sent_content == "Hello world!"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_chat_skips_draft_path(self):
|
||||
adapter = _make_draft_capable_adapter()
|
||||
cfg = StreamConsumerConfig(
|
||||
transport="auto", chat_type="group",
|
||||
edit_interval=0.01, buffer_threshold=5, cursor="",
|
||||
)
|
||||
consumer = GatewayStreamConsumer(adapter, "67890", cfg)
|
||||
|
||||
consumer.on_delta("Group message")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
# Group chats skip drafts entirely — no send_draft calls at all.
|
||||
assert adapter.draft_calls == []
|
||||
# Edit-based path delivered via send (first message).
|
||||
adapter.send.assert_awaited()
|
||||
|
||||
|
||||
class TestDraftFallbackOnFailure:
|
||||
"""When a draft frame fails, the consumer disables drafts for the rest
|
||||
of the response and continues via the edit-based path."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_draft_failure_disables_drafts_for_run(self):
|
||||
adapter = _make_draft_capable_adapter(draft_succeeds=False)
|
||||
cfg = StreamConsumerConfig(
|
||||
transport="auto", chat_type="dm",
|
||||
edit_interval=0.01, buffer_threshold=5, cursor="",
|
||||
)
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", cfg)
|
||||
|
||||
consumer.on_delta("Hello ")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.on_delta("world!")
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
# The consumer attempted draft, hit failure, disabled drafts.
|
||||
assert consumer._draft_failures >= 1
|
||||
assert consumer._use_draft_streaming is False
|
||||
# Final message delivered via the regular send path.
|
||||
adapter.send.assert_awaited()
|
||||
|
||||
|
||||
class TestDraftIdLifecycle:
|
||||
"""Each response gets its own draft_id (no animation collision across
|
||||
consecutive responses to the same chat)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consecutive_responses_use_distinct_draft_ids(self):
|
||||
adapter = _make_draft_capable_adapter()
|
||||
cfg1 = StreamConsumerConfig(
|
||||
transport="auto", chat_type="dm",
|
||||
edit_interval=0.01, buffer_threshold=5, cursor="",
|
||||
)
|
||||
consumer1 = GatewayStreamConsumer(adapter, "12345", cfg1)
|
||||
consumer1.on_delta("First reply")
|
||||
task1 = asyncio.create_task(consumer1.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer1.finish()
|
||||
await task1
|
||||
|
||||
cfg2 = StreamConsumerConfig(
|
||||
transport="auto", chat_type="dm",
|
||||
edit_interval=0.01, buffer_threshold=5, cursor="",
|
||||
)
|
||||
consumer2 = GatewayStreamConsumer(adapter, "12345", cfg2)
|
||||
consumer2.on_delta("Second reply")
|
||||
task2 = asyncio.create_task(consumer2.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer2.finish()
|
||||
await task2
|
||||
|
||||
# Two responses → two distinct draft_ids.
|
||||
all_ids = {c["draft_id"] for c in adapter.draft_calls}
|
||||
assert len(all_ids) >= 2, (
|
||||
f"expected distinct draft_ids across responses; got {all_ids}"
|
||||
)
|
||||
# Every draft_id must be non-zero (Telegram's contract).
|
||||
assert all(did != 0 for did in all_ids)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_boundary_bumps_draft_id(self):
|
||||
"""After a segment break (tool boundary), the next text segment
|
||||
animates via a new draft_id so it appears below the tool-progress
|
||||
bubble rather than overwriting the prior segment's preview."""
|
||||
adapter = _make_draft_capable_adapter()
|
||||
cfg = StreamConsumerConfig(
|
||||
transport="auto", chat_type="dm",
|
||||
edit_interval=0.01, buffer_threshold=5, cursor="",
|
||||
)
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", cfg)
|
||||
|
||||
consumer.on_delta("Pre-tool ")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
# Tool boundary
|
||||
consumer.on_segment_break()
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.on_delta("Post-tool")
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
# Pre-tool and post-tool segments must use different draft_ids.
|
||||
draft_ids = [c["draft_id"] for c in adapter.draft_calls]
|
||||
if len(draft_ids) >= 2:
|
||||
# Find pre-tool and post-tool calls by content
|
||||
pre_ids = {
|
||||
c["draft_id"] for c in adapter.draft_calls
|
||||
if "Pre-tool" in c["content"] and "Post-tool" not in c["content"]
|
||||
}
|
||||
post_ids = {
|
||||
c["draft_id"] for c in adapter.draft_calls
|
||||
if "Post-tool" in c["content"]
|
||||
}
|
||||
if pre_ids and post_ids:
|
||||
assert pre_ids.isdisjoint(post_ids), (
|
||||
f"pre-tool and post-tool segments must use distinct "
|
||||
f"draft_ids; got pre={pre_ids} post={post_ids}"
|
||||
)
|
||||
|
||||
|
||||
class TestAlreadySentInDraftMode:
|
||||
"""Drafts must NOT mark _already_sent — that flag gates the gateway's
|
||||
fallback final-send path, which we still need to fire so the user gets
|
||||
a real message in their history (drafts have no message_id)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drafts_do_not_set_already_sent_until_real_message(self):
|
||||
adapter = _make_draft_capable_adapter()
|
||||
cfg = StreamConsumerConfig(
|
||||
transport="auto", chat_type="dm",
|
||||
edit_interval=0.01, buffer_threshold=5, cursor="",
|
||||
)
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", cfg)
|
||||
|
||||
consumer.on_delta("Hello")
|
||||
# Drive the consumer for a bit but DON'T finish — only drafts have
|
||||
# been sent.
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
# At this point drafts may have fired but we haven't finalized.
|
||||
# _already_sent must still be False so a downstream fallback would
|
||||
# know it needs to deliver the final answer.
|
||||
if adapter.draft_calls:
|
||||
assert consumer._already_sent is False, (
|
||||
"drafts wrongly marked _already_sent — "
|
||||
"would suppress gateway fallback delivery"
|
||||
)
|
||||
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
# After the regular sendMessage finalize, _already_sent is True.
|
||||
assert consumer._already_sent is True
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Regression tests for stream consumer thread/topic routing fix.
|
||||
|
||||
Verifies that GatewayStreamConsumer correctly passes reply_to on the first
|
||||
message send, ensuring messages land in the correct topic/thread instead of
|
||||
the main group chat.
|
||||
|
||||
Covers: #6969, #9916, #7355
|
||||
"""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.stream_consumer import (
|
||||
GatewayStreamConsumer,
|
||||
StreamConsumerConfig,
|
||||
)
|
||||
|
||||
|
||||
def _make_adapter(send_result=None, edit_result=None, max_length=4096):
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(
|
||||
return_value=send_result or SimpleNamespace(success=True, message_id="msg_1")
|
||||
)
|
||||
adapter.edit_message = AsyncMock(
|
||||
return_value=edit_result or SimpleNamespace(success=True)
|
||||
)
|
||||
adapter.MAX_MESSAGE_LENGTH = max_length
|
||||
return adapter
|
||||
|
||||
|
||||
class TestInitialReplyToId:
|
||||
"""Verify initial_reply_to_id is passed as reply_to on first send."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_send_uses_initial_reply_to_id(self):
|
||||
"""When initial_reply_to_id is set, first adapter.send() should
|
||||
include reply_to=initial_reply_to_id."""
|
||||
adapter = _make_adapter()
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter,
|
||||
"chat_123",
|
||||
metadata={"thread_id": "omt_topic123"},
|
||||
initial_reply_to_id="om_user_msg_456",
|
||||
)
|
||||
await consumer._send_or_edit("Hello world")
|
||||
|
||||
adapter.send.assert_called_once()
|
||||
call_kwargs = adapter.send.call_args[1]
|
||||
assert call_kwargs["reply_to"] == "om_user_msg_456", (
|
||||
"First send should pass initial_reply_to_id as reply_to"
|
||||
)
|
||||
assert call_kwargs["chat_id"] == "chat_123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_send_without_initial_reply_to_id(self):
|
||||
"""When initial_reply_to_id is None, first send should have
|
||||
reply_to=None (backward compatible)."""
|
||||
adapter = _make_adapter()
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter,
|
||||
"chat_123",
|
||||
)
|
||||
await consumer._send_or_edit("Hello world")
|
||||
|
||||
adapter.send.assert_called_once()
|
||||
call_kwargs = adapter.send.call_args[1]
|
||||
assert call_kwargs.get("reply_to") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subsequent_edits_ignore_initial_reply_to_id(self):
|
||||
"""After first send, edits should use message_id, not initial_reply_to_id."""
|
||||
adapter = _make_adapter()
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter,
|
||||
"chat_123",
|
||||
metadata={"thread_id": "omt_topic123"},
|
||||
initial_reply_to_id="om_user_msg_456",
|
||||
)
|
||||
|
||||
# First send
|
||||
await consumer._send_or_edit("Hello world")
|
||||
assert adapter.send.call_count == 1
|
||||
|
||||
# Second call should edit, not send
|
||||
await consumer._send_or_edit("Hello world updated")
|
||||
assert adapter.send.call_count == 1, "Should edit, not send again"
|
||||
adapter.edit_message.assert_called_once()
|
||||
edit_kwargs = adapter.edit_message.call_args[1]
|
||||
assert edit_kwargs["message_id"] == "msg_1"
|
||||
assert edit_kwargs["chat_id"] == "chat_123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metadata_passed_on_first_send(self):
|
||||
"""Metadata (containing thread_id) should be forwarded on first send."""
|
||||
adapter = _make_adapter()
|
||||
metadata = {"thread_id": "omt_topic789"}
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter,
|
||||
"chat_123",
|
||||
metadata=metadata,
|
||||
initial_reply_to_id="om_msg_000",
|
||||
)
|
||||
await consumer._send_or_edit("Test")
|
||||
|
||||
call_kwargs = adapter.send.call_args[1]
|
||||
assert call_kwargs["metadata"] == metadata
|
||||
|
||||
|
||||
class TestOverflowFirstMessage:
|
||||
"""Verify thread routing is preserved when the first message overflows."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overflow_first_send_uses_initial_reply_to_id(self):
|
||||
"""When first message exceeds platform limit and is split into chunks,
|
||||
each chunk should be threaded to initial_reply_to_id, not None."""
|
||||
adapter = _make_adapter(max_length=10)
|
||||
adapter.truncate_message = MagicMock(
|
||||
return_value=["chunk_1", "chunk_2"]
|
||||
)
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter,
|
||||
"chat_123",
|
||||
metadata={"thread_id": "omt_topic123"},
|
||||
initial_reply_to_id="om_user_msg_789",
|
||||
)
|
||||
|
||||
# Inject oversized accumulated text to trigger overflow path
|
||||
consumer._accumulated = "A" * 100
|
||||
consumer._current_edit_interval = 999
|
||||
await consumer._send_new_chunk("chunk_1", consumer._message_id or consumer._initial_reply_to_id)
|
||||
|
||||
adapter.send.assert_called_once()
|
||||
call_kwargs = adapter.send.call_args[1]
|
||||
assert call_kwargs["reply_to"] == "om_user_msg_789", (
|
||||
"Overflow first chunk should use initial_reply_to_id"
|
||||
)
|
||||
|
||||
|
||||
class TestFeishuFallbackThreadRouting:
|
||||
"""Verify FeishuAdapter._send_raw_message routes to topic on fallback."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_uses_thread_id_when_available(self):
|
||||
"""When reply_to=None and metadata has thread_id, message.create
|
||||
should use receive_id_type='thread_id'."""
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
# We test the _send_raw_message method directly by mocking the client
|
||||
adapter = MagicMock(spec=FeishuAdapter)
|
||||
|
||||
# Set up the real _send_raw_message logic manually
|
||||
mock_client = MagicMock()
|
||||
mock_create_response = SimpleNamespace(
|
||||
success=lambda: True,
|
||||
data=SimpleNamespace(message_id="new_msg_1"),
|
||||
)
|
||||
mock_client.im.v1.message.create = MagicMock(return_value=mock_create_response)
|
||||
|
||||
# Use the real implementation path
|
||||
adapter._client = mock_client
|
||||
adapter._build_create_message_body = FeishuAdapter._build_create_message_body
|
||||
adapter._build_create_message_request = FeishuAdapter._build_create_message_request
|
||||
|
||||
# Call _send_raw_message with reply_to=None and thread_id in metadata
|
||||
import json
|
||||
result = await FeishuAdapter._send_raw_message(
|
||||
adapter,
|
||||
chat_id="oc_main_chat",
|
||||
msg_type="text",
|
||||
payload=json.dumps({"text": "hello"}),
|
||||
reply_to=None,
|
||||
metadata={"thread_id": "omt_topic_abc"},
|
||||
)
|
||||
|
||||
# Verify message.create was called (not message.reply)
|
||||
mock_client.im.v1.message.create.assert_called_once()
|
||||
|
||||
# The request should have receive_id_type="thread_id"
|
||||
call_args = mock_client.im.v1.message.create.call_args[0][0]
|
||||
# Lark SDK builder exposes .body; the in-tree fallback exposes .request_body.
|
||||
# The contributor's branch had the lark SDK installed, the test environment
|
||||
# may not — handle both shapes.
|
||||
body = getattr(call_args, "body", None) or getattr(call_args, "request_body", None)
|
||||
assert body is not None, "request has neither .body nor .request_body"
|
||||
# receive_id should be the thread_id, not the chat_id
|
||||
receive_id = getattr(body, "receive_id", None)
|
||||
if receive_id is None and isinstance(body, str):
|
||||
import json as _json
|
||||
receive_id = _json.loads(body).get("receive_id")
|
||||
assert receive_id == "omt_topic_abc", (
|
||||
f"Expected receive_id='omt_topic_abc', got '{receive_id}'"
|
||||
)
|
||||
# And receive_id_type must be 'thread_id', not 'chat_id'
|
||||
receive_id_type = getattr(call_args, "receive_id_type", None)
|
||||
assert receive_id_type == "thread_id", (
|
||||
f"Expected receive_id_type='thread_id', got '{receive_id_type}'"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_uses_chat_id_when_no_thread(self):
|
||||
"""When reply_to=None and metadata has no thread_id, message.create
|
||||
should use receive_id_type='chat_id' (original behavior)."""
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_create_response = SimpleNamespace(
|
||||
success=lambda: True,
|
||||
data=SimpleNamespace(message_id="new_msg_1"),
|
||||
)
|
||||
mock_client.im.v1.message.create = MagicMock(return_value=mock_create_response)
|
||||
|
||||
adapter = MagicMock(spec=FeishuAdapter)
|
||||
adapter._client = mock_client
|
||||
adapter._build_create_message_body = FeishuAdapter._build_create_message_body
|
||||
adapter._build_create_message_request = FeishuAdapter._build_create_message_request
|
||||
|
||||
import json
|
||||
result = await FeishuAdapter._send_raw_message(
|
||||
adapter,
|
||||
chat_id="oc_main_chat",
|
||||
msg_type="text",
|
||||
payload=json.dumps({"text": "hello"}),
|
||||
reply_to=None,
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
mock_client.im.v1.message.create.assert_called_once()
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -140,6 +141,34 @@ class TestTelegramExecApproval:
|
||||
kwargs = adapter._bot.send_message.call_args[1]
|
||||
assert kwargs.get("message_thread_id") == 999
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retries_without_thread_when_thread_not_found(self):
|
||||
adapter = _make_adapter()
|
||||
call_log = []
|
||||
|
||||
class FakeBadRequest(Exception):
|
||||
pass
|
||||
|
||||
async def mock_send_message(**kwargs):
|
||||
call_log.append(dict(kwargs))
|
||||
if kwargs.get("message_thread_id") is not None:
|
||||
raise FakeBadRequest("Message thread not found")
|
||||
return SimpleNamespace(message_id=42)
|
||||
|
||||
adapter._bot.send_message = AsyncMock(side_effect=mock_send_message)
|
||||
|
||||
result = await adapter.send_exec_approval(
|
||||
chat_id="12345",
|
||||
command="ls",
|
||||
session_key="s",
|
||||
metadata={"thread_id": "999"},
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert len(call_log) == 2
|
||||
assert call_log[0]["message_thread_id"] == 999
|
||||
assert "message_thread_id" not in call_log[1] or call_log[1]["message_thread_id"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_connected(self):
|
||||
adapter = _make_adapter()
|
||||
@@ -209,9 +238,11 @@ class TestTelegramApprovalCallback:
|
||||
update = MagicMock()
|
||||
update.callback_query = query
|
||||
context = MagicMock()
|
||||
query.from_user.id = "12345"
|
||||
|
||||
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||
await adapter._handle_callback_query(update, context)
|
||||
with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
|
||||
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||
await adapter._handle_callback_query(update, context)
|
||||
|
||||
mock_resolve.assert_called_once_with("agent:main:telegram:group:12345:99", "once")
|
||||
query.answer.assert_called_once()
|
||||
@@ -237,9 +268,11 @@ class TestTelegramApprovalCallback:
|
||||
update = MagicMock()
|
||||
update.callback_query = query
|
||||
context = MagicMock()
|
||||
query.from_user.id = "12345"
|
||||
|
||||
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||
await adapter._handle_callback_query(update, context)
|
||||
with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
|
||||
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||
await adapter._handle_callback_query(update, context)
|
||||
|
||||
mock_resolve.assert_called_once_with("some-session", "deny")
|
||||
edit_kwargs = query.edit_message_text.call_args[1]
|
||||
@@ -296,9 +329,11 @@ class TestTelegramApprovalCallback:
|
||||
update = MagicMock()
|
||||
update.callback_query = query
|
||||
context = MagicMock()
|
||||
query.from_user.id = "12345"
|
||||
|
||||
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
|
||||
await adapter._handle_callback_query(update, context)
|
||||
with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
|
||||
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
|
||||
await adapter._handle_callback_query(update, context)
|
||||
|
||||
# Should NOT resolve — already handled
|
||||
mock_resolve.assert_not_called()
|
||||
|
||||
@@ -759,6 +759,43 @@ class TestEditMessageStreamingSafety:
|
||||
"text": "final **bold**",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_too_long_splits_into_continuations_not_silent_truncation(self):
|
||||
"""When edit_message_text exceeds Telegram's 4096 UTF-16 limit, the
|
||||
adapter must split the content across the existing message + new
|
||||
continuation messages so the user gets the full reply. Previously
|
||||
the adapter best-effort truncated the content with '…' and returned
|
||||
success=True, dropping everything past the truncation boundary
|
||||
(#19537)."""
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token"))
|
||||
adapter._bot = MagicMock()
|
||||
adapter._bot.edit_message_text = AsyncMock()
|
||||
# Continuation sends return monotonically increasing message ids.
|
||||
_next_id = [1000]
|
||||
async def _fake_send(**kwargs):
|
||||
_next_id[0] += 1
|
||||
return SimpleNamespace(message_id=_next_id[0])
|
||||
adapter._bot.send_message = AsyncMock(side_effect=_fake_send)
|
||||
|
||||
# 6000-char content well over the 4096 UTF-16 limit.
|
||||
oversized = "x" * 6000
|
||||
result = await adapter.edit_message("123", "456", oversized, finalize=False)
|
||||
|
||||
# Adapter reports success with continuations populated.
|
||||
assert result.success is True
|
||||
assert result.error is None
|
||||
assert len(result.continuation_message_ids) >= 1, (
|
||||
"expected at least one continuation message"
|
||||
)
|
||||
# The reported message_id is the LAST visible message (the final
|
||||
# continuation), so subsequent edits target the most recent.
|
||||
assert result.message_id == result.continuation_message_ids[-1]
|
||||
# Original message_id (456) was edited with chunk 1.
|
||||
first_edit = adapter._bot.edit_message_text.call_args
|
||||
assert first_edit.kwargs["message_id"] == 456
|
||||
# Continuations were sent threaded as replies for visual grouping.
|
||||
assert adapter._bot.send_message.await_count == len(result.continuation_message_ids)
|
||||
|
||||
# =========================================================================
|
||||
# Telegram guest mention gating
|
||||
# =========================================================================
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Tests for Telegram model picker thread fallback."""
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _ensure_telegram_mock():
|
||||
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
|
||||
return
|
||||
|
||||
mod = MagicMock()
|
||||
mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
|
||||
mod.constants.ParseMode.MARKDOWN = "Markdown"
|
||||
mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
|
||||
mod.constants.ParseMode.HTML = "HTML"
|
||||
mod.constants.ChatType.PRIVATE = "private"
|
||||
mod.constants.ChatType.GROUP = "group"
|
||||
mod.constants.ChatType.SUPERGROUP = "supergroup"
|
||||
mod.constants.ChatType.CHANNEL = "channel"
|
||||
mod.error.NetworkError = type("NetworkError", (OSError,), {})
|
||||
mod.error.TimedOut = type("TimedOut", (OSError,), {})
|
||||
mod.error.BadRequest = type("BadRequest", (Exception,), {})
|
||||
|
||||
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
|
||||
sys.modules.setdefault(name, mod)
|
||||
sys.modules.setdefault("telegram.error", mod.error)
|
||||
|
||||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="test-token"))
|
||||
adapter._bot = AsyncMock()
|
||||
adapter._app = MagicMock()
|
||||
return adapter
|
||||
|
||||
|
||||
class TestTelegramModelPicker:
|
||||
@pytest.mark.asyncio
|
||||
async def test_retries_without_thread_when_thread_not_found(self):
|
||||
adapter = _make_adapter()
|
||||
providers = [{"slug": "openai", "name": "OpenAI", "total_models": 2, "is_current": True}]
|
||||
call_log = []
|
||||
|
||||
class FakeBadRequest(Exception):
|
||||
pass
|
||||
|
||||
async def mock_send_message(**kwargs):
|
||||
call_log.append(dict(kwargs))
|
||||
if kwargs.get("message_thread_id") is not None:
|
||||
raise FakeBadRequest("Message thread not found")
|
||||
return SimpleNamespace(message_id=99)
|
||||
|
||||
adapter._bot.send_message = AsyncMock(side_effect=mock_send_message)
|
||||
|
||||
result = await adapter.send_model_picker(
|
||||
chat_id="12345",
|
||||
providers=providers,
|
||||
current_model="gpt-5",
|
||||
current_provider="openai",
|
||||
session_key="s",
|
||||
on_model_selected=AsyncMock(),
|
||||
metadata={"thread_id": "99999"},
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert len(call_log) == 2
|
||||
assert call_log[0]["message_thread_id"] == 99999
|
||||
assert "message_thread_id" not in call_log[1] or call_log[1]["message_thread_id"] is None
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Regression tests for the Telegram text-batch adaptive-delay fast-path
|
||||
and _env_float_clamped helper introduced by PR #10388 (Telegram latency
|
||||
tuning).
|
||||
|
||||
The fast-path lets short replies stream near-instantly while keeping the
|
||||
configured cap as the upper bound, so an operator who tightens the cap
|
||||
gets the lower number on every tier.
|
||||
|
||||
The env-clamped helper guarantees float env vars never produce NaN/Inf
|
||||
or out-of-bounds values that could break asyncio.sleep().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
"""Build a TelegramAdapter shell without going through __init__'s
|
||||
network-touching setup. Just need the class for static-method access
|
||||
and the instance for instance-method tests."""
|
||||
return TelegramAdapter.__new__(TelegramAdapter)
|
||||
|
||||
|
||||
class TestEnvFloatClamped:
|
||||
"""_env_float_clamped is the fence around every float env var the
|
||||
adapter reads — must reject NaN/Inf and honor min/max bounds."""
|
||||
|
||||
def test_default_when_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_TEST_VAR", raising=False)
|
||||
assert TelegramAdapter._env_float_clamped("HERMES_TEST_VAR", 0.5) == 0.5
|
||||
|
||||
def test_parses_valid_value(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_TEST_VAR", "1.25")
|
||||
assert TelegramAdapter._env_float_clamped("HERMES_TEST_VAR", 0.5) == 1.25
|
||||
|
||||
def test_falls_back_to_default_on_garbage(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_TEST_VAR", "not-a-float")
|
||||
assert TelegramAdapter._env_float_clamped("HERMES_TEST_VAR", 0.5) == 0.5
|
||||
|
||||
def test_rejects_nan(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_TEST_VAR", "nan")
|
||||
result = TelegramAdapter._env_float_clamped("HERMES_TEST_VAR", 0.5)
|
||||
assert math.isfinite(result)
|
||||
assert result == 0.5
|
||||
|
||||
def test_rejects_inf(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_TEST_VAR", "inf")
|
||||
result = TelegramAdapter._env_float_clamped("HERMES_TEST_VAR", 0.5)
|
||||
assert math.isfinite(result)
|
||||
assert result == 0.5
|
||||
|
||||
def test_clamps_below_min(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_TEST_VAR", "0.01")
|
||||
assert TelegramAdapter._env_float_clamped(
|
||||
"HERMES_TEST_VAR", 0.5, min_value=0.1,
|
||||
) == 0.1
|
||||
|
||||
def test_clamps_above_max(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_TEST_VAR", "10.0")
|
||||
assert TelegramAdapter._env_float_clamped(
|
||||
"HERMES_TEST_VAR", 0.5, max_value=2.0,
|
||||
) == 2.0
|
||||
|
||||
|
||||
class TestAdaptiveTextBatchTiers:
|
||||
"""The fast-path tiers cap delay for short / medium messages. Tier
|
||||
constants must compose with the configured cap (operators who set a
|
||||
lower cap get the lower number on every tier)."""
|
||||
|
||||
def test_class_constants_are_sensible(self):
|
||||
"""Sanity check that the tier constants form a non-overlapping
|
||||
ascending ladder."""
|
||||
assert TelegramAdapter._TEXT_BATCH_FAST_LEN < TelegramAdapter._TEXT_BATCH_SHORT_LEN
|
||||
assert TelegramAdapter._TEXT_BATCH_FAST_DELAY_S < TelegramAdapter._TEXT_BATCH_SHORT_DELAY_S
|
||||
assert TelegramAdapter._TEXT_BATCH_FAST_DELAY_S > 0
|
||||
assert TelegramAdapter._TEXT_BATCH_SHORT_DELAY_S > 0
|
||||
|
||||
def test_fast_tier_uses_min_with_configured_cap(self, adapter):
|
||||
"""A short message picks the lower of the fast-tier delay and
|
||||
the operator's configured cap."""
|
||||
# Operator set a generous cap (0.6s); fast tier should win.
|
||||
adapter._text_batch_delay_seconds = 0.6
|
||||
delay = min(
|
||||
adapter._text_batch_delay_seconds,
|
||||
TelegramAdapter._TEXT_BATCH_FAST_DELAY_S,
|
||||
)
|
||||
assert delay == TelegramAdapter._TEXT_BATCH_FAST_DELAY_S
|
||||
|
||||
# Operator tightened the cap below the fast-tier delay; cap wins.
|
||||
adapter._text_batch_delay_seconds = 0.10
|
||||
delay = min(
|
||||
adapter._text_batch_delay_seconds,
|
||||
TelegramAdapter._TEXT_BATCH_FAST_DELAY_S,
|
||||
)
|
||||
assert delay == 0.10
|
||||
|
||||
def test_short_tier_uses_min_with_configured_cap(self, adapter):
|
||||
"""Same composition rule for the medium tier."""
|
||||
adapter._text_batch_delay_seconds = 0.6
|
||||
delay = min(
|
||||
adapter._text_batch_delay_seconds,
|
||||
TelegramAdapter._TEXT_BATCH_SHORT_DELAY_S,
|
||||
)
|
||||
assert delay == TelegramAdapter._TEXT_BATCH_SHORT_DELAY_S
|
||||
|
||||
def test_long_message_uses_full_cap(self, adapter):
|
||||
"""Messages above the medium threshold use the configured cap
|
||||
without the tier-clamp."""
|
||||
adapter._text_batch_delay_seconds = 0.5
|
||||
# Beyond _TEXT_BATCH_SHORT_LEN there's no tier-clamp; cap wins.
|
||||
delay = adapter._text_batch_delay_seconds
|
||||
assert delay == 0.5
|
||||
|
||||
def test_split_threshold_takes_priority_over_fast_tier(self, adapter):
|
||||
"""If the latest chunk hits the platform split threshold a
|
||||
continuation is almost certain — wait the longer split delay
|
||||
regardless of total length."""
|
||||
adapter._text_batch_delay_seconds = 0.3
|
||||
adapter._text_batch_split_delay_seconds = 1.0
|
||||
last_chunk_len = TelegramAdapter._SPLIT_THRESHOLD + 50
|
||||
# The flush path checks last_chunk_len first; assert the contract.
|
||||
assert last_chunk_len >= TelegramAdapter._SPLIT_THRESHOLD
|
||||
delay = adapter._text_batch_split_delay_seconds
|
||||
assert delay == 1.0
|
||||
assert delay > adapter._text_batch_delay_seconds
|
||||
@@ -81,6 +81,81 @@ class TestLoadConfigDefaults:
|
||||
assert "max_turns" not in config
|
||||
|
||||
|
||||
class TestLoadConfigParseFailure:
|
||||
"""A YAML parse failure must NOT silently fall back to defaults.
|
||||
|
||||
Before issue #23570 this was a single ``print(...)`` that scrolled past
|
||||
on the first invocation — users saw aux-fallback misbehavior with no clue
|
||||
their config.yaml was being ignored. The helper must:
|
||||
* log at WARNING (so ``hermes logs`` surfaces it)
|
||||
* also write to stderr (so it's visible at startup even before
|
||||
``setup_logging()`` has wired up file handlers)
|
||||
* dedup on (path, mtime_ns, size) so concurrent loads don't spam
|
||||
* re-warn after the user edits the file (different mtime)
|
||||
"""
|
||||
|
||||
def test_logs_and_warns_on_parse_failure(self, tmp_path, caplog, capsys):
|
||||
# Reset the dedup cache so this test isn't affected by other tests
|
||||
# that may have warned about a different broken config.
|
||||
from hermes_cli import config as cfg_mod
|
||||
cfg_mod._CONFIG_PARSE_WARNED.clear()
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
(tmp_path / "config.yaml").write_text("\tbroken tab indent:\n")
|
||||
|
||||
import logging
|
||||
with caplog.at_level(logging.WARNING, logger="hermes_cli.config"):
|
||||
config = load_config()
|
||||
|
||||
# Falls back to defaults — confirms the silent-fallback we're warning about
|
||||
assert config["model"] == DEFAULT_CONFIG["model"]
|
||||
|
||||
# WARNING-level log was emitted with file path + reason
|
||||
assert any(
|
||||
str(tmp_path / "config.yaml") in rec.message
|
||||
and "Falling back to default config" in rec.message
|
||||
for rec in caplog.records
|
||||
), f"expected WARNING log, got: {[r.message for r in caplog.records]}"
|
||||
|
||||
# stderr also got a user-visible message (with the ⚠️ marker so it
|
||||
# stands out at hermes startup before logging is configured)
|
||||
captured = capsys.readouterr()
|
||||
assert "hermes config:" in captured.err
|
||||
assert str(tmp_path / "config.yaml") in captured.err
|
||||
|
||||
def test_dedup_on_repeated_load_same_file(self, tmp_path, capsys):
|
||||
from hermes_cli import config as cfg_mod
|
||||
cfg_mod._CONFIG_PARSE_WARNED.clear()
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
(tmp_path / "config.yaml").write_text("\tbroken:\n")
|
||||
|
||||
load_config()
|
||||
first = capsys.readouterr().err
|
||||
assert "hermes config:" in first
|
||||
|
||||
load_config()
|
||||
second = capsys.readouterr().err
|
||||
assert second == "", "second load should NOT re-warn (same file, same mtime)"
|
||||
|
||||
def test_rewarns_after_file_edit(self, tmp_path, capsys):
|
||||
import time
|
||||
from hermes_cli import config as cfg_mod
|
||||
cfg_mod._CONFIG_PARSE_WARNED.clear()
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
(tmp_path / "config.yaml").write_text("\tbroken:\n")
|
||||
load_config()
|
||||
capsys.readouterr() # discard first warning
|
||||
|
||||
# Edit the file (still broken, but different content) — mtime changes
|
||||
time.sleep(0.05)
|
||||
(tmp_path / "config.yaml").write_text("\tstill broken differently:\n")
|
||||
load_config()
|
||||
after_edit = capsys.readouterr().err
|
||||
assert "hermes config:" in after_edit, "edited file should re-warn"
|
||||
|
||||
|
||||
class TestSaveAndLoadRoundtrip:
|
||||
def test_roundtrip(self, tmp_path):
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
|
||||
@@ -392,3 +392,13 @@ def test_run_slash_missing_required_arg_friendly_error(kanban_home):
|
||||
out = kc.run_slash("show")
|
||||
assert "/kanban show" in out
|
||||
assert "task_id" in out
|
||||
|
||||
|
||||
def test_run_slash_board_override_restores_prior_env(kanban_home, monkeypatch):
|
||||
kb.create_board("alpha")
|
||||
kb.create_board("beta")
|
||||
monkeypatch.setenv("HERMES_KANBAN_BOARD", "beta")
|
||||
|
||||
kc.run_slash("--board alpha list")
|
||||
|
||||
assert os.environ.get("HERMES_KANBAN_BOARD") == "beta"
|
||||
|
||||
@@ -510,10 +510,12 @@ def test_notify_sub_crud(kanban_home):
|
||||
tid = kb.create_task(conn, title="x")
|
||||
kb.add_notify_sub(
|
||||
conn, task_id=tid, platform="telegram", chat_id="123", user_id="u1",
|
||||
notifier_profile="default",
|
||||
)
|
||||
subs = kb.list_notify_subs(conn, tid)
|
||||
assert len(subs) == 1
|
||||
assert subs[0]["platform"] == "telegram"
|
||||
assert subs[0]["notifier_profile"] == "default"
|
||||
# Duplicate add is a no-op.
|
||||
kb.add_notify_sub(
|
||||
conn, task_id=tid, platform="telegram", chat_id="123",
|
||||
@@ -568,6 +570,57 @@ def test_notify_cursor_advances(kanban_home):
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_notify_claim_is_single_owner_and_rewindable(kanban_home):
|
||||
conn1 = kb.connect()
|
||||
conn2 = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn1, title="x", assignee="w")
|
||||
kb.add_notify_sub(conn1, task_id=tid, platform="telegram", chat_id="123")
|
||||
kb.complete_task(conn1, tid, result="ok")
|
||||
|
||||
old_cursor, claimed_cursor, events = kb.claim_unseen_events_for_sub(
|
||||
conn1,
|
||||
task_id=tid,
|
||||
platform="telegram",
|
||||
chat_id="123",
|
||||
kinds=["completed", "blocked"],
|
||||
)
|
||||
assert old_cursor == 0
|
||||
assert claimed_cursor > old_cursor
|
||||
assert [ev.kind for ev in events] == ["completed"]
|
||||
|
||||
# A concurrent notifier instance sees the advanced cursor and cannot
|
||||
# claim/send the same event range.
|
||||
_, _, duplicate_events = kb.claim_unseen_events_for_sub(
|
||||
conn2,
|
||||
task_id=tid,
|
||||
platform="telegram",
|
||||
chat_id="123",
|
||||
kinds=["completed", "blocked"],
|
||||
)
|
||||
assert duplicate_events == []
|
||||
|
||||
assert kb.rewind_notify_cursor(
|
||||
conn1,
|
||||
task_id=tid,
|
||||
platform="telegram",
|
||||
chat_id="123",
|
||||
claimed_cursor=claimed_cursor,
|
||||
old_cursor=old_cursor,
|
||||
) is True
|
||||
_, retried_events = kb.unseen_events_for_sub(
|
||||
conn2,
|
||||
task_id=tid,
|
||||
platform="telegram",
|
||||
chat_id="123",
|
||||
kinds=["completed", "blocked"],
|
||||
)
|
||||
assert [ev.kind for ev in retried_events] == ["completed"]
|
||||
finally:
|
||||
conn1.close()
|
||||
conn2.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GC + retention
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2691,6 +2744,48 @@ def test_create_task_skills_rejects_comma_embedded(kanban_home):
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_create_task_skills_rejects_toolset_names(kanban_home):
|
||||
"""Toolset names belong in profile config, not per-task skills."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
with pytest.raises(ValueError, match="toolset name"):
|
||||
kb.create_task(
|
||||
conn,
|
||||
title="bad toolset skill",
|
||||
assignee="x",
|
||||
skills=["web", "translation"],
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_create_task_skills_lists_all_toolset_typos(kanban_home):
|
||||
"""When several toolset names are passed, the error names every one.
|
||||
|
||||
Agents that confuse skills with toolsets usually pass several at once
|
||||
(``skills=["web", "browser", "terminal"]``). Listing only the first
|
||||
mistake forces serial fix-then-retry; listing all of them lets the
|
||||
caller correct in one round-trip.
|
||||
"""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
kb.create_task(
|
||||
conn,
|
||||
title="three bad",
|
||||
assignee="x",
|
||||
skills=["web", "browser", "terminal"],
|
||||
)
|
||||
msg = str(exc_info.value)
|
||||
assert "'web'" in msg
|
||||
assert "'browser'" in msg
|
||||
assert "'terminal'" in msg
|
||||
# Plural noun form when multiple toolsets are flagged.
|
||||
assert "are toolset names" in msg
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_default_spawn_appends_per_task_skills(kanban_home, monkeypatch):
|
||||
"""Dispatcher argv must carry one `--skills X` pair per task skill,
|
||||
in addition to the built-in kanban-worker."""
|
||||
@@ -3446,6 +3541,76 @@ def test_complete_accepts_cross_worker_card_when_linked_as_child(kanban_home):
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_complete_can_retry_after_phantom_rejection(kanban_home):
|
||||
"""A worker that hits the hallucinated-card gate must be able to
|
||||
retry kanban_complete on the same task — both with a corrected
|
||||
created_cards list and with an empty list (the documented escape
|
||||
hatch). Regression test for #22923, where workers were believed to
|
||||
be unrecoverable after the first rejection.
|
||||
"""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
# Two parallel completing tasks so we can exercise both retry
|
||||
# shapes without status interference.
|
||||
parent_a = kb.create_task(conn, title="retry-empty", assignee="alice")
|
||||
kb.claim_task(conn, parent_a)
|
||||
parent_b = kb.create_task(conn, title="retry-corrected", assignee="alice")
|
||||
kb.claim_task(conn, parent_b)
|
||||
real = kb.create_task(
|
||||
conn, title="real-child", assignee="x", created_by="alice",
|
||||
)
|
||||
|
||||
# First attempt: phantom in the list rejects, task stays running.
|
||||
with pytest.raises(kb.HallucinatedCardsError):
|
||||
kb.complete_task(
|
||||
conn, parent_a,
|
||||
summary="oops",
|
||||
created_cards=["t_phantomdeadbeef"],
|
||||
)
|
||||
assert kb.get_task(conn, parent_a).status == "running"
|
||||
|
||||
# Retry with [] (escape hatch): gate is skipped, completion lands.
|
||||
ok = kb.complete_task(
|
||||
conn, parent_a,
|
||||
summary="retry without claims",
|
||||
created_cards=[],
|
||||
)
|
||||
assert ok is True
|
||||
assert kb.get_task(conn, parent_a).status == "done"
|
||||
|
||||
# Same flow on parent_b, but recover via a corrected list rather
|
||||
# than the empty escape hatch.
|
||||
with pytest.raises(kb.HallucinatedCardsError):
|
||||
kb.complete_task(
|
||||
conn, parent_b,
|
||||
summary="oops",
|
||||
created_cards=[real, "t_anotherphantom"],
|
||||
)
|
||||
assert kb.get_task(conn, parent_b).status == "running"
|
||||
|
||||
ok = kb.complete_task(
|
||||
conn, parent_b,
|
||||
summary="retry with corrected list",
|
||||
created_cards=[real],
|
||||
)
|
||||
assert ok is True
|
||||
assert kb.get_task(conn, parent_b).status == "done"
|
||||
|
||||
# Both audit events landed; the eventual completion event is
|
||||
# also present on each task.
|
||||
for parent in (parent_a, parent_b):
|
||||
kinds = [
|
||||
r["kind"] for r in conn.execute(
|
||||
"SELECT kind FROM task_events WHERE task_id=? ORDER BY id",
|
||||
(parent,),
|
||||
)
|
||||
]
|
||||
assert kinds.count("completion_blocked_hallucination") == 1
|
||||
assert kinds.count("completed") == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_complete_prose_scan_flags_nonexistent_ids(kanban_home):
|
||||
"""Successful completion whose summary references a ``t_<hex>`` id
|
||||
that doesn't resolve emits a ``suspected_hallucinated_references``
|
||||
|
||||
@@ -177,12 +177,9 @@ def test_stale_claim_reclaimed(kanban_home, monkeypatch):
|
||||
host = _kb._claimer_id().split(":", 1)[0]
|
||||
kb.claim_task(conn, t, claimer=f"{host}:worker")
|
||||
killed: list[int] = []
|
||||
state = {"alive": True}
|
||||
|
||||
def _signal(pid, sig):
|
||||
def _signal(_pid, sig):
|
||||
killed.append(sig)
|
||||
if sig == signal.SIGTERM:
|
||||
state["alive"] = False
|
||||
|
||||
kb._set_worker_pid(conn, t, 12345)
|
||||
# Rewind claim_expires so it looks stale.
|
||||
@@ -190,13 +187,96 @@ def test_stale_claim_reclaimed(kanban_home, monkeypatch):
|
||||
"UPDATE tasks SET claim_expires = ? WHERE id = ?",
|
||||
(int(time.time()) - 3600, t),
|
||||
)
|
||||
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: state["alive"])
|
||||
# Worker PID has died — exactly the case ``release_stale_claims``
|
||||
# should still reclaim (post-#23025: live PIDs are now extended).
|
||||
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
|
||||
reclaimed = kb.release_stale_claims(conn, signal_fn=_signal)
|
||||
assert reclaimed == 1
|
||||
assert kb.get_task(conn, t).status == "ready"
|
||||
assert killed == [signal.SIGTERM]
|
||||
|
||||
|
||||
def test_stale_claim_with_live_pid_extends_instead_of_reclaiming(
|
||||
kanban_home, monkeypatch,
|
||||
):
|
||||
"""A stale-by-TTL claim whose worker PID is still alive should be
|
||||
extended, not reclaimed (#23025). Slow models can spend longer than
|
||||
``DEFAULT_CLAIM_TTL_SECONDS`` inside a single tool-free LLM call;
|
||||
killing those healthy workers produces a respawn loop with zero
|
||||
progress."""
|
||||
import hermes_cli.kanban_db as _kb
|
||||
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="x", assignee="a")
|
||||
host = _kb._claimer_id().split(":", 1)[0]
|
||||
kb.claim_task(conn, t, claimer=f"{host}:worker")
|
||||
kb._set_worker_pid(conn, t, 12345)
|
||||
|
||||
old_expires = int(time.time()) - 60
|
||||
conn.execute(
|
||||
"UPDATE tasks SET claim_expires = ? WHERE id = ?",
|
||||
(old_expires, t),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: True)
|
||||
killed: list[int] = []
|
||||
reclaimed = kb.release_stale_claims(
|
||||
conn, signal_fn=lambda _p, sig: killed.append(sig),
|
||||
)
|
||||
assert reclaimed == 0
|
||||
task = kb.get_task(conn, t)
|
||||
assert task.status == "running"
|
||||
assert task.claim_expires is not None
|
||||
assert task.claim_expires > old_expires
|
||||
assert killed == [] # live worker not killed
|
||||
|
||||
kinds = [
|
||||
r["kind"] for r in conn.execute(
|
||||
"SELECT kind FROM task_events WHERE task_id = ?", (t,),
|
||||
).fetchall()
|
||||
]
|
||||
assert "claim_extended" in kinds
|
||||
assert "reclaimed" not in kinds
|
||||
|
||||
|
||||
def test_stale_claim_reclaim_event_records_diagnostic_payload(
|
||||
kanban_home, monkeypatch,
|
||||
):
|
||||
"""``reclaimed`` events should carry claim_expires, last_heartbeat_at,
|
||||
and worker_pid so operators can diagnose why a claim went stale
|
||||
(#23025: previous payload only had ``stale_lock`` which gives no
|
||||
timing context)."""
|
||||
import json
|
||||
import hermes_cli.kanban_db as _kb
|
||||
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="x", assignee="a")
|
||||
host = _kb._claimer_id().split(":", 1)[0]
|
||||
kb.claim_task(conn, t, claimer=f"{host}:worker")
|
||||
kb._set_worker_pid(conn, t, 12345)
|
||||
old_expires = int(time.time()) - 3600
|
||||
hb_at = int(time.time()) - 1800
|
||||
conn.execute(
|
||||
"UPDATE tasks SET claim_expires = ?, last_heartbeat_at = ? "
|
||||
"WHERE id = ?",
|
||||
(old_expires, hb_at, t),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
|
||||
kb.release_stale_claims(conn, signal_fn=lambda _p, _s: None)
|
||||
row = conn.execute(
|
||||
"SELECT payload FROM task_events "
|
||||
"WHERE task_id = ? AND kind = 'reclaimed'",
|
||||
(t,),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
payload = json.loads(row["payload"])
|
||||
assert payload["claim_expires"] == old_expires
|
||||
assert payload["last_heartbeat_at"] == hb_at
|
||||
assert payload["worker_pid"] == 12345
|
||||
assert payload["host_local"] is True
|
||||
|
||||
|
||||
def test_max_runtime_uses_current_run_start_after_retry(kanban_home):
|
||||
"""A retry should get a fresh max-runtime window.
|
||||
|
||||
@@ -605,6 +685,57 @@ def test_dispatch_spawn_failure_releases_claim(kanban_home, all_assignees_spawna
|
||||
assert kb.get_task(conn, t).claim_lock is None
|
||||
|
||||
|
||||
def test_dispatch_max_spawn_counts_existing_running_tasks(
|
||||
kanban_home, all_assignees_spawnable
|
||||
):
|
||||
"""max_spawn is a live concurrency cap, not a per-tick spawn cap.
|
||||
|
||||
Without counting tasks already in ``running``, every dispatcher tick can
|
||||
launch up to ``max_spawn`` more workers while previous workers are still
|
||||
alive. Long-running boards then accumulate unbounded worker subprocesses.
|
||||
"""
|
||||
spawns = []
|
||||
|
||||
def fake_spawn(task, workspace):
|
||||
spawns.append(task.id)
|
||||
|
||||
with kb.connect() as conn:
|
||||
running_a = kb.create_task(conn, title="running-a", assignee="alice")
|
||||
running_b = kb.create_task(conn, title="running-b", assignee="bob")
|
||||
ready = kb.create_task(conn, title="ready", assignee="carol")
|
||||
kb.claim_task(conn, running_a)
|
||||
kb.claim_task(conn, running_b)
|
||||
|
||||
res = kb.dispatch_once(conn, spawn_fn=fake_spawn, max_spawn=2)
|
||||
|
||||
assert res.spawned == []
|
||||
assert spawns == []
|
||||
assert kb.get_task(conn, ready).status == "ready"
|
||||
|
||||
|
||||
def test_dispatch_max_spawn_fills_remaining_capacity(
|
||||
kanban_home, all_assignees_spawnable
|
||||
):
|
||||
"""When below cap, dispatch only fills available worker slots."""
|
||||
spawns = []
|
||||
|
||||
def fake_spawn(task, workspace):
|
||||
spawns.append(task.id)
|
||||
|
||||
with kb.connect() as conn:
|
||||
running = kb.create_task(conn, title="running", assignee="alice")
|
||||
ready_a = kb.create_task(conn, title="ready-a", assignee="bob")
|
||||
ready_b = kb.create_task(conn, title="ready-b", assignee="carol")
|
||||
kb.claim_task(conn, running)
|
||||
|
||||
res = kb.dispatch_once(conn, spawn_fn=fake_spawn, max_spawn=2)
|
||||
|
||||
assert len(res.spawned) == 1
|
||||
assert spawns == [ready_a]
|
||||
assert kb.get_task(conn, ready_a).status == "running"
|
||||
assert kb.get_task(conn, ready_b).status == "ready"
|
||||
|
||||
|
||||
def test_dispatch_reclaims_stale_before_spawning(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="x", assignee="alice")
|
||||
@@ -1199,3 +1330,203 @@ def test_migrate_add_optional_columns_tolerates_concurrent_migration(kanban_home
|
||||
# Running migration on an already-migrated schema must not raise.
|
||||
kb._migrate_add_optional_columns(conn)
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatcher spawn invocation — _resolve_hermes_argv()
|
||||
#
|
||||
# Workers spawned by the dispatcher must use a `hermes` invocation that does
|
||||
# not depend on PATH being set up correctly. cron jobs, systemd User= services,
|
||||
# launchd jobs, and other detached processes routinely run with a stripped
|
||||
# $PATH that doesn't include the venv's bin/, so a bare `["hermes", ...]`
|
||||
# spawn fails with FileNotFoundError and the task gets stuck. The resolver
|
||||
# prefers the PATH shim (familiar `ps` output) but falls back to the module
|
||||
# form so the spawn keeps working when PATH is missing the shim.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_hermes_argv_prefers_path_shim(monkeypatch):
|
||||
"""When `hermes` is on PATH, use the shim — preserves familiar ps output."""
|
||||
import shutil
|
||||
import hermes_cli.kanban_db as kb
|
||||
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/hermes")
|
||||
argv = kb._resolve_hermes_argv()
|
||||
assert argv == ["/usr/local/bin/hermes"]
|
||||
|
||||
|
||||
def test_resolve_hermes_argv_falls_back_to_module_form_when_no_path_shim(monkeypatch):
|
||||
"""When the shim is not on PATH, fall back to `python -m hermes_cli.main`.
|
||||
|
||||
Pins the correct module name (NOT `hermes` — there is no top-level
|
||||
`hermes` package). Regression for #23198: the original PR shipped
|
||||
`python -m hermes` which fails with `No module named hermes` on every
|
||||
invocation.
|
||||
"""
|
||||
import shutil
|
||||
import sys
|
||||
import hermes_cli.kanban_db as kb
|
||||
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
argv = kb._resolve_hermes_argv()
|
||||
assert argv == [sys.executable, "-m", "hermes_cli.main"]
|
||||
|
||||
|
||||
def test_resolve_hermes_argv_module_actually_runs():
|
||||
"""The fallback module name must be importable + runnable.
|
||||
|
||||
A unit test that pins the literal string is necessary but not
|
||||
sufficient — if `hermes_cli.main` ever loses `if __name__ == "__main__"`
|
||||
handling or its argparse setup, `python -m hermes_cli.main --version`
|
||||
would fail and so would every dispatcher spawn that hits the fallback.
|
||||
Run it as a real subprocess to catch that regression.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
import hermes_cli.kanban_db as kb
|
||||
import shutil
|
||||
import unittest.mock as mock
|
||||
|
||||
with mock.patch.object(shutil, "which", return_value=None):
|
||||
argv = kb._resolve_hermes_argv()
|
||||
r = subprocess.run(argv + ["--version"], capture_output=True, text=True, timeout=30)
|
||||
assert r.returncode == 0, (
|
||||
f"`{' '.join(argv)} --version` failed (rc={r.returncode}); "
|
||||
f"stderr={r.stderr[:200]!r}"
|
||||
)
|
||||
assert "Hermes Agent" in r.stdout, f"unexpected output: {r.stdout[:200]!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# task_age — guard against corrupt timestamp values
|
||||
#
|
||||
# The Task dataclass declares ``created_at: int`` but rows come from sqlite
|
||||
# without coercion at the boundary. A row that ever held a non-int (e.g. an
|
||||
# unsubstituted ``'%s'`` from a logged format string, ``None``, an arbitrary
|
||||
# string, or a float-as-string) used to crash ``task_age`` with ``ValueError``
|
||||
# and turn ``GET /api/plugins/kanban/board`` into a 500 because the dashboard
|
||||
# calls ``task_age`` unguarded for every task in the response.
|
||||
#
|
||||
# After the fix, ``_safe_int`` returns ``None`` on bad input and ``task_age``
|
||||
# degrades gracefully (per-field ``None`` rather than a hard crash).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_task(**overrides) -> "kb.Task":
|
||||
"""Minimal Task with all required fields filled in. Override anything."""
|
||||
defaults = dict(
|
||||
id="t_age",
|
||||
title="x",
|
||||
body=None,
|
||||
assignee=None,
|
||||
status="ready",
|
||||
priority=0,
|
||||
created_by=None,
|
||||
created_at=0,
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
workspace_kind="scratch",
|
||||
workspace_path=None,
|
||||
claim_lock=None,
|
||||
claim_expires=None,
|
||||
tenant=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return kb.Task(**defaults)
|
||||
|
||||
|
||||
def test_safe_int_accepts_int_and_int_string():
|
||||
"""Sanity: well-typed values pass through."""
|
||||
assert kb._safe_int(0) == 0
|
||||
assert kb._safe_int(1700000000) == 1700000000
|
||||
assert kb._safe_int("1700000000") == 1700000000
|
||||
|
||||
|
||||
def test_safe_int_returns_none_on_corrupt_inputs():
|
||||
"""All the failure modes that used to crash task_age."""
|
||||
# None — common when the column was never written
|
||||
assert kb._safe_int(None) is None
|
||||
# Unsubstituted format string — the literal case the PR title cites
|
||||
assert kb._safe_int("%s") is None
|
||||
# Arbitrary non-numeric strings
|
||||
assert kb._safe_int("abc") is None
|
||||
assert kb._safe_int("") is None
|
||||
# Float-ish strings: int("1.5") raises ValueError too — caller wants None.
|
||||
assert kb._safe_int("1.5") is None
|
||||
# Random object — covered by TypeError branch
|
||||
assert kb._safe_int(object()) is None
|
||||
|
||||
|
||||
def test_task_age_handles_corrupt_created_at():
|
||||
"""Pre-fix this raised ValueError and 500'd /api/plugins/kanban/board."""
|
||||
t = _make_task(created_at="%s")
|
||||
age = kb.task_age(t)
|
||||
assert age["created_age_seconds"] is None
|
||||
assert age["started_age_seconds"] is None
|
||||
assert age["time_to_complete_seconds"] is None
|
||||
|
||||
|
||||
def test_task_age_handles_corrupt_started_and_completed():
|
||||
"""All three timestamp fields share the same _safe_int treatment."""
|
||||
t = _make_task(
|
||||
created_at=1700000000,
|
||||
started_at="garbage",
|
||||
completed_at=None,
|
||||
)
|
||||
age = kb.task_age(t)
|
||||
assert isinstance(age["created_age_seconds"], int)
|
||||
assert age["started_age_seconds"] is None
|
||||
assert age["time_to_complete_seconds"] is None
|
||||
|
||||
|
||||
def test_task_age_well_formed_task():
|
||||
"""Regression: the safe-int path must not change behavior for normal data."""
|
||||
import time
|
||||
now = int(time.time())
|
||||
t = _make_task(
|
||||
created_at=now - 60,
|
||||
started_at=now - 30,
|
||||
completed_at=now,
|
||||
)
|
||||
age = kb.task_age(t)
|
||||
assert 55 <= age["created_age_seconds"] <= 65
|
||||
assert 25 <= age["started_age_seconds"] <= 35
|
||||
assert 25 <= age["time_to_complete_seconds"] <= 35
|
||||
|
||||
|
||||
def test_task_dict_survives_corrupt_created_at(tmp_path, monkeypatch):
|
||||
"""Defense in depth: even if task_age ever raised, plugin_api must not 500.
|
||||
|
||||
The PR also added a try/except around the task_age call in
|
||||
`plugins/kanban/dashboard/plugin_api.py::_task_dict`. Verify a single
|
||||
corrupt row doesn't turn the whole board response into an error.
|
||||
"""
|
||||
# Set up an isolated kanban home so we can write a corrupt created_at.
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
kb._INITIALIZED_PATHS.clear()
|
||||
kb.init_db()
|
||||
|
||||
# Insert a row with a non-int created_at (simulates the historical
|
||||
# bug that produced corrupt rows).
|
||||
conn = kb.connect()
|
||||
try:
|
||||
good_id = kb.create_task(conn, title="good")
|
||||
# Now write a row with corrupt created_at directly.
|
||||
conn.execute(
|
||||
"UPDATE tasks SET created_at = ? WHERE id = ?",
|
||||
("%s", good_id),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Re-read and pass through task_age — must not raise.
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task = kb.get_task(conn, good_id)
|
||||
finally:
|
||||
conn.close()
|
||||
age = kb.task_age(task)
|
||||
assert age["created_age_seconds"] is None
|
||||
|
||||
@@ -75,10 +75,13 @@ def test_hallucinated_cards_fires_on_blocked_event():
|
||||
phantom_cards=["t_bad1", "t_bad2"],
|
||||
verified_cards=["t_good1"]),
|
||||
]
|
||||
diags = kd.compute_task_diagnostics(task, events, [])
|
||||
assert len(diags) == 1
|
||||
d = diags[0]
|
||||
assert d.kind == "hallucinated_cards"
|
||||
# ``now=300`` keeps the synthetic event timestamps in scope without
|
||||
# tripping the stranded_in_ready rule (events are 100/200 epoch
|
||||
# which time.time() would treat as ~50yr old).
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=300)
|
||||
halluc = [d for d in diags if d.kind == "hallucinated_cards"]
|
||||
assert len(halluc) == 1
|
||||
d = halluc[0]
|
||||
assert d.severity == "error"
|
||||
assert d.data["phantom_ids"] == ["t_bad1", "t_bad2"]
|
||||
# Generic recovery actions always available; comment action too.
|
||||
@@ -379,3 +382,176 @@ def test_broken_rule_is_isolated(monkeypatch):
|
||||
# The broken rule silently drops, the real one still fires.
|
||||
kinds = [d.kind for d in diags]
|
||||
assert "repeated_failures" in kinds
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stranded_in_ready
|
||||
#
|
||||
# Surfaces ready tasks that nobody has claimed within the threshold.
|
||||
# Identity-agnostic by design: catches typo'd assignees, deleted profiles,
|
||||
# down external worker pools, and misconfigured dispatchers in one rule.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stranded_in_ready_fires_when_age_exceeds_threshold():
|
||||
"""Default threshold = 30 min. A ready task promoted 45 min ago
|
||||
with no claim should fire as a warning."""
|
||||
now = 100_000
|
||||
task = _task(status="ready", assignee="demo", claim_lock=None)
|
||||
# 45 min = 2700s, threshold = 1800s.
|
||||
events = [_event("created", ts=now - 45 * 60)]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
|
||||
assert len(stranded) == 1
|
||||
assert stranded[0].severity == "warning"
|
||||
assert stranded[0].data["age_seconds"] == 45 * 60
|
||||
assert stranded[0].data["assignee"] == "demo"
|
||||
|
||||
|
||||
def test_stranded_in_ready_silent_below_threshold():
|
||||
"""A ready task only 10 min old should NOT fire."""
|
||||
now = 100_000
|
||||
task = _task(status="ready", assignee="demo", claim_lock=None)
|
||||
events = [_event("created", ts=now - 10 * 60)]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
|
||||
|
||||
|
||||
def test_stranded_in_ready_skips_non_ready_status():
|
||||
"""Tasks not in ready status are out of scope (running tasks have
|
||||
their own crash / failure rules)."""
|
||||
now = 100_000
|
||||
for status in ("running", "blocked", "done", "todo", "triage"):
|
||||
task = _task(status=status, assignee="demo")
|
||||
events = [_event("created", ts=now - 6 * 3600)]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
assert [d for d in diags if d.kind == "stranded_in_ready"] == [], status
|
||||
|
||||
|
||||
def test_stranded_in_ready_skips_unassigned_tasks():
|
||||
"""Empty assignee = `skipped_unassigned` on the dispatcher already.
|
||||
Don't double-flag here."""
|
||||
now = 100_000
|
||||
task = _task(status="ready", assignee="", claim_lock=None)
|
||||
events = [_event("created", ts=now - 6 * 3600)]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
|
||||
|
||||
|
||||
def test_stranded_in_ready_skips_claimed_tasks():
|
||||
"""A live claim_lock means a worker is on it — even an old one. Don't
|
||||
second-guess: the run-level liveness signal owns that decision."""
|
||||
now = 100_000
|
||||
task = _task(
|
||||
status="ready", assignee="demo", claim_lock="run_xyz",
|
||||
)
|
||||
events = [_event("created", ts=now - 6 * 3600)]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
|
||||
|
||||
|
||||
def test_stranded_in_ready_uses_latest_ready_transition():
|
||||
"""When multiple ready-transition events exist, the rule should
|
||||
age-from the most recent — a task reclaimed 20 min ago is NOT
|
||||
stranded for 6h even if it was first created 6h ago."""
|
||||
now = 100_000
|
||||
task = _task(status="ready", assignee="demo")
|
||||
events = [
|
||||
_event("created", ts=now - 6 * 3600), # 6 h ago
|
||||
_event("reclaimed", ts=now - 20 * 60), # 20 min ago — wins
|
||||
]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
|
||||
|
||||
|
||||
def test_stranded_in_ready_severity_escalates_with_age():
|
||||
"""warning → error → critical at 2x and 6x threshold."""
|
||||
now = 100_000
|
||||
task = _task(status="ready", assignee="demo")
|
||||
# Default threshold = 1800s.
|
||||
cases = [
|
||||
(45 * 60, "warning"), # 1.5x → warning
|
||||
(90 * 60, "error"), # 3x → error
|
||||
(4 * 3600, "critical"), # 8x → critical
|
||||
]
|
||||
for age, expected in cases:
|
||||
events = [_event("created", ts=now - age)]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
|
||||
assert len(stranded) == 1, f"age={age}"
|
||||
assert stranded[0].severity == expected, (
|
||||
f"age={age} expected {expected}, got {stranded[0].severity}"
|
||||
)
|
||||
|
||||
|
||||
def test_stranded_in_ready_respects_config_override():
|
||||
"""Config override changes the threshold."""
|
||||
now = 100_000
|
||||
task = _task(status="ready", assignee="demo")
|
||||
events = [_event("created", ts=now - 10 * 60)] # 10 min
|
||||
# Default 30 min — wouldn't fire.
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
|
||||
# Lower the threshold to 5 min — now it fires.
|
||||
diags = kd.compute_task_diagnostics(
|
||||
task, events, [], now=now,
|
||||
config={"stranded_threshold_seconds": 5 * 60},
|
||||
)
|
||||
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
|
||||
assert len(stranded) == 1
|
||||
|
||||
|
||||
def test_stranded_in_ready_falls_back_to_created_at():
|
||||
"""When events have no ready-transition kind, the rule falls back
|
||||
to the task's ``created_at`` so an ancient stranded task isn't
|
||||
invisible just because its events got pruned."""
|
||||
now = 100_000
|
||||
task = _task(
|
||||
status="ready", assignee="demo", created_at=now - 4 * 3600,
|
||||
)
|
||||
# No qualifying events.
|
||||
events = [_event("commented", ts=now - 100)]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
|
||||
assert len(stranded) == 1
|
||||
assert stranded[0].data["age_seconds"] == 4 * 3600
|
||||
|
||||
|
||||
def test_stranded_in_ready_works_on_real_db_row(kanban_home):
|
||||
"""Round-trip through real kanban_db.connect() — confirms the rule
|
||||
works on sqlite3.Row objects, not just dicts."""
|
||||
import time as _t
|
||||
conn = kb.connect()
|
||||
try:
|
||||
# Create a task and force its created_at into the past.
|
||||
tid = kb.create_task(conn, title="stranded one", assignee="ghost")
|
||||
old_ts = int(_t.time()) - 90 * 60 # 90 min old
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status = 'ready', created_at = ? WHERE id = ?",
|
||||
(old_ts, tid),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
task_row = conn.execute(
|
||||
"SELECT * FROM tasks WHERE id = ?", (tid,)
|
||||
).fetchone()
|
||||
events = list(conn.execute(
|
||||
"SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at",
|
||||
(tid,),
|
||||
).fetchall())
|
||||
# Override created event timestamps too so age calc lines up.
|
||||
conn.execute(
|
||||
"UPDATE task_events SET created_at = ? WHERE task_id = ?",
|
||||
(old_ts, tid),
|
||||
)
|
||||
conn.commit()
|
||||
events = list(conn.execute(
|
||||
"SELECT * FROM task_events WHERE task_id = ?", (tid,),
|
||||
).fetchall())
|
||||
|
||||
diags = kd.compute_task_diagnostics(task_row, events, [])
|
||||
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
|
||||
assert len(stranded) == 1
|
||||
assert stranded[0].data["assignee"] == "ghost"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import pytest
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli import kanban_db as kb
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -76,7 +77,13 @@ async def test_notifier_unsubs_after_completed_event(kanban_home):
|
||||
@pytest.mark.parametrize('kind', ["gave_up", "crashed", "timed_out"])
|
||||
async def test_notifier_unsubs_after_abnormal_events(kind, kanban_home):
|
||||
"""
|
||||
Event kind of gave_up, crashed, time_out would be cover, and remove subscription
|
||||
Event kinds gave_up / crashed / timed_out send a notification but DO
|
||||
NOT delete the subscription. The dispatcher may respawn the task and
|
||||
fire the same event kind again (e.g. a worker that crashes, gets
|
||||
reclaimed, and crashes a second time); the user must hear about the
|
||||
second event too. Subscriptions are removed only when the task hits
|
||||
a truly final status (done / archived) — see the comment on
|
||||
TERMINAL_KINDS in gateway/run.py and PR #21398.
|
||||
"""
|
||||
import hermes_cli.kanban_db as kb
|
||||
from gateway.run import GatewayRunner
|
||||
@@ -114,15 +121,27 @@ async def test_notifier_unsubs_after_abnormal_events(kind, kanban_home):
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
# The user is notified about the abnormal event...
|
||||
fake_adapter.send.assert_called_once()
|
||||
assert kind.replace('_', ' ') in fake_adapter.send.call_args[0][1]
|
||||
|
||||
# ...but the subscription survives so a respawn-then-same-event cycle
|
||||
# reaches the user too. The cursor (last_event_id) advanced inside
|
||||
# the same write txn as the claim, so the same event won't re-fire.
|
||||
conn = kb.connect()
|
||||
try:
|
||||
subs = kb.list_notify_subs(conn, tid)
|
||||
finally:
|
||||
conn.close()
|
||||
assert subs == [], "Subscription should be unsub after abnormal crash"
|
||||
assert len(subs) == 1, (
|
||||
f"Subscription should survive {kind!r} so the next cycle of the "
|
||||
f"same event reaches the user; got {subs!r}"
|
||||
)
|
||||
assert int(subs[0]["last_event_id"]) >= 1, (
|
||||
"Cursor should have advanced past the delivered event "
|
||||
"(claim_unseen_events_for_sub advances atomically inside the "
|
||||
"same write txn as the read)."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -301,3 +320,162 @@ def test_dispatcher_tick_does_not_call_init_db(kanban_home, monkeypatch):
|
||||
"_kanban_notifier_watcher must not call _kb.init_db(board=slug) — "
|
||||
"see issue #21378."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notifier_skips_subscription_owned_by_other_profile(kanban_home):
|
||||
"""Each gateway keeps its watcher on, but only the subscribing profile claims."""
|
||||
import hermes_cli.kanban_db as kb
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.config import Platform
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="owned task", assignee="backend-engineer")
|
||||
kb.add_notify_sub(
|
||||
conn,
|
||||
task_id=tid,
|
||||
platform="telegram",
|
||||
chat_id="chat1",
|
||||
notifier_profile="default",
|
||||
)
|
||||
kb.complete_task(conn, tid, result="done")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._running = True
|
||||
runner._kanban_sub_fail_counts = {}
|
||||
runner._kanban_notifier_profile = "business-partner"
|
||||
|
||||
fake_adapter = MagicMock()
|
||||
fake_adapter.send = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: fake_adapter}
|
||||
|
||||
_orig_sleep = asyncio.sleep
|
||||
tick_count = 0
|
||||
|
||||
async def _fast_sleep(_):
|
||||
nonlocal tick_count
|
||||
await _orig_sleep(0)
|
||||
tick_count += 1
|
||||
if tick_count >= 3:
|
||||
runner._running = False
|
||||
|
||||
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep):
|
||||
await asyncio.wait_for(
|
||||
runner._kanban_notifier_watcher(interval=1),
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
fake_adapter.send.assert_not_called()
|
||||
conn = kb.connect()
|
||||
try:
|
||||
subs = kb.list_notify_subs(conn, tid)
|
||||
finally:
|
||||
conn.close()
|
||||
assert len(subs) == 1
|
||||
assert int(subs[0]["last_event_id"]) == 0, "wrong profile must not claim the event"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notifier_delivers_subscription_owned_by_current_profile(kanban_home):
|
||||
"""The gateway for the profile that created/subscribed the task reports it."""
|
||||
import hermes_cli.kanban_db as kb
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.config import Platform
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="owned task", assignee="backend-engineer")
|
||||
kb.add_notify_sub(
|
||||
conn,
|
||||
task_id=tid,
|
||||
platform="telegram",
|
||||
chat_id="chat1",
|
||||
notifier_profile="default",
|
||||
)
|
||||
kb.complete_task(conn, tid, result="done")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._running = True
|
||||
runner._kanban_sub_fail_counts = {}
|
||||
runner._kanban_notifier_profile = "default"
|
||||
|
||||
fake_adapter = MagicMock()
|
||||
|
||||
async def _send_and_stop(chat_id, msg, metadata=None):
|
||||
runner._running = False
|
||||
|
||||
fake_adapter.send = AsyncMock(side_effect=_send_and_stop)
|
||||
runner.adapters = {Platform.TELEGRAM: fake_adapter}
|
||||
|
||||
_orig_sleep = asyncio.sleep
|
||||
|
||||
async def _fast_sleep(_):
|
||||
await _orig_sleep(0)
|
||||
|
||||
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep):
|
||||
await asyncio.wait_for(
|
||||
runner._kanban_notifier_watcher(interval=1),
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
fake_adapter.send.assert_called_once()
|
||||
conn = kb.connect()
|
||||
try:
|
||||
subs = kb.list_notify_subs(conn, tid)
|
||||
finally:
|
||||
conn.close()
|
||||
assert subs == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home):
|
||||
"""`/kanban --board <slug> create ...` must subscribe on that board.
|
||||
|
||||
The gateway handler currently auto-subscribes after `/kanban create`,
|
||||
but the create detection must still work when the shared `--board`
|
||||
flag appears before the subcommand, and the subscription must land in
|
||||
that board's DB rather than the ambient/default board.
|
||||
"""
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.config import Platform
|
||||
|
||||
kb.create_board("projx")
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
source = SimpleNamespace(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="chat1",
|
||||
thread_id="th1",
|
||||
user_id="u1",
|
||||
)
|
||||
event = SimpleNamespace(
|
||||
text='/kanban --board projx create "hello" --assignee alice',
|
||||
source=source,
|
||||
)
|
||||
|
||||
out = await GatewayRunner._handle_kanban_command(runner, event)
|
||||
|
||||
assert "subscribed" in out.lower()
|
||||
|
||||
conn = kb.connect(board="projx")
|
||||
try:
|
||||
subs = kb.list_notify_subs(conn)
|
||||
tasks = kb.list_tasks(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert [t.title for t in tasks] == ["hello"]
|
||||
assert len(subs) == 1
|
||||
assert subs[0]["chat_id"] == "chat1"
|
||||
assert subs[0]["thread_id"] == "th1"
|
||||
|
||||
conn = kb.connect(board="default")
|
||||
try:
|
||||
assert kb.list_notify_subs(conn) == []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -282,3 +283,48 @@ class TestIntegrationWithModelsModule:
|
||||
result = get_curated_nous_model_ids()
|
||||
|
||||
assert result == ["anthropic/claude-opus-4.7", "moonshotai/kimi-k2.6"]
|
||||
|
||||
def test_picker_nous_row_uses_manifest(self, tmp_path, monkeypatch):
|
||||
"""The /model picker must surface the manifest's nous list, not the
|
||||
in-repo _PROVIDER_MODELS["nous"] snapshot. Regression: before this
|
||||
fix, list_authenticated_providers() built the curated dict from
|
||||
_PROVIDER_MODELS only — so newly-added Portal models never reached
|
||||
the slash-command picker until the next Hermes release.
|
||||
"""
|
||||
# We deliberately do NOT use the ``isolated_home`` fixture here:
|
||||
# that fixture monkeypatches ``Path.home`` to ``tmp_path``, which
|
||||
# trips the auth-store seat-belt in ``_auth_file_path()`` because
|
||||
# ``HERMES_HOME / auth.json`` then resolves to the same path the
|
||||
# seat-belt thinks is the "real" user store. Use the autouse
|
||||
# ``_hermetic_environment`` HERMES_HOME directly instead.
|
||||
import importlib
|
||||
from hermes_cli import model_catalog
|
||||
importlib.reload(model_catalog)
|
||||
try:
|
||||
from hermes_cli.model_switch import list_picker_providers
|
||||
|
||||
active_home = Path(os.environ["HERMES_HOME"])
|
||||
(active_home / "auth.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"providers": {"nous": {"access_token": "fake"}},
|
||||
"credential_pool": {},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
model_catalog, "_fetch_manifest", return_value=_valid_manifest()
|
||||
):
|
||||
picker = list_picker_providers(
|
||||
current_provider="nous", max_models=99
|
||||
)
|
||||
finally:
|
||||
model_catalog.reset_cache()
|
||||
|
||||
nous_row = next((r for r in picker if r["slug"] == "nous"), None)
|
||||
assert nous_row is not None, "nous row must appear when authed"
|
||||
assert nous_row["models"] == [
|
||||
"anthropic/claude-opus-4.7",
|
||||
"moonshotai/kimi-k2.6",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Tests for session handoff (CLI to gateway platform).
|
||||
|
||||
The handoff state machine lives on the ``sessions`` table:
|
||||
|
||||
None → "pending" → "running" → ("completed" | "failed")
|
||||
|
||||
CLI side calls ``request_handoff`` and poll-waits on ``get_handoff_state``.
|
||||
Gateway side iterates ``list_pending_handoffs``, calls ``claim_handoff`` to
|
||||
flip pending → running, and finishes with ``complete_handoff`` or
|
||||
``fail_handoff``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
class TestHandoffStateDB:
|
||||
"""Test the handoff schema + helper methods on SessionDB."""
|
||||
|
||||
@pytest.fixture
|
||||
def db(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
return SessionDB(db_path=home / "state.db")
|
||||
|
||||
def _make_session(self, db, session_id, source="cli", title=None):
|
||||
"""Insert a session row directly for testing."""
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO sessions (id, source, title, started_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(session_id, source, title, time.time()),
|
||||
)
|
||||
db._execute_write(_do)
|
||||
|
||||
def test_columns_exist(self, db):
|
||||
db._conn.execute(
|
||||
"SELECT handoff_state, handoff_platform, handoff_error "
|
||||
"FROM sessions LIMIT 0"
|
||||
)
|
||||
|
||||
def test_request_handoff_marks_pending(self, db):
|
||||
sid = "sess-1"
|
||||
self._make_session(db, sid)
|
||||
|
||||
assert db.request_handoff(sid, "telegram") is True
|
||||
|
||||
state = db.get_handoff_state(sid)
|
||||
assert state == {
|
||||
"state": "pending",
|
||||
"platform": "telegram",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
def test_request_handoff_rejects_in_flight(self, db):
|
||||
sid = "sess-2"
|
||||
self._make_session(db, sid)
|
||||
|
||||
assert db.request_handoff(sid, "telegram") is True
|
||||
# Still pending → reject re-request
|
||||
assert db.request_handoff(sid, "discord") is False
|
||||
|
||||
# And after gateway claims it (running) → still rejected
|
||||
assert db.claim_handoff(sid) is True
|
||||
assert db.request_handoff(sid, "discord") is False
|
||||
|
||||
def test_request_handoff_after_terminal_state_resets_error(self, db):
|
||||
sid = "sess-3"
|
||||
self._make_session(db, sid)
|
||||
db.request_handoff(sid, "telegram")
|
||||
db.claim_handoff(sid)
|
||||
db.fail_handoff(sid, "earlier failure")
|
||||
|
||||
# User retries — should be allowed and clear the prior error.
|
||||
assert db.request_handoff(sid, "discord") is True
|
||||
state = db.get_handoff_state(sid)
|
||||
assert state["state"] == "pending"
|
||||
assert state["platform"] == "discord"
|
||||
assert state["error"] is None
|
||||
|
||||
def test_list_pending_handoffs_excludes_running_and_terminal(self, db):
|
||||
a, b, c, d = "sess-a", "sess-b", "sess-c", "sess-d"
|
||||
for sid in (a, b, c, d):
|
||||
self._make_session(db, sid)
|
||||
|
||||
db.request_handoff(a, "telegram")
|
||||
db.request_handoff(b, "discord")
|
||||
db.request_handoff(c, "telegram")
|
||||
db.claim_handoff(c) # c is now running, not pending
|
||||
db.request_handoff(d, "slack")
|
||||
db.claim_handoff(d)
|
||||
db.complete_handoff(d) # d is terminal
|
||||
|
||||
pending = db.list_pending_handoffs()
|
||||
ids = [r["id"] for r in pending]
|
||||
assert set(ids) == {a, b}
|
||||
|
||||
def test_claim_handoff_is_atomic(self, db):
|
||||
sid = "sess-claim"
|
||||
self._make_session(db, sid)
|
||||
db.request_handoff(sid, "telegram")
|
||||
|
||||
# First claim wins
|
||||
assert db.claim_handoff(sid) is True
|
||||
# Second claim is a no-op (state is now "running", not "pending")
|
||||
assert db.claim_handoff(sid) is False
|
||||
assert db.get_handoff_state(sid)["state"] == "running"
|
||||
|
||||
def test_complete_handoff_clears_error(self, db):
|
||||
sid = "sess-complete"
|
||||
self._make_session(db, sid)
|
||||
db.request_handoff(sid, "telegram")
|
||||
db.claim_handoff(sid)
|
||||
db.fail_handoff(sid, "transient")
|
||||
# User retries; mock the watcher path
|
||||
db.request_handoff(sid, "telegram")
|
||||
db.claim_handoff(sid)
|
||||
db.complete_handoff(sid)
|
||||
|
||||
state = db.get_handoff_state(sid)
|
||||
assert state["state"] == "completed"
|
||||
assert state["error"] is None
|
||||
|
||||
def test_fail_handoff_records_reason(self, db):
|
||||
sid = "sess-fail"
|
||||
self._make_session(db, sid)
|
||||
db.request_handoff(sid, "telegram")
|
||||
db.claim_handoff(sid)
|
||||
db.fail_handoff(sid, "no home channel for telegram")
|
||||
|
||||
state = db.get_handoff_state(sid)
|
||||
assert state["state"] == "failed"
|
||||
assert state["error"] == "no home channel for telegram"
|
||||
|
||||
def test_fail_handoff_truncates_long_reasons(self, db):
|
||||
sid = "sess-fail-long"
|
||||
self._make_session(db, sid)
|
||||
db.request_handoff(sid, "telegram")
|
||||
db.claim_handoff(sid)
|
||||
|
||||
# 1000-character error string
|
||||
big_err = "x" * 1000
|
||||
db.fail_handoff(sid, big_err)
|
||||
|
||||
state = db.get_handoff_state(sid)
|
||||
assert len(state["error"]) <= 500
|
||||
|
||||
def test_get_handoff_state_for_unknown_session(self, db):
|
||||
assert db.get_handoff_state("does-not-exist") is None
|
||||
|
||||
def test_full_pending_to_completed_flow(self, db):
|
||||
"""End-to-end sequence the CLI + gateway watcher follow."""
|
||||
sid = "sess-flow"
|
||||
self._make_session(db, sid, title="my session")
|
||||
db.append_message(sid, "user", "Hello")
|
||||
db.append_message(sid, "assistant", "Hi there!")
|
||||
|
||||
# CLI: request handoff
|
||||
assert db.request_handoff(sid, "telegram") is True
|
||||
assert db.get_handoff_state(sid)["state"] == "pending"
|
||||
|
||||
# Gateway watcher: discover + claim
|
||||
pending = db.list_pending_handoffs()
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["id"] == sid
|
||||
assert db.claim_handoff(sid) is True
|
||||
assert db.get_handoff_state(sid)["state"] == "running"
|
||||
|
||||
# Gateway uses get_messages to load the transcript (real flow uses
|
||||
# session_store.switch_session which reads the same table).
|
||||
messages = db.get_messages(sid)
|
||||
assert [m["role"] for m in messages] == ["user", "assistant"]
|
||||
|
||||
# Gateway: mark completed
|
||||
db.complete_handoff(sid)
|
||||
assert db.get_handoff_state(sid)["state"] == "completed"
|
||||
assert db.list_pending_handoffs() == []
|
||||
|
||||
|
||||
class TestHandoffCommandRegistration:
|
||||
"""Slash-command surface checks."""
|
||||
|
||||
def test_command_registered(self):
|
||||
from hermes_cli.commands import resolve_command
|
||||
cmd = resolve_command("handoff")
|
||||
assert cmd is not None
|
||||
assert cmd.name == "handoff"
|
||||
assert cmd.category == "Session"
|
||||
|
||||
def test_command_is_cli_only(self):
|
||||
"""`/handoff` is initiated from the CLI; gateway shouldn't expose it."""
|
||||
from hermes_cli.commands import resolve_command, GATEWAY_KNOWN_COMMANDS
|
||||
cmd = resolve_command("handoff")
|
||||
assert cmd is not None
|
||||
assert cmd.cli_only is True
|
||||
assert "handoff" not in GATEWAY_KNOWN_COMMANDS
|
||||
@@ -1946,6 +1946,117 @@ class TestNormaliseThemeExtensions:
|
||||
assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"}
|
||||
|
||||
|
||||
class TestPluginAPIAuth:
|
||||
"""Tests that plugin API routes require the session token (issue #19533)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
|
||||
"""Create a TestClient without the session token header."""
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
|
||||
self.client = TestClient(app)
|
||||
self.auth_client = TestClient(app)
|
||||
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
def test_plugin_route_requires_auth(self):
|
||||
"""Plugin API routes should return 401 without a valid session token."""
|
||||
# Use a known plugin route (kanban board)
|
||||
resp = self.client.get("/api/plugins/kanban/board")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_route_allows_auth(self):
|
||||
"""Plugin API routes should work with a valid session token.
|
||||
|
||||
Use ``/api/plugins/example/hello`` from the example-dashboard plugin —
|
||||
a stable, side-effect-free GET that's always loaded in tests. With a
|
||||
valid token the handler should run (200); without one the middleware
|
||||
should 401 before the handler is reached.
|
||||
"""
|
||||
# Without auth: middleware blocks before reaching the handler.
|
||||
resp = self.client.get("/api/plugins/example/hello")
|
||||
assert resp.status_code == 401
|
||||
|
||||
# With auth: handler runs.
|
||||
resp = self.auth_client.get("/api/plugins/example/hello")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_plugin_post_requires_auth(self):
|
||||
"""Plugin POST routes should return 401 without a valid session token."""
|
||||
resp = self.client.post("/api/plugins/kanban/tasks", json={"title": "test"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_patch_requires_auth(self):
|
||||
"""Plugin PATCH routes should return 401 without a valid session token.
|
||||
|
||||
PATCH is the mutation method most commonly used by the dashboard for
|
||||
kanban task edits — explicitly cover it so a future middleware
|
||||
regression that whitelists non-GET methods can't sneak through.
|
||||
"""
|
||||
resp = self.client.patch(
|
||||
"/api/plugins/kanban/tasks/t_fake",
|
||||
json={"title": "renamed"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_delete_requires_auth(self):
|
||||
"""Plugin DELETE routes should return 401 without a valid session token."""
|
||||
resp = self.client.delete("/api/plugins/kanban/tasks/t_fake")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_non_kanban_plugin_route_requires_auth(self):
|
||||
"""Auth must be plugin-agnostic, not kanban-specific.
|
||||
|
||||
The middleware fix is at the gate level (no per-plugin allowlist),
|
||||
so any plugin's API surface — kanban, hermes-achievements, future
|
||||
plugins — must require the session token. Hit a non-kanban plugin
|
||||
path to lock that in.
|
||||
"""
|
||||
# Real plugin path (hermes-achievements is loaded by default).
|
||||
resp = self.client.get("/api/plugins/hermes-achievements/overview")
|
||||
assert resp.status_code == 401
|
||||
# Same for an arbitrary plugin namespace that doesn't even exist —
|
||||
# the middleware should 401 before routing decides 404, so an
|
||||
# attacker can't fingerprint plugin names by status codes.
|
||||
resp = self.client.get("/api/plugins/_definitely_not_a_plugin_/anything")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_websocket_unaffected_by_http_middleware(self):
|
||||
"""The kanban /events WebSocket has its own ``?token=`` check;
|
||||
the HTTP middleware change must not start gating WS upgrades.
|
||||
|
||||
Starlette doesn't run HTTP middleware on WebSocket upgrades anyway,
|
||||
but pin the behavior so a future refactor that moves auth into a
|
||||
shared layer can't silently break the WS auth contract.
|
||||
"""
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
from hermes_cli.web_server import _SESSION_TOKEN
|
||||
|
||||
# Without a token the WS endpoint must close the upgrade itself
|
||||
# (its own _check_ws_token), NOT 401 from the HTTP middleware.
|
||||
try:
|
||||
with self.client.websocket_connect(
|
||||
"/api/plugins/kanban/events"
|
||||
):
|
||||
pass # if we got here without disconnect, the WS accepted us
|
||||
except WebSocketDisconnect:
|
||||
pass # expected — WS endpoint rejected via its own check
|
||||
except Exception:
|
||||
# The kanban plugin may not be mounted in this test environment,
|
||||
# in which case the route doesn't exist at all (3xx/4xx during
|
||||
# upgrade). That's fine for this regression — it only matters
|
||||
# that the HTTP middleware didn't start intercepting WS upgrades.
|
||||
pass
|
||||
|
||||
|
||||
class TestDashboardPluginManifestExtensions:
|
||||
"""Tests for the extended plugin manifest fields (tab.override,
|
||||
tab.hidden, slots) read by _discover_dashboard_plugins()."""
|
||||
|
||||
@@ -13,7 +13,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.main import _web_ui_build_needed, _build_web_ui
|
||||
from hermes_cli.main import _web_ui_build_needed, _build_web_ui, _run_npm_install_deterministic
|
||||
|
||||
|
||||
def _touch(path: Path, offset: float = 0.0) -> None:
|
||||
@@ -119,3 +119,92 @@ class TestBuildWebUISkipsWhenFresh:
|
||||
|
||||
assert result is True
|
||||
assert mock_run.call_count == 2 # npm install + npm run build
|
||||
|
||||
def test_npm_install_uses_utf8_replace_output_decoding(self, tmp_path):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
(web_dir / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
with patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run:
|
||||
result = _run_npm_install_deterministic("/usr/bin/npm", web_dir)
|
||||
|
||||
assert result.returncode == 0
|
||||
_, kwargs = mock_run.call_args
|
||||
assert kwargs["text"] is True
|
||||
assert kwargs["encoding"] == "utf-8"
|
||||
assert kwargs["errors"] == "replace"
|
||||
|
||||
def test_web_build_uses_utf8_replace_output_decoding(self, tmp_path):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
|
||||
mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[mock_cp, mock_cp]) as mock_run:
|
||||
result = _build_web_ui(web_dir)
|
||||
|
||||
assert result is True
|
||||
_, build_kwargs = mock_run.call_args_list[1]
|
||||
assert build_kwargs["text"] is True
|
||||
assert build_kwargs["encoding"] == "utf-8"
|
||||
assert build_kwargs["errors"] == "replace"
|
||||
|
||||
|
||||
class TestBuildWebUIRetryAndStaleFallback:
|
||||
"""Coverage for the retry + stale-dist fallback added in #23824 / issue #23817."""
|
||||
|
||||
def test_retries_build_once_on_failure(self, tmp_path):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
Subprocess = __import__("subprocess")
|
||||
# install: success; build attempt 1: fail; build attempt 2: success
|
||||
install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="EPERM")
|
||||
build_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._time.sleep") as mock_sleep, \
|
||||
patch("hermes_cli.main.subprocess.run",
|
||||
side_effect=[install_ok, build_fail, build_ok]) as mock_run:
|
||||
result = _build_web_ui(web_dir)
|
||||
|
||||
assert result is True
|
||||
assert mock_run.call_count == 3 # install + build + retry
|
||||
mock_sleep.assert_called_once_with(3)
|
||||
|
||||
def test_falls_back_to_stale_dist_when_retry_also_fails(self, tmp_path, capsys):
|
||||
web_dir, dist_dir = _make_web_dir(tmp_path)
|
||||
# Stale dist exists but is older than source
|
||||
_touch(dist_dir / "index.html", offset=-100)
|
||||
_touch(web_dir / "src" / "App.tsx") # newer source -> build_needed=True
|
||||
|
||||
Subprocess = __import__("subprocess")
|
||||
install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="vite ENOMEM")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._time.sleep"), \
|
||||
patch("hermes_cli.main.subprocess.run",
|
||||
side_effect=[install_ok, build_fail, build_fail]):
|
||||
result = _build_web_ui(web_dir, fatal=True)
|
||||
|
||||
# MUST return True (serve stale) — issue #23817 — even with fatal=True,
|
||||
# because cmd_dashboard passes fatal=True and is the primary caller.
|
||||
assert result is True
|
||||
out = capsys.readouterr().out
|
||||
assert "serving stale dist as fallback" in out
|
||||
assert "vite ENOMEM" in out # stderr surfaced to user
|
||||
|
||||
def test_hard_fails_when_no_dist_to_fall_back_to(self, tmp_path, capsys):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
|
||||
Subprocess = __import__("subprocess")
|
||||
install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="vite ENOMEM")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._time.sleep"), \
|
||||
patch("hermes_cli.main.subprocess.run",
|
||||
side_effect=[install_ok, build_fail, build_fail]):
|
||||
result = _build_web_ui(web_dir, fatal=True)
|
||||
|
||||
assert result is False
|
||||
out = capsys.readouterr().out
|
||||
assert "Web UI build failed" in out
|
||||
assert "vite ENOMEM" in out
|
||||
assert "Run manually" in out
|
||||
|
||||
@@ -534,6 +534,9 @@ def test_board_auto_initializes_missing_db(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.delenv("HERMES_KANBAN_BOARD", raising=False)
|
||||
monkeypatch.delenv("HERMES_KANBAN_DB", raising=False)
|
||||
monkeypatch.delenv("HERMES_KANBAN_HOME", raising=False)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
# Deliberately DO NOT call kb.init_db().
|
||||
|
||||
@@ -1036,6 +1039,20 @@ def test_create_task_without_skills_defaults_to_empty_list(client):
|
||||
assert task.get("skills") in (None, [])
|
||||
|
||||
|
||||
def test_create_task_with_toolset_name_in_skills_is_rejected(client):
|
||||
"""POST /tasks fails fast when callers confuse toolsets with skills."""
|
||||
r = client.post(
|
||||
"/api/plugins/kanban/tasks",
|
||||
json={
|
||||
"title": "bad skills payload",
|
||||
"assignee": "linguist",
|
||||
"skills": ["web"],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "toolset name" in r.json()["detail"]
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatcher-presence warning in POST /tasks response
|
||||
@@ -1723,3 +1740,57 @@ def test_dashboard_requests_default_board_explicitly():
|
||||
assert "SDK.fetchJSON(withBoard(`${API}/config`, board))" in dist
|
||||
assert "SDK.fetchJSON(withBoard(`${API}/boards`, board))" in dist
|
||||
assert "}, [loadBoardList, switchBoard, board]);" in dist
|
||||
|
||||
|
||||
def test_dashboard_search_includes_body_and_result():
|
||||
"""Client-side search must match body, result, latest_summary, and summary
|
||||
so full card contents are findable."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
||||
|
||||
assert "t.body || \"\"" in dist
|
||||
assert "t.result || \"\"" in dist
|
||||
assert "t.latest_summary || \"\"" in dist
|
||||
|
||||
|
||||
def test_dashboard_bulk_actions_include_reclaim_first():
|
||||
"""Bulk action bar must expose reclaim_first checkbox and expanded status buttons."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
||||
|
||||
assert "reclaim_first: reclaimFirst" in dist
|
||||
assert "hermes-kanban-bulk-reclaim-first" in dist
|
||||
assert '"→ todo"' in dist
|
||||
assert '"Block"' in dist
|
||||
assert '"Unblock"' in dist
|
||||
|
||||
|
||||
def test_dashboard_shift_click_range_selection_exists():
|
||||
"""Shift-click must trigger range selection via toggleRange."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
||||
|
||||
assert "function toggleRange" in dist or "const toggleRange =" in dist
|
||||
assert "props.toggleRange(t.id)" in dist or "props.toggleRange" in dist
|
||||
assert "e.shiftKey" in dist
|
||||
|
||||
|
||||
def test_dashboard_multi_move_bulk_exists():
|
||||
"""Dragging a selected card with other selections must use /tasks/bulk."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
||||
|
||||
assert "onMoveSelected" in dist
|
||||
assert "props.onMoveSelected" in dist
|
||||
assert "`${API}/tasks/bulk`" in dist
|
||||
|
||||
|
||||
def test_dashboard_failed_card_highlight_class_exists():
|
||||
"""Partial bulk failures must highlight failing cards."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
js = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
||||
css = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "style.css").read_text()
|
||||
|
||||
assert "hermes-kanban-card--failed" in js
|
||||
assert "hermes-kanban-card--failed" in css
|
||||
assert "failedIds" in js
|
||||
|
||||
@@ -290,3 +290,102 @@ class TestExplicitOverrides:
|
||||
model="anthropic/claude-sonnet-4.6",
|
||||
)
|
||||
assert (should, native) == (True, False)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Long-lived prefix cache policy (cross-session 1h tier)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSupportsLongLivedAnthropicCache:
|
||||
"""Narrower than _anthropic_prompt_cache_policy — only Claude on the 4
|
||||
explicitly-validated endpoints get the long-lived layout."""
|
||||
|
||||
def test_native_anthropic_claude_supported(self):
|
||||
agent = _make_agent(
|
||||
provider="anthropic",
|
||||
base_url="https://api.anthropic.com",
|
||||
api_mode="anthropic_messages",
|
||||
model="claude-sonnet-4.6",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is True
|
||||
|
||||
def test_anthropic_oauth_supported(self):
|
||||
# OAuth uses the same transport as native Anthropic
|
||||
agent = _make_agent(
|
||||
provider="anthropic",
|
||||
base_url="https://api.anthropic.com",
|
||||
api_mode="anthropic_messages",
|
||||
model="claude-opus-4.6",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is True
|
||||
|
||||
def test_openrouter_claude_supported(self):
|
||||
agent = _make_agent(
|
||||
provider="openrouter",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_mode="chat_completions",
|
||||
model="anthropic/claude-sonnet-4.6",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is True
|
||||
|
||||
def test_nous_portal_claude_supported(self):
|
||||
# Nous Portal proxies to OpenRouter — same wire format
|
||||
agent = _make_agent(
|
||||
provider="nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
api_mode="chat_completions",
|
||||
model="anthropic/claude-opus-4.7",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is True
|
||||
|
||||
def test_openrouter_non_claude_rejected(self):
|
||||
agent = _make_agent(
|
||||
provider="openrouter",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_mode="chat_completions",
|
||||
model="openai/gpt-5.4",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is False
|
||||
|
||||
def test_third_party_anthropic_gateway_rejected(self):
|
||||
# MiniMax / Kimi / etc. — anthropic-wire but not in our validated list
|
||||
agent = _make_agent(
|
||||
provider="minimax",
|
||||
base_url="https://api.minimax.io/anthropic",
|
||||
api_mode="anthropic_messages",
|
||||
model="minimax-m2.7",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is False
|
||||
|
||||
def test_alibaba_dashscope_rejected(self):
|
||||
agent = _make_agent(
|
||||
provider="alibaba",
|
||||
base_url="https://dashscope.aliyuncs.com/api/v1/anthropic",
|
||||
api_mode="anthropic_messages",
|
||||
model="qwen3.5-plus",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is False
|
||||
|
||||
def test_opencode_qwen_rejected(self):
|
||||
agent = _make_agent(
|
||||
provider="opencode-go",
|
||||
base_url="https://api.opencode-go.example/v1",
|
||||
api_mode="chat_completions",
|
||||
model="qwen3.6-plus",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is False
|
||||
|
||||
def test_fallback_target_evaluated_independently(self):
|
||||
# Starting on a non-supported provider, falling back to OpenRouter Claude
|
||||
agent = _make_agent(
|
||||
provider="minimax",
|
||||
base_url="https://api.minimax.io/anthropic",
|
||||
api_mode="anthropic_messages",
|
||||
model="minimax-m2.7",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache(
|
||||
provider="openrouter",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_mode="chat_completions",
|
||||
model="anthropic/claude-sonnet-4.6",
|
||||
) is True
|
||||
|
||||
@@ -194,6 +194,7 @@ class TestImageRejectionPhraseIsolation:
|
||||
"does not support multimodal",
|
||||
"does not support vision",
|
||||
"model does not support image",
|
||||
"image_url'. expected",
|
||||
)
|
||||
|
||||
def _matches(self, body: str) -> bool:
|
||||
@@ -238,6 +239,29 @@ class TestImageRejectionPhraseIsolation:
|
||||
"This model does not support images",
|
||||
"vision is not supported on this endpoint",
|
||||
"model does not support image input",
|
||||
# ChatGPT-account Codex backend (issue #23570) — rejects
|
||||
# data:image/...base64 URLs in input_image fields. Without this
|
||||
# match the agent cascaded into compression / context-too-large
|
||||
# recovery instead of just stripping the images.
|
||||
"Invalid 'input[56].content[1].image_url'. Expected a valid URL, but got a value with an invalid format.",
|
||||
]
|
||||
for body in bodies:
|
||||
assert self._matches(body) is True, f"false negative on: {body}"
|
||||
|
||||
def test_codex_data_url_rejection_does_not_false_match_other_url_errors(self):
|
||||
"""The narrow 'image_url'. expected' phrase (keyed on the
|
||||
field-path apostrophe used in the Codex Responses error format)
|
||||
must NOT trip on URL validation errors that aren't about
|
||||
image_url specifically. See issue #23570 for the original error.
|
||||
"""
|
||||
bodies = [
|
||||
# Generic URL validation errors — should NOT trip
|
||||
"Invalid webhook_url. Must be a valid URL.",
|
||||
"Expected a valid URL but got an empty string.",
|
||||
"redirect_uri does not look like a valid URL.",
|
||||
# An image_url error worded differently — also should not trip
|
||||
# the narrow phrase (a separate phrase would be needed)
|
||||
"image_url field cannot be empty",
|
||||
]
|
||||
for body in bodies:
|
||||
assert self._matches(body) is False, f"false positive on: {body}"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Regression test: temp file cleanup when materializing data URLs for vision.
|
||||
|
||||
`_materialize_data_url_for_vision` creates a `NamedTemporaryFile(delete=False)`
|
||||
so the path can be handed to vision backends. If `base64.b64decode` raises on
|
||||
a corrupt/unsupported data URL the temp file would otherwise persist forever
|
||||
on disk, leaking once per failed call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from run_agent import AIAgent
|
||||
|
||||
|
||||
def _list_anthropic_tmpfiles(tmpdir: str) -> list[str]:
|
||||
return [
|
||||
name for name in os.listdir(tmpdir)
|
||||
if name.startswith("anthropic_image_")
|
||||
]
|
||||
|
||||
|
||||
def test_b64decode_failure_does_not_leak_tempfile(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
|
||||
|
||||
bad_url = "data:image/png;base64,!!!not-valid-base64!!!"
|
||||
with pytest.raises(Exception):
|
||||
AIAgent._materialize_data_url_for_vision(bad_url)
|
||||
|
||||
leftovers = _list_anthropic_tmpfiles(str(tmp_path))
|
||||
assert leftovers == [], f"leaked temp files after decode failure: {leftovers}"
|
||||
|
||||
|
||||
def test_successful_decode_returns_path_to_existing_file(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
|
||||
|
||||
payload = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 # a few bytes is enough
|
||||
encoded = base64.b64encode(payload).decode("ascii")
|
||||
good_url = f"data:image/png;base64,{encoded}"
|
||||
|
||||
path_str, path_obj = AIAgent._materialize_data_url_for_vision(good_url)
|
||||
|
||||
assert isinstance(path_obj, Path)
|
||||
assert path_obj.exists()
|
||||
assert path_obj.read_bytes() == payload
|
||||
assert path_str == str(path_obj)
|
||||
# Caller is responsible for cleanup; mimic that here so the test leaves
|
||||
# no artifacts behind.
|
||||
path_obj.unlink()
|
||||
@@ -3344,6 +3344,88 @@ class TestRunConversation:
|
||||
assert "truncated due to output length limit" in result["error"]
|
||||
mock_handle_function_call.assert_not_called()
|
||||
|
||||
def test_kanban_block_called_on_iteration_exhaustion(self, agent, monkeypatch):
|
||||
"""Regression: kanban worker must call kanban_block when iteration
|
||||
budget is exhausted, otherwise the dispatcher sees a protocol
|
||||
violation and gives up after 1 failure (issue #23216)."""
|
||||
self._setup_agent(agent)
|
||||
agent.max_iterations = 2
|
||||
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_test_task_123")
|
||||
|
||||
# Return a tool call for every iteration to exhaust the budget.
|
||||
tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1")
|
||||
tool_resp = _mock_response(
|
||||
content="", finish_reason="tool_calls", tool_calls=[tc],
|
||||
)
|
||||
# Final summary response from _handle_max_iterations.
|
||||
summary_resp = _mock_response(
|
||||
content="Could not finish — budget exhausted.", finish_reason="stop",
|
||||
)
|
||||
agent.client.chat.completions.create.side_effect = [
|
||||
tool_resp, tool_resp, summary_resp,
|
||||
]
|
||||
|
||||
with (
|
||||
patch("run_agent.handle_function_call", return_value="ok") as mock_hfc,
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
result = agent.run_conversation("do the kanban work")
|
||||
|
||||
# The agent should have reported the task as not completed.
|
||||
assert result["completed"] is False
|
||||
|
||||
# Among all handle_function_call invocations, one must be
|
||||
# kanban_block with the correct task_id and a reason mentioning
|
||||
# iteration exhaustion.
|
||||
kanban_block_calls = [
|
||||
c for c in mock_hfc.call_args_list
|
||||
if c[0][0] == "kanban_block"
|
||||
]
|
||||
assert len(kanban_block_calls) == 1, (
|
||||
f"Expected exactly 1 kanban_block call, got {len(kanban_block_calls)}. "
|
||||
f"All calls: {mock_hfc.call_args_list}"
|
||||
)
|
||||
call = kanban_block_calls[0]
|
||||
assert call[0][1]["task_id"] == "t_test_task_123"
|
||||
assert "Iteration budget exhausted" in call[0][1]["reason"]
|
||||
|
||||
def test_no_kanban_block_when_not_in_kanban_mode(self, agent, monkeypatch):
|
||||
"""kanban_block must NOT be called when HERMES_KANBAN_TASK is unset."""
|
||||
self._setup_agent(agent)
|
||||
agent.max_iterations = 2
|
||||
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
|
||||
tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1")
|
||||
tool_resp = _mock_response(
|
||||
content="", finish_reason="tool_calls", tool_calls=[tc],
|
||||
)
|
||||
summary_resp = _mock_response(
|
||||
content="Summary.", finish_reason="stop",
|
||||
)
|
||||
agent.client.chat.completions.create.side_effect = [
|
||||
tool_resp, tool_resp, summary_resp,
|
||||
]
|
||||
|
||||
with (
|
||||
patch("run_agent.handle_function_call", return_value="ok") as mock_hfc,
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
agent.run_conversation("do stuff")
|
||||
|
||||
kanban_block_calls = [
|
||||
c for c in mock_hfc.call_args_list
|
||||
if c[0][0] == "kanban_block"
|
||||
]
|
||||
assert len(kanban_block_calls) == 0, (
|
||||
"kanban_block should not be called outside kanban mode"
|
||||
)
|
||||
|
||||
|
||||
class TestRetryExhaustion:
|
||||
"""Regression: retry_count > max_retries was dead code (off-by-one).
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Test that HERMES_SESSION_ID is exposed as an env var and ContextVar."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
|
||||
|
||||
from run_agent import AIAgent
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cleanup_env():
|
||||
"""Remove HERMES_SESSION_ID before/after each test."""
|
||||
os.environ.pop("HERMES_SESSION_ID", None)
|
||||
yield
|
||||
os.environ.pop("HERMES_SESSION_ID", None)
|
||||
|
||||
|
||||
def test_session_id_env_set_on_init():
|
||||
"""AIAgent.__init__ sets HERMES_SESSION_ID in the environment."""
|
||||
agent = AIAgent(
|
||||
api_key="test-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
assert os.environ.get("HERMES_SESSION_ID") == agent.session_id
|
||||
assert len(agent.session_id) > 0
|
||||
|
||||
|
||||
def test_session_id_env_uses_provided_id():
|
||||
"""When session_id is passed explicitly, HERMES_SESSION_ID reflects it."""
|
||||
custom_id = "20260511_120000_abc12345"
|
||||
agent = AIAgent(
|
||||
api_key="test-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
session_id=custom_id,
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
assert os.environ["HERMES_SESSION_ID"] == custom_id
|
||||
assert agent.session_id == custom_id
|
||||
|
||||
|
||||
def test_session_id_contextvar_set():
|
||||
"""AIAgent.__init__ also sets the ContextVar for concurrency safety."""
|
||||
custom_id = "20260511_130000_def67890"
|
||||
AIAgent(
|
||||
api_key="test-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
session_id=custom_id,
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
from gateway.session_context import get_session_env
|
||||
assert get_session_env("HERMES_SESSION_ID") == custom_id
|
||||
@@ -0,0 +1,358 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SCRIPT_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "optional-skills"
|
||||
/ "blockchain"
|
||||
/ "hyperliquid"
|
||||
/ "scripts"
|
||||
/ "hyperliquid_client.py"
|
||||
)
|
||||
|
||||
|
||||
def load_module():
|
||||
spec = importlib.util.spec_from_file_location("hyperliquid_skill", SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_normalize_perp_markets_extracts_change_and_volume():
|
||||
mod = load_module()
|
||||
|
||||
payload = [
|
||||
{
|
||||
"universe": [
|
||||
{"name": "BTC", "szDecimals": 5, "maxLeverage": 50},
|
||||
{"name": "ETH", "szDecimals": 4, "maxLeverage": 25, "isDelisted": True},
|
||||
]
|
||||
},
|
||||
[
|
||||
{
|
||||
"markPx": "100000",
|
||||
"prevDayPx": "95000",
|
||||
"funding": "0.0001",
|
||||
"openInterest": "123456789",
|
||||
"dayNtlVlm": "999999999",
|
||||
},
|
||||
{
|
||||
"markPx": "2500",
|
||||
"prevDayPx": "2600",
|
||||
"funding": "-0.0002",
|
||||
"openInterest": "20000000",
|
||||
"dayNtlVlm": "11111111",
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
rows = mod._normalize_perp_markets(payload)
|
||||
|
||||
assert len(rows) == 2
|
||||
assert rows[0]["coin"] == "BTC"
|
||||
assert round(rows[0]["change_pct"], 2) == 5.26
|
||||
assert rows[0]["day_ntl_vlm"] == "999999999"
|
||||
assert rows[1]["is_delisted"] is True
|
||||
|
||||
|
||||
def test_normalize_dexs_includes_first_perp_dex_placeholder():
|
||||
mod = load_module()
|
||||
|
||||
rows = mod._normalize_dexs(
|
||||
[
|
||||
None,
|
||||
{
|
||||
"name": "test",
|
||||
"fullName": "test dex",
|
||||
"deployer": "0x1234567890abcdef1234567890abcdef12345678",
|
||||
"assetToStreamingOiCap": [["COIN", "100"]],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert rows[0]["label"] == "first-perp-dex"
|
||||
assert rows[1]["label"] == "test"
|
||||
assert rows[1]["asset_caps"] == 1
|
||||
|
||||
|
||||
def test_main_markets_json_prints_normalized_payload(capsys):
|
||||
mod = load_module()
|
||||
|
||||
payload = [
|
||||
{"universe": [{"name": "BTC", "szDecimals": 5, "maxLeverage": 50}]},
|
||||
[{"markPx": "101000", "prevDayPx": "100000", "dayNtlVlm": "10"}],
|
||||
]
|
||||
|
||||
with patch.object(mod, "_post_info", return_value=payload):
|
||||
exit_code = mod.main(["markets", "--limit", "1", "--json"])
|
||||
|
||||
stdout = capsys.readouterr().out
|
||||
rendered = json.loads(stdout)
|
||||
|
||||
assert exit_code == 0
|
||||
assert rendered["count"] == 1
|
||||
assert rendered["markets"][0]["coin"] == "BTC"
|
||||
assert round(rendered["markets"][0]["change_pct"], 2) == 1.0
|
||||
|
||||
|
||||
def test_main_candles_json_limits_rows(capsys):
|
||||
mod = load_module()
|
||||
|
||||
payload = [
|
||||
{"t": 1000, "o": "1", "h": "2", "l": "0.5", "c": "1.5", "v": "10", "n": 3},
|
||||
{"t": 2000, "o": "1.5", "h": "2.5", "l": "1.4", "c": "2.0", "v": "20", "n": 5},
|
||||
{"t": 3000, "o": "2.0", "h": "2.2", "l": "1.8", "c": "2.1", "v": "15", "n": 4},
|
||||
]
|
||||
|
||||
with patch.object(mod, "_post_info", return_value=payload):
|
||||
exit_code = mod.main(["candles", "BTC", "--limit", "2", "--json"])
|
||||
|
||||
stdout = capsys.readouterr().out
|
||||
rendered = json.loads(stdout)
|
||||
|
||||
assert exit_code == 0
|
||||
assert rendered["count"] == 3
|
||||
assert len(rendered["candles"]) == 2
|
||||
assert rendered["summary"]["open"] == "1"
|
||||
assert rendered["summary"]["close"] == "2.1"
|
||||
|
||||
|
||||
def test_main_review_json_builds_market_context_and_findings(capsys):
|
||||
mod = load_module()
|
||||
|
||||
def fake_post_info(payload):
|
||||
payload_type = payload["type"]
|
||||
if payload_type == "userFillsByTime":
|
||||
return [
|
||||
{"fill": {"coin": "BTC", "dir": "Close Long", "px": "110000", "sz": "0.1", "closedPnl": "120", "fee": "5", "feeToken": "USDC", "time": 4000}},
|
||||
{"fill": {"coin": "BTC", "dir": "Open Long", "px": "100000", "sz": "0.1", "closedPnl": "0", "fee": "1", "feeToken": "USDC", "time": 3000}},
|
||||
{"fill": {"coin": "ETH", "dir": "Close Short", "px": "2200", "sz": "1", "closedPnl": "-80", "fee": "4", "feeToken": "USDC", "time": 2000}},
|
||||
{"fill": {"coin": "ETH", "dir": "Open Short", "px": "2000", "sz": "1", "closedPnl": "0", "fee": "1", "feeToken": "USDC", "time": 1000}},
|
||||
]
|
||||
if payload_type == "candleSnapshot" and payload["req"]["coin"] == "BTC":
|
||||
return [
|
||||
{"t": 1000, "o": "100000", "h": "111000", "l": "99000", "c": "110000", "v": "10", "n": 3},
|
||||
]
|
||||
if payload_type == "candleSnapshot" and payload["req"]["coin"] == "ETH":
|
||||
return [
|
||||
{"t": 1000, "o": "2000", "h": "2210", "l": "1990", "c": "2200", "v": "50", "n": 10},
|
||||
]
|
||||
if payload_type == "fundingHistory" and payload["coin"] == "BTC":
|
||||
return [{"coin": "BTC", "fundingRate": "0.0001", "premium": "0.0002", "time": 1000}]
|
||||
if payload_type == "fundingHistory" and payload["coin"] == "ETH":
|
||||
return [{"coin": "ETH", "fundingRate": "0.0002", "premium": "0.0003", "time": 1000}]
|
||||
raise AssertionError(f"Unexpected payload: {payload}")
|
||||
|
||||
with patch.object(mod, "_post_info", side_effect=fake_post_info):
|
||||
exit_code = mod.main(["review", "0xabc", "--hours", "72", "--json"])
|
||||
|
||||
stdout = capsys.readouterr().out
|
||||
rendered = json.loads(stdout)
|
||||
|
||||
assert exit_code == 0
|
||||
assert rendered["summary"]["fill_count"] == 4
|
||||
assert rendered["summary"]["realized_pnl"] == 40.0
|
||||
assert rendered["summary"]["total_fees"] == 11.0
|
||||
assert rendered["summary"]["net_after_fees"] == 29.0
|
||||
assert len(rendered["coin_reviews"]) == 2
|
||||
eth_review = next(item for item in rendered["coin_reviews"] if item["coin"] == "ETH")
|
||||
assert round(eth_review["market_context"]["price_change_pct"], 2) == 10.0
|
||||
assert eth_review["market_context"]["average_funding_rate"] == 0.0002
|
||||
assert any("ETH" in finding and "rising market" in finding for finding in rendered["findings"])
|
||||
|
||||
|
||||
def test_main_review_json_respects_coin_filter(capsys):
|
||||
mod = load_module()
|
||||
|
||||
def fake_post_info(payload):
|
||||
if payload["type"] == "userFillsByTime":
|
||||
return [
|
||||
{"fill": {"coin": "BTC", "dir": "Close Long", "px": "110000", "sz": "0.1", "closedPnl": "120", "fee": "5", "feeToken": "USDC", "time": 4000}},
|
||||
{"fill": {"coin": "ETH", "dir": "Close Short", "px": "2200", "sz": "1", "closedPnl": "-80", "fee": "4", "feeToken": "USDC", "time": 2000}},
|
||||
]
|
||||
if payload["type"] == "candleSnapshot":
|
||||
return [{"t": 1000, "o": "100000", "h": "111000", "l": "99000", "c": "110000", "v": "10", "n": 3}]
|
||||
if payload["type"] == "fundingHistory":
|
||||
return [{"coin": "BTC", "fundingRate": "0.0001", "premium": "0.0002", "time": 1000}]
|
||||
raise AssertionError(f"Unexpected payload: {payload}")
|
||||
|
||||
with patch.object(mod, "_post_info", side_effect=fake_post_info):
|
||||
exit_code = mod.main(["review", "0xabc", "--coin", "BTC", "--json"])
|
||||
|
||||
stdout = capsys.readouterr().out
|
||||
rendered = json.loads(stdout)
|
||||
|
||||
assert exit_code == 0
|
||||
assert rendered["summary"]["fill_count"] == 1
|
||||
assert rendered["summary"]["unique_coins"] == 1
|
||||
assert rendered["coin_reviews"][0]["coin"] == "BTC"
|
||||
|
||||
|
||||
def test_resolve_user_uses_env_fallback(monkeypatch):
|
||||
mod = load_module()
|
||||
monkeypatch.setenv("HYPERLIQUID_USER_ADDRESS", "0xenv123")
|
||||
|
||||
assert mod._resolve_user("") == "0xenv123"
|
||||
assert mod._resolve_user(None) == "0xenv123"
|
||||
assert mod._resolve_user("0xcli456") == "0xcli456"
|
||||
|
||||
|
||||
def test_resolve_user_errors_when_missing(monkeypatch, tmp_path):
|
||||
mod = load_module()
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
monkeypatch.delenv("HYPERLIQUID_USER_ADDRESS", raising=False)
|
||||
|
||||
try:
|
||||
mod._resolve_user("")
|
||||
except SystemExit as exc:
|
||||
message = str(exc)
|
||||
else:
|
||||
raise AssertionError("Expected SystemExit when no user is provided")
|
||||
|
||||
assert "HYPERLIQUID_USER_ADDRESS" in message
|
||||
|
||||
|
||||
def test_main_state_json_uses_env_fallback(monkeypatch, capsys):
|
||||
mod = load_module()
|
||||
monkeypatch.setenv("HYPERLIQUID_USER_ADDRESS", "0xenv999")
|
||||
|
||||
with patch.object(
|
||||
mod,
|
||||
"_post_info",
|
||||
return_value={"marginSummary": {"accountValue": "123"}, "assetPositions": [], "withdrawable": "50"},
|
||||
) as mock_post:
|
||||
exit_code = mod.main(["state", "--json"])
|
||||
|
||||
stdout = capsys.readouterr().out
|
||||
rendered = json.loads(stdout)
|
||||
|
||||
assert exit_code == 0
|
||||
assert rendered["user"] == "0xenv999"
|
||||
assert mock_post.call_args[0][0]["user"] == "0xenv999"
|
||||
|
||||
|
||||
def test_env_lookup_reads_hermes_dotenv(tmp_path, monkeypatch):
|
||||
mod = load_module()
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir(parents=True)
|
||||
(hermes_home / ".env").write_text(
|
||||
"HYPERLIQUID_USER_ADDRESS=0xdotenv123\nHYPERLIQUID_API_URL=https://api.hyperliquid-testnet.xyz\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("HYPERLIQUID_USER_ADDRESS", raising=False)
|
||||
monkeypatch.delenv("HYPERLIQUID_API_URL", raising=False)
|
||||
|
||||
assert mod._env_lookup("HYPERLIQUID_USER_ADDRESS") == "0xdotenv123"
|
||||
assert mod._resolve_user("") == "0xdotenv123"
|
||||
assert mod._info_url() == "https://api.hyperliquid-testnet.xyz/info"
|
||||
|
||||
|
||||
def test_user_dotenv_overrides_project_dotenv(tmp_path, monkeypatch):
|
||||
mod = load_module()
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".env").write_text("HYPERLIQUID_USER_ADDRESS=0xproject\n", encoding="utf-8")
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / ".env").write_text("HYPERLIQUID_USER_ADDRESS=0xuserhome\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("HYPERLIQUID_USER_ADDRESS", raising=False)
|
||||
|
||||
assert mod._env_lookup("HYPERLIQUID_USER_ADDRESS") == "0xuserhome"
|
||||
|
||||
|
||||
def test_main_export_json_writes_expected_contract(tmp_path, capsys):
|
||||
mod = load_module()
|
||||
output_path = tmp_path / "exports" / "btc-1h.json"
|
||||
|
||||
def fake_post_info(payload):
|
||||
if payload["type"] == "candleSnapshot":
|
||||
return [
|
||||
{"t": 1000, "o": "100", "h": "110", "l": "95", "c": "108", "v": "50", "n": 4},
|
||||
{"t": 2000, "o": "108", "h": "115", "l": "107", "c": "112", "v": "60", "n": 5},
|
||||
]
|
||||
if payload["type"] == "fundingHistory":
|
||||
return [
|
||||
{"coin": "BTC", "fundingRate": "0.0001", "premium": "0.0002", "time": 1500},
|
||||
{"coin": "BTC", "fundingRate": "0.0003", "premium": "0.0004", "time": 2000},
|
||||
]
|
||||
raise AssertionError(f"Unexpected payload: {payload}")
|
||||
|
||||
with patch.object(mod, "_post_info", side_effect=fake_post_info):
|
||||
exit_code = mod.main(
|
||||
[
|
||||
"export",
|
||||
"BTC",
|
||||
"--interval",
|
||||
"1h",
|
||||
"--hours",
|
||||
"24",
|
||||
"--end-time-ms",
|
||||
"5000",
|
||||
"--output",
|
||||
str(output_path),
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
|
||||
stdout = capsys.readouterr().out
|
||||
rendered = json.loads(stdout)
|
||||
saved = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert exit_code == 0
|
||||
assert rendered["output_path"] == str(output_path)
|
||||
assert saved["schema_version"] == "hyperliquid-market-export-v1"
|
||||
assert saved["source"]["coin"] == "BTC"
|
||||
assert saved["window"]["start_time_ms"] == 5000 - 24 * 60 * 60 * 1000
|
||||
assert saved["window"]["end_time_ms"] == 5000
|
||||
assert saved["summary"]["candle_count"] == 2
|
||||
assert saved["summary"]["funding_count"] == 2
|
||||
assert round(saved["summary"]["price_change_pct"], 2) == 12.0
|
||||
assert saved["summary"]["average_funding_rate"] == 0.0002
|
||||
assert len(saved["candles"]) == 2
|
||||
assert len(saved["funding_history"]) == 2
|
||||
|
||||
|
||||
def test_main_export_json_skips_funding_for_spot(tmp_path, capsys):
|
||||
mod = load_module()
|
||||
output_path = tmp_path / "purr-usdc.json"
|
||||
|
||||
def fake_post_info(payload):
|
||||
if payload["type"] == "candleSnapshot":
|
||||
return [{"t": 1000, "o": "1", "h": "1.2", "l": "0.9", "c": "1.1", "v": "100", "n": 10}]
|
||||
raise AssertionError(f"Unexpected payload: {payload}")
|
||||
|
||||
with patch.object(mod, "_post_info", side_effect=fake_post_info):
|
||||
exit_code = mod.main(
|
||||
[
|
||||
"export",
|
||||
"PURR/USDC",
|
||||
"--end-time-ms",
|
||||
"5000",
|
||||
"--output",
|
||||
str(output_path),
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
|
||||
stdout = capsys.readouterr().out
|
||||
rendered = json.loads(stdout)
|
||||
saved = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert exit_code == 0
|
||||
assert rendered["summary"]["funding_count"] == 0
|
||||
assert saved["source"]["market_type"] == "spot"
|
||||
assert saved["funding_history"] == []
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Self-test for the live-system guard fixture in tests/conftest.py.
|
||||
|
||||
This file is the canary. If anyone removes a guard or weakens it, these
|
||||
tests fail. If anyone adds a NEW kill primitive to the codebase without
|
||||
adding it to the guard, the corresponding test added here will fail too.
|
||||
|
||||
The guard exists to protect the developer's live ``hermes-gateway`` process
|
||||
from being SIGTERMed by tests. See PR #23397 for the original incident
|
||||
(5+ live gateway kills in 3 days). Per Teknium 2026-05-10:
|
||||
|
||||
> "You better do such a deep scan and scrub of the tests that this
|
||||
> never is possible ever again for all eternity."
|
||||
|
||||
Every primitive that can deliver a signal to a foreign process or mutate
|
||||
the live systemd unit MUST be exercised below. Adding a new primitive to
|
||||
the guard? Add a test here too.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
# A guaranteed-foreign PID: PID 1 (init). Owned by root, not us, and
|
||||
# always exists. A sane guard refuses to signal it.
|
||||
FOREIGN_PID = 1
|
||||
|
||||
|
||||
# ──────────────────── kill primitives ─────────────────────────
|
||||
|
||||
|
||||
def test_os_kill_blocks_foreign_pid():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
os.kill(FOREIGN_PID, signal.SIGTERM)
|
||||
|
||||
|
||||
def test_os_kill_blocks_negative_one():
|
||||
"""``os.kill(-1, sig)`` signals every process we can reach. Must be blocked."""
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
os.kill(-1, signal.SIGTERM)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "killpg"), reason="killpg POSIX-only")
|
||||
def test_os_killpg_blocks_foreign_pgid():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
os.killpg(FOREIGN_PID, signal.SIGTERM)
|
||||
|
||||
|
||||
# ──────────────────── subprocess regex bypasses ────────────────
|
||||
|
||||
|
||||
def test_subprocess_run_systemctl_restart_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.run(["systemctl", "--user", "restart", "hermes-gateway"])
|
||||
|
||||
|
||||
def test_subprocess_run_full_path_systemctl_blocked():
|
||||
"""``/usr/bin/systemctl`` (full path) must be blocked too."""
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.run(["/usr/bin/systemctl", "--user", "stop", "hermes-gateway"])
|
||||
|
||||
|
||||
def test_subprocess_run_sudo_systemctl_blocked():
|
||||
"""``sudo systemctl ...`` defeated the old head==systemctl check."""
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.run(["sudo", "systemctl", "restart", "hermes-gateway"])
|
||||
|
||||
|
||||
def test_subprocess_run_env_systemctl_blocked():
|
||||
"""``env systemctl ...`` similarly defeated the old head check."""
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.run(["env", "systemctl", "--user", "restart", "hermes-gateway"])
|
||||
|
||||
|
||||
def test_subprocess_run_bash_c_systemctl_blocked():
|
||||
"""``bash -c "systemctl ..."`` must also be caught."""
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.run(["bash", "-c", "systemctl --user restart hermes-gateway"])
|
||||
|
||||
|
||||
def test_subprocess_run_sh_c_systemctl_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.run(["sh", "-c", "systemctl --user stop hermes-gateway"])
|
||||
|
||||
|
||||
def test_subprocess_run_setsid_systemctl_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.run(["setsid", "systemctl", "kill", "hermes-gateway"])
|
||||
|
||||
|
||||
def test_subprocess_run_string_shell_true_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.run(
|
||||
"systemctl --user restart hermes-gateway",
|
||||
shell=True,
|
||||
)
|
||||
|
||||
|
||||
def test_subprocess_popen_systemctl_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.Popen(["systemctl", "--user", "stop", "hermes-gateway"])
|
||||
|
||||
|
||||
def test_subprocess_call_systemctl_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.call(["systemctl", "--user", "restart", "hermes-gateway"])
|
||||
|
||||
|
||||
def test_subprocess_check_call_systemctl_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.check_call(["systemctl", "--user", "restart", "hermes-gateway"])
|
||||
|
||||
|
||||
def test_subprocess_check_output_systemctl_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.check_output(["systemctl", "--user", "restart", "hermes-gateway"])
|
||||
|
||||
|
||||
def test_subprocess_getoutput_systemctl_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.getoutput("systemctl --user restart hermes-gateway")
|
||||
|
||||
|
||||
def test_subprocess_getstatusoutput_systemctl_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.getstatusoutput("systemctl --user restart hermes-gateway")
|
||||
|
||||
|
||||
# ──────────────────── os.system / os.popen ────────────────────
|
||||
|
||||
|
||||
def test_os_system_systemctl_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
os.system("systemctl --user restart hermes-gateway")
|
||||
|
||||
|
||||
def test_os_popen_systemctl_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
os.popen("systemctl --user restart hermes-gateway")
|
||||
|
||||
|
||||
# ──────────────────── pty.spawn ────────────────────────────────
|
||||
|
||||
|
||||
def test_pty_spawn_systemctl_blocked():
|
||||
import pty
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
pty.spawn(["systemctl", "--user", "restart", "hermes-gateway"])
|
||||
|
||||
|
||||
# ──────────────────── asyncio.create_subprocess_* ──────────────
|
||||
|
||||
|
||||
def test_asyncio_create_subprocess_exec_systemctl_blocked():
|
||||
import asyncio
|
||||
|
||||
async def _attempt():
|
||||
await asyncio.create_subprocess_exec(
|
||||
"systemctl", "--user", "restart", "hermes-gateway"
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
asyncio.run(_attempt())
|
||||
|
||||
|
||||
def test_asyncio_create_subprocess_shell_systemctl_blocked():
|
||||
import asyncio
|
||||
|
||||
async def _attempt():
|
||||
await asyncio.create_subprocess_shell(
|
||||
"systemctl --user restart hermes-gateway"
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
asyncio.run(_attempt())
|
||||
|
||||
|
||||
# ──────────────────── pkill / killall / taskkill ───────────────
|
||||
|
||||
|
||||
def test_subprocess_pkill_hermes_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.run(["pkill", "-f", "hermes"])
|
||||
|
||||
|
||||
def test_subprocess_pkill_hermes_gateway_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.run(["pkill", "-f", "hermes-gateway"])
|
||||
|
||||
|
||||
def test_subprocess_pkill_python_dash_f_blocked():
|
||||
"""``pkill -f python`` matches the gateway's "python -m hermes_cli.main"."""
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.run(["pkill", "-f", "python"])
|
||||
|
||||
|
||||
def test_subprocess_killall_hermes_blocked():
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
subprocess.run(["killall", "hermes"])
|
||||
|
||||
|
||||
# ──────────────────── pass-through cases (must NOT raise) ──────
|
||||
|
||||
|
||||
def test_systemctl_status_passes_through():
|
||||
"""Read-only systemctl probes (status/show/list-units) are fine."""
|
||||
# Run with check=False so we don't fail on the gateway's exit code.
|
||||
r = subprocess.run(
|
||||
["systemctl", "--user", "status", "hermes-gateway", "--no-pager"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert r is not None # Did not raise — the guard let it through.
|
||||
|
||||
|
||||
def test_systemctl_show_passes_through():
|
||||
r = subprocess.run(
|
||||
["systemctl", "--user", "show", "hermes-gateway", "--no-pager"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert r is not None
|
||||
|
||||
|
||||
def test_systemctl_list_units_passes_through():
|
||||
r = subprocess.run(
|
||||
["systemctl", "--user", "list-units", "fake-not-real-unit*", "--no-pager"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert r is not None
|
||||
|
||||
|
||||
def test_systemctl_unrelated_unit_passes_through():
|
||||
"""systemctl restart of a non-hermes unit is allowed (we only protect hermes)."""
|
||||
# Use --dry-run so we don't actually try to restart anything; just
|
||||
# verify the guard doesn't block the call. systemctl supports
|
||||
# --dry-run via the privileged API; on user scope it usually fails
|
||||
# quickly without side effects.
|
||||
r = subprocess.run(
|
||||
["systemctl", "--user", "show", "fake-not-real-unit"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert r is not None
|
||||
|
||||
|
||||
def test_kill_own_subtree_passes_through():
|
||||
"""We CAN kill our own children — guard recognizes them via psutil."""
|
||||
p = subprocess.Popen(["sleep", "30"])
|
||||
try:
|
||||
os.kill(p.pid, signal.SIGTERM)
|
||||
finally:
|
||||
p.wait(timeout=2)
|
||||
# SIGTERM = 15; subprocess returncode is -15 on POSIX.
|
||||
assert p.returncode in (-signal.SIGTERM, 128 + int(signal.SIGTERM))
|
||||
|
||||
|
||||
def test_subprocess_pkill_with_unrelated_pattern_passes_through():
|
||||
"""``pkill -f some-unrelated-pattern`` (no hermes/python) is fine."""
|
||||
# We don't actually run pkill — just verify the guard would let it
|
||||
# through by inspecting the matcher. Re-implementing the check here
|
||||
# would duplicate the guard; instead spawn a noop to confirm no raise.
|
||||
# Use 'true' so it succeeds quickly.
|
||||
r = subprocess.run(["true"], capture_output=True)
|
||||
assert r.returncode == 0
|
||||
|
||||
|
||||
def test_normal_subprocess_run_passes_through():
|
||||
"""Plain non-systemctl subprocess.run should work normally."""
|
||||
r = subprocess.run(["echo", "hello"], capture_output=True, text=True)
|
||||
assert r.stdout.strip() == "hello"
|
||||
|
||||
|
||||
# ──────────────────── bypass marker ─────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.live_system_guard_bypass
|
||||
def test_bypass_marker_disables_guard():
|
||||
"""The bypass marker exists for tests that genuinely need real signal delivery
|
||||
(e.g. PTY tests SIGINTing their own child). Verify it works.
|
||||
|
||||
We use it harmlessly here by signaling our own PID 0 (own group) so we
|
||||
don't actually kill anything — but the call goes through real os.kill.
|
||||
"""
|
||||
# With bypass, the guard yields without installing the monkeypatch,
|
||||
# so we get the real os.kill. Calling os.kill(os.getpid(), 0) just
|
||||
# checks that the PID exists — harmless.
|
||||
os.kill(os.getpid(), 0) # No exception — guard is OFF.
|
||||
@@ -965,3 +965,140 @@ class TestFailClosedUnderPromptToolkit:
|
||||
assert result == "once"
|
||||
finally:
|
||||
ptc.get_app_or_none = orig
|
||||
|
||||
|
||||
class TestDetectSudoStdin:
|
||||
"""Sudo with stdin / askpass / shell / list-privileges flags (#17873 cat 4).
|
||||
|
||||
An LLM-driven agent has no TTY, so the sudo invocations that succeed
|
||||
without human interaction are those reading the password from stdin
|
||||
(-S / --stdin) or via an askpass helper (-A / --askpass). The
|
||||
shell-launch (-s) and list-privileges (-a) flags are also gated since
|
||||
they are privilege-relevant invocations the agent can chain after
|
||||
acquiring the password.
|
||||
|
||||
`_normalize_command_for_detection` lowercases input before pattern
|
||||
matching, so -S/-s and -A/-a are indistinguishable at the regex
|
||||
layer; both letter-pairs are gated.
|
||||
"""
|
||||
|
||||
# Positive cases (must match)
|
||||
|
||||
def test_canonical_pipe_to_sudo_S_detected(self):
|
||||
is_dangerous, _, desc = detect_dangerous_command(
|
||||
"echo pwd | sudo -S whoami"
|
||||
)
|
||||
assert is_dangerous is True
|
||||
assert "sudo" in desc.lower()
|
||||
|
||||
def test_long_flag_stdin_detected(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command("sudo --stdin id")
|
||||
assert is_dangerous is True
|
||||
|
||||
def test_non_interactive_plus_stdin_detected(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command("sudo -n -S id")
|
||||
assert is_dangerous is True
|
||||
|
||||
def test_user_then_stdin_detected(self):
|
||||
# Codex audit caught that the original "leading flags only" regex
|
||||
# missed this form because `-u root` has a flag-argument (`root`)
|
||||
# that broke the (?:\s+-[^\s]+)* loop. The lazy [^;|&\n]*? class
|
||||
# consumes flag-args without spanning command separators.
|
||||
is_dangerous, _, _ = detect_dangerous_command(
|
||||
"sudo -u root -S whoami"
|
||||
)
|
||||
assert is_dangerous is True
|
||||
|
||||
def test_long_non_interactive_plus_stdin_detected(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command(
|
||||
"sudo --non-interactive -S whoami"
|
||||
)
|
||||
assert is_dangerous is True
|
||||
|
||||
def test_long_user_equals_stdin_detected(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command(
|
||||
"sudo --user=root -S id"
|
||||
)
|
||||
assert is_dangerous is True
|
||||
|
||||
def test_herestring_input_detected(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command(
|
||||
"sudo -S id <<< 'mypwd'"
|
||||
)
|
||||
assert is_dangerous is True
|
||||
|
||||
def test_combined_short_flags_nS_detected(self):
|
||||
# `-nS` packs `-n` and `-S` into one arg; second pattern catches.
|
||||
is_dangerous, _, _ = detect_dangerous_command("sudo -nS id")
|
||||
assert is_dangerous is True
|
||||
|
||||
def test_printf_form_detected(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command(
|
||||
'printf "%s\\n" "$PW" | sudo -S id'
|
||||
)
|
||||
assert is_dangerous is True
|
||||
|
||||
def test_askpass_short_flag_detected(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command("sudo -A id")
|
||||
assert is_dangerous is True
|
||||
|
||||
def test_askpass_long_flag_detected(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command("sudo --askpass id")
|
||||
assert is_dangerous is True
|
||||
|
||||
def test_two_sudo_invocations_second_caught(self):
|
||||
# The first sudo here is benign (no -S); the second has -S.
|
||||
# Lazy [^;|&\n]*? does NOT span past `;`, so re.search anchors
|
||||
# on the second sudo invocation independently.
|
||||
is_dangerous, _, _ = detect_dangerous_command(
|
||||
"sudo whoami; sudo -S id"
|
||||
)
|
||||
assert is_dangerous is True
|
||||
|
||||
# Negative cases (must NOT match)
|
||||
|
||||
def test_plain_sudo_safe(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command("sudo whoami")
|
||||
assert is_dangerous is False
|
||||
|
||||
def test_sudo_interactive_shell_safe(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command("sudo -i")
|
||||
assert is_dangerous is False
|
||||
|
||||
def test_sudo_with_user_no_stdin_flag_safe(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command("sudo -u root -i")
|
||||
assert is_dangerous is False
|
||||
|
||||
def test_man_sudo_safe(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command("man sudo")
|
||||
assert is_dangerous is False
|
||||
|
||||
def test_which_sudo_safe(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command("which sudo")
|
||||
assert is_dangerous is False
|
||||
|
||||
def test_sudo_user_env_reference_safe(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command(
|
||||
"echo SUDO_USER=$SUDO_USER"
|
||||
)
|
||||
assert is_dangerous is False
|
||||
|
||||
def test_apt_install_sudo_safe(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command("apt install sudo")
|
||||
assert is_dangerous is False
|
||||
|
||||
def test_ls_etc_sudoers_safe(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command("ls /etc/sudoers")
|
||||
assert is_dangerous is False
|
||||
|
||||
def test_pseudosudo_safe_word_boundary(self):
|
||||
# `\bsudo\b` requires a word boundary; `pseudosudo` has none
|
||||
# before `sudo`, so should not trigger.
|
||||
is_dangerous, _, _ = detect_dangerous_command("pseudosudo -S id")
|
||||
assert is_dangerous is False
|
||||
|
||||
def test_unrelated_redirection_safe(self):
|
||||
is_dangerous, _, _ = detect_dangerous_command(
|
||||
"make 2>&1 | tee build.log"
|
||||
)
|
||||
assert is_dangerous is False
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Unit tests for the supervisor-WS fast path in browser_console / _browser_eval.
|
||||
|
||||
These exercise the dispatch logic in ``tools.browser_tool._browser_eval`` and
|
||||
the response shaping in ``CDPSupervisor.evaluate_runtime`` using mocks — no
|
||||
real browser, no real WebSocket. Real-CDP coverage lives in
|
||||
``tests/tools/test_browser_supervisor.py`` (gated on Chrome being installed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fast-path dispatch: tools.browser_tool._browser_eval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_camofox(monkeypatch):
|
||||
"""Force the non-camofox path so our supervisor branch is reached."""
|
||||
import tools.browser_tool as bt
|
||||
|
||||
monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False)
|
||||
monkeypatch.setattr(bt, "_last_session_key", lambda task_id: "test-task")
|
||||
|
||||
|
||||
def _patch_supervisor(monkeypatch, supervisor):
|
||||
"""Wire SUPERVISOR_REGISTRY.get to return ``supervisor`` for any task_id."""
|
||||
import tools.browser_supervisor as bs
|
||||
|
||||
registry = MagicMock()
|
||||
registry.get.return_value = supervisor
|
||||
monkeypatch.setattr(bs, "SUPERVISOR_REGISTRY", registry)
|
||||
return registry
|
||||
|
||||
|
||||
class TestBrowserEvalSupervisorPath:
|
||||
"""The supervisor fast path replaces the agent-browser subprocess hop."""
|
||||
|
||||
def test_primitive_result_routes_through_supervisor(self, monkeypatch):
|
||||
import tools.browser_tool as bt
|
||||
|
||||
sup = MagicMock()
|
||||
sup.evaluate_runtime.return_value = {
|
||||
"ok": True,
|
||||
"result": 42,
|
||||
"result_type": "number",
|
||||
}
|
||||
_patch_supervisor(monkeypatch, sup)
|
||||
# If the subprocess path is hit we want a loud failure.
|
||||
monkeypatch.setattr(
|
||||
bt, "_run_browser_command",
|
||||
lambda *a, **kw: pytest.fail("subprocess path must not run when supervisor is healthy"),
|
||||
)
|
||||
|
||||
out = json.loads(bt._browser_eval("1 + 41"))
|
||||
assert out["success"] is True
|
||||
assert out["result"] == 42
|
||||
assert out["method"] == "cdp_supervisor"
|
||||
sup.evaluate_runtime.assert_called_once_with("1 + 41")
|
||||
|
||||
def test_json_string_result_is_parsed(self, monkeypatch):
|
||||
"""Match agent-browser semantics: JSON-string results get parsed."""
|
||||
import tools.browser_tool as bt
|
||||
|
||||
sup = MagicMock()
|
||||
sup.evaluate_runtime.return_value = {
|
||||
"ok": True,
|
||||
"result": '{"a": 1, "b": [2, 3]}',
|
||||
"result_type": "string",
|
||||
}
|
||||
_patch_supervisor(monkeypatch, sup)
|
||||
monkeypatch.setattr(
|
||||
bt, "_run_browser_command",
|
||||
lambda *a, **kw: pytest.fail("subprocess path must not run"),
|
||||
)
|
||||
|
||||
out = json.loads(bt._browser_eval('JSON.stringify({a:1,b:[2,3]})'))
|
||||
assert out["success"] is True
|
||||
assert out["result"] == {"a": 1, "b": [2, 3]}
|
||||
# result_type reflects the parsed Python type, not the raw JS type.
|
||||
assert out["result_type"] == "dict"
|
||||
|
||||
def test_non_json_string_result_kept_as_string(self, monkeypatch):
|
||||
import tools.browser_tool as bt
|
||||
|
||||
sup = MagicMock()
|
||||
sup.evaluate_runtime.return_value = {
|
||||
"ok": True,
|
||||
"result": "hello world",
|
||||
"result_type": "string",
|
||||
}
|
||||
_patch_supervisor(monkeypatch, sup)
|
||||
monkeypatch.setattr(bt, "_run_browser_command", lambda *a, **kw: pytest.fail("nope"))
|
||||
|
||||
out = json.loads(bt._browser_eval('"hello world"'))
|
||||
assert out["result"] == "hello world"
|
||||
assert out["result_type"] == "str"
|
||||
|
||||
def test_js_exception_surfaces_without_subprocess_fallthrough(self, monkeypatch):
|
||||
"""A JS-side error must NOT trigger a (slow + redundant) subprocess retry."""
|
||||
import tools.browser_tool as bt
|
||||
|
||||
sup = MagicMock()
|
||||
sup.evaluate_runtime.return_value = {
|
||||
"ok": False,
|
||||
"error": "Uncaught ReferenceError: foo is not defined",
|
||||
}
|
||||
_patch_supervisor(monkeypatch, sup)
|
||||
called = {"subprocess": False}
|
||||
|
||||
def _fake_subprocess(*a, **kw):
|
||||
called["subprocess"] = True
|
||||
return {"success": True, "data": {"result": "should-not-be-used"}}
|
||||
|
||||
monkeypatch.setattr(bt, "_run_browser_command", _fake_subprocess)
|
||||
|
||||
out = json.loads(bt._browser_eval("foo.bar"))
|
||||
assert out["success"] is False
|
||||
assert "ReferenceError" in out["error"]
|
||||
assert called["subprocess"] is False, \
|
||||
"JS exception should be surfaced, not retried via subprocess"
|
||||
|
||||
def test_supervisor_loop_down_falls_through_to_subprocess(self, monkeypatch):
|
||||
"""When the supervisor itself is unavailable, fall back to the subprocess."""
|
||||
import tools.browser_tool as bt
|
||||
|
||||
sup = MagicMock()
|
||||
sup.evaluate_runtime.return_value = {
|
||||
"ok": False,
|
||||
"error": "supervisor loop is not running",
|
||||
}
|
||||
_patch_supervisor(monkeypatch, sup)
|
||||
|
||||
called = {"subprocess": False}
|
||||
|
||||
def _fake_subprocess(task_id, cmd, args):
|
||||
called["subprocess"] = True
|
||||
assert cmd == "eval"
|
||||
return {"success": True, "data": {"result": "fallback-result"}}
|
||||
|
||||
monkeypatch.setattr(bt, "_run_browser_command", _fake_subprocess)
|
||||
|
||||
out = json.loads(bt._browser_eval("anything"))
|
||||
assert called["subprocess"] is True
|
||||
assert out["success"] is True
|
||||
assert out["result"] == "fallback-result"
|
||||
# Subprocess path doesn't tag the response with method=cdp_supervisor.
|
||||
assert out.get("method") != "cdp_supervisor"
|
||||
|
||||
def test_no_active_supervisor_falls_through_to_subprocess(self, monkeypatch):
|
||||
"""When SUPERVISOR_REGISTRY.get returns None, subprocess path runs."""
|
||||
import tools.browser_tool as bt
|
||||
|
||||
_patch_supervisor(monkeypatch, None)
|
||||
called = {"subprocess": False}
|
||||
|
||||
def _fake_subprocess(task_id, cmd, args):
|
||||
called["subprocess"] = True
|
||||
return {"success": True, "data": {"result": "agent-browser-result"}}
|
||||
|
||||
monkeypatch.setattr(bt, "_run_browser_command", _fake_subprocess)
|
||||
|
||||
out = json.loads(bt._browser_eval("1+1"))
|
||||
assert called["subprocess"] is True
|
||||
assert out["success"] is True
|
||||
assert out.get("method") != "cdp_supervisor"
|
||||
|
||||
def test_supervisor_no_session_falls_through(self, monkeypatch):
|
||||
"""A supervisor without an attached page session must fall through cleanly."""
|
||||
import tools.browser_tool as bt
|
||||
|
||||
sup = MagicMock()
|
||||
sup.evaluate_runtime.return_value = {
|
||||
"ok": False,
|
||||
"error": "supervisor has no attached page session",
|
||||
}
|
||||
_patch_supervisor(monkeypatch, sup)
|
||||
called = {"subprocess": False}
|
||||
|
||||
def _fake_subprocess(*a, **kw):
|
||||
called["subprocess"] = True
|
||||
return {"success": True, "data": {"result": "fallback"}}
|
||||
|
||||
monkeypatch.setattr(bt, "_run_browser_command", _fake_subprocess)
|
||||
json.loads(bt._browser_eval("1+1"))
|
||||
assert called["subprocess"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response shaping: CDPSupervisor.evaluate_runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_supervisor_with_cdp(cdp_response):
|
||||
"""Build a CDPSupervisor instance that mocks ``_cdp`` to return ``cdp_response``.
|
||||
|
||||
Bypasses ``__init__`` entirely so we don't need a real WS connection. We
|
||||
set just the state ``evaluate_runtime`` reads.
|
||||
"""
|
||||
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"
|
||||
|
||||
# Build a real running event loop on a background thread so
|
||||
# asyncio.run_coroutine_threadsafe has somewhere to dispatch.
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
def _runner():
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_forever()
|
||||
|
||||
thread = threading.Thread(target=_runner, daemon=True)
|
||||
thread.start()
|
||||
|
||||
async def _fake_cdp(method, params=None, *, session_id=None, timeout=10.0):
|
||||
return cdp_response
|
||||
|
||||
sup._cdp = _fake_cdp # type: ignore[method-assign]
|
||||
sup._loop = loop
|
||||
sup._thread = thread
|
||||
return sup
|
||||
|
||||
|
||||
def _stop_supervisor(sup):
|
||||
sup._loop.call_soon_threadsafe(sup._loop.stop)
|
||||
sup._thread.join(timeout=2)
|
||||
|
||||
|
||||
class TestEvaluateRuntimeResponseShaping:
|
||||
"""CDPSupervisor.evaluate_runtime decodes the Runtime.evaluate response correctly."""
|
||||
|
||||
def test_primitive_value(self):
|
||||
sup = _make_supervisor_with_cdp({
|
||||
"id": 1,
|
||||
"result": {"result": {"type": "number", "value": 42}},
|
||||
})
|
||||
try:
|
||||
out = sup.evaluate_runtime("1 + 41")
|
||||
assert out == {"ok": True, "result": 42, "result_type": "number"}
|
||||
finally:
|
||||
_stop_supervisor(sup)
|
||||
|
||||
def test_object_value_returned_by_value(self):
|
||||
sup = _make_supervisor_with_cdp({
|
||||
"id": 1,
|
||||
"result": {
|
||||
"result": {
|
||||
"type": "object",
|
||||
"value": {"foo": "bar", "n": 7},
|
||||
}
|
||||
},
|
||||
})
|
||||
try:
|
||||
out = sup.evaluate_runtime('({foo:"bar", n:7})')
|
||||
assert out["ok"] is True
|
||||
assert out["result"] == {"foo": "bar", "n": 7}
|
||||
assert out["result_type"] == "object"
|
||||
finally:
|
||||
_stop_supervisor(sup)
|
||||
|
||||
def test_undefined_value(self):
|
||||
sup = _make_supervisor_with_cdp({
|
||||
"id": 1,
|
||||
"result": {"result": {"type": "undefined"}},
|
||||
})
|
||||
try:
|
||||
out = sup.evaluate_runtime("undefined")
|
||||
assert out == {"ok": True, "result": None, "result_type": "undefined"}
|
||||
finally:
|
||||
_stop_supervisor(sup)
|
||||
|
||||
def test_dom_node_returns_description(self):
|
||||
"""Non-serializable values (DOM nodes, functions) come back as description strings."""
|
||||
sup = _make_supervisor_with_cdp({
|
||||
"id": 1,
|
||||
"result": {
|
||||
"result": {
|
||||
"type": "object",
|
||||
"subtype": "node",
|
||||
"description": "div#main.app",
|
||||
# No 'value' key — returnByValue couldn't serialize it.
|
||||
}
|
||||
},
|
||||
})
|
||||
try:
|
||||
out = sup.evaluate_runtime("document.querySelector('#main')")
|
||||
assert out["ok"] is True
|
||||
assert out["result"] == "div#main.app"
|
||||
assert out["result_type"] == "object"
|
||||
finally:
|
||||
_stop_supervisor(sup)
|
||||
|
||||
def test_js_exception_returns_error(self):
|
||||
sup = _make_supervisor_with_cdp({
|
||||
"id": 1,
|
||||
"result": {
|
||||
"result": {"type": "undefined"},
|
||||
"exceptionDetails": {
|
||||
"text": "Uncaught",
|
||||
"exception": {
|
||||
"description": "ReferenceError: foo is not defined",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
try:
|
||||
out = sup.evaluate_runtime("foo.bar")
|
||||
assert out["ok"] is False
|
||||
assert "ReferenceError" in out["error"]
|
||||
finally:
|
||||
_stop_supervisor(sup)
|
||||
|
||||
def test_inactive_supervisor_returns_error_without_dispatch(self):
|
||||
"""Inactive supervisor short-circuits before even touching the loop."""
|
||||
import threading
|
||||
from tools.browser_supervisor import CDPSupervisor
|
||||
|
||||
sup = object.__new__(CDPSupervisor)
|
||||
sup._state_lock = threading.Lock()
|
||||
sup._active = False # ← key
|
||||
sup._page_session_id = None
|
||||
sup._loop = None
|
||||
|
||||
out = sup.evaluate_runtime("1+1")
|
||||
assert out["ok"] is False
|
||||
# Either "loop is not running" or "is not active" is acceptable —
|
||||
# both are caught by the supervisor-side error branch in _browser_eval.
|
||||
assert "supervisor" in out["error"].lower()
|
||||
|
||||
def test_no_session_attached_returns_error(self):
|
||||
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 = None # ← attach hasn't happened yet
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
thread = threading.Thread(
|
||||
target=lambda: (asyncio.set_event_loop(loop), loop.run_forever()),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
sup._loop = loop
|
||||
try:
|
||||
out = sup.evaluate_runtime("1+1")
|
||||
assert out["ok"] is False
|
||||
assert "session" in out["error"].lower()
|
||||
finally:
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=2)
|
||||
@@ -561,3 +561,80 @@ def test_bridge_captures_prompt_and_returns_reply_text(chrome_cdp, supervisor_re
|
||||
|
||||
value = asyncio.run(nav_and_read())
|
||||
assert value == "AGENT-SUPPLIED-REPLY", f"expected AGENT-SUPPLIED-REPLY, got {value!r}"
|
||||
|
||||
|
||||
def test_evaluate_runtime_primitive(chrome_cdp, supervisor_registry):
|
||||
"""evaluate_runtime returns primitive values via the supervisor's live WS."""
|
||||
cdp_url, _port = chrome_cdp
|
||||
supervisor = supervisor_registry.get_or_start(task_id="pytest-eval-1", cdp_url=cdp_url)
|
||||
|
||||
# Need a page to evaluate against.
|
||||
_fire_on_page(cdp_url, "void 0")
|
||||
time.sleep(0.5)
|
||||
|
||||
out = supervisor.evaluate_runtime("1 + 41")
|
||||
assert out["ok"] is True
|
||||
assert out["result"] == 42
|
||||
assert out["result_type"] == "number"
|
||||
|
||||
|
||||
def test_evaluate_runtime_object(chrome_cdp, supervisor_registry):
|
||||
"""Plain objects come back JSON-serialized via returnByValue=True."""
|
||||
cdp_url, _port = chrome_cdp
|
||||
supervisor = supervisor_registry.get_or_start(task_id="pytest-eval-2", cdp_url=cdp_url)
|
||||
|
||||
_fire_on_page(cdp_url, "void 0")
|
||||
time.sleep(0.5)
|
||||
|
||||
out = supervisor.evaluate_runtime('({foo: "bar", n: 7})')
|
||||
assert out["ok"] is True
|
||||
assert out["result"] == {"foo": "bar", "n": 7}
|
||||
assert out["result_type"] == "object"
|
||||
|
||||
|
||||
def test_evaluate_runtime_js_exception(chrome_cdp, supervisor_registry):
|
||||
"""JS exceptions surface as ok=False with the exception message."""
|
||||
cdp_url, _port = chrome_cdp
|
||||
supervisor = supervisor_registry.get_or_start(task_id="pytest-eval-3", cdp_url=cdp_url)
|
||||
|
||||
_fire_on_page(cdp_url, "void 0")
|
||||
time.sleep(0.5)
|
||||
|
||||
out = supervisor.evaluate_runtime("nonExistentVar.nope")
|
||||
assert out["ok"] is False
|
||||
assert "ReferenceError" in out["error"] or "not defined" in out["error"]
|
||||
|
||||
|
||||
def test_evaluate_runtime_dom_node_returns_empty_object(chrome_cdp, supervisor_registry):
|
||||
"""DOM nodes with returnByValue=true serialize to ``{}`` (Chrome quirk).
|
||||
|
||||
This is honest — DOM nodes can't be deeply JSON-serialized — and matches
|
||||
DevTools console behaviour for the same expression. Documenting the
|
||||
contract here so a future change that "fixes" it (e.g. switching to
|
||||
returnByValue=false + DOM.describeNode) doesn't break callers expecting
|
||||
the current shape.
|
||||
"""
|
||||
cdp_url, _port = chrome_cdp
|
||||
supervisor = supervisor_registry.get_or_start(task_id="pytest-eval-4", cdp_url=cdp_url)
|
||||
|
||||
_fire_on_page(cdp_url, "void 0")
|
||||
time.sleep(0.5)
|
||||
|
||||
out = supervisor.evaluate_runtime("document.querySelector('h1')")
|
||||
assert out["ok"] is True
|
||||
assert out["result_type"] == "object"
|
||||
# Empty dict — Chrome can't deeply-serialize a DOM node through returnByValue.
|
||||
assert out["result"] == {}
|
||||
|
||||
|
||||
def test_evaluate_runtime_unserializable_value(chrome_cdp, supervisor_registry):
|
||||
"""``Infinity``/``NaN``/``BigInt`` come back via ``unserializableValue``."""
|
||||
cdp_url, _port = chrome_cdp
|
||||
supervisor = supervisor_registry.get_or_start(task_id="pytest-eval-5", cdp_url=cdp_url)
|
||||
|
||||
_fire_on_page(cdp_url, "void 0")
|
||||
time.sleep(0.5)
|
||||
|
||||
out = supervisor.evaluate_runtime("Infinity")
|
||||
assert out["ok"] is True
|
||||
assert out["result"] == "Infinity"
|
||||
|
||||
@@ -288,3 +288,91 @@ def test_hardline_list_is_small():
|
||||
f"HARDLINE_PATTERNS has grown to {len(HARDLINE_PATTERNS)} entries; "
|
||||
"only truly unrecoverable commands belong here."
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Sudo stdin guard — blocks "sudo -S" without SUDO_PASSWORD
|
||||
# =========================================================================
|
||||
|
||||
_SUDO_STDIN_BLOCK = [
|
||||
"sudo -S whoami",
|
||||
"echo hunter2 | sudo -S whoami",
|
||||
"sudo -S -u root whoami",
|
||||
"sudo -S apt-get install foo",
|
||||
"echo password | sudo -S systemctl restart nginx",
|
||||
"sudo -k && sudo -S whoami",
|
||||
]
|
||||
|
||||
_SUDO_STDIN_ALLOW = [
|
||||
# Plain sudo without -S — goes through normal approval
|
||||
"sudo whoami",
|
||||
"sudo apt-get update",
|
||||
"sudo -u root whoami",
|
||||
# -S flag not attached to sudo
|
||||
"echo -S hello",
|
||||
"some_tool -S thing",
|
||||
# Literal text mention of sudo
|
||||
"echo 'use sudo -S to pipe passwords'",
|
||||
]
|
||||
|
||||
_SUDO_STDIN_BLOCK_YOLO = [
|
||||
"sudo -S whoami",
|
||||
"echo hunter2 | sudo -S apt-get install",
|
||||
]
|
||||
|
||||
|
||||
def test_sudo_stdin_guard_detects_without_password():
|
||||
"""sudo -S is dangerous when SUDO_PASSWORD is not configured."""
|
||||
import tools.approval as approval_mod
|
||||
|
||||
for cmd in _SUDO_STDIN_BLOCK:
|
||||
is_blocked, desc = approval_mod._check_sudo_stdin_guard(cmd)
|
||||
assert is_blocked, f"expected sudo stdin guard to block {cmd!r}"
|
||||
assert "sudo" in desc.lower()
|
||||
|
||||
|
||||
def test_sudo_stdin_guard_allows_benign_commands():
|
||||
"""Commands without explicit sudo -S are not blocked."""
|
||||
import tools.approval as approval_mod
|
||||
|
||||
for cmd in _SUDO_STDIN_ALLOW:
|
||||
is_blocked, desc = approval_mod._check_sudo_stdin_guard(cmd)
|
||||
assert not is_blocked, f"expected sudo stdin guard NOT to block {cmd!r}"
|
||||
|
||||
|
||||
def test_sudo_stdin_guard_bypassed_when_password_configured(monkeypatch):
|
||||
"""When SUDO_PASSWORD is set, sudo -S is legitimate (injected by transform)."""
|
||||
import tools.approval as approval_mod
|
||||
|
||||
monkeypatch.setenv("SUDO_PASSWORD", "testpass")
|
||||
for cmd in _SUDO_STDIN_BLOCK:
|
||||
is_blocked, _ = approval_mod._check_sudo_stdin_guard(cmd)
|
||||
assert not is_blocked, f"with SUDO_PASSWORD set, {cmd!r} should NOT be blocked"
|
||||
|
||||
|
||||
def test_sudo_stdin_guard_blocks_via_check_all_command_guards(clean_session):
|
||||
"""Integration: check_all_command_guards returns block for sudo -S."""
|
||||
for cmd in _SUDO_STDIN_BLOCK:
|
||||
result = check_all_command_guards(cmd, "local")
|
||||
assert result["approved"] is False, f"expected block on {cmd!r}"
|
||||
# Should NOT be marked as hardline (it's sudo-specific)
|
||||
assert result.get("hardline") is not True
|
||||
assert "BLOCKED" in result["message"]
|
||||
assert "sudo -S" in result["message"].lower() or "sudo password" in result["message"].lower()
|
||||
|
||||
|
||||
def test_sudo_stdin_guard_not_blocked_by_yolo(clean_session, monkeypatch):
|
||||
"""yolo/approvals.mode=off must NOT bypass sudo stdin guard."""
|
||||
monkeypatch.setenv("HERMES_YOLO_MODE", "1")
|
||||
|
||||
for cmd in _SUDO_STDIN_BLOCK_YOLO:
|
||||
result = check_all_command_guards(cmd, "local")
|
||||
assert result["approved"] is False, f"yolo leaked sudo guard on {cmd!r}"
|
||||
|
||||
|
||||
def test_sudo_stdin_guard_container_bypass(clean_session):
|
||||
"""Containerized backends still bypass — they can't touch the host."""
|
||||
for env in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
|
||||
for cmd in _SUDO_STDIN_BLOCK:
|
||||
result = check_all_command_guards(cmd, env)
|
||||
assert result["approved"] is True, f"container {env} should bypass sudo guard on {cmd!r}"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Verifies:
|
||||
- Tools are gated on HERMES_KANBAN_TASK: a normal chat session sees
|
||||
zero kanban tools in its schema; a worker session sees all seven.
|
||||
zero kanban tools in its schema; a worker session sees the kanban set.
|
||||
- Each handler's happy path.
|
||||
- Error paths (missing required args, bad metadata type, etc).
|
||||
"""
|
||||
@@ -27,9 +27,10 @@ def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
import tools.kanban_tools # ensure registered
|
||||
from tools.registry import registry
|
||||
from tools.registry import invalidate_check_fn_cache, registry
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
invalidate_check_fn_cache()
|
||||
schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True)
|
||||
names = {s["function"].get("name") for s in schema if "function" in s}
|
||||
kanban = {n for n in names if n and n.startswith("kanban_")}
|
||||
@@ -39,16 +40,17 @@ def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path):
|
||||
|
||||
|
||||
def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path):
|
||||
"""Worker sessions (HERMES_KANBAN_TASK set) must have all 7 tools."""
|
||||
"""Worker sessions get task lifecycle tools, not board-routing tools."""
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake")
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
import tools.kanban_tools # ensure registered
|
||||
from tools.registry import registry
|
||||
from tools.registry import invalidate_check_fn_cache, registry
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
invalidate_check_fn_cache()
|
||||
schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True)
|
||||
names = {s["function"].get("name") for s in schema if "function" in s}
|
||||
kanban = {n for n in names if n and n.startswith("kanban_")}
|
||||
@@ -59,6 +61,61 @@ def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path):
|
||||
assert kanban == expected, f"expected {expected}, got {kanban}"
|
||||
|
||||
|
||||
def test_worker_with_kanban_toolset_still_hides_board_routing(monkeypatch, tmp_path):
|
||||
"""Task scope wins over profile config for board-routing tools.
|
||||
|
||||
Even if a worker process happens to also have ``toolsets: [kanban]``
|
||||
in its config, the HERMES_KANBAN_TASK env var means it's a focused
|
||||
worker and must not see kanban_list / kanban_unblock.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake")
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
(home / "config.yaml").write_text("toolsets:\n - kanban\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
import tools.kanban_tools # ensure registered
|
||||
from tools.registry import invalidate_check_fn_cache, registry
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
invalidate_check_fn_cache()
|
||||
schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True)
|
||||
names = {s["function"].get("name") for s in schema if "function" in s}
|
||||
kanban = {n for n in names if n and n.startswith("kanban_")}
|
||||
assert {
|
||||
"kanban_list",
|
||||
"kanban_unblock",
|
||||
}.isdisjoint(kanban), (
|
||||
f"Board-routing tools leaked into worker schema: "
|
||||
f"{kanban & {'kanban_list', 'kanban_unblock'}}"
|
||||
)
|
||||
|
||||
|
||||
def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path):
|
||||
"""Orchestrator profiles with toolsets: [kanban] see all kanban tools."""
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
(home / "config.yaml").write_text("toolsets:\n - kanban\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
import tools.kanban_tools # ensure registered
|
||||
from tools.registry import invalidate_check_fn_cache, registry
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
invalidate_check_fn_cache()
|
||||
schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True)
|
||||
names = {s["function"].get("name") for s in schema if "function" in s}
|
||||
kanban = {n for n in names if n and n.startswith("kanban_")}
|
||||
expected = {
|
||||
"kanban_list",
|
||||
"kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat",
|
||||
"kanban_comment", "kanban_create", "kanban_link",
|
||||
"kanban_unblock",
|
||||
}
|
||||
assert kanban == expected, f"expected {expected}, got {kanban}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler happy paths
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -112,6 +169,100 @@ def test_show_explicit_task_id(worker_env):
|
||||
assert d["task"]["id"] == other
|
||||
|
||||
|
||||
def test_list_filters_tasks(monkeypatch, worker_env):
|
||||
"""kanban_list gives orchestrators filtered board discovery."""
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
a = kb.create_task(conn, title="alpha", assignee="factory", priority=5)
|
||||
b = kb.create_task(conn, title="beta", assignee="reviewer")
|
||||
c = kb.create_task(conn, title="gamma", assignee="factory", tenant="other")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_list({"assignee": "factory", "status": "ready", "limit": 10})
|
||||
d = json.loads(out)
|
||||
ids = [t["id"] for t in d["tasks"]]
|
||||
assert ids == [a, c]
|
||||
assert d["count"] == 2
|
||||
assert d["tasks"][0]["title"] == "alpha"
|
||||
assert d["tasks"][0]["parent_count"] == 0
|
||||
assert b not in ids
|
||||
|
||||
tenant_out = kt._handle_list({
|
||||
"assignee": "factory",
|
||||
"status": "ready",
|
||||
"tenant": "other",
|
||||
})
|
||||
tenant_ids = [t["id"] for t in json.loads(tenant_out)["tasks"]]
|
||||
assert tenant_ids == [c]
|
||||
|
||||
|
||||
def test_list_rejects_invalid_status(monkeypatch, worker_env):
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_list({"status": "not-a-state"})
|
||||
assert "status must be one of" in json.loads(out).get("error", "")
|
||||
|
||||
|
||||
def test_list_rejects_bad_limit(monkeypatch, worker_env):
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
from tools import kanban_tools as kt
|
||||
assert json.loads(kt._handle_list({"limit": "nope"})).get("error")
|
||||
assert json.loads(kt._handle_list({"limit": 0})).get("error")
|
||||
|
||||
|
||||
def test_list_parses_include_archived_string_false(monkeypatch, worker_env):
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
live = kb.create_task(conn, title="live task", assignee="factory")
|
||||
archived = kb.create_task(conn, title="archived task", assignee="factory")
|
||||
assert kb.archive_task(conn, archived)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_list({
|
||||
"assignee": "factory",
|
||||
"include_archived": "false",
|
||||
})
|
||||
ids = [t["id"] for t in json.loads(out)["tasks"]]
|
||||
assert live in ids
|
||||
assert archived not in ids
|
||||
|
||||
|
||||
def test_list_parses_include_archived_string_true(monkeypatch, worker_env):
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
live = kb.create_task(conn, title="live task", assignee="factory")
|
||||
archived = kb.create_task(conn, title="archived task", assignee="factory")
|
||||
assert kb.archive_task(conn, archived)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_list({
|
||||
"assignee": "factory",
|
||||
"include_archived": "true",
|
||||
})
|
||||
ids = [t["id"] for t in json.loads(out)["tasks"]]
|
||||
assert live in ids
|
||||
assert archived in ids
|
||||
|
||||
|
||||
def test_list_rejects_bad_include_archived(monkeypatch, worker_env):
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_list({"include_archived": "sometimes"})
|
||||
assert "include_archived must be" in json.loads(out).get("error", "")
|
||||
|
||||
|
||||
def test_complete_happy_path(worker_env):
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_complete({
|
||||
@@ -179,6 +330,106 @@ def test_complete_rejects_non_dict_metadata(worker_env):
|
||||
assert json.loads(out).get("error")
|
||||
|
||||
|
||||
def test_complete_phantom_card_message_advertises_retry(worker_env):
|
||||
"""A phantom-card rejection must surface a tool_error that explicitly
|
||||
tells the worker the task is still in-flight and how to retry — the
|
||||
worker has no other channel to discover that. Regression for #22923,
|
||||
where the previous wording read like a terminal failure and workers
|
||||
routinely abandoned the run instead of trying again.
|
||||
"""
|
||||
from hermes_cli import kanban_db as kb
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
out = kt._handle_complete({
|
||||
"summary": "oops claimed a phantom",
|
||||
"created_cards": ["t_phantomdeadbeef"],
|
||||
})
|
||||
err = json.loads(out).get("error", "")
|
||||
assert err, f"expected an error, got {out!r}"
|
||||
# Phantom id surfaced verbatim.
|
||||
assert "t_phantomdeadbeef" in err
|
||||
# The retry-is-supported phrasing — these are the literal cues a
|
||||
# worker reads to decide whether to retry vs block/abandon. If a
|
||||
# future change rewords the message, these checks will catch the
|
||||
# regression. See #22923 for the failure mode.
|
||||
assert "still in-flight" in err
|
||||
assert "Retry kanban_complete" in err
|
||||
assert "created_cards=[]" in err
|
||||
|
||||
# Critically: the task is genuinely still in-flight — the gate
|
||||
# rejection did not mutate state, so the worker's retry can land.
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.get_task(conn, worker_env).status == "running"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_complete_retry_with_empty_created_cards_succeeds(worker_env):
|
||||
"""After a phantom rejection, retrying kanban_complete with
|
||||
created_cards=[] (the documented escape hatch) must complete the
|
||||
task. Regression for #22923."""
|
||||
from hermes_cli import kanban_db as kb
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
# Hit the gate first.
|
||||
rejected = json.loads(kt._handle_complete({
|
||||
"summary": "oops",
|
||||
"created_cards": ["t_phantomdeadbeef"],
|
||||
}))
|
||||
assert rejected.get("error")
|
||||
|
||||
# Retry with the escape hatch.
|
||||
ok = json.loads(kt._handle_complete({
|
||||
"summary": "retry without claims",
|
||||
"created_cards": [],
|
||||
}))
|
||||
assert ok.get("ok") is True
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.get_task(conn, worker_env).status == "done"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_complete_retry_with_corrected_created_cards_succeeds(worker_env):
|
||||
"""After a phantom rejection, retrying kanban_complete with a
|
||||
corrected created_cards list (phantom ids removed) must complete the
|
||||
task. Regression for #22923."""
|
||||
from hermes_cli import kanban_db as kb
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
# Create a real child via the tool so it gets the worker-profile
|
||||
# attribution the gate trusts.
|
||||
child = json.loads(kt._handle_create({
|
||||
"title": "real child", "assignee": "peer",
|
||||
}))
|
||||
assert child["ok"]
|
||||
real_id = child["task_id"]
|
||||
|
||||
# First attempt mixes real + phantom — gate rejects.
|
||||
rejected = json.loads(kt._handle_complete({
|
||||
"summary": "oops",
|
||||
"created_cards": [real_id, "t_phantomdeadbeef"],
|
||||
}))
|
||||
assert rejected.get("error")
|
||||
assert "t_phantomdeadbeef" in rejected["error"]
|
||||
|
||||
# Retry with corrected list.
|
||||
ok = json.loads(kt._handle_complete({
|
||||
"summary": "retry with corrected list",
|
||||
"created_cards": [real_id],
|
||||
}))
|
||||
assert ok.get("ok") is True
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.get_task(conn, worker_env).status == "done"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_block_happy_path(worker_env):
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_block({"reason": "need clarification"})
|
||||
@@ -368,6 +619,52 @@ def test_create_rejects_non_list_parents(worker_env):
|
||||
assert json.loads(out).get("error")
|
||||
|
||||
|
||||
def test_create_parses_triage_string_false(worker_env):
|
||||
from tools import kanban_tools as kt
|
||||
from hermes_cli import kanban_db as kb
|
||||
out = kt._handle_create({
|
||||
"title": "not triage",
|
||||
"assignee": "peer",
|
||||
"triage": "false",
|
||||
})
|
||||
d = json.loads(out)
|
||||
assert d["ok"] is True
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task = kb.get_task(conn, d["task_id"])
|
||||
assert task.status == "ready"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_create_parses_triage_string_true(worker_env):
|
||||
from tools import kanban_tools as kt
|
||||
from hermes_cli import kanban_db as kb
|
||||
out = kt._handle_create({
|
||||
"title": "needs triage",
|
||||
"assignee": "peer",
|
||||
"triage": "true",
|
||||
})
|
||||
d = json.loads(out)
|
||||
assert d["ok"] is True
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task = kb.get_task(conn, d["task_id"])
|
||||
assert task.status == "triage"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_create_rejects_bad_triage(worker_env):
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_create({
|
||||
"title": "bad triage",
|
||||
"assignee": "peer",
|
||||
"triage": "sometimes",
|
||||
})
|
||||
assert "triage must be" in json.loads(out).get("error", "")
|
||||
|
||||
|
||||
def test_create_accepts_string_parent(worker_env):
|
||||
"""Convenience: a single parent id as string is coerced to [id]."""
|
||||
from tools import kanban_tools as kt
|
||||
@@ -458,9 +755,35 @@ def test_link_rejects_cycle(worker_env):
|
||||
assert json.loads(out).get("error")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: simulate a full worker lifecycle through the tools
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_unblock_happy_path(monkeypatch, worker_env):
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="blocked", assignee="worker")
|
||||
kb.block_task(conn, tid, reason="waiting")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_unblock({"task_id": tid})
|
||||
d = json.loads(out)
|
||||
assert d["ok"] is True
|
||||
assert d["status"] == "ready"
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.get_task(conn, tid).status == "ready"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_unblock_rejects_non_blocked_task(monkeypatch, worker_env):
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_unblock({"task_id": worker_env})
|
||||
assert json.loads(out).get("error")
|
||||
|
||||
|
||||
def test_worker_lifecycle_through_tools(worker_env):
|
||||
"""Drive the full claim -> heartbeat -> comment -> complete lifecycle
|
||||
@@ -599,11 +922,12 @@ def test_kanban_guidance_prompt_size_bounded(monkeypatch, tmp_path):
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A worker process has HERMES_KANBAN_TASK set to its own task id. The
|
||||
# destructive tools (kanban_complete, kanban_block, kanban_heartbeat)
|
||||
# must refuse to operate on any OTHER task id, even if the caller
|
||||
# supplies an explicit `task_id` argument. Workers legitimately call
|
||||
# kanban_show / kanban_comment / kanban_create / kanban_link on other
|
||||
# tasks, so those are unrestricted.
|
||||
# destructive tools (kanban_complete, kanban_block, kanban_heartbeat,
|
||||
# kanban_unblock) must refuse to operate
|
||||
# on any OTHER task id, even if the caller supplies an explicit `task_id`
|
||||
# argument. Workers legitimately call kanban_show / kanban_list /
|
||||
# kanban_comment / kanban_create / kanban_link on other tasks, so those
|
||||
# are unrestricted.
|
||||
#
|
||||
# Orchestrator profiles (no HERMES_KANBAN_TASK in env) are intentionally
|
||||
# exempt — their job is routing, and they sometimes close out child
|
||||
@@ -712,6 +1036,37 @@ def test_worker_can_comment_on_foreign_task(worker_env):
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_worker_unblock_rejects_foreign_task_id(worker_env):
|
||||
"""A worker cannot unblock any task — kanban_unblock is orchestrator-only.
|
||||
|
||||
The check fires before the per-task ownership check, so the error
|
||||
surface is the orchestrator-only refusal rather than the
|
||||
cross-task-ownership refusal. Either is fine — the property we're
|
||||
pinning is "worker cannot mutate foreign task via kanban_unblock".
|
||||
"""
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
other = kb.create_task(conn, title="blocked sibling", assignee="peer")
|
||||
kb.block_task(conn, other, reason="waiting")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_unblock({"task_id": other})
|
||||
d = json.loads(out)
|
||||
err = d.get("error", "")
|
||||
assert "orchestrator-only" in err or "refusing to mutate" in err, (
|
||||
f"expected worker-rejection error, got {err}"
|
||||
)
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.get_task(conn, other).status == "blocked"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_worker_complete_own_task_still_works(worker_env):
|
||||
"""The ownership check doesn't break the normal own-task happy path."""
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
@@ -2229,3 +2229,106 @@ class TestSendViaAdapterStandaloneFallback:
|
||||
assert result["success"] is True
|
||||
assert result["message_id"] == "abc-123"
|
||||
assert result["extra_field"] == "preserved"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _check_send_message — availability gating
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCheckSendMessage:
|
||||
"""The tool's check_fn governs whether the model sees ``send_message`` as
|
||||
callable for a given session. The four passing conditions are:
|
||||
|
||||
1. ``HERMES_KANBAN_TASK`` is set (worker spawned by the kanban dispatcher
|
||||
— parent gateway is by definition running, but the worker's
|
||||
``HERMES_HOME`` may be a profile dir without a ``gateway.pid``).
|
||||
2. ``HERMES_SESSION_PLATFORM`` resolves to a non-empty, non-``local`` value
|
||||
(the session is wired to a messaging platform like Telegram).
|
||||
3. ``is_gateway_running()`` returns True (CLI / orchestrator profile with
|
||||
a live gateway colocated under the same ``HERMES_HOME``).
|
||||
4. None of the above → False, tool is hidden.
|
||||
"""
|
||||
|
||||
def test_kanban_task_env_grants_access(self, monkeypatch):
|
||||
"""Workers spawned by the dispatcher (HERMES_KANBAN_TASK set) must be
|
||||
allowed regardless of session_platform / gateway-pid state."""
|
||||
from tools.send_message_tool import _check_send_message
|
||||
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_abc12345")
|
||||
monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
|
||||
|
||||
with patch("gateway.session_context.get_session_env", return_value=""), \
|
||||
patch("gateway.status.is_gateway_running", return_value=False):
|
||||
assert _check_send_message() is True
|
||||
|
||||
def test_kanban_task_env_short_circuits_before_gateway_check(self, monkeypatch):
|
||||
"""Honoring HERMES_KANBAN_TASK must not depend on importing or calling
|
||||
gateway.status — the worker may run with a HERMES_HOME that has no
|
||||
gateway.pid, and we don't want that import path to be load-bearing."""
|
||||
from tools.send_message_tool import _check_send_message
|
||||
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_abc12345")
|
||||
|
||||
with patch("gateway.session_context.get_session_env",
|
||||
side_effect=AssertionError("session_context not consulted "
|
||||
"when HERMES_KANBAN_TASK is set")), \
|
||||
patch("gateway.status.is_gateway_running",
|
||||
side_effect=AssertionError("gateway.status not consulted "
|
||||
"when HERMES_KANBAN_TASK is set")):
|
||||
assert _check_send_message() is True
|
||||
|
||||
def test_messaging_platform_session_grants_access(self, monkeypatch):
|
||||
"""Telegram/Discord/etc. sessions pass via the platform branch even
|
||||
without HERMES_KANBAN_TASK."""
|
||||
from tools.send_message_tool import _check_send_message
|
||||
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
|
||||
with patch("gateway.session_context.get_session_env", return_value="telegram"), \
|
||||
patch("gateway.status.is_gateway_running", return_value=False):
|
||||
assert _check_send_message() is True
|
||||
|
||||
def test_local_platform_falls_through_to_gateway_check(self, monkeypatch):
|
||||
"""``HERMES_SESSION_PLATFORM=local`` means CLI-style — must defer to
|
||||
is_gateway_running() rather than auto-grant."""
|
||||
from tools.send_message_tool import _check_send_message
|
||||
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
|
||||
with patch("gateway.session_context.get_session_env", return_value="local"), \
|
||||
patch("gateway.status.is_gateway_running", return_value=True) as gw_mock:
|
||||
assert _check_send_message() is True
|
||||
gw_mock.assert_called_once()
|
||||
|
||||
def test_running_gateway_grants_access(self, monkeypatch):
|
||||
"""Plain CLI session (no kanban task, empty platform) with a live
|
||||
gateway: tool is callable."""
|
||||
from tools.send_message_tool import _check_send_message
|
||||
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
|
||||
with patch("gateway.session_context.get_session_env", return_value=""), \
|
||||
patch("gateway.status.is_gateway_running", return_value=True):
|
||||
assert _check_send_message() is True
|
||||
|
||||
def test_no_signals_means_unavailable(self, monkeypatch):
|
||||
"""No kanban task, no platform, no gateway: tool is hidden."""
|
||||
from tools.send_message_tool import _check_send_message
|
||||
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
|
||||
with patch("gateway.session_context.get_session_env", return_value=""), \
|
||||
patch("gateway.status.is_gateway_running", return_value=False):
|
||||
assert _check_send_message() is False
|
||||
|
||||
def test_gateway_status_import_error_is_swallowed(self, monkeypatch):
|
||||
"""If gateway.status can't be imported (unusual deployment / partial
|
||||
install), the check returns False rather than raising."""
|
||||
from tools.send_message_tool import _check_send_message
|
||||
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
|
||||
with patch("gateway.session_context.get_session_env", return_value=""), \
|
||||
patch("gateway.status.is_gateway_running",
|
||||
side_effect=ImportError("simulated")):
|
||||
assert _check_send_message() is False
|
||||
|
||||
Reference in New Issue
Block a user