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:
Brooklyn Nicholson
2026-05-11 16:40:09 -04:00
310 changed files with 43818 additions and 4220 deletions
+383
View File
@@ -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
+103
View File
@@ -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
+6 -1
View File
@@ -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():
+210
View File
@@ -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)
)
+991
View File
@@ -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"]
+131
View File
@@ -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
+112
View File
@@ -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:
+27 -4
View File
@@ -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")
+61 -2
View File
@@ -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
+41
View File
@@ -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
+397
View File
@@ -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
+59
View File
@@ -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
+9 -5
View File
@@ -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."""
+40 -1
View File
@@ -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),
+236
View File
@@ -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()
+644
View File
@@ -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"
+250
View File
@@ -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)
+289
View File
@@ -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
+558
View File
@@ -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
+288
View File
@@ -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
+318
View File
@@ -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()
+37
View File
@@ -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
+75
View File
@@ -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)}):
+10
View File
@@ -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``
+336 -5
View File
@@ -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
+180 -4
View File
@@ -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()
+180 -2
View File
@@ -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()
+46
View File
@@ -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",
]
+202
View File
@@ -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
+111
View File
@@ -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()."""
+90 -1
View File
@@ -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()
+82
View File
@@ -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).
+61
View File
@@ -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
+358
View File
@@ -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"] == []
+295
View File
@@ -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.
+137
View File
@@ -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)
+77
View File
@@ -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"
+88
View File
@@ -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}"
+367 -12
View File
@@ -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
+103
View File
@@ -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