Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
@@ -77,6 +77,20 @@ class TestReadCodexAccessToken:
|
||||
result = _read_codex_access_token()
|
||||
assert result == "tok-123"
|
||||
|
||||
def test_pool_without_selected_entry_falls_back_to_auth_store(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
valid_jwt = "eyJhbGciOiJSUzI1NiJ9.eyJleHAiOjk5OTk5OTk5OTl9.sig"
|
||||
with patch("agent.auxiliary_client._select_pool_entry", return_value=(True, None)), \
|
||||
patch("hermes_cli.auth._read_codex_tokens", return_value={
|
||||
"tokens": {"access_token": valid_jwt, "refresh_token": "refresh"}
|
||||
}):
|
||||
result = _read_codex_access_token()
|
||||
|
||||
assert result == valid_jwt
|
||||
|
||||
def test_missing_returns_none(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
@@ -238,6 +252,24 @@ class TestAnthropicOAuthFlag:
|
||||
assert mock_build.call_args.args[0] == "sk-ant-oat01-pooled"
|
||||
|
||||
|
||||
class TestTryCodex:
|
||||
def test_pool_without_selected_entry_falls_back_to_auth_store(self):
|
||||
with (
|
||||
patch("agent.auxiliary_client._select_pool_entry", return_value=(True, None)),
|
||||
patch("agent.auxiliary_client._read_codex_access_token", return_value="codex-auth-token"),
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai,
|
||||
):
|
||||
mock_openai.return_value = MagicMock()
|
||||
from agent.auxiliary_client import _try_codex
|
||||
|
||||
client, model = _try_codex()
|
||||
|
||||
assert client is not None
|
||||
assert model == "gpt-5.2-codex"
|
||||
assert mock_openai.call_args.kwargs["api_key"] == "codex-auth-token"
|
||||
assert mock_openai.call_args.kwargs["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
|
||||
class TestExpiredCodexFallback:
|
||||
"""Test that expired Codex tokens don't block the auto chain."""
|
||||
|
||||
|
||||
@@ -324,7 +324,10 @@ class TestCompressWithClient:
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
|
||||
|
||||
# Last head message (index 1) is "assistant" → summary should be "user"
|
||||
# Last head message (index 1) is "assistant" → summary should be "user".
|
||||
# With min_tail=3, tail = last 3 messages (indices 5-7).
|
||||
# head_last=assistant, tail_first=assistant → summary_role="user", no collision.
|
||||
# Need 8 messages: min_for_compress = 2+3+1 = 6, must have > 6.
|
||||
msgs = [
|
||||
{"role": "user", "content": "msg 0"},
|
||||
{"role": "assistant", "content": "msg 1"},
|
||||
@@ -332,6 +335,8 @@ class TestCompressWithClient:
|
||||
{"role": "assistant", "content": "msg 3"},
|
||||
{"role": "user", "content": "msg 4"},
|
||||
{"role": "assistant", "content": "msg 5"},
|
||||
{"role": "user", "content": "msg 6"},
|
||||
{"role": "assistant", "content": "msg 7"},
|
||||
]
|
||||
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
||||
result = c.compress(msgs)
|
||||
@@ -460,8 +465,10 @@ class TestCompressWithClient:
|
||||
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
|
||||
|
||||
# Head: [system, user] → last head = user
|
||||
# Tail: [assistant, user] → first tail = assistant
|
||||
# Tail: [assistant, user, assistant] → first tail = assistant
|
||||
# summary_role="assistant" collides with tail, "user" collides with head → merge
|
||||
# With min_tail=3, tail = last 3 messages (indices 5-7).
|
||||
# Need 8 messages: min_for_compress = 2+3+1 = 6, must have > 6.
|
||||
msgs = [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": "msg 1"},
|
||||
@@ -470,6 +477,7 @@ class TestCompressWithClient:
|
||||
{"role": "assistant", "content": "msg 4"}, # compressed
|
||||
{"role": "assistant", "content": "msg 5"}, # tail start
|
||||
{"role": "user", "content": "msg 6"},
|
||||
{"role": "assistant", "content": "msg 7"},
|
||||
]
|
||||
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
||||
result = c.compress(msgs)
|
||||
@@ -481,7 +489,7 @@ class TestCompressWithClient:
|
||||
if r1 in ("user", "assistant") and r2 in ("user", "assistant"):
|
||||
assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}"
|
||||
|
||||
# The summary should be merged into the first tail message (assistant)
|
||||
# The summary should be merged into the first tail message (assistant at index 5)
|
||||
first_tail = [m for m in result if "msg 5" in (m.get("content") or "")]
|
||||
assert len(first_tail) == 1
|
||||
assert "summary text" in first_tail[0]["content"]
|
||||
@@ -496,14 +504,18 @@ class TestCompressWithClient:
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
|
||||
|
||||
# Head=assistant, Tail=assistant → summary_role="user", no collision
|
||||
# Head=assistant, Tail=assistant → summary_role="user", no collision.
|
||||
# With min_tail=3, tail = last 3 messages (indices 5-7).
|
||||
# Need 8 messages: min_for_compress = 2+3+1 = 6, must have > 6.
|
||||
msgs = [
|
||||
{"role": "user", "content": "msg 0"},
|
||||
{"role": "assistant", "content": "msg 1"},
|
||||
{"role": "user", "content": "msg 2"},
|
||||
{"role": "assistant", "content": "msg 3"},
|
||||
{"role": "assistant", "content": "msg 4"},
|
||||
{"role": "user", "content": "msg 5"},
|
||||
{"role": "user", "content": "msg 4"},
|
||||
{"role": "assistant", "content": "msg 5"},
|
||||
{"role": "user", "content": "msg 6"},
|
||||
{"role": "assistant", "content": "msg 7"},
|
||||
]
|
||||
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
||||
result = c.compress(msgs)
|
||||
@@ -600,3 +612,158 @@ class TestSummaryTargetRatio:
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100_000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True)
|
||||
assert c.protect_last_n == 20
|
||||
|
||||
|
||||
class TestTokenBudgetTailProtection:
|
||||
"""Tests for token-budget-based tail protection (PR #6240).
|
||||
|
||||
The core change: tail protection is now based on a token budget rather
|
||||
than a fixed message count. This prevents large tool outputs from
|
||||
blocking compaction.
|
||||
"""
|
||||
|
||||
@pytest.fixture()
|
||||
def budget_compressor(self):
|
||||
"""Compressor with known token budget for tail protection tests."""
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
|
||||
c = ContextCompressor(
|
||||
model="test/model",
|
||||
threshold_percent=0.50, # 100K threshold
|
||||
protect_first_n=2,
|
||||
protect_last_n=20,
|
||||
quiet_mode=True,
|
||||
)
|
||||
return c
|
||||
|
||||
def test_large_tool_outputs_no_longer_block_compaction(self, budget_compressor):
|
||||
"""The motivating scenario: 20 messages with large tool outputs should
|
||||
NOT prevent compaction. With message-count tail protection they would
|
||||
all be protected, leaving nothing to summarize."""
|
||||
c = budget_compressor
|
||||
messages = [
|
||||
{"role": "user", "content": "Start task"},
|
||||
{"role": "assistant", "content": "On it"},
|
||||
]
|
||||
# Add 20 messages with large tool outputs (~5K chars each ≈ 1250 tokens)
|
||||
for i in range(10):
|
||||
messages.append({
|
||||
"role": "assistant", "content": None,
|
||||
"tool_calls": [{"function": {"name": f"tool_{i}", "arguments": "{}"}}],
|
||||
})
|
||||
messages.append({
|
||||
"role": "tool", "content": "x" * 5000,
|
||||
"tool_call_id": f"call_{i}",
|
||||
})
|
||||
# Add 3 recent small messages
|
||||
messages.append({"role": "user", "content": "What's the status?"})
|
||||
messages.append({"role": "assistant", "content": "Here's what I found..."})
|
||||
messages.append({"role": "user", "content": "Continue"})
|
||||
|
||||
# The tail cut should NOT protect all 20 tool messages
|
||||
head_end = c.protect_first_n
|
||||
cut = c._find_tail_cut_by_tokens(messages, head_end)
|
||||
tail_size = len(messages) - cut
|
||||
# With token budget, the tail should be much smaller than 20+
|
||||
assert tail_size < 20, f"Tail {tail_size} messages — large tool outputs are blocking compaction"
|
||||
# But at least 3 (hard minimum)
|
||||
assert tail_size >= 3
|
||||
|
||||
def test_min_tail_always_3_messages(self, budget_compressor):
|
||||
"""Even with a tiny token budget, at least 3 messages are protected."""
|
||||
c = budget_compressor
|
||||
# Override to a tiny budget
|
||||
c.tail_token_budget = 10
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
{"role": "user", "content": "do something"},
|
||||
{"role": "assistant", "content": "working on it"},
|
||||
{"role": "user", "content": "more work"},
|
||||
{"role": "assistant", "content": "done"},
|
||||
{"role": "user", "content": "thanks"},
|
||||
]
|
||||
head_end = 2
|
||||
cut = c._find_tail_cut_by_tokens(messages, head_end)
|
||||
tail_size = len(messages) - cut
|
||||
assert tail_size >= 3, f"Tail is only {tail_size} messages, min should be 3"
|
||||
|
||||
def test_soft_ceiling_allows_oversized_message(self, budget_compressor):
|
||||
"""The 1.5x soft ceiling allows an oversized message to be included
|
||||
rather than splitting it."""
|
||||
c = budget_compressor
|
||||
# Set a small budget — 500 tokens
|
||||
c.tail_token_budget = 500
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
{"role": "user", "content": "read the file"},
|
||||
# This message is ~600 tokens (> budget of 500, but < 1.5x = 750)
|
||||
{"role": "assistant", "content": "a" * 2400},
|
||||
{"role": "user", "content": "short"},
|
||||
{"role": "assistant", "content": "short reply"},
|
||||
{"role": "user", "content": "continue"},
|
||||
]
|
||||
head_end = 2
|
||||
cut = c._find_tail_cut_by_tokens(messages, head_end)
|
||||
# The oversized message at index 3 should NOT be the cut point
|
||||
# because 1.5x ceiling = 750 tokens and accumulated would be ~610
|
||||
# (short msgs + oversized msg) which is < 750
|
||||
tail_size = len(messages) - cut
|
||||
assert tail_size >= 3
|
||||
|
||||
def test_small_conversation_still_compresses(self, budget_compressor):
|
||||
"""With the new min of 8 messages (head=2 + 3 + 1 guard + 2 middle),
|
||||
a small but compressible conversation should still compress."""
|
||||
c = budget_compressor
|
||||
# 9 messages: head(2) + 4 middle + 3 tail = compressible
|
||||
messages = []
|
||||
for i in range(9):
|
||||
role = "user" if i % 2 == 0 else "assistant"
|
||||
messages.append({"role": role, "content": f"Message {i}"})
|
||||
|
||||
# Should not early-return (needs > protect_first_n + 3 + 1 = 6)
|
||||
# Mock the summary generation to avoid real API call
|
||||
with patch.object(c, "_generate_summary", return_value="Summary of conversation"):
|
||||
result = c.compress(messages, current_tokens=90_000)
|
||||
# Should have compressed (fewer messages than original)
|
||||
assert len(result) < len(messages)
|
||||
|
||||
def test_prune_with_token_budget(self, budget_compressor):
|
||||
"""_prune_old_tool_results with protect_tail_tokens respects the budget."""
|
||||
c = budget_compressor
|
||||
messages = [
|
||||
{"role": "user", "content": "start"},
|
||||
{"role": "assistant", "content": None,
|
||||
"tool_calls": [{"function": {"name": "read_file", "arguments": '{"path": "big.txt"}'}}]},
|
||||
{"role": "tool", "content": "x" * 10000, "tool_call_id": "c1"}, # ~2500 tokens
|
||||
{"role": "assistant", "content": None,
|
||||
"tool_calls": [{"function": {"name": "read_file", "arguments": '{"path": "small.txt"}'}}]},
|
||||
{"role": "tool", "content": "y" * 10000, "tool_call_id": "c2"}, # ~2500 tokens
|
||||
{"role": "user", "content": "short recent message"},
|
||||
{"role": "assistant", "content": "short reply"},
|
||||
]
|
||||
# With a 1000-token budget, only the last couple messages should be protected
|
||||
result, pruned = c._prune_old_tool_results(
|
||||
messages, protect_tail_count=2, protect_tail_tokens=1000,
|
||||
)
|
||||
# At least one old tool result should have been pruned
|
||||
assert pruned >= 1
|
||||
|
||||
def test_prune_without_token_budget_uses_message_count(self, budget_compressor):
|
||||
"""Without protect_tail_tokens, falls back to message-count behavior."""
|
||||
c = budget_compressor
|
||||
messages = [
|
||||
{"role": "user", "content": "start"},
|
||||
{"role": "assistant", "content": None,
|
||||
"tool_calls": [{"function": {"name": "tool", "arguments": "{}"}}]},
|
||||
{"role": "tool", "content": "x" * 5000, "tool_call_id": "c1"},
|
||||
{"role": "user", "content": "recent"},
|
||||
{"role": "assistant", "content": "reply"},
|
||||
]
|
||||
# protect_tail_count=3 means last 3 messages protected
|
||||
result, pruned = c._prune_old_tool_results(
|
||||
messages, protect_tail_count=3,
|
||||
)
|
||||
# Tool at index 2 is outside the protected tail (last 3 = indices 2,3,4)
|
||||
# so it might or might not be pruned depending on boundary
|
||||
assert isinstance(pruned, int)
|
||||
|
||||
@@ -214,6 +214,42 @@ def test_exhausted_entry_resets_after_ttl(tmp_path, monkeypatch):
|
||||
assert entry.last_status == "ok"
|
||||
|
||||
|
||||
def test_exhausted_402_entry_resets_after_one_hour(tmp_path, monkeypatch):
|
||||
"""402-exhausted credentials recover after 1 hour, not 24."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"openrouter": [
|
||||
{
|
||||
"id": "cred-1",
|
||||
"label": "primary",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": "***",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"last_status": "exhausted",
|
||||
"last_status_at": time.time() - 3700, # ~1h2m ago
|
||||
"last_error_code": 402,
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("openrouter")
|
||||
entry = pool.select()
|
||||
|
||||
assert entry is not None
|
||||
assert entry.id == "cred-1"
|
||||
assert entry.last_status == "ok"
|
||||
|
||||
|
||||
def test_explicit_reset_timestamp_overrides_default_429_ttl(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(
|
||||
|
||||
750
tests/agent/test_error_classifier.py
Normal file
750
tests/agent/test_error_classifier.py
Normal file
@@ -0,0 +1,750 @@
|
||||
"""Tests for agent.error_classifier — structured API error classification."""
|
||||
|
||||
import pytest
|
||||
from agent.error_classifier import (
|
||||
ClassifiedError,
|
||||
FailoverReason,
|
||||
classify_api_error,
|
||||
_extract_status_code,
|
||||
_extract_error_body,
|
||||
_extract_error_code,
|
||||
_classify_402,
|
||||
)
|
||||
|
||||
|
||||
# ── Helper: mock API errors ────────────────────────────────────────────
|
||||
|
||||
class MockAPIError(Exception):
|
||||
"""Simulates an OpenAI SDK APIStatusError."""
|
||||
def __init__(self, message, status_code=None, body=None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.body = body or {}
|
||||
|
||||
|
||||
class MockTransportError(Exception):
|
||||
"""Simulates a transport-level error with a specific type name."""
|
||||
pass
|
||||
|
||||
|
||||
class ReadTimeout(MockTransportError):
|
||||
pass
|
||||
|
||||
|
||||
class ConnectError(MockTransportError):
|
||||
pass
|
||||
|
||||
|
||||
class RemoteProtocolError(MockTransportError):
|
||||
pass
|
||||
|
||||
|
||||
class ServerDisconnectedError(MockTransportError):
|
||||
pass
|
||||
|
||||
|
||||
# ── Test: FailoverReason enum ──────────────────────────────────────────
|
||||
|
||||
class TestFailoverReason:
|
||||
def test_all_reasons_have_string_values(self):
|
||||
for reason in FailoverReason:
|
||||
assert isinstance(reason.value, str)
|
||||
|
||||
def test_enum_members_exist(self):
|
||||
expected = {
|
||||
"auth", "auth_permanent", "billing", "rate_limit",
|
||||
"overloaded", "server_error", "timeout",
|
||||
"context_overflow", "payload_too_large",
|
||||
"model_not_found", "format_error",
|
||||
"thinking_signature", "long_context_tier", "unknown",
|
||||
}
|
||||
actual = {r.value for r in FailoverReason}
|
||||
assert expected == actual
|
||||
|
||||
|
||||
# ── Test: ClassifiedError ──────────────────────────────────────────────
|
||||
|
||||
class TestClassifiedError:
|
||||
def test_is_auth_property(self):
|
||||
e1 = ClassifiedError(reason=FailoverReason.auth)
|
||||
assert e1.is_auth is True
|
||||
|
||||
e2 = ClassifiedError(reason=FailoverReason.auth_permanent)
|
||||
assert e2.is_auth is True
|
||||
|
||||
e3 = ClassifiedError(reason=FailoverReason.billing)
|
||||
assert e3.is_auth is False
|
||||
|
||||
def test_is_transient_property(self):
|
||||
transient_reasons = [
|
||||
FailoverReason.rate_limit,
|
||||
FailoverReason.overloaded,
|
||||
FailoverReason.server_error,
|
||||
FailoverReason.timeout,
|
||||
FailoverReason.unknown,
|
||||
]
|
||||
for reason in transient_reasons:
|
||||
e = ClassifiedError(reason=reason)
|
||||
assert e.is_transient is True, f"{reason} should be transient"
|
||||
|
||||
non_transient = [
|
||||
FailoverReason.auth,
|
||||
FailoverReason.billing,
|
||||
FailoverReason.model_not_found,
|
||||
FailoverReason.format_error,
|
||||
]
|
||||
for reason in non_transient:
|
||||
e = ClassifiedError(reason=reason)
|
||||
assert e.is_transient is False, f"{reason} should NOT be transient"
|
||||
|
||||
def test_defaults(self):
|
||||
e = ClassifiedError(reason=FailoverReason.unknown)
|
||||
assert e.retryable is True
|
||||
assert e.should_compress is False
|
||||
assert e.should_rotate_credential is False
|
||||
assert e.should_fallback is False
|
||||
assert e.status_code is None
|
||||
assert e.message == ""
|
||||
|
||||
|
||||
# ── Test: Status code extraction ───────────────────────────────────────
|
||||
|
||||
class TestExtractStatusCode:
|
||||
def test_from_status_code_attr(self):
|
||||
e = MockAPIError("fail", status_code=429)
|
||||
assert _extract_status_code(e) == 429
|
||||
|
||||
def test_from_status_attr(self):
|
||||
class ErrWithStatus(Exception):
|
||||
status = 503
|
||||
assert _extract_status_code(ErrWithStatus()) == 503
|
||||
|
||||
def test_from_cause_chain(self):
|
||||
inner = MockAPIError("inner", status_code=401)
|
||||
outer = Exception("outer")
|
||||
outer.__cause__ = inner
|
||||
assert _extract_status_code(outer) == 401
|
||||
|
||||
def test_none_when_missing(self):
|
||||
assert _extract_status_code(Exception("generic")) is None
|
||||
|
||||
def test_rejects_non_http_status(self):
|
||||
"""Integers outside 100-599 on .status should be ignored."""
|
||||
class ErrWeirdStatus(Exception):
|
||||
status = 42
|
||||
assert _extract_status_code(ErrWeirdStatus()) is None
|
||||
|
||||
|
||||
# ── Test: Error body extraction ────────────────────────────────────────
|
||||
|
||||
class TestExtractErrorBody:
|
||||
def test_from_body_attr(self):
|
||||
e = MockAPIError("fail", body={"error": {"message": "bad"}})
|
||||
assert _extract_error_body(e) == {"error": {"message": "bad"}}
|
||||
|
||||
def test_empty_when_no_body(self):
|
||||
assert _extract_error_body(Exception("generic")) == {}
|
||||
|
||||
|
||||
# ── Test: Error code extraction ────────────────────────────────────────
|
||||
|
||||
class TestExtractErrorCode:
|
||||
def test_from_nested_error_code(self):
|
||||
body = {"error": {"code": "rate_limit_exceeded"}}
|
||||
assert _extract_error_code(body) == "rate_limit_exceeded"
|
||||
|
||||
def test_from_nested_error_type(self):
|
||||
body = {"error": {"type": "invalid_request_error"}}
|
||||
assert _extract_error_code(body) == "invalid_request_error"
|
||||
|
||||
def test_from_top_level_code(self):
|
||||
body = {"code": "model_not_found"}
|
||||
assert _extract_error_code(body) == "model_not_found"
|
||||
|
||||
def test_empty_when_no_code(self):
|
||||
assert _extract_error_code({}) == ""
|
||||
assert _extract_error_code({"error": {"message": "oops"}}) == ""
|
||||
|
||||
|
||||
# ── Test: 402 disambiguation ───────────────────────────────────────────
|
||||
|
||||
class TestClassify402:
|
||||
"""The critical 402 billing vs rate_limit disambiguation."""
|
||||
|
||||
def test_billing_exhaustion(self):
|
||||
"""Plain 402 = billing."""
|
||||
result = _classify_402(
|
||||
"payment required",
|
||||
lambda reason, **kw: ClassifiedError(reason=reason, **kw),
|
||||
)
|
||||
assert result.reason == FailoverReason.billing
|
||||
assert result.should_rotate_credential is True
|
||||
|
||||
def test_transient_usage_limit(self):
|
||||
"""402 with 'usage limit' + 'try again' = rate limit, not billing."""
|
||||
result = _classify_402(
|
||||
"usage limit exceeded. try again in 5 minutes",
|
||||
lambda reason, **kw: ClassifiedError(reason=reason, **kw),
|
||||
)
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
assert result.should_rotate_credential is True
|
||||
|
||||
def test_quota_with_retry(self):
|
||||
"""402 with 'quota' + 'retry' = rate limit."""
|
||||
result = _classify_402(
|
||||
"quota exceeded, please retry after the window resets",
|
||||
lambda reason, **kw: ClassifiedError(reason=reason, **kw),
|
||||
)
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
|
||||
def test_quota_without_retry(self):
|
||||
"""402 with just 'quota' but no transient signal = billing."""
|
||||
result = _classify_402(
|
||||
"quota exceeded",
|
||||
lambda reason, **kw: ClassifiedError(reason=reason, **kw),
|
||||
)
|
||||
assert result.reason == FailoverReason.billing
|
||||
|
||||
def test_insufficient_credits(self):
|
||||
result = _classify_402(
|
||||
"insufficient credits to complete request",
|
||||
lambda reason, **kw: ClassifiedError(reason=reason, **kw),
|
||||
)
|
||||
assert result.reason == FailoverReason.billing
|
||||
|
||||
|
||||
# ── Test: Full classification pipeline ─────────────────────────────────
|
||||
|
||||
class TestClassifyApiError:
|
||||
"""End-to-end classification tests."""
|
||||
|
||||
# ── Auth errors ──
|
||||
|
||||
def test_401_classified_as_auth(self):
|
||||
e = MockAPIError("Unauthorized", status_code=401)
|
||||
result = classify_api_error(e, provider="openrouter")
|
||||
assert result.reason == FailoverReason.auth
|
||||
assert result.should_rotate_credential is True
|
||||
# 401 is non-retryable on its own — credential rotation runs
|
||||
# before the retryability check in the agent loop.
|
||||
assert result.retryable is False
|
||||
assert result.should_fallback is True
|
||||
|
||||
def test_403_classified_as_auth(self):
|
||||
e = MockAPIError("Forbidden", status_code=403)
|
||||
result = classify_api_error(e, provider="anthropic")
|
||||
assert result.reason == FailoverReason.auth
|
||||
assert result.should_fallback is True
|
||||
|
||||
def test_403_key_limit_classified_as_billing(self):
|
||||
"""OpenRouter 403 'key limit exceeded' is billing, not auth."""
|
||||
e = MockAPIError("Key limit exceeded for this key", status_code=403)
|
||||
result = classify_api_error(e, provider="openrouter")
|
||||
assert result.reason == FailoverReason.billing
|
||||
assert result.should_rotate_credential is True
|
||||
assert result.should_fallback is True
|
||||
|
||||
def test_403_spending_limit_classified_as_billing(self):
|
||||
e = MockAPIError("spending limit reached", status_code=403)
|
||||
result = classify_api_error(e, provider="openrouter")
|
||||
assert result.reason == FailoverReason.billing
|
||||
|
||||
# ── Billing ──
|
||||
|
||||
def test_402_plain_billing(self):
|
||||
e = MockAPIError("Payment Required", status_code=402)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.billing
|
||||
assert result.retryable is False
|
||||
|
||||
def test_402_transient_usage_limit(self):
|
||||
e = MockAPIError("usage limit exceeded, try again later", status_code=402)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
assert result.retryable is True
|
||||
|
||||
# ── Rate limit ──
|
||||
|
||||
def test_429_rate_limit(self):
|
||||
e = MockAPIError("Too Many Requests", status_code=429)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
assert result.should_fallback is True
|
||||
|
||||
# ── Server errors ──
|
||||
|
||||
def test_500_server_error(self):
|
||||
e = MockAPIError("Internal Server Error", status_code=500)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.server_error
|
||||
assert result.retryable is True
|
||||
|
||||
def test_502_server_error(self):
|
||||
e = MockAPIError("Bad Gateway", status_code=502)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.server_error
|
||||
|
||||
def test_503_overloaded(self):
|
||||
e = MockAPIError("Service Unavailable", status_code=503)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.overloaded
|
||||
|
||||
def test_529_anthropic_overloaded(self):
|
||||
e = MockAPIError("Overloaded", status_code=529)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.overloaded
|
||||
|
||||
# ── Model not found ──
|
||||
|
||||
def test_404_model_not_found(self):
|
||||
e = MockAPIError("model not found", status_code=404)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.model_not_found
|
||||
assert result.should_fallback is True
|
||||
assert result.retryable is False
|
||||
|
||||
def test_404_generic(self):
|
||||
e = MockAPIError("Not Found", status_code=404)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.model_not_found
|
||||
|
||||
# ── Payload too large ──
|
||||
|
||||
def test_413_payload_too_large(self):
|
||||
e = MockAPIError("Request Entity Too Large", status_code=413)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.payload_too_large
|
||||
assert result.should_compress is True
|
||||
|
||||
# ── Context overflow ──
|
||||
|
||||
def test_400_context_length(self):
|
||||
e = MockAPIError("context length exceeded: 250000 > 200000", status_code=400)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
assert result.should_compress is True
|
||||
|
||||
def test_400_too_many_tokens(self):
|
||||
e = MockAPIError("This model's maximum context is 128000 tokens, too many tokens", status_code=400)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
|
||||
def test_400_prompt_too_long(self):
|
||||
e = MockAPIError("prompt is too long: 300000 tokens > 200000 maximum", status_code=400)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
|
||||
def test_400_generic_large_session(self):
|
||||
"""Generic 400 with large session → context overflow heuristic."""
|
||||
e = MockAPIError(
|
||||
"Error",
|
||||
status_code=400,
|
||||
body={"error": {"message": "Error"}},
|
||||
)
|
||||
result = classify_api_error(e, approx_tokens=100000, context_length=200000)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
|
||||
def test_400_generic_small_session_is_format_error(self):
|
||||
"""Generic 400 with small session → format error, not context overflow."""
|
||||
e = MockAPIError(
|
||||
"Error",
|
||||
status_code=400,
|
||||
body={"error": {"message": "Error"}},
|
||||
)
|
||||
result = classify_api_error(e, approx_tokens=1000, context_length=200000)
|
||||
assert result.reason == FailoverReason.format_error
|
||||
|
||||
# ── Server disconnect + large session ──
|
||||
|
||||
def test_disconnect_large_session_context_overflow(self):
|
||||
"""Server disconnect with large session → context overflow."""
|
||||
e = Exception("server disconnected without sending complete message")
|
||||
result = classify_api_error(e, approx_tokens=150000, context_length=200000)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
assert result.should_compress is True
|
||||
|
||||
def test_disconnect_small_session_timeout(self):
|
||||
"""Server disconnect with small session → timeout."""
|
||||
e = Exception("server disconnected without sending complete message")
|
||||
result = classify_api_error(e, approx_tokens=5000, context_length=200000)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
|
||||
# ── Provider-specific: Anthropic thinking signature ──
|
||||
|
||||
def test_anthropic_thinking_signature(self):
|
||||
e = MockAPIError(
|
||||
"thinking block has invalid signature",
|
||||
status_code=400,
|
||||
)
|
||||
result = classify_api_error(e, provider="anthropic")
|
||||
assert result.reason == FailoverReason.thinking_signature
|
||||
assert result.retryable is True
|
||||
|
||||
def test_non_anthropic_400_with_signature_not_classified_as_thinking(self):
|
||||
"""400 with 'signature' but from non-Anthropic → format error."""
|
||||
e = MockAPIError("invalid signature", status_code=400)
|
||||
result = classify_api_error(e, provider="openrouter", approx_tokens=0)
|
||||
# Without "thinking" in the message, it shouldn't be thinking_signature
|
||||
assert result.reason != FailoverReason.thinking_signature
|
||||
|
||||
# ── Provider-specific: Anthropic long-context tier ──
|
||||
|
||||
def test_anthropic_long_context_tier(self):
|
||||
e = MockAPIError(
|
||||
"Extra usage is required for long context requests over 200k tokens",
|
||||
status_code=429,
|
||||
)
|
||||
result = classify_api_error(e, provider="anthropic", model="claude-sonnet-4")
|
||||
assert result.reason == FailoverReason.long_context_tier
|
||||
assert result.should_compress is True
|
||||
|
||||
def test_normal_429_not_long_context(self):
|
||||
"""Normal 429 without 'extra usage' + 'long context' → rate_limit."""
|
||||
e = MockAPIError("Too Many Requests", status_code=429)
|
||||
result = classify_api_error(e, provider="anthropic")
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
|
||||
# ── Transport errors ──
|
||||
|
||||
def test_read_timeout(self):
|
||||
e = ReadTimeout("Read timed out")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.retryable is True
|
||||
|
||||
def test_connect_error(self):
|
||||
e = ConnectError("Connection refused")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
|
||||
def test_connection_error_builtin(self):
|
||||
e = ConnectionError("Connection reset by peer")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
|
||||
def test_timeout_error_builtin(self):
|
||||
e = TimeoutError("timed out")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
|
||||
# ── Error code classification ──
|
||||
|
||||
def test_error_code_resource_exhausted(self):
|
||||
e = MockAPIError(
|
||||
"Resource exhausted",
|
||||
body={"error": {"code": "resource_exhausted", "message": "Too many requests"}},
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
|
||||
def test_error_code_model_not_found(self):
|
||||
e = MockAPIError(
|
||||
"Model not available",
|
||||
body={"error": {"code": "model_not_found"}},
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.model_not_found
|
||||
|
||||
def test_error_code_context_length_exceeded(self):
|
||||
e = MockAPIError(
|
||||
"Context too large",
|
||||
body={"error": {"code": "context_length_exceeded"}},
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
|
||||
# ── Message-only patterns (no status code) ──
|
||||
|
||||
def test_message_billing_pattern(self):
|
||||
e = Exception("insufficient credits to complete this request")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.billing
|
||||
|
||||
def test_message_rate_limit_pattern(self):
|
||||
e = Exception("rate limit reached for this model")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
|
||||
def test_message_auth_pattern(self):
|
||||
e = Exception("invalid api key provided")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.auth
|
||||
|
||||
def test_message_model_not_found_pattern(self):
|
||||
e = Exception("gpt-99 is not a valid model")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.model_not_found
|
||||
|
||||
def test_message_context_overflow_pattern(self):
|
||||
e = Exception("maximum context length exceeded")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
|
||||
# ── Unknown / fallback ──
|
||||
|
||||
def test_generic_exception_is_unknown(self):
|
||||
e = Exception("something weird happened")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.unknown
|
||||
assert result.retryable is True
|
||||
|
||||
# ── Format error ──
|
||||
|
||||
def test_400_descriptive_format_error(self):
|
||||
"""400 with descriptive message (not context overflow) → format error."""
|
||||
e = MockAPIError(
|
||||
"Invalid value for parameter 'temperature': must be between 0 and 2",
|
||||
status_code=400,
|
||||
body={"error": {"message": "Invalid value for parameter 'temperature': must be between 0 and 2"}},
|
||||
)
|
||||
result = classify_api_error(e, approx_tokens=1000)
|
||||
assert result.reason == FailoverReason.format_error
|
||||
assert result.retryable is False
|
||||
|
||||
def test_422_format_error(self):
|
||||
e = MockAPIError("Unprocessable Entity", status_code=422)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.format_error
|
||||
assert result.retryable is False
|
||||
|
||||
# ── Peer closed + large session ──
|
||||
|
||||
def test_peer_closed_large_session(self):
|
||||
e = Exception("peer closed connection without sending complete message")
|
||||
result = classify_api_error(e, approx_tokens=130000, context_length=200000)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
|
||||
# ── Chinese error messages ──
|
||||
|
||||
def test_chinese_context_overflow(self):
|
||||
e = MockAPIError("超过最大长度限制", status_code=400)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
|
||||
# ── Result metadata ──
|
||||
|
||||
def test_provider_and_model_in_result(self):
|
||||
e = MockAPIError("fail", status_code=500)
|
||||
result = classify_api_error(e, provider="openrouter", model="gpt-5")
|
||||
assert result.provider == "openrouter"
|
||||
assert result.model == "gpt-5"
|
||||
assert result.status_code == 500
|
||||
|
||||
def test_message_extracted(self):
|
||||
e = MockAPIError(
|
||||
"outer",
|
||||
status_code=500,
|
||||
body={"error": {"message": "Internal server error occurred"}},
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result.message == "Internal server error occurred"
|
||||
|
||||
|
||||
# ── Test: Adversarial / edge cases (from live testing) ─────────────────
|
||||
|
||||
class TestAdversarialEdgeCases:
|
||||
"""Edge cases discovered during live testing with real SDK objects."""
|
||||
|
||||
def test_empty_exception_message(self):
|
||||
result = classify_api_error(Exception(""))
|
||||
assert result.reason == FailoverReason.unknown
|
||||
assert result.retryable is True
|
||||
|
||||
def test_500_with_none_body(self):
|
||||
e = MockAPIError("fail", status_code=500, body=None)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.server_error
|
||||
|
||||
def test_non_dict_body(self):
|
||||
"""Some providers return strings instead of JSON."""
|
||||
class StringBodyError(Exception):
|
||||
status_code = 400
|
||||
body = "just a string"
|
||||
result = classify_api_error(StringBodyError("bad"))
|
||||
assert result.reason == FailoverReason.format_error
|
||||
|
||||
def test_list_body(self):
|
||||
class ListBodyError(Exception):
|
||||
status_code = 500
|
||||
body = [{"error": "something"}]
|
||||
result = classify_api_error(ListBodyError("server error"))
|
||||
assert result.reason == FailoverReason.server_error
|
||||
|
||||
def test_circular_cause_chain(self):
|
||||
"""Must not infinite-loop on circular __cause__."""
|
||||
e = Exception("circular")
|
||||
e.__cause__ = e
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.unknown
|
||||
|
||||
def test_three_level_cause_chain(self):
|
||||
inner = MockAPIError("inner", status_code=429)
|
||||
middle = Exception("middle")
|
||||
middle.__cause__ = inner
|
||||
outer = RuntimeError("outer")
|
||||
outer.__cause__ = middle
|
||||
result = classify_api_error(outer)
|
||||
assert result.status_code == 429
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
|
||||
def test_400_with_rate_limit_text(self):
|
||||
"""Some providers send rate limits as 400 instead of 429."""
|
||||
e = MockAPIError(
|
||||
"rate limit policy",
|
||||
status_code=400,
|
||||
body={"error": {"message": "rate limit exceeded on this model"}},
|
||||
)
|
||||
result = classify_api_error(e, provider="openrouter")
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
|
||||
def test_400_with_billing_text(self):
|
||||
"""Some providers send billing errors as 400."""
|
||||
e = MockAPIError(
|
||||
"billing",
|
||||
status_code=400,
|
||||
body={"error": {"message": "insufficient credits for this request"}},
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.billing
|
||||
|
||||
def test_200_with_error_body(self):
|
||||
"""200 status with error in body — should be unknown, not crash."""
|
||||
class WeirdSuccess(Exception):
|
||||
status_code = 200
|
||||
body = {"error": {"message": "loading"}}
|
||||
result = classify_api_error(WeirdSuccess("model loading"))
|
||||
assert result.reason == FailoverReason.unknown
|
||||
|
||||
def test_ollama_context_size_exceeded(self):
|
||||
e = MockAPIError(
|
||||
"Error",
|
||||
status_code=400,
|
||||
body={"error": {"message": "context size has been exceeded"}},
|
||||
)
|
||||
result = classify_api_error(e, provider="ollama")
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
|
||||
def test_connection_refused_error(self):
|
||||
e = ConnectionRefusedError("Connection refused: localhost:11434")
|
||||
result = classify_api_error(e, provider="ollama")
|
||||
assert result.reason == FailoverReason.timeout
|
||||
|
||||
def test_body_message_enrichment(self):
|
||||
"""Body message must be included in pattern matching even when
|
||||
str(error) doesn't contain it (OpenAI SDK APIStatusError)."""
|
||||
e = MockAPIError(
|
||||
"Usage limit", # str(e) = "usage limit"
|
||||
status_code=402,
|
||||
body={"error": {"message": "Usage limit reached, try again in 5 minutes"}},
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
# "try again" is only in body, not in str(e)
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
|
||||
def test_disconnect_pattern_ordering(self):
|
||||
"""Disconnect + large session must beat generic transport catch."""
|
||||
class FakeRemoteProtocol(Exception):
|
||||
pass
|
||||
# Type name isn't in _TRANSPORT_ERROR_TYPES but message has disconnect pattern
|
||||
e = Exception("peer closed connection without sending complete message")
|
||||
result = classify_api_error(e, approx_tokens=150000, context_length=200000)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
assert result.should_compress is True
|
||||
|
||||
def test_credit_balance_too_low(self):
|
||||
e = MockAPIError(
|
||||
"Credits low",
|
||||
status_code=402,
|
||||
body={"error": {"message": "Your credit balance is too low"}},
|
||||
)
|
||||
result = classify_api_error(e, provider="anthropic")
|
||||
assert result.reason == FailoverReason.billing
|
||||
|
||||
def test_deepseek_402_chinese(self):
|
||||
"""Chinese billing message should still match billing patterns."""
|
||||
# "余额不足" doesn't match English billing patterns, but 402 defaults to billing
|
||||
e = MockAPIError("余额不足", status_code=402)
|
||||
result = classify_api_error(e, provider="deepseek")
|
||||
assert result.reason == FailoverReason.billing
|
||||
|
||||
def test_openrouter_wrapped_context_overflow_in_metadata_raw(self):
|
||||
"""OpenRouter wraps provider errors in metadata.raw JSON string."""
|
||||
e = MockAPIError(
|
||||
"Provider returned error",
|
||||
status_code=400,
|
||||
body={
|
||||
"error": {
|
||||
"message": "Provider returned error",
|
||||
"code": 400,
|
||||
"metadata": {
|
||||
"raw": '{"error":{"message":"context length exceeded: 50000 > 32768"}}'
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
result = classify_api_error(e, provider="openrouter", approx_tokens=10000)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
assert result.should_compress is True
|
||||
|
||||
def test_openrouter_wrapped_rate_limit_in_metadata_raw(self):
|
||||
e = MockAPIError(
|
||||
"Provider returned error",
|
||||
status_code=400,
|
||||
body={
|
||||
"error": {
|
||||
"message": "Provider returned error",
|
||||
"metadata": {
|
||||
"raw": '{"error":{"message":"Rate limit exceeded. Please retry after 30s."}}'
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
result = classify_api_error(e, provider="openrouter")
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
|
||||
def test_thinking_signature_via_openrouter(self):
|
||||
"""Thinking signature errors proxied through OpenRouter must be caught."""
|
||||
e = MockAPIError(
|
||||
"thinking block has invalid signature",
|
||||
status_code=400,
|
||||
)
|
||||
# provider is openrouter, not anthropic — old code missed this
|
||||
result = classify_api_error(e, provider="openrouter", model="anthropic/claude-sonnet-4")
|
||||
assert result.reason == FailoverReason.thinking_signature
|
||||
|
||||
def test_generic_400_large_by_message_count(self):
|
||||
"""Many small messages (>80) should trigger context overflow heuristic."""
|
||||
e = MockAPIError(
|
||||
"Error",
|
||||
status_code=400,
|
||||
body={"error": {"message": "Error"}},
|
||||
)
|
||||
# Low token count but high message count
|
||||
result = classify_api_error(
|
||||
e, approx_tokens=5000, context_length=200000, num_messages=100,
|
||||
)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
|
||||
def test_disconnect_large_by_message_count(self):
|
||||
"""Server disconnect with 200+ messages should trigger context overflow."""
|
||||
e = Exception("server disconnected without sending complete message")
|
||||
result = classify_api_error(
|
||||
e, approx_tokens=5000, context_length=200000, num_messages=250,
|
||||
)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
|
||||
def test_openrouter_wrapped_model_not_found_in_metadata_raw(self):
|
||||
e = MockAPIError(
|
||||
"Provider returned error",
|
||||
status_code=400,
|
||||
body={
|
||||
"error": {
|
||||
"message": "Provider returned error",
|
||||
"metadata": {
|
||||
"raw": '{"error":{"message":"The model gpt-99 does not exist"}}'
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
result = classify_api_error(e, provider="openrouter")
|
||||
assert result.reason == FailoverReason.model_not_found
|
||||
212
tests/agent/test_rate_limit_tracker.py
Normal file
212
tests/agent/test_rate_limit_tracker.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Tests for agent.rate_limit_tracker — header parsing and formatting."""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
from agent.rate_limit_tracker import (
|
||||
RateLimitBucket,
|
||||
RateLimitState,
|
||||
parse_rate_limit_headers,
|
||||
format_rate_limit_display,
|
||||
format_rate_limit_compact,
|
||||
_fmt_count,
|
||||
_fmt_seconds,
|
||||
_bar,
|
||||
)
|
||||
|
||||
|
||||
# ── Sample headers from Nous inference API ──────────────────────────────
|
||||
|
||||
NOUS_HEADERS = {
|
||||
"x-ratelimit-limit-requests": "800",
|
||||
"x-ratelimit-limit-requests-1h": "33600",
|
||||
"x-ratelimit-limit-tokens": "8000000",
|
||||
"x-ratelimit-limit-tokens-1h": "336000000",
|
||||
"x-ratelimit-remaining-requests": "795",
|
||||
"x-ratelimit-remaining-requests-1h": "33590",
|
||||
"x-ratelimit-remaining-tokens": "7999500",
|
||||
"x-ratelimit-remaining-tokens-1h": "335999000",
|
||||
"x-ratelimit-reset-requests": "45.5",
|
||||
"x-ratelimit-reset-requests-1h": "3500.0",
|
||||
"x-ratelimit-reset-tokens": "42.3",
|
||||
"x-ratelimit-reset-tokens-1h": "3490.0",
|
||||
}
|
||||
|
||||
|
||||
class TestParseHeaders:
|
||||
def test_basic_parsing(self):
|
||||
state = parse_rate_limit_headers(NOUS_HEADERS, provider="nous")
|
||||
assert state is not None
|
||||
assert state.provider == "nous"
|
||||
assert state.has_data
|
||||
|
||||
assert state.requests_min.limit == 800
|
||||
assert state.requests_min.remaining == 795
|
||||
assert state.requests_min.reset_seconds == 45.5
|
||||
|
||||
assert state.requests_hour.limit == 33600
|
||||
assert state.requests_hour.remaining == 33590
|
||||
|
||||
assert state.tokens_min.limit == 8000000
|
||||
assert state.tokens_min.remaining == 7999500
|
||||
|
||||
assert state.tokens_hour.limit == 336000000
|
||||
assert state.tokens_hour.remaining == 335999000
|
||||
assert state.tokens_hour.reset_seconds == 3490.0
|
||||
|
||||
def test_no_headers(self):
|
||||
state = parse_rate_limit_headers({})
|
||||
assert state is None
|
||||
|
||||
def test_partial_headers(self):
|
||||
headers = {
|
||||
"x-ratelimit-limit-requests": "100",
|
||||
"x-ratelimit-remaining-requests": "50",
|
||||
}
|
||||
state = parse_rate_limit_headers(headers)
|
||||
assert state is not None
|
||||
assert state.requests_min.limit == 100
|
||||
assert state.requests_min.remaining == 50
|
||||
# Missing fields default to 0
|
||||
assert state.tokens_min.limit == 0
|
||||
|
||||
def test_non_rate_limit_headers_ignored(self):
|
||||
headers = {
|
||||
"content-type": "application/json",
|
||||
"server": "nginx",
|
||||
}
|
||||
state = parse_rate_limit_headers(headers)
|
||||
assert state is None
|
||||
|
||||
def test_malformed_values(self):
|
||||
headers = {
|
||||
"x-ratelimit-limit-requests": "not-a-number",
|
||||
"x-ratelimit-remaining-requests": "",
|
||||
"x-ratelimit-reset-requests": "abc",
|
||||
}
|
||||
state = parse_rate_limit_headers(headers)
|
||||
assert state is not None
|
||||
assert state.requests_min.limit == 0
|
||||
assert state.requests_min.remaining == 0
|
||||
assert state.requests_min.reset_seconds == 0.0
|
||||
|
||||
|
||||
class TestBucket:
|
||||
def test_used(self):
|
||||
b = RateLimitBucket(limit=800, remaining=795, reset_seconds=45.0, captured_at=time.time())
|
||||
assert b.used == 5
|
||||
|
||||
def test_usage_pct(self):
|
||||
b = RateLimitBucket(limit=100, remaining=20, reset_seconds=30.0, captured_at=time.time())
|
||||
assert b.usage_pct == pytest.approx(80.0)
|
||||
|
||||
def test_usage_pct_zero_limit(self):
|
||||
b = RateLimitBucket(limit=0, remaining=0)
|
||||
assert b.usage_pct == 0.0
|
||||
|
||||
def test_remaining_seconds_now(self):
|
||||
now = time.time()
|
||||
b = RateLimitBucket(limit=800, remaining=795, reset_seconds=60.0, captured_at=now - 10)
|
||||
# ~50 seconds should remain
|
||||
assert 49 <= b.remaining_seconds_now <= 51
|
||||
|
||||
def test_remaining_seconds_expired(self):
|
||||
b = RateLimitBucket(limit=800, remaining=795, reset_seconds=30.0, captured_at=time.time() - 60)
|
||||
assert b.remaining_seconds_now == 0.0
|
||||
|
||||
|
||||
class TestFormatting:
|
||||
def test_fmt_count_millions(self):
|
||||
assert _fmt_count(8000000) == "8.0M"
|
||||
assert _fmt_count(336000000) == "336.0M"
|
||||
|
||||
def test_fmt_count_thousands(self):
|
||||
assert _fmt_count(33600) == "33.6K"
|
||||
assert _fmt_count(1500) == "1.5K"
|
||||
|
||||
def test_fmt_count_small(self):
|
||||
assert _fmt_count(800) == "800"
|
||||
assert _fmt_count(0) == "0"
|
||||
|
||||
def test_fmt_seconds_short(self):
|
||||
assert _fmt_seconds(45) == "45s"
|
||||
assert _fmt_seconds(0) == "0s"
|
||||
|
||||
def test_fmt_seconds_minutes(self):
|
||||
assert _fmt_seconds(125) == "2m 5s"
|
||||
assert _fmt_seconds(120) == "2m"
|
||||
|
||||
def test_fmt_seconds_hours(self):
|
||||
assert _fmt_seconds(3660) == "1h 1m"
|
||||
assert _fmt_seconds(3600) == "1h"
|
||||
|
||||
def test_bar(self):
|
||||
bar = _bar(50.0, width=10)
|
||||
assert bar == "[█████░░░░░]"
|
||||
assert _bar(0.0, width=10) == "[░░░░░░░░░░]"
|
||||
assert _bar(100.0, width=10) == "[██████████]"
|
||||
|
||||
def test_format_display_no_data(self):
|
||||
state = RateLimitState()
|
||||
result = format_rate_limit_display(state)
|
||||
assert "No rate limit data" in result
|
||||
|
||||
def test_format_display_with_data(self):
|
||||
state = parse_rate_limit_headers(NOUS_HEADERS, provider="nous")
|
||||
result = format_rate_limit_display(state)
|
||||
assert "Nous" in result
|
||||
assert "Requests/min" in result
|
||||
assert "Requests/hr" in result
|
||||
assert "Tokens/min" in result
|
||||
assert "Tokens/hr" in result
|
||||
assert "resets in" in result
|
||||
|
||||
def test_format_display_warning_on_high_usage(self):
|
||||
headers = {
|
||||
**NOUS_HEADERS,
|
||||
"x-ratelimit-remaining-requests": "50", # 750/800 used = 93.75%
|
||||
}
|
||||
state = parse_rate_limit_headers(headers)
|
||||
result = format_rate_limit_display(state)
|
||||
assert "⚠" in result
|
||||
|
||||
def test_format_compact(self):
|
||||
state = parse_rate_limit_headers(NOUS_HEADERS, provider="nous")
|
||||
result = format_rate_limit_compact(state)
|
||||
assert "RPM:" in result
|
||||
assert "RPH:" in result
|
||||
assert "TPM:" in result
|
||||
assert "TPH:" in result
|
||||
assert "resets" in result
|
||||
|
||||
def test_format_compact_no_data(self):
|
||||
state = RateLimitState()
|
||||
result = format_rate_limit_compact(state)
|
||||
assert "No rate limit data" in result
|
||||
|
||||
|
||||
class TestAgentIntegration:
|
||||
"""Test that AIAgent captures rate limit state correctly."""
|
||||
|
||||
def test_capture_rate_limits_from_headers(self):
|
||||
"""Simulate the header capture path without a real API call."""
|
||||
import sys
|
||||
import os
|
||||
# Use a mock httpx-like response
|
||||
class MockResponse:
|
||||
headers = NOUS_HEADERS
|
||||
|
||||
# Import AIAgent minimally
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Test the parsing directly
|
||||
state = parse_rate_limit_headers(MockResponse.headers, provider="nous")
|
||||
assert state is not None
|
||||
assert state.requests_min.limit == 800
|
||||
assert state.tokens_hour.limit == 336000000
|
||||
|
||||
def test_capture_rate_limits_none_response(self):
|
||||
"""_capture_rate_limits should handle None gracefully."""
|
||||
from agent.rate_limit_tracker import parse_rate_limit_headers
|
||||
# None should not crash
|
||||
result = parse_rate_limit_headers({})
|
||||
assert result is None
|
||||
@@ -3,6 +3,7 @@
|
||||
import os
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.subdirectory_hints import SubdirectoryHintTracker
|
||||
|
||||
@@ -189,3 +190,45 @@ class TestSubdirectoryHintTracker:
|
||||
"terminal", {"command": "curl https://example.com/frontend/api"}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestPermissionErrorHandling:
|
||||
"""Regression tests for PermissionError in filesystem checks (ref #6214)."""
|
||||
|
||||
def test_is_valid_subdir_permission_error(self, tmp_path):
|
||||
"""_is_valid_subdir should return False when is_dir() raises PermissionError."""
|
||||
tracker = SubdirectoryHintTracker(working_dir=str(tmp_path))
|
||||
restricted = tmp_path / "restricted"
|
||||
restricted.mkdir()
|
||||
with patch.object(Path, "is_dir", side_effect=PermissionError("Permission denied")):
|
||||
assert tracker._is_valid_subdir(restricted) is False
|
||||
|
||||
def test_load_hints_permission_error_on_is_file(self, tmp_path):
|
||||
"""_load_hints_for_directory should skip files when is_file() raises PermissionError."""
|
||||
tracker = SubdirectoryHintTracker(working_dir=str(tmp_path))
|
||||
restricted = tmp_path / "restricted"
|
||||
restricted.mkdir()
|
||||
original_is_file = Path.is_file
|
||||
def patched_is_file(self):
|
||||
if "restricted" in str(self):
|
||||
raise PermissionError("Permission denied")
|
||||
return original_is_file(self)
|
||||
with patch.object(Path, "is_file", patched_is_file):
|
||||
result = tracker._load_hints_for_directory(restricted)
|
||||
assert result is None
|
||||
|
||||
def test_check_tool_call_survives_inaccessible_path(self, project):
|
||||
"""Full check_tool_call should not crash when a path is inaccessible."""
|
||||
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
||||
original_is_dir = Path.is_dir
|
||||
def patched_is_dir(self):
|
||||
if "backend" in str(self) and "src" not in str(self):
|
||||
raise PermissionError("Permission denied")
|
||||
return original_is_dir(self)
|
||||
with patch.object(Path, "is_dir", patched_is_dir):
|
||||
# Should not raise — gracefully skip the inaccessible directory
|
||||
result = tracker.check_tool_call(
|
||||
"read_file", {"path": str(project / "backend" / "src" / "main.py")}
|
||||
)
|
||||
# Result may be None (backend skipped) — the key point is no crash
|
||||
assert result is None or isinstance(result, str)
|
||||
|
||||
@@ -2,22 +2,65 @@ import queue
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import cli as cli_module
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
class _FakeBuffer:
|
||||
def __init__(self, text="", cursor_position=None):
|
||||
self.text = text
|
||||
self.cursor_position = len(text) if cursor_position is None else cursor_position
|
||||
|
||||
def reset(self, append_to_history=False):
|
||||
self.text = ""
|
||||
self.cursor_position = 0
|
||||
|
||||
|
||||
def _make_cli_stub():
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._approval_state = None
|
||||
cli._approval_deadline = 0
|
||||
cli._approval_lock = threading.Lock()
|
||||
cli._sudo_state = None
|
||||
cli._sudo_deadline = 0
|
||||
cli._modal_input_snapshot = None
|
||||
cli._invalidate = MagicMock()
|
||||
cli._app = SimpleNamespace(invalidate=MagicMock())
|
||||
cli._app = SimpleNamespace(invalidate=MagicMock(), current_buffer=_FakeBuffer())
|
||||
return cli
|
||||
|
||||
|
||||
class TestCliApprovalUi:
|
||||
def test_sudo_prompt_restores_existing_draft_after_response(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._app.current_buffer = _FakeBuffer("draft command", cursor_position=5)
|
||||
result = {}
|
||||
|
||||
def _run_callback():
|
||||
result["value"] = cli._sudo_password_callback()
|
||||
|
||||
with patch.object(cli_module, "_cprint"):
|
||||
thread = threading.Thread(target=_run_callback, daemon=True)
|
||||
thread.start()
|
||||
|
||||
deadline = time.time() + 2
|
||||
while cli._sudo_state is None and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
|
||||
assert cli._sudo_state is not None
|
||||
assert cli._app.current_buffer.text == ""
|
||||
|
||||
cli._app.current_buffer.text = "secret"
|
||||
cli._app.current_buffer.cursor_position = len("secret")
|
||||
cli._sudo_state["response_queue"].put("secret")
|
||||
|
||||
thread.join(timeout=2)
|
||||
|
||||
assert result["value"] == "secret"
|
||||
assert cli._app.current_buffer.text == "draft command"
|
||||
assert cli._app.current_buffer.cursor_position == 5
|
||||
|
||||
def test_approval_callback_includes_view_for_long_commands(self):
|
||||
cli = _make_cli_stub()
|
||||
command = "sudo dd if=/tmp/githubcli-keyring.gpg of=/usr/share/keyrings/githubcli-archive-keyring.gpg bs=4M status=progress"
|
||||
|
||||
361
tests/gateway/test_bluebubbles.py
Normal file
361
tests/gateway/test_bluebubbles.py
Normal file
@@ -0,0 +1,361 @@
|
||||
"""Tests for the BlueBubbles iMessage gateway adapter."""
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch, **extra):
|
||||
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
|
||||
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
|
||||
from gateway.platforms.bluebubbles import BlueBubblesAdapter
|
||||
|
||||
cfg = PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"server_url": "http://localhost:1234",
|
||||
"password": "secret",
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
return BlueBubblesAdapter(cfg)
|
||||
|
||||
|
||||
class TestBlueBubblesPlatformEnum:
|
||||
def test_bluebubbles_enum_exists(self):
|
||||
assert Platform.BLUEBUBBLES.value == "bluebubbles"
|
||||
|
||||
|
||||
class TestBlueBubblesConfigLoading:
|
||||
def test_apply_env_overrides_bluebubbles(self, monkeypatch):
|
||||
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
|
||||
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
|
||||
monkeypatch.setenv("BLUEBUBBLES_WEBHOOK_PORT", "9999")
|
||||
from gateway.config import GatewayConfig, _apply_env_overrides
|
||||
|
||||
config = GatewayConfig()
|
||||
_apply_env_overrides(config)
|
||||
assert Platform.BLUEBUBBLES in config.platforms
|
||||
bc = config.platforms[Platform.BLUEBUBBLES]
|
||||
assert bc.enabled is True
|
||||
assert bc.extra["server_url"] == "http://localhost:1234"
|
||||
assert bc.extra["password"] == "secret"
|
||||
assert bc.extra["webhook_port"] == 9999
|
||||
|
||||
def test_connected_platforms_includes_bluebubbles(self, monkeypatch):
|
||||
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
|
||||
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
|
||||
from gateway.config import GatewayConfig, _apply_env_overrides
|
||||
|
||||
config = GatewayConfig()
|
||||
_apply_env_overrides(config)
|
||||
assert Platform.BLUEBUBBLES in config.get_connected_platforms()
|
||||
|
||||
def test_home_channel_set_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
|
||||
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
|
||||
monkeypatch.setenv("BLUEBUBBLES_HOME_CHANNEL", "user@example.com")
|
||||
from gateway.config import GatewayConfig, _apply_env_overrides
|
||||
|
||||
config = GatewayConfig()
|
||||
_apply_env_overrides(config)
|
||||
hc = config.platforms[Platform.BLUEBUBBLES].home_channel
|
||||
assert hc is not None
|
||||
assert hc.chat_id == "user@example.com"
|
||||
|
||||
def test_not_connected_without_password(self, monkeypatch):
|
||||
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
|
||||
monkeypatch.delenv("BLUEBUBBLES_PASSWORD", raising=False)
|
||||
from gateway.config import GatewayConfig, _apply_env_overrides
|
||||
|
||||
config = GatewayConfig()
|
||||
_apply_env_overrides(config)
|
||||
assert Platform.BLUEBUBBLES not in config.get_connected_platforms()
|
||||
|
||||
|
||||
class TestBlueBubblesHelpers:
|
||||
def test_check_requirements(self, monkeypatch):
|
||||
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
|
||||
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
|
||||
from gateway.platforms.bluebubbles import check_bluebubbles_requirements
|
||||
|
||||
assert check_bluebubbles_requirements() is True
|
||||
|
||||
def test_format_message_strips_markdown(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter.format_message("**Hello** `world`") == "Hello world"
|
||||
|
||||
def test_strip_markdown_headers(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter.format_message("## Heading\ntext") == "Heading\ntext"
|
||||
|
||||
def test_strip_markdown_links(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter.format_message("[click here](http://example.com)") == "click here"
|
||||
|
||||
def test_init_normalizes_webhook_path(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch, webhook_path="bluebubbles-webhook")
|
||||
assert adapter.webhook_path == "/bluebubbles-webhook"
|
||||
|
||||
def test_init_preserves_leading_slash(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch, webhook_path="/my-hook")
|
||||
assert adapter.webhook_path == "/my-hook"
|
||||
|
||||
def test_server_url_normalized(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch, server_url="http://localhost:1234/")
|
||||
assert adapter.server_url == "http://localhost:1234"
|
||||
|
||||
def test_server_url_adds_scheme(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch, server_url="localhost:1234")
|
||||
assert adapter.server_url == "http://localhost:1234"
|
||||
|
||||
|
||||
class TestBlueBubblesWebhookParsing:
|
||||
def test_webhook_prefers_chat_guid_over_message_guid(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
payload = {
|
||||
"guid": "MESSAGE-GUID",
|
||||
"chatGuid": "iMessage;-;user@example.com",
|
||||
"chatIdentifier": "user@example.com",
|
||||
}
|
||||
record = adapter._extract_payload_record(payload) or {}
|
||||
chat_guid = adapter._value(
|
||||
record.get("chatGuid"),
|
||||
payload.get("chatGuid"),
|
||||
record.get("chat_guid"),
|
||||
payload.get("chat_guid"),
|
||||
payload.get("guid"),
|
||||
)
|
||||
assert chat_guid == "iMessage;-;user@example.com"
|
||||
|
||||
def test_webhook_can_fall_back_to_sender_when_chat_fields_missing(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
payload = {
|
||||
"data": {
|
||||
"guid": "MESSAGE-GUID",
|
||||
"text": "hello",
|
||||
"handle": {"address": "user@example.com"},
|
||||
"isFromMe": False,
|
||||
}
|
||||
}
|
||||
record = adapter._extract_payload_record(payload) or {}
|
||||
chat_guid = adapter._value(
|
||||
record.get("chatGuid"),
|
||||
payload.get("chatGuid"),
|
||||
record.get("chat_guid"),
|
||||
payload.get("chat_guid"),
|
||||
payload.get("guid"),
|
||||
)
|
||||
chat_identifier = adapter._value(
|
||||
record.get("chatIdentifier"),
|
||||
record.get("identifier"),
|
||||
payload.get("chatIdentifier"),
|
||||
payload.get("identifier"),
|
||||
)
|
||||
sender = (
|
||||
adapter._value(
|
||||
record.get("handle", {}).get("address")
|
||||
if isinstance(record.get("handle"), dict)
|
||||
else None,
|
||||
record.get("sender"),
|
||||
record.get("from"),
|
||||
record.get("address"),
|
||||
)
|
||||
or chat_identifier
|
||||
or chat_guid
|
||||
)
|
||||
if not (chat_guid or chat_identifier) and sender:
|
||||
chat_identifier = sender
|
||||
assert chat_identifier == "user@example.com"
|
||||
|
||||
def test_extract_payload_record_accepts_list_data(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
payload = {
|
||||
"type": "new-message",
|
||||
"data": [
|
||||
{
|
||||
"text": "hello",
|
||||
"chatGuid": "iMessage;-;user@example.com",
|
||||
"chatIdentifier": "user@example.com",
|
||||
}
|
||||
],
|
||||
}
|
||||
record = adapter._extract_payload_record(payload)
|
||||
assert record == payload["data"][0]
|
||||
|
||||
def test_extract_payload_record_dict_data(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
payload = {"data": {"text": "hello", "chatGuid": "iMessage;-;+1234"}}
|
||||
record = adapter._extract_payload_record(payload)
|
||||
assert record["text"] == "hello"
|
||||
|
||||
def test_extract_payload_record_fallback_to_message(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
payload = {"message": {"text": "hello"}}
|
||||
record = adapter._extract_payload_record(payload)
|
||||
assert record["text"] == "hello"
|
||||
|
||||
|
||||
class TestBlueBubblesGuidResolution:
|
||||
def test_raw_guid_returned_as_is(self, monkeypatch):
|
||||
"""If target already contains ';' it's a raw GUID — return unchanged."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
import asyncio
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._resolve_chat_guid("iMessage;-;user@example.com")
|
||||
)
|
||||
assert result == "iMessage;-;user@example.com"
|
||||
|
||||
def test_empty_target_returns_none(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
import asyncio
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._resolve_chat_guid("")
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestBlueBubblesToolsetIntegration:
|
||||
def test_toolset_exists(self):
|
||||
from toolsets import TOOLSETS
|
||||
|
||||
assert "hermes-bluebubbles" in TOOLSETS
|
||||
|
||||
def test_toolset_in_gateway_composite(self):
|
||||
from toolsets import TOOLSETS
|
||||
|
||||
gateway = TOOLSETS["hermes-gateway"]
|
||||
assert "hermes-bluebubbles" in gateway["includes"]
|
||||
|
||||
|
||||
class TestBlueBubblesPromptHint:
|
||||
def test_platform_hint_exists(self):
|
||||
from agent.prompt_builder import PLATFORM_HINTS
|
||||
|
||||
assert "bluebubbles" in PLATFORM_HINTS
|
||||
hint = PLATFORM_HINTS["bluebubbles"]
|
||||
assert "iMessage" in hint
|
||||
assert "plain text" in hint
|
||||
|
||||
|
||||
class TestBlueBubblesAttachmentDownload:
|
||||
"""Verify _download_attachment routes to the correct cache helper."""
|
||||
|
||||
def test_download_image_uses_image_cache(self, monkeypatch):
|
||||
"""Image MIME routes to cache_image_from_bytes."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
# Mock the HTTP client response
|
||||
class MockResponse:
|
||||
status_code = 200
|
||||
content = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
async def mock_get(*args, **kwargs):
|
||||
return MockResponse()
|
||||
|
||||
adapter.client = type("MockClient", (), {"get": mock_get})()
|
||||
|
||||
cached_path = None
|
||||
|
||||
def mock_cache_image(data, ext):
|
||||
nonlocal cached_path
|
||||
cached_path = f"/tmp/test_image{ext}"
|
||||
return cached_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.bluebubbles.cache_image_from_bytes",
|
||||
mock_cache_image,
|
||||
)
|
||||
|
||||
att_meta = {"mimeType": "image/png", "transferName": "photo.png"}
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._download_attachment("att-guid-123", att_meta)
|
||||
)
|
||||
assert result == "/tmp/test_image.png"
|
||||
|
||||
def test_download_audio_uses_audio_cache(self, monkeypatch):
|
||||
"""Audio MIME routes to cache_audio_from_bytes."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
import asyncio
|
||||
|
||||
class MockResponse:
|
||||
status_code = 200
|
||||
content = b"fake-audio-data"
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
async def mock_get(*args, **kwargs):
|
||||
return MockResponse()
|
||||
|
||||
adapter.client = type("MockClient", (), {"get": mock_get})()
|
||||
|
||||
cached_path = None
|
||||
|
||||
def mock_cache_audio(data, ext):
|
||||
nonlocal cached_path
|
||||
cached_path = f"/tmp/test_audio{ext}"
|
||||
return cached_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.bluebubbles.cache_audio_from_bytes",
|
||||
mock_cache_audio,
|
||||
)
|
||||
|
||||
att_meta = {"mimeType": "audio/mpeg", "transferName": "voice.mp3"}
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._download_attachment("att-guid-456", att_meta)
|
||||
)
|
||||
assert result == "/tmp/test_audio.mp3"
|
||||
|
||||
def test_download_document_uses_document_cache(self, monkeypatch):
|
||||
"""Non-image/audio MIME routes to cache_document_from_bytes."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
import asyncio
|
||||
|
||||
class MockResponse:
|
||||
status_code = 200
|
||||
content = b"fake-doc-data"
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
async def mock_get(*args, **kwargs):
|
||||
return MockResponse()
|
||||
|
||||
adapter.client = type("MockClient", (), {"get": mock_get})()
|
||||
|
||||
cached_path = None
|
||||
|
||||
def mock_cache_doc(data, filename):
|
||||
nonlocal cached_path
|
||||
cached_path = f"/tmp/{filename}"
|
||||
return cached_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.bluebubbles.cache_document_from_bytes",
|
||||
mock_cache_doc,
|
||||
)
|
||||
|
||||
att_meta = {"mimeType": "application/pdf", "transferName": "report.pdf"}
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._download_attachment("att-guid-789", att_meta)
|
||||
)
|
||||
assert result == "/tmp/report.pdf"
|
||||
|
||||
def test_download_returns_none_without_client(self, monkeypatch):
|
||||
"""No client → returns None gracefully."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = None
|
||||
import asyncio
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._download_attachment("att-guid", {"mimeType": "image/png"})
|
||||
)
|
||||
assert result is None
|
||||
@@ -209,14 +209,31 @@ class TestIncomingDocumentHandling:
|
||||
assert "[Content of readme.md]:" in event.text
|
||||
assert "# Title" in event.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_content_injected(self, adapter):
|
||||
""".log file under 100KB should be treated as text/plain and injected."""
|
||||
file_content = b"BLE trace line 1\nBLE trace line 2"
|
||||
|
||||
with _mock_aiohttp_download(file_content):
|
||||
msg = make_message(
|
||||
attachments=[make_attachment(filename="btsnoop_hci.log", content_type="text/plain")],
|
||||
content="please inspect this",
|
||||
)
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
event = adapter.handle_message.call_args[0][0]
|
||||
assert "[Content of btsnoop_hci.log]:" in event.text
|
||||
assert "BLE trace line 1" in event.text
|
||||
assert "please inspect this" in event.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oversized_document_skipped(self, adapter):
|
||||
"""A document over 20MB should be skipped — media_urls stays empty."""
|
||||
"""A document over 32MB should be skipped — media_urls stays empty."""
|
||||
msg = make_message([
|
||||
make_attachment(
|
||||
filename="huge.pdf",
|
||||
content_type="application/pdf",
|
||||
size=25 * 1024 * 1024,
|
||||
size=33 * 1024 * 1024,
|
||||
)
|
||||
])
|
||||
await adapter._handle_message(msg)
|
||||
@@ -226,6 +243,24 @@ class TestIncomingDocumentHandling:
|
||||
# handler must still be called
|
||||
adapter.handle_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mid_sized_zip_under_32mb_is_cached(self, adapter):
|
||||
"""A 25MB .zip should be accepted now that Discord documents allow up to 32MB."""
|
||||
msg = make_message([
|
||||
make_attachment(
|
||||
filename="bugreport.zip",
|
||||
content_type="application/zip",
|
||||
size=25 * 1024 * 1024,
|
||||
)
|
||||
])
|
||||
|
||||
with _mock_aiohttp_download(b"PK\x03\x04test"):
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
event = adapter.handle_message.call_args[0][0]
|
||||
assert len(event.media_urls) == 1
|
||||
assert event.media_types == ["application/zip"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zip_document_cached(self, adapter):
|
||||
"""A .zip file should be cached as a supported document."""
|
||||
|
||||
315
tests/gateway/test_gateway_inactivity_timeout.py
Normal file
315
tests/gateway/test_gateway_inactivity_timeout.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""Tests for staged inactivity timeout in gateway agent runs.
|
||||
|
||||
Tests cover:
|
||||
- Warning fires once when inactivity reaches gateway_timeout_warning threshold
|
||||
- Warning does not fire when gateway_timeout is 0 (unlimited)
|
||||
- Warning fires only once per run, not on every poll
|
||||
- Full timeout still fires at gateway_timeout threshold
|
||||
- Warning respects HERMES_AGENT_TIMEOUT_WARNING env var
|
||||
- Warning disabled when gateway_timeout_warning is 0
|
||||
"""
|
||||
|
||||
import concurrent.futures
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
|
||||
class FakeAgent:
|
||||
"""Mock agent with controllable activity summary for timeout tests."""
|
||||
|
||||
def __init__(self, idle_seconds=0.0, activity_desc="tool_call",
|
||||
current_tool=None, api_call_count=5, max_iterations=90):
|
||||
self._idle_seconds = idle_seconds
|
||||
self._activity_desc = activity_desc
|
||||
self._current_tool = current_tool
|
||||
self._api_call_count = api_call_count
|
||||
self._max_iterations = max_iterations
|
||||
self._interrupted = False
|
||||
self._interrupt_msg = None
|
||||
|
||||
def get_activity_summary(self):
|
||||
return {
|
||||
"last_activity_ts": time.time() - self._idle_seconds,
|
||||
"last_activity_desc": self._activity_desc,
|
||||
"seconds_since_activity": self._idle_seconds,
|
||||
"current_tool": self._current_tool,
|
||||
"api_call_count": self._api_call_count,
|
||||
"max_iterations": self._max_iterations,
|
||||
}
|
||||
|
||||
def interrupt(self, msg):
|
||||
self._interrupted = True
|
||||
self._interrupt_msg = msg
|
||||
|
||||
def run_conversation(self, prompt):
|
||||
return {"final_response": "Done", "messages": []}
|
||||
|
||||
|
||||
class SlowFakeAgent(FakeAgent):
|
||||
"""Agent that runs for a while, then goes idle."""
|
||||
|
||||
def __init__(self, run_duration=0.5, idle_after=None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._run_duration = run_duration
|
||||
self._idle_after = idle_after
|
||||
self._start_time = None
|
||||
|
||||
def get_activity_summary(self):
|
||||
summary = super().get_activity_summary()
|
||||
if self._idle_after is not None and self._start_time:
|
||||
elapsed = time.time() - self._start_time
|
||||
if elapsed > self._idle_after:
|
||||
idle_time = elapsed - self._idle_after
|
||||
summary["seconds_since_activity"] = idle_time
|
||||
summary["last_activity_desc"] = "api_call_streaming"
|
||||
else:
|
||||
summary["seconds_since_activity"] = 0.0
|
||||
return summary
|
||||
|
||||
def run_conversation(self, prompt):
|
||||
self._start_time = time.time()
|
||||
time.sleep(self._run_duration)
|
||||
return {"final_response": "Completed after work", "messages": []}
|
||||
|
||||
|
||||
class TestStagedInactivityWarning:
|
||||
"""Test the staged inactivity warning before full timeout."""
|
||||
|
||||
def test_warning_fires_once_before_timeout(self):
|
||||
"""Warning fires when inactivity reaches warning threshold."""
|
||||
agent = SlowFakeAgent(
|
||||
run_duration=10.0,
|
||||
idle_after=0.1,
|
||||
activity_desc="api_call_streaming",
|
||||
)
|
||||
|
||||
_agent_timeout = 20.0
|
||||
_agent_warning = 5.0
|
||||
_POLL_INTERVAL = 0.1
|
||||
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
future = pool.submit(agent.run_conversation, "test prompt")
|
||||
_inactivity_timeout = False
|
||||
_warning_fired = False
|
||||
_warning_send_count = 0
|
||||
|
||||
while True:
|
||||
done, _ = concurrent.futures.wait({future}, timeout=_POLL_INTERVAL)
|
||||
if done:
|
||||
result = future.result()
|
||||
break
|
||||
_idle_secs = 0.0
|
||||
if hasattr(agent, "get_activity_summary"):
|
||||
try:
|
||||
_act = agent.get_activity_summary()
|
||||
_idle_secs = _act.get("seconds_since_activity", 0.0)
|
||||
except Exception:
|
||||
pass
|
||||
if (not _warning_fired and _agent_warning > 0
|
||||
and _idle_secs >= _agent_warning):
|
||||
_warning_fired = True
|
||||
_warning_send_count += 1
|
||||
if _idle_secs >= _agent_timeout:
|
||||
_inactivity_timeout = True
|
||||
break
|
||||
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
assert _warning_fired
|
||||
assert _warning_send_count == 1
|
||||
assert not _inactivity_timeout
|
||||
|
||||
def test_warning_disabled_when_zero(self):
|
||||
"""No warning fires when gateway_timeout_warning is 0."""
|
||||
agent = SlowFakeAgent(
|
||||
run_duration=5.0,
|
||||
idle_after=0.1,
|
||||
)
|
||||
|
||||
_agent_timeout = 20.0
|
||||
_agent_warning = 0.0
|
||||
_POLL_INTERVAL = 0.1
|
||||
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
future = pool.submit(agent.run_conversation, "test")
|
||||
_warning_fired = False
|
||||
|
||||
while True:
|
||||
done, _ = concurrent.futures.wait({future}, timeout=_POLL_INTERVAL)
|
||||
if done:
|
||||
future.result()
|
||||
break
|
||||
_idle_secs = 0.0
|
||||
if hasattr(agent, "get_activity_summary"):
|
||||
try:
|
||||
_act = agent.get_activity_summary()
|
||||
_idle_secs = _act.get("seconds_since_activity", 0.0)
|
||||
except Exception:
|
||||
pass
|
||||
if (not _warning_fired and _agent_warning > 0
|
||||
and _idle_secs >= _agent_warning):
|
||||
_warning_fired = True
|
||||
if _idle_secs >= _agent_timeout:
|
||||
break
|
||||
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
assert not _warning_fired
|
||||
|
||||
def test_warning_fires_only_once(self):
|
||||
"""Warning fires exactly once even if agent remains idle."""
|
||||
agent = SlowFakeAgent(
|
||||
run_duration=10.0,
|
||||
idle_after=0.05,
|
||||
)
|
||||
|
||||
_agent_timeout = 20.0
|
||||
_agent_warning = 0.2
|
||||
_POLL_INTERVAL = 0.05
|
||||
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
future = pool.submit(agent.run_conversation, "test")
|
||||
_warning_count = 0
|
||||
|
||||
while True:
|
||||
done, _ = concurrent.futures.wait({future}, timeout=_POLL_INTERVAL)
|
||||
if done:
|
||||
future.result()
|
||||
break
|
||||
_idle_secs = 0.0
|
||||
if hasattr(agent, "get_activity_summary"):
|
||||
try:
|
||||
_act = agent.get_activity_summary()
|
||||
_idle_secs = _act.get("seconds_since_activity", 0.0)
|
||||
except Exception:
|
||||
pass
|
||||
if (not _warning_count and _agent_warning > 0
|
||||
and _idle_secs >= _agent_warning):
|
||||
_warning_count += 1
|
||||
if _idle_secs >= _agent_timeout:
|
||||
break
|
||||
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
assert _warning_count == 1
|
||||
|
||||
def test_full_timeout_still_fires_after_warning(self):
|
||||
"""Full timeout fires even after warning was sent."""
|
||||
agent = SlowFakeAgent(
|
||||
run_duration=15.0,
|
||||
idle_after=0.1,
|
||||
activity_desc="waiting for provider response (streaming)",
|
||||
)
|
||||
|
||||
_agent_timeout = 1.0
|
||||
_agent_warning = 0.3
|
||||
_POLL_INTERVAL = 0.05
|
||||
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
future = pool.submit(agent.run_conversation, "test")
|
||||
_inactivity_timeout = False
|
||||
_warning_fired = False
|
||||
|
||||
while True:
|
||||
done, _ = concurrent.futures.wait({future}, timeout=_POLL_INTERVAL)
|
||||
if done:
|
||||
future.result()
|
||||
break
|
||||
_idle_secs = 0.0
|
||||
if hasattr(agent, "get_activity_summary"):
|
||||
try:
|
||||
_act = agent.get_activity_summary()
|
||||
_idle_secs = _act.get("seconds_since_activity", 0.0)
|
||||
except Exception:
|
||||
pass
|
||||
if (not _warning_fired and _agent_warning > 0
|
||||
and _idle_secs >= _agent_warning):
|
||||
_warning_fired = True
|
||||
if _idle_secs >= _agent_timeout:
|
||||
_inactivity_timeout = True
|
||||
break
|
||||
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
assert _warning_fired
|
||||
assert _inactivity_timeout
|
||||
|
||||
def test_warning_env_var_respected(self, monkeypatch):
|
||||
"""HERMES_AGENT_TIMEOUT_WARNING env var is parsed correctly."""
|
||||
monkeypatch.setenv("HERMES_AGENT_TIMEOUT_WARNING", "600")
|
||||
_warning = float(os.getenv("HERMES_AGENT_TIMEOUT_WARNING", 900))
|
||||
assert _warning == 600.0
|
||||
|
||||
def test_warning_zero_means_disabled(self, monkeypatch):
|
||||
"""HERMES_AGENT_TIMEOUT_WARNING=0 disables the warning."""
|
||||
monkeypatch.setenv("HERMES_AGENT_TIMEOUT_WARNING", "0")
|
||||
_raw = float(os.getenv("HERMES_AGENT_TIMEOUT_WARNING", 900))
|
||||
_warning = _raw if _raw > 0 else None
|
||||
assert _warning is None
|
||||
|
||||
def test_unlimited_timeout_no_warning(self):
|
||||
"""When timeout is unlimited (0), no warning fires either."""
|
||||
agent = SlowFakeAgent(
|
||||
run_duration=0.5,
|
||||
idle_after=0.0,
|
||||
)
|
||||
|
||||
_agent_timeout = None
|
||||
_agent_warning = 5.0
|
||||
_POLL_INTERVAL = 0.05
|
||||
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
future = pool.submit(agent.run_conversation, "test")
|
||||
|
||||
result = future.result(timeout=2.0)
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
assert result["final_response"] == "Completed after work"
|
||||
|
||||
|
||||
class TestWarningThresholdBelowTimeout:
|
||||
"""Test that warning threshold must be less than timeout threshold."""
|
||||
|
||||
def test_warning_at_half_timeout(self):
|
||||
"""Warning fires at half the timeout duration."""
|
||||
agent = SlowFakeAgent(
|
||||
run_duration=10.0,
|
||||
idle_after=0.1,
|
||||
activity_desc="receiving stream response",
|
||||
)
|
||||
|
||||
_agent_timeout = 2.0
|
||||
_agent_warning = 1.0
|
||||
_POLL_INTERVAL = 0.05
|
||||
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
future = pool.submit(agent.run_conversation, "test")
|
||||
_warning_fired = False
|
||||
_timeout_fired = False
|
||||
|
||||
while True:
|
||||
done, _ = concurrent.futures.wait({future}, timeout=_POLL_INTERVAL)
|
||||
if done:
|
||||
future.result()
|
||||
break
|
||||
_idle_secs = 0.0
|
||||
if hasattr(agent, "get_activity_summary"):
|
||||
try:
|
||||
_act = agent.get_activity_summary()
|
||||
_idle_secs = _act.get("seconds_since_activity", 0.0)
|
||||
except Exception:
|
||||
pass
|
||||
if (not _warning_fired and _agent_warning > 0
|
||||
and _idle_secs >= _agent_warning):
|
||||
_warning_fired = True
|
||||
if _idle_secs >= _agent_timeout:
|
||||
_timeout_fired = True
|
||||
break
|
||||
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
assert _warning_fired
|
||||
assert _timeout_fired
|
||||
226
tests/gateway/test_internal_event_bypass_pairing.py
Normal file
226
tests/gateway/test_internal_event_bypass_pairing.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Tests that internal synthetic events (e.g. background process completion)
|
||||
bypass user authorization and do not trigger DM pairing.
|
||||
|
||||
Regression test for the bug where ``_run_process_watcher`` with
|
||||
``notify_on_complete=True`` injected a ``MessageEvent`` without ``user_id``,
|
||||
causing ``_is_user_authorized`` to reject it and the gateway to send a
|
||||
pairing code to the chat.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeRegistry:
|
||||
"""Return pre-canned sessions, then None once exhausted."""
|
||||
|
||||
def __init__(self, sessions):
|
||||
self._sessions = list(sessions)
|
||||
|
||||
def get(self, session_id):
|
||||
if self._sessions:
|
||||
return self._sessions.pop(0)
|
||||
return None
|
||||
|
||||
|
||||
def _build_runner(monkeypatch, tmp_path) -> GatewayRunner:
|
||||
"""Create a GatewayRunner with notifications set to 'all'."""
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"display:\n background_process_notifications: all\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
import gateway.run as gateway_run
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
|
||||
runner = GatewayRunner(GatewayConfig())
|
||||
adapter = SimpleNamespace(send=AsyncMock(), handle_message=AsyncMock())
|
||||
runner.adapters[Platform.DISCORD] = adapter
|
||||
return runner
|
||||
|
||||
|
||||
def _watcher_dict_with_notify():
|
||||
return {
|
||||
"session_id": "proc_test_internal",
|
||||
"check_interval": 0,
|
||||
"session_key": "agent:main:discord:dm:123",
|
||||
"platform": "discord",
|
||||
"chat_id": "123",
|
||||
"thread_id": "",
|
||||
"notify_on_complete": True,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_on_complete_sets_internal_flag(monkeypatch, tmp_path):
|
||||
"""Synthetic completion event must have internal=True."""
|
||||
import tools.process_registry as pr_module
|
||||
|
||||
sessions = [
|
||||
SimpleNamespace(
|
||||
output_buffer="done\n", exited=True, exit_code=0, command="echo test"
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(pr_module, "process_registry", _FakeRegistry(sessions))
|
||||
|
||||
async def _instant_sleep(*_a, **_kw):
|
||||
pass
|
||||
monkeypatch.setattr(asyncio, "sleep", _instant_sleep)
|
||||
|
||||
runner = _build_runner(monkeypatch, tmp_path)
|
||||
adapter = runner.adapters[Platform.DISCORD]
|
||||
|
||||
await runner._run_process_watcher(_watcher_dict_with_notify())
|
||||
|
||||
assert adapter.handle_message.await_count == 1
|
||||
event = adapter.handle_message.await_args.args[0]
|
||||
assert isinstance(event, MessageEvent)
|
||||
assert event.internal is True, "Synthetic completion event must be marked internal"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_event_bypasses_authorization(monkeypatch, tmp_path):
|
||||
"""An internal event should skip _is_user_authorized entirely."""
|
||||
import gateway.run as gateway_run
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
(tmp_path / "config.yaml").write_text("", encoding="utf-8")
|
||||
|
||||
runner = GatewayRunner(GatewayConfig())
|
||||
|
||||
# Create an internal event with no user_id (simulates the bug scenario)
|
||||
source = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="123",
|
||||
chat_type="dm",
|
||||
)
|
||||
event = MessageEvent(
|
||||
text="[SYSTEM: Background process completed]",
|
||||
source=source,
|
||||
internal=True,
|
||||
)
|
||||
|
||||
# Track if _is_user_authorized is called
|
||||
auth_called = False
|
||||
original_auth = GatewayRunner._is_user_authorized
|
||||
|
||||
def tracking_auth(self, src):
|
||||
nonlocal auth_called
|
||||
auth_called = True
|
||||
return original_auth(self, src)
|
||||
|
||||
monkeypatch.setattr(GatewayRunner, "_is_user_authorized", tracking_auth)
|
||||
|
||||
# _handle_message will proceed past auth check and eventually fail on
|
||||
# downstream logic. We just need to verify auth is skipped.
|
||||
try:
|
||||
await runner._handle_message(event)
|
||||
except Exception:
|
||||
pass # Expected — downstream code needs more setup
|
||||
|
||||
assert not auth_called, (
|
||||
"_is_user_authorized should NOT be called for internal events"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_event_does_not_trigger_pairing(monkeypatch, tmp_path):
|
||||
"""An internal event with no user_id must not generate a pairing code."""
|
||||
import gateway.run as gateway_run
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
(tmp_path / "config.yaml").write_text("", encoding="utf-8")
|
||||
|
||||
runner = GatewayRunner(GatewayConfig())
|
||||
# Add adapter so pairing would have somewhere to send
|
||||
adapter = SimpleNamespace(send=AsyncMock())
|
||||
runner.adapters[Platform.DISCORD] = adapter
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="123",
|
||||
chat_type="dm", # DM would normally trigger pairing
|
||||
)
|
||||
event = MessageEvent(
|
||||
text="[SYSTEM: Background process completed]",
|
||||
source=source,
|
||||
internal=True,
|
||||
)
|
||||
|
||||
# Track pairing code generation
|
||||
generate_called = False
|
||||
original_generate = runner.pairing_store.generate_code
|
||||
|
||||
def tracking_generate(*args, **kwargs):
|
||||
nonlocal generate_called
|
||||
generate_called = True
|
||||
return original_generate(*args, **kwargs)
|
||||
|
||||
runner.pairing_store.generate_code = tracking_generate
|
||||
|
||||
try:
|
||||
await runner._handle_message(event)
|
||||
except Exception:
|
||||
pass # Expected — downstream code needs more setup
|
||||
|
||||
assert not generate_called, (
|
||||
"Pairing code should NOT be generated for internal events"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_internal_event_without_user_triggers_pairing(monkeypatch, tmp_path):
|
||||
"""Verify the normal (non-internal) path still triggers pairing for unknown users."""
|
||||
import gateway.run as gateway_run
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
(tmp_path / "config.yaml").write_text("", encoding="utf-8")
|
||||
|
||||
# Clear env vars that could let all users through (loaded by
|
||||
# module-level dotenv in gateway/run.py from the real ~/.hermes/.env).
|
||||
monkeypatch.delenv("DISCORD_ALLOW_ALL_USERS", raising=False)
|
||||
monkeypatch.delenv("DISCORD_ALLOWED_USERS", raising=False)
|
||||
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
|
||||
monkeypatch.delenv("GATEWAY_ALLOWED_USERS", raising=False)
|
||||
|
||||
runner = GatewayRunner(GatewayConfig())
|
||||
adapter = SimpleNamespace(send=AsyncMock())
|
||||
runner.adapters[Platform.DISCORD] = adapter
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="123",
|
||||
chat_type="dm",
|
||||
user_id="unknown_user_999",
|
||||
)
|
||||
# Normal event (not internal)
|
||||
event = MessageEvent(
|
||||
text="hello",
|
||||
source=source,
|
||||
internal=False,
|
||||
)
|
||||
|
||||
result = await runner._handle_message(event)
|
||||
|
||||
# Should return None (unauthorized) and send pairing message
|
||||
assert result is None
|
||||
assert adapter.send.await_count == 1
|
||||
sent_text = adapter.send.await_args.args[1]
|
||||
assert "don't recognize you" in sent_text
|
||||
@@ -707,3 +707,66 @@ class TestSignalSendDocumentViaHelper:
|
||||
|
||||
assert result.success is False
|
||||
assert "/nonexistent.pdf" in result.error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send() returns message_id from timestamp (#4647)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalSendReturnsMessageId:
|
||||
"""Signal send() must return a timestamp-based message_id so the stream
|
||||
consumer can follow its edit→fallback path correctly."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_returns_timestamp_as_message_id(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, _ = _stub_rpc({"timestamp": 1712345678000})
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
result = await adapter.send(chat_id="+155****4567", content="hello")
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == "1712345678000"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_returns_none_message_id_when_no_timestamp(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, _ = _stub_rpc({}) # No timestamp key
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
result = await adapter.send(chat_id="+155****4567", content="hello")
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_returns_none_message_id_for_non_dict(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, _ = _stub_rpc("ok") # Non-dict result
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
result = await adapter.send(chat_id="+155****4567", content="hello")
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stop_typing() delegates to _stop_typing_indicator (#4647)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalStopTyping:
|
||||
"""Signal must expose a public stop_typing() so base adapter's
|
||||
_keep_typing finally block can clean up platform-level typing tasks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_calls_private_method(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
await adapter.stop_typing("+155****4567")
|
||||
|
||||
adapter._stop_typing_indicator.assert_awaited_once_with("+155****4567")
|
||||
|
||||
@@ -96,7 +96,7 @@ class TestAppMentionHandler:
|
||||
"""Verify that the app_mention event handler is registered."""
|
||||
|
||||
def test_app_mention_registered_on_connect(self):
|
||||
"""connect() should register both 'message' and 'app_mention' handlers."""
|
||||
"""connect() should register message + assistant lifecycle handlers."""
|
||||
config = PlatformConfig(enabled=True, token="xoxb-fake")
|
||||
adapter = SlackAdapter(config)
|
||||
|
||||
@@ -145,6 +145,8 @@ class TestAppMentionHandler:
|
||||
|
||||
assert "message" in registered_events
|
||||
assert "app_mention" in registered_events
|
||||
assert "assistant_thread_started" in registered_events
|
||||
assert "assistant_thread_context_changed" in registered_events
|
||||
assert "/hermes" in registered_commands
|
||||
|
||||
|
||||
@@ -840,6 +842,114 @@ class TestThreadReplyHandling:
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAssistantThreadLifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAssistantThreadLifecycle:
|
||||
"""Slack Assistant lifecycle events should seed session/user context."""
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_session_store(self):
|
||||
store = MagicMock()
|
||||
store._entries = {}
|
||||
store._ensure_loaded = MagicMock()
|
||||
store.config = MagicMock()
|
||||
store.config.group_sessions_per_user = True
|
||||
store.get_or_create_session = MagicMock()
|
||||
return store
|
||||
|
||||
@pytest.fixture()
|
||||
def assistant_adapter(self, mock_session_store):
|
||||
config = PlatformConfig(enabled=True, token="***")
|
||||
a = SlackAdapter(config)
|
||||
a._app = MagicMock()
|
||||
a._app.client = AsyncMock()
|
||||
a._bot_user_id = "U_BOT"
|
||||
a._team_bot_user_ids = {"T_TEAM": "U_BOT"}
|
||||
a._running = True
|
||||
a.handle_message = AsyncMock()
|
||||
a.set_session_store(mock_session_store)
|
||||
return a
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_event_seeds_session_store(self, assistant_adapter, mock_session_store):
|
||||
event = {
|
||||
"type": "assistant_thread_started",
|
||||
"team_id": "T_TEAM",
|
||||
"assistant_thread": {
|
||||
"channel_id": "D123",
|
||||
"thread_ts": "171.000",
|
||||
"user_id": "U_USER",
|
||||
"context": {"channel_id": "C_ORIGIN"},
|
||||
},
|
||||
}
|
||||
|
||||
await assistant_adapter._handle_assistant_thread_lifecycle_event(event)
|
||||
|
||||
assert assistant_adapter._assistant_threads[("D123", "171.000")]["user_id"] == "U_USER"
|
||||
mock_session_store.get_or_create_session.assert_called_once()
|
||||
source = mock_session_store.get_or_create_session.call_args[0][0]
|
||||
assert source.chat_id == "D123"
|
||||
assert source.chat_type == "dm"
|
||||
assert source.user_id == "U_USER"
|
||||
assert source.thread_id == "171.000"
|
||||
assert source.chat_topic == "C_ORIGIN"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_uses_cached_assistant_thread_identity(self, assistant_adapter):
|
||||
assistant_adapter._assistant_threads[("D123", "171.000")] = {
|
||||
"channel_id": "D123",
|
||||
"thread_ts": "171.000",
|
||||
"user_id": "U_USER",
|
||||
"team_id": "T_TEAM",
|
||||
}
|
||||
assistant_adapter._app.client.users_info = AsyncMock(return_value={
|
||||
"user": {"profile": {"display_name": "Tyler"}}
|
||||
})
|
||||
assistant_adapter._app.client.reactions_add = AsyncMock()
|
||||
assistant_adapter._app.client.reactions_remove = AsyncMock()
|
||||
|
||||
event = {
|
||||
"text": "hello from assistant dm",
|
||||
"channel": "D123",
|
||||
"channel_type": "im",
|
||||
"thread_ts": "171.000",
|
||||
"ts": "171.111",
|
||||
"team": "T_TEAM",
|
||||
}
|
||||
|
||||
await assistant_adapter._handle_slack_message(event)
|
||||
|
||||
msg_event = assistant_adapter.handle_message.call_args[0][0]
|
||||
assert msg_event.source.user_id == "U_USER"
|
||||
assert msg_event.source.thread_id == "171.000"
|
||||
assert msg_event.source.user_name == "Tyler"
|
||||
|
||||
def test_assistant_threads_cache_eviction(self, assistant_adapter):
|
||||
"""Cache should evict oldest entries when exceeding the size limit."""
|
||||
assistant_adapter._ASSISTANT_THREADS_MAX = 10
|
||||
# Fill to the limit
|
||||
for i in range(10):
|
||||
assistant_adapter._cache_assistant_thread_metadata({
|
||||
"channel_id": f"D{i}",
|
||||
"thread_ts": f"{i}.000",
|
||||
"user_id": f"U{i}",
|
||||
})
|
||||
assert len(assistant_adapter._assistant_threads) == 10
|
||||
|
||||
# Adding one more should trigger eviction (down to max // 2 = 5)
|
||||
assistant_adapter._cache_assistant_thread_metadata({
|
||||
"channel_id": "D999",
|
||||
"thread_ts": "999.000",
|
||||
"user_id": "U999",
|
||||
})
|
||||
assert len(assistant_adapter._assistant_threads) <= 10
|
||||
# The newest entry must survive eviction
|
||||
assert ("D999", "999.000") in assistant_adapter._assistant_threads
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestUserNameResolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -383,6 +383,60 @@ class TestSegmentBreakOnToolBoundary:
|
||||
sent_texts = [call[1]["content"] for call in adapter.send.call_args_list]
|
||||
assert sent_texts == ["Hello ▉", "Next segment"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_message_id_enters_fallback_mode(self):
|
||||
"""Platform returns success but no message_id (Signal) — must not
|
||||
re-send on every delta. Should enter fallback mode and send only
|
||||
the continuation at finish."""
|
||||
adapter = MagicMock()
|
||||
# First send succeeds but returns no message_id (Signal behavior)
|
||||
send_result_no_id = SimpleNamespace(success=True, message_id=None)
|
||||
# Fallback final send succeeds
|
||||
send_result_final = SimpleNamespace(success=True, message_id="msg_final")
|
||||
adapter.send = AsyncMock(side_effect=[send_result_no_id, send_result_final])
|
||||
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.on_delta("Hello")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.08)
|
||||
consumer.on_delta(" world, this is a longer response.")
|
||||
await asyncio.sleep(0.08)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
# Should send exactly 2 messages: initial chunk + fallback continuation
|
||||
# NOT one message per delta
|
||||
assert adapter.send.call_count == 2
|
||||
assert consumer.already_sent
|
||||
# edit_message should NOT have been called (no valid message_id to edit)
|
||||
adapter.edit_message.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_message_id_single_delta_marks_already_sent(self):
|
||||
"""When the entire response fits in one delta and platform returns no
|
||||
message_id, already_sent must still be True to prevent the gateway
|
||||
from re-sending the full response."""
|
||||
adapter = MagicMock()
|
||||
send_result = SimpleNamespace(success=True, message_id=None)
|
||||
adapter.send = AsyncMock(return_value=send_result)
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
consumer.on_delta("Short response.")
|
||||
consumer.finish()
|
||||
|
||||
await consumer.run()
|
||||
|
||||
assert consumer.already_sent
|
||||
# Only one send call (the initial message)
|
||||
assert adapter.send.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_final_splits_long_continuation_without_dropping_text(self):
|
||||
"""Long continuation tails should be chunked when fallback final-send runs."""
|
||||
|
||||
70
tests/hermes_cli/test_model_switch_variant_tags.py
Normal file
70
tests/hermes_cli/test_model_switch_variant_tags.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Tests for OpenRouter variant tag preservation in model switching.
|
||||
|
||||
Regression test for GitHub PR #6088 / Discord report: OpenRouter model IDs
|
||||
with variant suffixes like ``:free``, ``:extended``, ``:fast`` were being
|
||||
mangled by the colon-to-slash conversion in model_switch.py Step c.
|
||||
|
||||
The fix: Step c now skips colon→slash conversion when the model name already
|
||||
contains a forward slash (i.e. is already in ``vendor/model`` format), since
|
||||
the colon is a variant tag, not a vendor separator.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.model_switch import switch_model
|
||||
|
||||
|
||||
# Shared mock context — skip network calls, credential resolution, catalog lookups
|
||||
_MOCK_VALIDATION = {"accepted": True, "persist": True, "recognized": True, "message": None}
|
||||
|
||||
|
||||
def _run_switch(raw_input: str, current_provider: str = "openrouter") -> str:
|
||||
"""Run switch_model with mocked dependencies, return the resolved model name."""
|
||||
with patch("hermes_cli.model_switch.resolve_alias", return_value=None), \
|
||||
patch("hermes_cli.model_switch.list_provider_models", return_value=[]), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={"api_key": "test", "base_url": "", "api_mode": "chat_completions"}), \
|
||||
patch("hermes_cli.models.validate_requested_model", return_value=_MOCK_VALIDATION), \
|
||||
patch("hermes_cli.model_switch.get_model_info", return_value=None), \
|
||||
patch("hermes_cli.model_switch.get_model_capabilities", return_value=None), \
|
||||
patch("hermes_cli.models.detect_provider_for_model", return_value=None):
|
||||
result = switch_model(
|
||||
raw_input=raw_input,
|
||||
current_provider=current_provider,
|
||||
current_model="anthropic/claude-sonnet-4.6",
|
||||
)
|
||||
assert result.success, f"switch_model failed: {result.error_message}"
|
||||
return result.new_model
|
||||
|
||||
|
||||
class TestVariantTagPreservation:
|
||||
"""OpenRouter variant tags (:free, :extended, :fast) must survive model switching."""
|
||||
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
("nvidia/nemotron-3-super-120b-a12b:free", "nvidia/nemotron-3-super-120b-a12b:free"),
|
||||
("anthropic/claude-sonnet-4.6:extended", "anthropic/claude-sonnet-4.6:extended"),
|
||||
("meta-llama/llama-4-maverick:fast", "meta-llama/llama-4-maverick:fast"),
|
||||
])
|
||||
def test_slash_format_preserves_variant_tag(self, model, expected):
|
||||
"""Models already in vendor/model:tag format must not have their tag mangled."""
|
||||
assert _run_switch(model) == expected
|
||||
|
||||
def test_legacy_colon_format_converts_to_slash(self):
|
||||
"""Legacy vendor:model (no slash) should still be converted to vendor/model."""
|
||||
result = _run_switch("nvidia:nemotron-3-super-120b-a12b")
|
||||
assert result == "nvidia/nemotron-3-super-120b-a12b"
|
||||
|
||||
def test_legacy_colon_format_with_tag_converts_first_colon_only(self):
|
||||
"""vendor:model:free (no slash) → vendor/model:free — first colon becomes slash."""
|
||||
result = _run_switch("nvidia:nemotron-3-super-120b-a12b:free")
|
||||
assert result == "nvidia/nemotron-3-super-120b-a12b:free"
|
||||
|
||||
def test_bare_model_name_unaffected(self):
|
||||
"""Bare model names without colons or slashes should work normally."""
|
||||
result = _run_switch("claude-sonnet-4.6")
|
||||
assert result == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
def test_already_correct_slug_no_tag(self):
|
||||
"""Standard vendor/model slugs without tags pass through unchanged."""
|
||||
result = _run_switch("anthropic/claude-sonnet-4.6")
|
||||
assert result == "anthropic/claude-sonnet-4.6"
|
||||
598
tests/plugins/memory/test_hindsight_provider.py
Normal file
598
tests/plugins/memory/test_hindsight_provider.py
Normal file
@@ -0,0 +1,598 @@
|
||||
"""Tests for the Hindsight memory provider plugin.
|
||||
|
||||
Tests cover config loading, tool handlers (tags, max_tokens, types),
|
||||
prefetch (auto_recall, preamble, query truncation), sync_turn (auto_retain,
|
||||
turn counting, tags), and schema completeness.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.hindsight import (
|
||||
HindsightMemoryProvider,
|
||||
RECALL_SCHEMA,
|
||||
REFLECT_SCHEMA,
|
||||
RETAIN_SCHEMA,
|
||||
_load_config,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
"""Ensure no stale env vars leak between tests."""
|
||||
for key in (
|
||||
"HINDSIGHT_API_KEY", "HINDSIGHT_API_URL", "HINDSIGHT_BANK_ID",
|
||||
"HINDSIGHT_BUDGET", "HINDSIGHT_MODE", "HINDSIGHT_LLM_API_KEY",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def _make_mock_client():
|
||||
"""Create a mock Hindsight client with async methods."""
|
||||
client = MagicMock()
|
||||
client.aretain = AsyncMock()
|
||||
client.arecall = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
results=[
|
||||
SimpleNamespace(text="Memory 1"),
|
||||
SimpleNamespace(text="Memory 2"),
|
||||
]
|
||||
)
|
||||
)
|
||||
client.areflect = AsyncMock(
|
||||
return_value=SimpleNamespace(text="Synthesized answer")
|
||||
)
|
||||
client.aretain_batch = AsyncMock()
|
||||
client.aclose = AsyncMock()
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def provider(tmp_path, monkeypatch):
|
||||
"""Create an initialized HindsightMemoryProvider with a mock client."""
|
||||
config = {
|
||||
"mode": "cloud",
|
||||
"apiKey": "test-key",
|
||||
"api_url": "http://localhost:9999",
|
||||
"bank_id": "test-bank",
|
||||
"budget": "mid",
|
||||
"memory_mode": "hybrid",
|
||||
}
|
||||
config_path = tmp_path / "hindsight" / "config.json"
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(json.dumps(config))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.hindsight.get_hermes_home", lambda: tmp_path
|
||||
)
|
||||
|
||||
p = HindsightMemoryProvider()
|
||||
p.initialize(session_id="test-session", hermes_home=str(tmp_path), platform="cli")
|
||||
p._client = _make_mock_client()
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def provider_with_config(tmp_path, monkeypatch):
|
||||
"""Create a provider factory that accepts custom config overrides."""
|
||||
def _make(**overrides):
|
||||
config = {
|
||||
"mode": "cloud",
|
||||
"apiKey": "test-key",
|
||||
"api_url": "http://localhost:9999",
|
||||
"bank_id": "test-bank",
|
||||
"budget": "mid",
|
||||
"memory_mode": "hybrid",
|
||||
}
|
||||
config.update(overrides)
|
||||
config_path = tmp_path / "hindsight" / "config.json"
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(json.dumps(config))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.hindsight.get_hermes_home", lambda: tmp_path
|
||||
)
|
||||
|
||||
p = HindsightMemoryProvider()
|
||||
p.initialize(session_id="test-session", hermes_home=str(tmp_path), platform="cli")
|
||||
p._client = _make_mock_client()
|
||||
return p
|
||||
return _make
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSchemas:
|
||||
def test_retain_schema_has_content(self):
|
||||
assert RETAIN_SCHEMA["name"] == "hindsight_retain"
|
||||
assert "content" in RETAIN_SCHEMA["parameters"]["properties"]
|
||||
assert "content" in RETAIN_SCHEMA["parameters"]["required"]
|
||||
|
||||
def test_recall_schema_has_query(self):
|
||||
assert RECALL_SCHEMA["name"] == "hindsight_recall"
|
||||
assert "query" in RECALL_SCHEMA["parameters"]["properties"]
|
||||
assert "query" in RECALL_SCHEMA["parameters"]["required"]
|
||||
|
||||
def test_reflect_schema_has_query(self):
|
||||
assert REFLECT_SCHEMA["name"] == "hindsight_reflect"
|
||||
assert "query" in REFLECT_SCHEMA["parameters"]["properties"]
|
||||
|
||||
def test_get_tool_schemas_returns_three(self, provider):
|
||||
schemas = provider.get_tool_schemas()
|
||||
assert len(schemas) == 3
|
||||
names = {s["name"] for s in schemas}
|
||||
assert names == {"hindsight_retain", "hindsight_recall", "hindsight_reflect"}
|
||||
|
||||
def test_context_mode_returns_no_tools(self, provider_with_config):
|
||||
p = provider_with_config(memory_mode="context")
|
||||
assert p.get_tool_schemas() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_default_values(self, provider):
|
||||
assert provider._auto_retain is True
|
||||
assert provider._auto_recall is True
|
||||
assert provider._retain_every_n_turns == 1
|
||||
assert provider._recall_max_tokens == 4096
|
||||
assert provider._recall_max_input_chars == 800
|
||||
assert provider._tags is None
|
||||
assert provider._recall_tags is None
|
||||
assert provider._bank_mission == ""
|
||||
assert provider._bank_retain_mission is None
|
||||
assert provider._retain_context == "conversation between Hermes Agent and the User"
|
||||
|
||||
def test_custom_config_values(self, provider_with_config):
|
||||
p = provider_with_config(
|
||||
tags=["tag1", "tag2"],
|
||||
recall_tags=["recall-tag"],
|
||||
recall_tags_match="all",
|
||||
auto_retain=False,
|
||||
auto_recall=False,
|
||||
retain_every_n_turns=3,
|
||||
retain_context="custom-ctx",
|
||||
bank_retain_mission="Extract key facts",
|
||||
recall_max_tokens=2048,
|
||||
recall_types=["world", "experience"],
|
||||
recall_prompt_preamble="Custom preamble:",
|
||||
recall_max_input_chars=500,
|
||||
bank_mission="Test agent mission",
|
||||
)
|
||||
assert p._tags == ["tag1", "tag2"]
|
||||
assert p._recall_tags == ["recall-tag"]
|
||||
assert p._recall_tags_match == "all"
|
||||
assert p._auto_retain is False
|
||||
assert p._auto_recall is False
|
||||
assert p._retain_every_n_turns == 3
|
||||
assert p._retain_context == "custom-ctx"
|
||||
assert p._bank_retain_mission == "Extract key facts"
|
||||
assert p._recall_max_tokens == 2048
|
||||
assert p._recall_types == ["world", "experience"]
|
||||
assert p._recall_prompt_preamble == "Custom preamble:"
|
||||
assert p._recall_max_input_chars == 500
|
||||
assert p._bank_mission == "Test agent mission"
|
||||
|
||||
def test_config_from_env_fallback(self, tmp_path, monkeypatch):
|
||||
"""When no config file exists, falls back to env vars."""
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.hindsight.get_hermes_home",
|
||||
lambda: tmp_path / "nonexistent",
|
||||
)
|
||||
monkeypatch.setenv("HINDSIGHT_MODE", "cloud")
|
||||
monkeypatch.setenv("HINDSIGHT_API_KEY", "env-key")
|
||||
monkeypatch.setenv("HINDSIGHT_BANK_ID", "env-bank")
|
||||
monkeypatch.setenv("HINDSIGHT_BUDGET", "high")
|
||||
|
||||
cfg = _load_config()
|
||||
assert cfg["apiKey"] == "env-key"
|
||||
assert cfg["banks"]["hermes"]["bankId"] == "env-bank"
|
||||
assert cfg["banks"]["hermes"]["budget"] == "high"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool handler tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolHandlers:
|
||||
def test_retain_success(self, provider):
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"hindsight_retain", {"content": "user likes dark mode"}
|
||||
))
|
||||
assert result["result"] == "Memory stored successfully."
|
||||
provider._client.aretain.assert_called_once()
|
||||
call_kwargs = provider._client.aretain.call_args.kwargs
|
||||
assert call_kwargs["bank_id"] == "test-bank"
|
||||
assert call_kwargs["content"] == "user likes dark mode"
|
||||
|
||||
def test_retain_with_tags(self, provider_with_config):
|
||||
p = provider_with_config(tags=["pref", "ui"])
|
||||
p.handle_tool_call("hindsight_retain", {"content": "likes dark mode"})
|
||||
call_kwargs = p._client.aretain.call_args.kwargs
|
||||
assert call_kwargs["tags"] == ["pref", "ui"]
|
||||
|
||||
def test_retain_without_tags(self, provider):
|
||||
provider.handle_tool_call("hindsight_retain", {"content": "hello"})
|
||||
call_kwargs = provider._client.aretain.call_args.kwargs
|
||||
assert "tags" not in call_kwargs
|
||||
|
||||
def test_retain_missing_content(self, provider):
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"hindsight_retain", {}
|
||||
))
|
||||
assert "error" in result
|
||||
|
||||
def test_recall_success(self, provider):
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"hindsight_recall", {"query": "dark mode"}
|
||||
))
|
||||
assert "Memory 1" in result["result"]
|
||||
assert "Memory 2" in result["result"]
|
||||
|
||||
def test_recall_passes_max_tokens(self, provider_with_config):
|
||||
p = provider_with_config(recall_max_tokens=2048)
|
||||
p.handle_tool_call("hindsight_recall", {"query": "test"})
|
||||
call_kwargs = p._client.arecall.call_args.kwargs
|
||||
assert call_kwargs["max_tokens"] == 2048
|
||||
|
||||
def test_recall_passes_tags(self, provider_with_config):
|
||||
p = provider_with_config(recall_tags=["tag1"], recall_tags_match="all")
|
||||
p.handle_tool_call("hindsight_recall", {"query": "test"})
|
||||
call_kwargs = p._client.arecall.call_args.kwargs
|
||||
assert call_kwargs["tags"] == ["tag1"]
|
||||
assert call_kwargs["tags_match"] == "all"
|
||||
|
||||
def test_recall_passes_types(self, provider_with_config):
|
||||
p = provider_with_config(recall_types=["world", "experience"])
|
||||
p.handle_tool_call("hindsight_recall", {"query": "test"})
|
||||
call_kwargs = p._client.arecall.call_args.kwargs
|
||||
assert call_kwargs["types"] == ["world", "experience"]
|
||||
|
||||
def test_recall_no_results(self, provider):
|
||||
provider._client.arecall.return_value = SimpleNamespace(results=[])
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"hindsight_recall", {"query": "test"}
|
||||
))
|
||||
assert result["result"] == "No relevant memories found."
|
||||
|
||||
def test_recall_missing_query(self, provider):
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"hindsight_recall", {}
|
||||
))
|
||||
assert "error" in result
|
||||
|
||||
def test_reflect_success(self, provider):
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"hindsight_reflect", {"query": "summarize"}
|
||||
))
|
||||
assert result["result"] == "Synthesized answer"
|
||||
|
||||
def test_reflect_missing_query(self, provider):
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"hindsight_reflect", {}
|
||||
))
|
||||
assert "error" in result
|
||||
|
||||
def test_unknown_tool(self, provider):
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"hindsight_unknown", {}
|
||||
))
|
||||
assert "error" in result
|
||||
|
||||
def test_retain_error_handling(self, provider):
|
||||
provider._client.aretain.side_effect = RuntimeError("connection failed")
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"hindsight_retain", {"content": "test"}
|
||||
))
|
||||
assert "error" in result
|
||||
assert "connection failed" in result["error"]
|
||||
|
||||
def test_recall_error_handling(self, provider):
|
||||
provider._client.arecall.side_effect = RuntimeError("timeout")
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"hindsight_recall", {"query": "test"}
|
||||
))
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prefetch tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPrefetch:
|
||||
def test_prefetch_returns_empty_when_no_result(self, provider):
|
||||
assert provider.prefetch("test") == ""
|
||||
|
||||
def test_prefetch_default_preamble(self, provider):
|
||||
provider._prefetch_result = "- some memory"
|
||||
result = provider.prefetch("test")
|
||||
assert "Hindsight Memory" in result
|
||||
assert "- some memory" in result
|
||||
|
||||
def test_prefetch_custom_preamble(self, provider_with_config):
|
||||
p = provider_with_config(recall_prompt_preamble="Custom header:")
|
||||
p._prefetch_result = "- memory line"
|
||||
result = p.prefetch("test")
|
||||
assert result.startswith("Custom header:")
|
||||
assert "- memory line" in result
|
||||
|
||||
def test_queue_prefetch_skipped_in_tools_mode(self, provider_with_config):
|
||||
p = provider_with_config(memory_mode="tools")
|
||||
p.queue_prefetch("test")
|
||||
# Should not start a thread
|
||||
assert p._prefetch_thread is None
|
||||
|
||||
def test_queue_prefetch_skipped_when_auto_recall_off(self, provider_with_config):
|
||||
p = provider_with_config(auto_recall=False)
|
||||
p.queue_prefetch("test")
|
||||
assert p._prefetch_thread is None
|
||||
|
||||
def test_queue_prefetch_truncates_query(self, provider_with_config):
|
||||
p = provider_with_config(recall_max_input_chars=10)
|
||||
# Mock _run_sync to capture the query
|
||||
original_query = None
|
||||
|
||||
def _capture_recall(**kwargs):
|
||||
nonlocal original_query
|
||||
original_query = kwargs.get("query", "")
|
||||
return SimpleNamespace(results=[])
|
||||
|
||||
p._client.arecall = AsyncMock(side_effect=_capture_recall)
|
||||
|
||||
long_query = "a" * 100
|
||||
p.queue_prefetch(long_query)
|
||||
if p._prefetch_thread:
|
||||
p._prefetch_thread.join(timeout=5.0)
|
||||
|
||||
# The query passed to arecall should be truncated
|
||||
if original_query is not None:
|
||||
assert len(original_query) <= 10
|
||||
|
||||
def test_queue_prefetch_passes_recall_params(self, provider_with_config):
|
||||
p = provider_with_config(
|
||||
recall_tags=["t1"],
|
||||
recall_tags_match="all",
|
||||
recall_max_tokens=1024,
|
||||
recall_types=["world"],
|
||||
)
|
||||
p.queue_prefetch("test query")
|
||||
if p._prefetch_thread:
|
||||
p._prefetch_thread.join(timeout=5.0)
|
||||
|
||||
call_kwargs = p._client.arecall.call_args.kwargs
|
||||
assert call_kwargs["max_tokens"] == 1024
|
||||
assert call_kwargs["tags"] == ["t1"]
|
||||
assert call_kwargs["tags_match"] == "all"
|
||||
assert call_kwargs["types"] == ["world"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sync_turn tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSyncTurn:
|
||||
def _get_retain_kwargs(self, provider):
|
||||
"""Helper to get the kwargs from the aretain_batch call."""
|
||||
return provider._client.aretain_batch.call_args.kwargs
|
||||
|
||||
def _get_retain_content(self, provider):
|
||||
"""Helper to get the raw content string from the first item."""
|
||||
kwargs = self._get_retain_kwargs(provider)
|
||||
return kwargs["items"][0]["content"]
|
||||
|
||||
def _get_retain_messages(self, provider):
|
||||
"""Helper to parse the first turn's messages from retained content.
|
||||
|
||||
Content is a JSON array of turns: [[msgs...], [msgs...], ...]
|
||||
For single-turn tests, returns the first turn's messages.
|
||||
"""
|
||||
content = self._get_retain_content(provider)
|
||||
turns = json.loads(content)
|
||||
return turns[0] if len(turns) == 1 else turns
|
||||
|
||||
def test_sync_turn_retains(self, provider):
|
||||
provider.sync_turn("hello", "hi there")
|
||||
if provider._sync_thread:
|
||||
provider._sync_thread.join(timeout=5.0)
|
||||
provider._client.aretain_batch.assert_called_once()
|
||||
messages = self._get_retain_messages(provider)
|
||||
assert len(messages) == 2
|
||||
assert messages[0]["role"] == "user"
|
||||
assert messages[0]["content"] == "hello"
|
||||
assert "timestamp" in messages[0]
|
||||
assert messages[1]["role"] == "assistant"
|
||||
assert messages[1]["content"] == "hi there"
|
||||
assert "timestamp" in messages[1]
|
||||
|
||||
def test_sync_turn_skipped_when_auto_retain_off(self, provider_with_config):
|
||||
p = provider_with_config(auto_retain=False)
|
||||
p.sync_turn("hello", "hi")
|
||||
assert p._sync_thread is None
|
||||
p._client.aretain_batch.assert_not_called()
|
||||
|
||||
def test_sync_turn_with_tags(self, provider_with_config):
|
||||
p = provider_with_config(tags=["conv", "session1"])
|
||||
p.sync_turn("hello", "hi")
|
||||
if p._sync_thread:
|
||||
p._sync_thread.join(timeout=5.0)
|
||||
item = p._client.aretain_batch.call_args.kwargs["items"][0]
|
||||
assert item["tags"] == ["conv", "session1"]
|
||||
|
||||
def test_sync_turn_uses_aretain_batch(self, provider):
|
||||
"""sync_turn should use aretain_batch with retain_async."""
|
||||
provider.sync_turn("hello", "hi")
|
||||
if provider._sync_thread:
|
||||
provider._sync_thread.join(timeout=5.0)
|
||||
provider._client.aretain_batch.assert_called_once()
|
||||
call_kwargs = provider._client.aretain_batch.call_args.kwargs
|
||||
assert call_kwargs["document_id"] == "test-session"
|
||||
assert call_kwargs["retain_async"] is True
|
||||
assert len(call_kwargs["items"]) == 1
|
||||
assert call_kwargs["items"][0]["context"] == "conversation between Hermes Agent and the User"
|
||||
|
||||
def test_sync_turn_custom_context(self, provider_with_config):
|
||||
p = provider_with_config(retain_context="my-agent")
|
||||
p.sync_turn("hello", "hi")
|
||||
if p._sync_thread:
|
||||
p._sync_thread.join(timeout=5.0)
|
||||
item = p._client.aretain_batch.call_args.kwargs["items"][0]
|
||||
assert item["context"] == "my-agent"
|
||||
|
||||
def test_sync_turn_every_n_turns(self, provider_with_config):
|
||||
"""With retain_every_n_turns=3, only retains on every 3rd turn."""
|
||||
p = provider_with_config(retain_every_n_turns=3)
|
||||
|
||||
p.sync_turn("turn1-user", "turn1-asst")
|
||||
assert p._sync_thread is None # not retained yet
|
||||
|
||||
p.sync_turn("turn2-user", "turn2-asst")
|
||||
assert p._sync_thread is None # not retained yet
|
||||
|
||||
p.sync_turn("turn3-user", "turn3-asst")
|
||||
assert p._sync_thread is not None # retained!
|
||||
p._sync_thread.join(timeout=5.0)
|
||||
|
||||
p._client.aretain_batch.assert_called_once()
|
||||
content = p._client.aretain_batch.call_args.kwargs["items"][0]["content"]
|
||||
# Should contain all 3 turns
|
||||
assert "turn1-user" in content
|
||||
assert "turn2-user" in content
|
||||
assert "turn3-user" in content
|
||||
|
||||
def test_sync_turn_accumulates_full_session(self, provider_with_config):
|
||||
"""Each retain sends the ENTIRE session, not just the latest batch."""
|
||||
p = provider_with_config(retain_every_n_turns=2)
|
||||
|
||||
p.sync_turn("turn1-user", "turn1-asst")
|
||||
p.sync_turn("turn2-user", "turn2-asst")
|
||||
if p._sync_thread:
|
||||
p._sync_thread.join(timeout=5.0)
|
||||
|
||||
p._client.aretain_batch.reset_mock()
|
||||
|
||||
p.sync_turn("turn3-user", "turn3-asst")
|
||||
p.sync_turn("turn4-user", "turn4-asst")
|
||||
if p._sync_thread:
|
||||
p._sync_thread.join(timeout=5.0)
|
||||
|
||||
content = p._client.aretain_batch.call_args.kwargs["items"][0]["content"]
|
||||
# Should contain ALL turns from the session
|
||||
assert "turn1-user" in content
|
||||
assert "turn2-user" in content
|
||||
assert "turn3-user" in content
|
||||
assert "turn4-user" in content
|
||||
|
||||
def test_sync_turn_passes_document_id(self, provider):
|
||||
"""sync_turn should pass session_id as document_id for dedup."""
|
||||
provider.sync_turn("hello", "hi")
|
||||
if provider._sync_thread:
|
||||
provider._sync_thread.join(timeout=5.0)
|
||||
call_kwargs = provider._client.aretain_batch.call_args.kwargs
|
||||
assert call_kwargs["document_id"] == "test-session"
|
||||
|
||||
def test_sync_turn_error_does_not_raise(self, provider):
|
||||
"""Errors in sync_turn should be swallowed (non-blocking)."""
|
||||
provider._client.aretain_batch.side_effect = RuntimeError("network error")
|
||||
provider.sync_turn("hello", "hi")
|
||||
if provider._sync_thread:
|
||||
provider._sync_thread.join(timeout=5.0)
|
||||
# Should not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System prompt tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSystemPrompt:
|
||||
def test_hybrid_mode_prompt(self, provider):
|
||||
block = provider.system_prompt_block()
|
||||
assert "Hindsight Memory" in block
|
||||
assert "hindsight_recall" in block
|
||||
assert "automatically injected" in block
|
||||
|
||||
def test_context_mode_prompt(self, provider_with_config):
|
||||
p = provider_with_config(memory_mode="context")
|
||||
block = p.system_prompt_block()
|
||||
assert "context mode" in block
|
||||
assert "hindsight_recall" not in block
|
||||
|
||||
def test_tools_mode_prompt(self, provider_with_config):
|
||||
p = provider_with_config(memory_mode="tools")
|
||||
block = p.system_prompt_block()
|
||||
assert "tools mode" in block
|
||||
assert "hindsight_recall" in block
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config schema tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigSchema:
|
||||
def test_schema_has_all_new_fields(self, provider):
|
||||
schema = provider.get_config_schema()
|
||||
keys = {f["key"] for f in schema}
|
||||
expected_keys = {
|
||||
"mode", "api_url", "api_key", "llm_provider", "llm_api_key",
|
||||
"llm_model", "bank_id", "bank_mission", "bank_retain_mission",
|
||||
"recall_budget", "memory_mode", "recall_prefetch_method",
|
||||
"tags", "recall_tags", "recall_tags_match",
|
||||
"auto_recall", "auto_retain",
|
||||
"retain_every_n_turns", "retain_async",
|
||||
"retain_context",
|
||||
"recall_max_tokens", "recall_max_input_chars",
|
||||
"recall_prompt_preamble",
|
||||
}
|
||||
assert expected_keys.issubset(keys), f"Missing: {expected_keys - keys}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Availability tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAvailability:
|
||||
def test_available_with_api_key(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.hindsight.get_hermes_home",
|
||||
lambda: tmp_path / "nonexistent",
|
||||
)
|
||||
monkeypatch.setenv("HINDSIGHT_API_KEY", "test-key")
|
||||
p = HindsightMemoryProvider()
|
||||
assert p.is_available()
|
||||
|
||||
def test_not_available_without_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.hindsight.get_hermes_home",
|
||||
lambda: tmp_path / "nonexistent",
|
||||
)
|
||||
p = HindsightMemoryProvider()
|
||||
assert not p.is_available()
|
||||
|
||||
def test_available_in_local_mode(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.hindsight.get_hermes_home",
|
||||
lambda: tmp_path / "nonexistent",
|
||||
)
|
||||
monkeypatch.setenv("HINDSIGHT_MODE", "local")
|
||||
p = HindsightMemoryProvider()
|
||||
assert p.is_available()
|
||||
@@ -150,8 +150,8 @@ def agent():
|
||||
class TestContextPressureFlags:
|
||||
"""Context pressure warning flag tracking on AIAgent."""
|
||||
|
||||
def test_flag_initialized_false(self, agent):
|
||||
assert agent._context_pressure_warned is False
|
||||
def test_flag_initialized_zero(self, agent):
|
||||
assert agent._context_pressure_warned_at == 0.0
|
||||
|
||||
def test_emit_calls_status_callback(self, agent):
|
||||
"""status_callback should be invoked with event type and message."""
|
||||
@@ -210,7 +210,7 @@ class TestContextPressureFlags:
|
||||
|
||||
def test_flag_reset_on_compression(self, agent):
|
||||
"""After _compress_context, context pressure flag should reset."""
|
||||
agent._context_pressure_warned = True
|
||||
agent._context_pressure_warned_at = 0.85
|
||||
agent.compression_enabled = True
|
||||
|
||||
agent.context_compressor = MagicMock()
|
||||
@@ -219,6 +219,7 @@ class TestContextPressureFlags:
|
||||
]
|
||||
agent.context_compressor.context_length = 200_000
|
||||
agent.context_compressor.threshold_tokens = 100_000
|
||||
agent.context_compressor.compression_count = 1
|
||||
|
||||
agent._todo_store = MagicMock()
|
||||
agent._todo_store.format_for_injection.return_value = None
|
||||
@@ -233,7 +234,7 @@ class TestContextPressureFlags:
|
||||
]
|
||||
agent._compress_context(messages, "system prompt")
|
||||
|
||||
assert agent._context_pressure_warned is False
|
||||
assert agent._context_pressure_warned_at == 0.0
|
||||
|
||||
def test_emit_callback_error_handled(self, agent):
|
||||
"""If status_callback raises, it should be caught gracefully."""
|
||||
@@ -246,3 +247,115 @@ class TestContextPressureFlags:
|
||||
|
||||
# Should not raise
|
||||
agent._emit_context_pressure(0.85, compressor)
|
||||
|
||||
def test_tiered_reemits_at_95(self, agent):
|
||||
"""Warning fires at 85%, then fires again when crossing 95%."""
|
||||
agent._context_pressure_warned_at = 0.85
|
||||
# Simulate crossing 95%: the tier (0.95) > warned_at (0.85)
|
||||
assert 0.95 > agent._context_pressure_warned_at
|
||||
# After emission at 95%, the tier should update
|
||||
agent._context_pressure_warned_at = 0.95
|
||||
assert agent._context_pressure_warned_at == 0.95
|
||||
|
||||
def test_tiered_no_double_emit_at_same_level(self, agent):
|
||||
"""Once warned at 85%, further 85%+ readings don't re-warn."""
|
||||
agent._context_pressure_warned_at = 0.85
|
||||
# At 88%, tier is 0.85, which is NOT > warned_at (0.85)
|
||||
_warn_tier = 0.85 if 0.88 >= 0.85 else 0.0
|
||||
assert not (_warn_tier > agent._context_pressure_warned_at)
|
||||
|
||||
def test_flag_not_reset_when_compression_insufficient(self, agent):
|
||||
"""When compression can't drop below 85%, keep the flag set."""
|
||||
agent._context_pressure_warned_at = 0.85
|
||||
agent.compression_enabled = True
|
||||
|
||||
agent.context_compressor = MagicMock()
|
||||
agent.context_compressor.compress.return_value = [
|
||||
{"role": "user", "content": "Summary of conversation so far."}
|
||||
]
|
||||
agent.context_compressor.context_length = 200
|
||||
# Use a small threshold so the tiny compressed output still
|
||||
# represents >= 85% of it (prevents flag reset).
|
||||
agent.context_compressor.threshold_tokens = 10
|
||||
agent.context_compressor.compression_count = 1
|
||||
agent.context_compressor.last_prompt_tokens = 0
|
||||
|
||||
agent._todo_store = MagicMock()
|
||||
agent._todo_store.format_for_injection.return_value = None
|
||||
agent._build_system_prompt = MagicMock(return_value="system prompt")
|
||||
agent._cached_system_prompt = "old system prompt"
|
||||
agent._session_db = None
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
]
|
||||
agent._compress_context(messages, "system prompt")
|
||||
|
||||
# Post-compression is ~90% of threshold — flag should NOT reset
|
||||
assert agent._context_pressure_warned_at == 0.85
|
||||
|
||||
|
||||
class TestContextPressureGatewayDedup:
|
||||
"""Class-level dedup prevents warning spam across AIAgent instances."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Clear class-level dedup state between tests."""
|
||||
AIAgent._context_pressure_last_warned.clear()
|
||||
|
||||
def test_second_instance_within_cooldown_suppressed(self):
|
||||
"""Same session, same tier, within cooldown — should be suppressed."""
|
||||
import time
|
||||
sid = "test_session_dedup"
|
||||
# Simulate first warning
|
||||
AIAgent._context_pressure_last_warned[sid] = (0.85, time.time())
|
||||
# Second instance checking same tier within cooldown
|
||||
_last = AIAgent._context_pressure_last_warned.get(sid)
|
||||
_should_warn = _last is None or _last[0] < 0.85 or (time.time() - _last[1]) >= AIAgent._CONTEXT_PRESSURE_COOLDOWN
|
||||
assert not _should_warn
|
||||
|
||||
def test_higher_tier_fires_despite_cooldown(self):
|
||||
"""Same session, higher tier — should fire even within cooldown."""
|
||||
import time
|
||||
sid = "test_session_tier"
|
||||
AIAgent._context_pressure_last_warned[sid] = (0.85, time.time())
|
||||
_last = AIAgent._context_pressure_last_warned.get(sid)
|
||||
# 0.95 > 0.85 stored tier → should warn
|
||||
_should_warn = _last is None or _last[0] < 0.95 or (time.time() - _last[1]) >= AIAgent._CONTEXT_PRESSURE_COOLDOWN
|
||||
assert _should_warn
|
||||
|
||||
def test_warning_fires_after_cooldown_expires(self):
|
||||
"""Same session, same tier, after cooldown — should fire again."""
|
||||
import time
|
||||
sid = "test_session_expired"
|
||||
# Set a timestamp far in the past
|
||||
AIAgent._context_pressure_last_warned[sid] = (0.85, time.time() - AIAgent._CONTEXT_PRESSURE_COOLDOWN - 1)
|
||||
_last = AIAgent._context_pressure_last_warned.get(sid)
|
||||
_should_warn = _last is None or _last[0] < 0.85 or (time.time() - _last[1]) >= AIAgent._CONTEXT_PRESSURE_COOLDOWN
|
||||
assert _should_warn
|
||||
|
||||
def test_compression_clears_dedup(self):
|
||||
"""After compression drops below 85%, dedup entry should be cleared."""
|
||||
import time
|
||||
sid = "test_session_clear"
|
||||
AIAgent._context_pressure_last_warned[sid] = (0.85, time.time())
|
||||
assert sid in AIAgent._context_pressure_last_warned
|
||||
# Simulate what _compress_context does on reset
|
||||
AIAgent._context_pressure_last_warned.pop(sid, None)
|
||||
assert sid not in AIAgent._context_pressure_last_warned
|
||||
|
||||
def test_eviction_removes_stale_entries(self):
|
||||
"""Stale entries older than 2x cooldown should be evicted."""
|
||||
import time
|
||||
_now = time.time()
|
||||
AIAgent._context_pressure_last_warned = {
|
||||
"fresh": (0.85, _now),
|
||||
"stale": (0.85, _now - AIAgent._CONTEXT_PRESSURE_COOLDOWN * 3),
|
||||
}
|
||||
_cutoff = _now - AIAgent._CONTEXT_PRESSURE_COOLDOWN * 2
|
||||
AIAgent._context_pressure_last_warned = {
|
||||
k: v for k, v in AIAgent._context_pressure_last_warned.items()
|
||||
if v[1] > _cutoff
|
||||
}
|
||||
assert "fresh" in AIAgent._context_pressure_last_warned
|
||||
assert "stale" not in AIAgent._context_pressure_last_warned
|
||||
|
||||
@@ -91,6 +91,61 @@ def _chat_response_with_memory_call():
|
||||
)
|
||||
|
||||
|
||||
class TestFlushMemoriesRespectsConfigTimeout:
|
||||
"""flush_memories() must NOT hardcode timeout=30.0 — it should defer
|
||||
to the config value via auxiliary.flush_memories.timeout."""
|
||||
|
||||
def test_auxiliary_path_omits_explicit_timeout(self, monkeypatch):
|
||||
"""When calling _call_llm, timeout should NOT be passed so that
|
||||
_get_task_timeout('flush_memories') reads from config."""
|
||||
agent = _make_agent(monkeypatch, api_mode="chat_completions", provider="openrouter")
|
||||
|
||||
mock_response = _chat_response_with_memory_call()
|
||||
|
||||
with patch("agent.auxiliary_client.call_llm", return_value=mock_response) as mock_call:
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi"},
|
||||
{"role": "user", "content": "Note this"},
|
||||
]
|
||||
with patch("tools.memory_tool.memory_tool", return_value="Saved."):
|
||||
agent.flush_memories(messages)
|
||||
|
||||
mock_call.assert_called_once()
|
||||
call_kwargs = mock_call.call_args
|
||||
# timeout must NOT be explicitly passed (so _get_task_timeout resolves it)
|
||||
assert "timeout" not in call_kwargs.kwargs, (
|
||||
"flush_memories should not pass explicit timeout to _call_llm; "
|
||||
"let _get_task_timeout('flush_memories') resolve from config"
|
||||
)
|
||||
|
||||
def test_fallback_path_uses_config_timeout(self, monkeypatch):
|
||||
"""When auxiliary client is unavailable and we fall back to direct
|
||||
OpenAI client, timeout should come from _get_task_timeout, not hardcoded."""
|
||||
agent = _make_agent(monkeypatch, api_mode="chat_completions", provider="openrouter")
|
||||
agent.client = MagicMock()
|
||||
agent.client.chat.completions.create.return_value = _chat_response_with_memory_call()
|
||||
|
||||
custom_timeout = 180.0
|
||||
|
||||
with patch("agent.auxiliary_client.call_llm", side_effect=RuntimeError("no provider")), \
|
||||
patch("agent.auxiliary_client._get_task_timeout", return_value=custom_timeout) as mock_gtt, \
|
||||
patch("tools.memory_tool.memory_tool", return_value="Saved."):
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi"},
|
||||
{"role": "user", "content": "Save this"},
|
||||
]
|
||||
agent.flush_memories(messages)
|
||||
|
||||
mock_gtt.assert_called_once_with("flush_memories")
|
||||
agent.client.chat.completions.create.assert_called_once()
|
||||
call_kwargs = agent.client.chat.completions.create.call_args
|
||||
assert call_kwargs.kwargs.get("timeout") == custom_timeout, (
|
||||
f"Expected timeout={custom_timeout} from config, got {call_kwargs.kwargs.get('timeout')}"
|
||||
)
|
||||
|
||||
|
||||
class TestFlushMemoriesUsesAuxiliaryClient:
|
||||
"""When an auxiliary client is available, flush_memories should use it
|
||||
instead of self.client -- especially critical in Codex mode."""
|
||||
|
||||
@@ -1668,12 +1668,15 @@ class TestRunConversation:
|
||||
if roles[i] == "assistant" and roles[i + 1] == "assistant":
|
||||
raise AssertionError("Consecutive assistant messages found in history")
|
||||
|
||||
def test_truly_empty_response_accepted_without_retry(self, agent):
|
||||
"""Truly empty response (no content, no reasoning) should still complete with (empty)."""
|
||||
def test_truly_empty_response_retries_3_times_then_empty(self, agent):
|
||||
"""Truly empty response (no content, no reasoning) retries 3 times then falls through to (empty)."""
|
||||
self._setup_agent(agent)
|
||||
agent.base_url = "http://127.0.0.1:1234/v1"
|
||||
empty_resp = _mock_response(content=None, finish_reason="stop")
|
||||
agent.client.chat.completions.create.side_effect = [empty_resp]
|
||||
# 4 responses: 1 original + 3 nudge retries, all empty
|
||||
agent.client.chat.completions.create.side_effect = [
|
||||
empty_resp, empty_resp, empty_resp, empty_resp,
|
||||
]
|
||||
with (
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
@@ -1682,7 +1685,28 @@ class TestRunConversation:
|
||||
result = agent.run_conversation("answer me")
|
||||
assert result["completed"] is True
|
||||
assert result["final_response"] == "(empty)"
|
||||
assert result["api_calls"] == 1 # no retries
|
||||
assert result["api_calls"] == 4 # 1 original + 3 retries
|
||||
|
||||
def test_truly_empty_response_succeeds_on_nudge(self, agent):
|
||||
"""Model produces content after being nudged for empty response."""
|
||||
self._setup_agent(agent)
|
||||
agent.base_url = "http://127.0.0.1:1234/v1"
|
||||
empty_resp = _mock_response(content=None, finish_reason="stop")
|
||||
content_resp = _mock_response(
|
||||
content="Here is the actual answer.",
|
||||
finish_reason="stop",
|
||||
)
|
||||
# 1 empty response, then model produces content on nudge
|
||||
agent.client.chat.completions.create.side_effect = [empty_resp, content_resp]
|
||||
with (
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
result = agent.run_conversation("answer me")
|
||||
assert result["completed"] is True
|
||||
assert result["final_response"] == "Here is the actual answer."
|
||||
assert result["api_calls"] == 2 # 1 original + 1 nudge retry
|
||||
|
||||
def test_nous_401_refreshes_after_remint_and_retries(self, agent):
|
||||
self._setup_agent(agent)
|
||||
|
||||
@@ -658,6 +658,47 @@ def test_workspace_agents_records_skip_when_missing(tmp_path: Path):
|
||||
assert wa_items[0]["status"] == "skipped"
|
||||
|
||||
|
||||
def test_cron_store_is_archived_without_config_cron_section(tmp_path: Path):
|
||||
"""Bug fix: archive cron store even when openclaw.json has no top-level cron config."""
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
output_dir = target / "migration-report"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
|
||||
(source / "openclaw.json").write_text(json.dumps({"channels": {}}), encoding="utf-8")
|
||||
(source / "cron").mkdir(parents=True)
|
||||
(source / "cron" / "jobs.json").write_text(
|
||||
json.dumps({"version": 1, "jobs": [{"id": "job-1", "name": "demo"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
migrator = mod.Migrator(
|
||||
source_root=source,
|
||||
target_root=target,
|
||||
execute=True,
|
||||
workspace_target=None,
|
||||
overwrite=False,
|
||||
migrate_secrets=False,
|
||||
output_dir=output_dir,
|
||||
selected_options={"cron-jobs"},
|
||||
)
|
||||
report = migrator.migrate()
|
||||
|
||||
cron_items = [item for item in report["items"] if item["kind"] == "cron-jobs"]
|
||||
archived_store = next(
|
||||
(item for item in cron_items if item["destination"] and item["destination"].endswith("archive/cron-store")),
|
||||
None,
|
||||
)
|
||||
assert archived_store is not None
|
||||
assert Path(archived_store["destination"]).joinpath("jobs.json").exists()
|
||||
|
||||
notes_text = (output_dir / "MIGRATION_NOTES.md").read_text(encoding="utf-8")
|
||||
assert "Run `hermes cron` to recreate scheduled tasks" in notes_text
|
||||
assert "archive/cron-config.json" not in notes_text
|
||||
|
||||
|
||||
def test_skill_installs_cleanly_under_skills_guard():
|
||||
skills_guard = load_skills_guard()
|
||||
result = skills_guard.scan_skill(
|
||||
|
||||
@@ -663,6 +663,84 @@ class TestPruneSessions:
|
||||
assert db.get_session("old_cli") is None
|
||||
assert db.get_session("old_tg") is not None
|
||||
|
||||
def test_prune_with_multilevel_chain(self, db):
|
||||
"""Pruning old sessions orphans newer children instead of crashing on FK."""
|
||||
old_ts = time.time() - 200 * 86400
|
||||
recent_ts = time.time() - 10 * 86400
|
||||
|
||||
# Chain: A (old) -> B (old) -> C (recent) -> D (recent)
|
||||
db.create_session(session_id="A", source="cli")
|
||||
db.end_session("A", end_reason="compressed")
|
||||
db.create_session(session_id="B", source="cli", parent_session_id="A")
|
||||
db.end_session("B", end_reason="compressed")
|
||||
db.create_session(session_id="C", source="cli", parent_session_id="B")
|
||||
db.end_session("C", end_reason="compressed")
|
||||
db.create_session(session_id="D", source="cli", parent_session_id="C")
|
||||
db.end_session("D", end_reason="done")
|
||||
|
||||
# Backdate A and B to be old; C and D stay recent
|
||||
for sid, ts in [("A", old_ts), ("B", old_ts), ("C", recent_ts), ("D", recent_ts)]:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?", (ts, sid)
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
# Should not raise IntegrityError
|
||||
pruned = db.prune_sessions(older_than_days=90)
|
||||
assert pruned == 2 # only A and B
|
||||
assert db.get_session("A") is None
|
||||
assert db.get_session("B") is None
|
||||
# C and D survive, C is orphaned (parent_session_id NULL)
|
||||
c = db.get_session("C")
|
||||
assert c is not None
|
||||
assert c["parent_session_id"] is None
|
||||
d = db.get_session("D")
|
||||
assert d is not None
|
||||
assert d["parent_session_id"] == "C"
|
||||
|
||||
def test_prune_entire_old_chain(self, db):
|
||||
"""All sessions in a chain are old — entire chain is pruned."""
|
||||
old_ts = time.time() - 200 * 86400
|
||||
|
||||
db.create_session(session_id="X", source="cli")
|
||||
db.end_session("X", end_reason="compressed")
|
||||
db.create_session(session_id="Y", source="cli", parent_session_id="X")
|
||||
db.end_session("Y", end_reason="compressed")
|
||||
db.create_session(session_id="Z", source="cli", parent_session_id="Y")
|
||||
db.end_session("Z", end_reason="done")
|
||||
|
||||
for sid in ("X", "Y", "Z"):
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?", (old_ts, sid)
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
pruned = db.prune_sessions(older_than_days=90)
|
||||
assert pruned == 3
|
||||
for sid in ("X", "Y", "Z"):
|
||||
assert db.get_session(sid) is None
|
||||
|
||||
|
||||
class TestDeleteSessionOrphansChildren:
|
||||
def test_delete_orphans_children(self, db):
|
||||
"""Deleting a parent session orphans its children."""
|
||||
db.create_session(session_id="parent", source="cli")
|
||||
db.create_session(session_id="child", source="cli", parent_session_id="parent")
|
||||
db.create_session(session_id="grandchild", source="cli", parent_session_id="child")
|
||||
|
||||
# Should not raise IntegrityError
|
||||
result = db.delete_session("parent")
|
||||
assert result is True
|
||||
assert db.get_session("parent") is None
|
||||
# Child is orphaned, not deleted
|
||||
child = db.get_session("child")
|
||||
assert child is not None
|
||||
assert child["parent_session_id"] is None
|
||||
# Grandchild is untouched
|
||||
grandchild = db.get_session("grandchild")
|
||||
assert grandchild is not None
|
||||
assert grandchild["parent_session_id"] == "child"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Schema and WAL mode
|
||||
|
||||
174
tests/tools/test_base_environment.py
Normal file
174
tests/tools/test_base_environment.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Tests for BaseEnvironment unified execution model.
|
||||
|
||||
Tests _wrap_command(), _extract_cwd_from_output(), _embed_stdin_heredoc(),
|
||||
init_session() failure handling, and the CWD marker contract.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from tools.environments.base import BaseEnvironment, _cwd_marker
|
||||
|
||||
|
||||
class _TestableEnv(BaseEnvironment):
|
||||
"""Concrete subclass for testing base class methods."""
|
||||
|
||||
def __init__(self, cwd="/tmp", timeout=10):
|
||||
super().__init__(cwd=cwd, timeout=timeout)
|
||||
|
||||
def _run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None):
|
||||
raise NotImplementedError("Use mock")
|
||||
|
||||
def cleanup(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestWrapCommand:
|
||||
def test_basic_shape(self):
|
||||
env = _TestableEnv()
|
||||
env._snapshot_ready = True
|
||||
wrapped = env._wrap_command("echo hello", "/tmp")
|
||||
|
||||
assert "source" in wrapped
|
||||
assert "cd /tmp" in wrapped or "cd '/tmp'" in wrapped
|
||||
assert "eval 'echo hello'" in wrapped
|
||||
assert "__hermes_ec=$?" in wrapped
|
||||
assert "export -p >" in wrapped
|
||||
assert "pwd -P >" in wrapped
|
||||
assert env._cwd_marker in wrapped
|
||||
assert "exit $__hermes_ec" in wrapped
|
||||
|
||||
def test_no_snapshot_skips_source(self):
|
||||
env = _TestableEnv()
|
||||
env._snapshot_ready = False
|
||||
wrapped = env._wrap_command("echo hello", "/tmp")
|
||||
|
||||
assert "source" not in wrapped
|
||||
|
||||
def test_single_quote_escaping(self):
|
||||
env = _TestableEnv()
|
||||
env._snapshot_ready = True
|
||||
wrapped = env._wrap_command("echo 'hello world'", "/tmp")
|
||||
|
||||
assert "eval 'echo '\\''hello world'\\'''" in wrapped
|
||||
|
||||
def test_tilde_not_quoted(self):
|
||||
env = _TestableEnv()
|
||||
env._snapshot_ready = True
|
||||
wrapped = env._wrap_command("ls", "~")
|
||||
|
||||
assert "cd ~" in wrapped
|
||||
assert "cd '~'" not in wrapped
|
||||
|
||||
def test_cd_failure_exit_126(self):
|
||||
env = _TestableEnv()
|
||||
env._snapshot_ready = True
|
||||
wrapped = env._wrap_command("ls", "/nonexistent")
|
||||
|
||||
assert "exit 126" in wrapped
|
||||
|
||||
|
||||
class TestExtractCwdFromOutput:
|
||||
def test_happy_path(self):
|
||||
env = _TestableEnv()
|
||||
marker = env._cwd_marker
|
||||
result = {
|
||||
"output": f"hello\n{marker}/home/user{marker}\n",
|
||||
}
|
||||
env._extract_cwd_from_output(result)
|
||||
|
||||
assert env.cwd == "/home/user"
|
||||
assert marker not in result["output"]
|
||||
|
||||
def test_missing_marker(self):
|
||||
env = _TestableEnv()
|
||||
result = {"output": "hello world\n"}
|
||||
env._extract_cwd_from_output(result)
|
||||
|
||||
assert env.cwd == "/tmp" # unchanged
|
||||
|
||||
def test_marker_in_command_output(self):
|
||||
"""If the marker appears in command output AND as the real marker,
|
||||
rfind grabs the last (real) one."""
|
||||
env = _TestableEnv()
|
||||
marker = env._cwd_marker
|
||||
result = {
|
||||
"output": f"user typed {marker} in their output\nreal output\n{marker}/correct/path{marker}\n",
|
||||
}
|
||||
env._extract_cwd_from_output(result)
|
||||
|
||||
assert env.cwd == "/correct/path"
|
||||
|
||||
def test_output_cleaned(self):
|
||||
env = _TestableEnv()
|
||||
marker = env._cwd_marker
|
||||
result = {
|
||||
"output": f"hello\n{marker}/tmp{marker}\n",
|
||||
}
|
||||
env._extract_cwd_from_output(result)
|
||||
|
||||
assert "hello" in result["output"]
|
||||
assert marker not in result["output"]
|
||||
|
||||
|
||||
class TestEmbedStdinHeredoc:
|
||||
def test_heredoc_format(self):
|
||||
result = BaseEnvironment._embed_stdin_heredoc("cat", "hello world")
|
||||
|
||||
assert result.startswith("cat << '")
|
||||
assert "hello world" in result
|
||||
assert "HERMES_STDIN_" in result
|
||||
|
||||
def test_unique_delimiter_each_call(self):
|
||||
r1 = BaseEnvironment._embed_stdin_heredoc("cat", "data")
|
||||
r2 = BaseEnvironment._embed_stdin_heredoc("cat", "data")
|
||||
|
||||
# Extract delimiters
|
||||
d1 = r1.split("'")[1]
|
||||
d2 = r2.split("'")[1]
|
||||
assert d1 != d2 # UUID-based, should be unique
|
||||
|
||||
|
||||
class TestInitSessionFailure:
|
||||
def test_snapshot_ready_false_on_failure(self):
|
||||
env = _TestableEnv()
|
||||
|
||||
def failing_run_bash(*args, **kwargs):
|
||||
raise RuntimeError("bash not found")
|
||||
|
||||
env._run_bash = failing_run_bash
|
||||
env.init_session()
|
||||
|
||||
assert env._snapshot_ready is False
|
||||
|
||||
def test_login_flag_when_snapshot_not_ready(self):
|
||||
"""When _snapshot_ready=False, execute() should pass login=True to _run_bash."""
|
||||
env = _TestableEnv()
|
||||
env._snapshot_ready = False
|
||||
|
||||
calls = []
|
||||
def mock_run_bash(cmd, *, login=False, timeout=120, stdin_data=None):
|
||||
calls.append({"login": login})
|
||||
# Return a mock process handle
|
||||
mock = MagicMock()
|
||||
mock.poll.return_value = 0
|
||||
mock.returncode = 0
|
||||
mock.stdout = iter([])
|
||||
return mock
|
||||
|
||||
env._run_bash = mock_run_bash
|
||||
env.execute("echo test")
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["login"] is True
|
||||
|
||||
|
||||
class TestCwdMarker:
|
||||
def test_marker_contains_session_id(self):
|
||||
env = _TestableEnv()
|
||||
assert env._session_id in env._cwd_marker
|
||||
|
||||
def test_unique_per_instance(self):
|
||||
env1 = _TestableEnv()
|
||||
env2 = _TestableEnv()
|
||||
assert env1._cwd_marker != env2._cwd_marker
|
||||
@@ -59,8 +59,8 @@ def daytona_sdk(monkeypatch):
|
||||
@pytest.fixture()
|
||||
def make_env(daytona_sdk, monkeypatch):
|
||||
"""Factory that creates a DaytonaEnvironment with a mocked SDK."""
|
||||
# Prevent is_interrupted from interfering
|
||||
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False)
|
||||
# Prevent is_interrupted from interfering — patch where it's used (base.py)
|
||||
monkeypatch.setattr("tools.environments.base.is_interrupted", lambda: False)
|
||||
# Prevent skills/credential sync from consuming mock exec calls
|
||||
monkeypatch.setattr("tools.credential_files.get_credential_file_mounts", lambda: [])
|
||||
monkeypatch.setattr("tools.credential_files.get_skills_directory_mount", lambda **kw: None)
|
||||
@@ -221,41 +221,45 @@ class TestCleanup:
|
||||
class TestExecute:
|
||||
def test_basic_command(self, make_env):
|
||||
sb = _make_sandbox()
|
||||
# First call: $HOME detection; subsequent calls: actual commands
|
||||
# Calls: (1) $HOME detection, (2) init_session bootstrap, (3) actual command
|
||||
sb.process.exec.side_effect = [
|
||||
_make_exec_response(result="/root"), # $HOME
|
||||
_make_exec_response(result="", exit_code=0), # init_session
|
||||
_make_exec_response(result="hello", exit_code=0), # actual cmd
|
||||
]
|
||||
sb.state = "started"
|
||||
env = make_env(sandbox=sb)
|
||||
|
||||
result = env.execute("echo hello")
|
||||
assert result["output"] == "hello"
|
||||
assert "hello" in result["output"]
|
||||
assert result["returncode"] == 0
|
||||
|
||||
def test_command_wrapped_with_shell_timeout(self, make_env):
|
||||
def test_sdk_timeout_passed_to_exec(self, make_env):
|
||||
"""SDK native timeout is passed to sandbox.process.exec()."""
|
||||
sb = _make_sandbox()
|
||||
sb.process.exec.side_effect = [
|
||||
_make_exec_response(result="/root"),
|
||||
_make_exec_response(result="", exit_code=0), # init_session
|
||||
_make_exec_response(result="ok", exit_code=0),
|
||||
]
|
||||
sb.state = "started"
|
||||
env = make_env(sandbox=sb, timeout=42)
|
||||
|
||||
env.execute("echo hello")
|
||||
# The command sent to exec should be wrapped with `timeout N sh -c '...'`
|
||||
# The exec call should receive timeout= kwarg (SDK native timeout)
|
||||
call_args = sb.process.exec.call_args_list[-1]
|
||||
assert call_args[1]["timeout"] == 42
|
||||
# The command should NOT have a shell `timeout` prefix
|
||||
cmd = call_args[0][0]
|
||||
assert cmd.startswith("timeout 42 sh -c ")
|
||||
# SDK timeout param should NOT be passed
|
||||
assert "timeout" not in call_args[1]
|
||||
assert not cmd.startswith("timeout ")
|
||||
|
||||
def test_timeout_returns_exit_code_124(self, make_env):
|
||||
"""Shell timeout utility returns exit code 124."""
|
||||
"""SDK-level timeout surfaces as exit code 124 via _wait_for_process."""
|
||||
sb = _make_sandbox()
|
||||
sb.process.exec.side_effect = [
|
||||
_make_exec_response(result="/root"),
|
||||
_make_exec_response(result="", exit_code=124),
|
||||
_make_exec_response(result="", exit_code=0), # init_session
|
||||
_make_exec_response(result="", exit_code=124), # actual cmd
|
||||
]
|
||||
sb.state = "started"
|
||||
env = make_env(sandbox=sb)
|
||||
@@ -267,6 +271,7 @@ class TestExecute:
|
||||
sb = _make_sandbox()
|
||||
sb.process.exec.side_effect = [
|
||||
_make_exec_response(result="/root"),
|
||||
_make_exec_response(result="", exit_code=0), # init_session
|
||||
_make_exec_response(result="not found", exit_code=127),
|
||||
]
|
||||
sb.state = "started"
|
||||
@@ -279,6 +284,7 @@ class TestExecute:
|
||||
sb = _make_sandbox()
|
||||
sb.process.exec.side_effect = [
|
||||
_make_exec_response(result="/root"),
|
||||
_make_exec_response(result="", exit_code=0), # init_session
|
||||
_make_exec_response(result="ok", exit_code=0),
|
||||
]
|
||||
sb.state = "started"
|
||||
@@ -286,39 +292,47 @@ class TestExecute:
|
||||
|
||||
env.execute("python3", stdin_data="print('hi')")
|
||||
# Check that the command passed to exec contains heredoc markers
|
||||
# (single quotes get shell-escaped by shlex.quote, so check components)
|
||||
# Base class uses HERMES_STDIN_ prefix for heredoc delimiters
|
||||
call_args = sb.process.exec.call_args_list[-1]
|
||||
cmd = call_args[0][0]
|
||||
assert "HERMES_EOF_" in cmd
|
||||
assert "HERMES_STDIN_" in cmd
|
||||
assert "print" in cmd
|
||||
assert "hi" in cmd
|
||||
|
||||
def test_custom_cwd_passed_through(self, make_env):
|
||||
def test_custom_cwd_in_command_wrapper(self, make_env):
|
||||
"""CWD is handled by _wrap_command() in the command string, not as a kwarg."""
|
||||
sb = _make_sandbox()
|
||||
sb.process.exec.side_effect = [
|
||||
_make_exec_response(result="/root"),
|
||||
_make_exec_response(result="", exit_code=0), # init_session
|
||||
_make_exec_response(result="/tmp", exit_code=0),
|
||||
]
|
||||
sb.state = "started"
|
||||
env = make_env(sandbox=sb)
|
||||
|
||||
env.execute("pwd", cwd="/tmp")
|
||||
call_kwargs = sb.process.exec.call_args_list[-1][1]
|
||||
assert call_kwargs["cwd"] == "/tmp"
|
||||
# CWD should be embedded in the command string via _wrap_command
|
||||
call_args = sb.process.exec.call_args_list[-1]
|
||||
cmd = call_args[0][0]
|
||||
assert "cd /tmp" in cmd
|
||||
# CWD should NOT be passed as a kwarg to exec
|
||||
assert "cwd" not in call_args[1]
|
||||
|
||||
def test_daytona_error_triggers_retry(self, make_env, daytona_sdk):
|
||||
sb = _make_sandbox()
|
||||
sb.state = "started"
|
||||
sb.process.exec.side_effect = [
|
||||
_make_exec_response(result="/root"), # $HOME
|
||||
_make_exec_response(result="", exit_code=0), # init_session
|
||||
daytona_sdk.DaytonaError("transient"), # first attempt fails
|
||||
_make_exec_response(result="ok", exit_code=0), # retry succeeds
|
||||
]
|
||||
env = make_env(sandbox=sb)
|
||||
|
||||
result = env.execute("echo retry")
|
||||
assert result["output"] == "ok"
|
||||
assert result["returncode"] == 0
|
||||
# DaytonaError now surfaces directly through _ThreadedProcessHandle
|
||||
# (no retry logic) — the error becomes returncode=1
|
||||
assert result["returncode"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -359,14 +373,18 @@ class TestInterrupt:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return _make_exec_response(result="/root") # $HOME detection
|
||||
if calls["n"] == 2:
|
||||
return _make_exec_response(result="", exit_code=0) # init_session
|
||||
event.wait(timeout=5) # simulate long-running command
|
||||
return _make_exec_response(result="done", exit_code=0)
|
||||
|
||||
sb.process.exec.side_effect = exec_side_effect
|
||||
env = make_env(sandbox=sb)
|
||||
|
||||
# is_interrupted is checked by base.py's _wait_for_process,
|
||||
# patch where it's actually referenced (base.py's local binding)
|
||||
monkeypatch.setattr(
|
||||
"tools.environments.daytona.is_interrupted", lambda: True
|
||||
"tools.environments.base.is_interrupted", lambda: True
|
||||
)
|
||||
try:
|
||||
result = env.execute("sleep 10")
|
||||
@@ -377,23 +395,24 @@ class TestInterrupt:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retry exhaustion
|
||||
# DaytonaError surfaces directly (no retry)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRetryExhausted:
|
||||
def test_both_attempts_fail(self, make_env, daytona_sdk):
|
||||
"""DaytonaError surfaces directly as rc=1 (retry logic was removed)."""
|
||||
sb = _make_sandbox()
|
||||
sb.state = "started"
|
||||
sb.process.exec.side_effect = [
|
||||
_make_exec_response(result="/root"), # $HOME
|
||||
daytona_sdk.DaytonaError("fail1"), # first attempt
|
||||
daytona_sdk.DaytonaError("fail2"), # retry
|
||||
_make_exec_response(result="", exit_code=0), # init_session
|
||||
daytona_sdk.DaytonaError("fail1"), # actual command fails
|
||||
]
|
||||
env = make_env(sandbox=sb)
|
||||
|
||||
result = env.execute("echo x")
|
||||
# Error surfaces directly through _ThreadedProcessHandle (rc=1)
|
||||
assert result["returncode"] == 1
|
||||
assert "Daytona execution error" in result["output"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -245,43 +245,42 @@ def _make_execute_only_env(forward_env=None):
|
||||
env._timeout_result = lambda timeout: {"output": f"timed out after {timeout}", "returncode": 124}
|
||||
env._container_id = "test-container"
|
||||
env._docker_exe = "/usr/bin/docker"
|
||||
# Base class attributes needed by unified execute()
|
||||
env._session_id = "test123"
|
||||
env._snapshot_path = "/tmp/hermes-snap-test123.sh"
|
||||
env._cwd_file = "/tmp/hermes-cwd-test123.txt"
|
||||
env._cwd_marker = "__HERMES_CWD_test123__"
|
||||
env._snapshot_ready = True
|
||||
env._last_sync_time = None
|
||||
env._init_env_args = []
|
||||
return env
|
||||
|
||||
|
||||
def test_execute_uses_hermes_dotenv_for_allowlisted_env(monkeypatch):
|
||||
def test_init_env_args_uses_hermes_dotenv_for_allowlisted_env(monkeypatch):
|
||||
"""_build_init_env_args picks up forwarded env vars from .env file at init time."""
|
||||
env = _make_execute_only_env(["GITHUB_TOKEN"])
|
||||
popen_calls = []
|
||||
|
||||
def _fake_popen(cmd, **kwargs):
|
||||
popen_calls.append(cmd)
|
||||
return _FakePopen(cmd, **kwargs)
|
||||
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"GITHUB_TOKEN": "value_from_dotenv"})
|
||||
monkeypatch.setattr(docker_env.subprocess, "Popen", _fake_popen)
|
||||
|
||||
result = env.execute("echo hi")
|
||||
args = env._build_init_env_args()
|
||||
args_str = " ".join(args)
|
||||
|
||||
assert result["returncode"] == 0
|
||||
assert "GITHUB_TOKEN=value_from_dotenv" in popen_calls[0]
|
||||
assert "GITHUB_TOKEN=value_from_dotenv" in args_str
|
||||
|
||||
|
||||
def test_execute_prefers_shell_env_over_hermes_dotenv(monkeypatch):
|
||||
def test_init_env_args_prefers_shell_env_over_hermes_dotenv(monkeypatch):
|
||||
"""Shell env vars take priority over .env file values in init env args."""
|
||||
env = _make_execute_only_env(["GITHUB_TOKEN"])
|
||||
popen_calls = []
|
||||
|
||||
def _fake_popen(cmd, **kwargs):
|
||||
popen_calls.append(cmd)
|
||||
return _FakePopen(cmd, **kwargs)
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "value_from_shell")
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"GITHUB_TOKEN": "value_from_dotenv"})
|
||||
monkeypatch.setattr(docker_env.subprocess, "Popen", _fake_popen)
|
||||
|
||||
env.execute("echo hi")
|
||||
args = env._build_init_env_args()
|
||||
args_str = " ".join(args)
|
||||
|
||||
assert "GITHUB_TOKEN=value_from_shell" in popen_calls[0]
|
||||
assert "GITHUB_TOKEN=value_from_dotenv" not in popen_calls[0]
|
||||
assert "GITHUB_TOKEN=value_from_shell" in args_str
|
||||
assert "value_from_dotenv" not in args_str
|
||||
|
||||
|
||||
# ── docker_env tests ──────────────────────────────────────────────
|
||||
@@ -302,64 +301,46 @@ def test_docker_env_appears_in_run_command(monkeypatch):
|
||||
assert "GNUPGHOME=/root/.gnupg" in run_args_str
|
||||
|
||||
|
||||
def test_docker_env_appears_in_exec_command(monkeypatch):
|
||||
"""Explicit docker_env values should also be passed via -e at docker exec time."""
|
||||
def test_docker_env_appears_in_init_env_args(monkeypatch):
|
||||
"""Explicit docker_env values should appear in _build_init_env_args."""
|
||||
env = _make_execute_only_env()
|
||||
env._env = {"MY_VAR": "my_value"}
|
||||
popen_calls = []
|
||||
|
||||
def _fake_popen(cmd, **kwargs):
|
||||
popen_calls.append(cmd)
|
||||
return _FakePopen(cmd, **kwargs)
|
||||
args = env._build_init_env_args()
|
||||
args_str = " ".join(args)
|
||||
|
||||
monkeypatch.setattr(docker_env.subprocess, "Popen", _fake_popen)
|
||||
|
||||
env.execute("echo hi")
|
||||
|
||||
assert popen_calls, "Popen should have been called"
|
||||
assert "MY_VAR=my_value" in popen_calls[0]
|
||||
assert "MY_VAR=my_value" in args_str
|
||||
|
||||
|
||||
def test_forward_env_overrides_docker_env(monkeypatch):
|
||||
def test_forward_env_overrides_docker_env_in_init_args(monkeypatch):
|
||||
"""docker_forward_env should override docker_env for the same key."""
|
||||
env = _make_execute_only_env(forward_env=["MY_KEY"])
|
||||
env._env = {"MY_KEY": "static_value"}
|
||||
popen_calls = []
|
||||
|
||||
def _fake_popen(cmd, **kwargs):
|
||||
popen_calls.append(cmd)
|
||||
return _FakePopen(cmd, **kwargs)
|
||||
|
||||
monkeypatch.setenv("MY_KEY", "dynamic_value")
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {})
|
||||
monkeypatch.setattr(docker_env.subprocess, "Popen", _fake_popen)
|
||||
|
||||
env.execute("echo hi")
|
||||
args = env._build_init_env_args()
|
||||
args_str = " ".join(args)
|
||||
|
||||
cmd_str = " ".join(popen_calls[0])
|
||||
assert "MY_KEY=dynamic_value" in cmd_str
|
||||
assert "MY_KEY=static_value" not in cmd_str
|
||||
assert "MY_KEY=dynamic_value" in args_str
|
||||
assert "MY_KEY=static_value" not in args_str
|
||||
|
||||
|
||||
def test_docker_env_and_forward_env_merge(monkeypatch):
|
||||
def test_docker_env_and_forward_env_merge_in_init_args(monkeypatch):
|
||||
"""docker_env and docker_forward_env with different keys should both appear."""
|
||||
env = _make_execute_only_env(forward_env=["TOKEN"])
|
||||
env._env = {"SSH_AUTH_SOCK": "/run/user/1000/agent.sock"}
|
||||
popen_calls = []
|
||||
|
||||
def _fake_popen(cmd, **kwargs):
|
||||
popen_calls.append(cmd)
|
||||
return _FakePopen(cmd, **kwargs)
|
||||
|
||||
monkeypatch.setenv("TOKEN", "secret123")
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {})
|
||||
monkeypatch.setattr(docker_env.subprocess, "Popen", _fake_popen)
|
||||
|
||||
env.execute("echo hi")
|
||||
args = env._build_init_env_args()
|
||||
args_str = " ".join(args)
|
||||
|
||||
assert "SSH_AUTH_SOCK=/run/user/1000/agent.sock" in args_str
|
||||
assert "TOKEN=secret123" in args_str
|
||||
|
||||
cmd_str = " ".join(popen_calls[0])
|
||||
assert "SSH_AUTH_SOCK=/run/user/1000/agent.sock" in cmd_str
|
||||
assert "TOKEN=secret123" in cmd_str
|
||||
|
||||
|
||||
def test_normalize_env_dict_filters_invalid_keys():
|
||||
|
||||
@@ -22,21 +22,19 @@ import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from tools.environments.local import (
|
||||
LocalEnvironment,
|
||||
_clean_shell_noise,
|
||||
_extract_fenced_output,
|
||||
_OUTPUT_FENCE,
|
||||
_SHELL_NOISE_SUBSTRINGS,
|
||||
)
|
||||
from tools.environments.local import LocalEnvironment
|
||||
from tools.file_operations import ShellFileOperations
|
||||
|
||||
|
||||
# ── Shared noise detection ───────────────────────────────────────────────
|
||||
# Every known shell noise pattern. If ANY of these appear in output that
|
||||
# isn't explicitly expected, the test fails with a clear message.
|
||||
# Known shell noise patterns that should never appear in command output.
|
||||
|
||||
_ALL_NOISE_PATTERNS = list(_SHELL_NOISE_SUBSTRINGS) + [
|
||||
_ALL_NOISE_PATTERNS = [
|
||||
"bash: cannot set terminal process group",
|
||||
"bash: no job control in this shell",
|
||||
"no job control in this shell",
|
||||
"cannot set terminal process group",
|
||||
"tcsetattr: Inappropriate ioctl for device",
|
||||
"bash: ",
|
||||
"Inappropriate ioctl",
|
||||
"Auto-suggestions:",
|
||||
@@ -88,134 +86,6 @@ def populated_dir(tmp_path):
|
||||
return tmp_path
|
||||
|
||||
|
||||
# ── _clean_shell_noise unit tests ────────────────────────────────────────
|
||||
|
||||
class TestCleanShellNoise:
|
||||
def test_single_noise_line(self):
|
||||
output = "bash: no job control in this shell\nhello world\n"
|
||||
result = _clean_shell_noise(output)
|
||||
assert result == "hello world\n"
|
||||
|
||||
def test_double_noise_lines(self):
|
||||
output = (
|
||||
"bash: cannot set terminal process group (-1): Inappropriate ioctl for device\n"
|
||||
"bash: no job control in this shell\n"
|
||||
"actual output here\n"
|
||||
)
|
||||
result = _clean_shell_noise(output)
|
||||
assert result == "actual output here\n"
|
||||
_assert_clean(result)
|
||||
|
||||
def test_tcsetattr_noise(self):
|
||||
output = (
|
||||
"bash: [12345: 2 (255)] tcsetattr: Inappropriate ioctl for device\n"
|
||||
"real content\n"
|
||||
)
|
||||
result = _clean_shell_noise(output)
|
||||
assert result == "real content\n"
|
||||
_assert_clean(result)
|
||||
|
||||
def test_triple_noise_lines(self):
|
||||
output = (
|
||||
"bash: cannot set terminal process group (-1): Inappropriate ioctl for device\n"
|
||||
"bash: no job control in this shell\n"
|
||||
"bash: [999: 2 (255)] tcsetattr: Inappropriate ioctl for device\n"
|
||||
"clean\n"
|
||||
)
|
||||
result = _clean_shell_noise(output)
|
||||
assert result == "clean\n"
|
||||
|
||||
def test_no_noise_untouched(self):
|
||||
assert _clean_shell_noise("hello\nworld\n") == "hello\nworld\n"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _clean_shell_noise("") == ""
|
||||
|
||||
def test_only_noise_produces_empty(self):
|
||||
output = "bash: no job control in this shell\n"
|
||||
result = _clean_shell_noise(output)
|
||||
_assert_clean(result)
|
||||
|
||||
def test_noise_in_middle_not_stripped(self):
|
||||
"""Noise in the middle is real output and should be preserved."""
|
||||
output = "real\nbash: no job control in this shell\nmore real\n"
|
||||
result = _clean_shell_noise(output)
|
||||
assert result == output
|
||||
|
||||
def test_zsh_restored_session(self):
|
||||
output = "Restored session: Mon Mar 2 22:16:54 +03 2026\nhello\n"
|
||||
result = _clean_shell_noise(output)
|
||||
assert result == "hello\n"
|
||||
|
||||
def test_zsh_saving_session_trailing(self):
|
||||
output = "hello\nSaving session...completed.\n"
|
||||
result = _clean_shell_noise(output)
|
||||
assert result == "hello\n"
|
||||
|
||||
def test_zsh_oh_my_zsh_banner(self):
|
||||
output = "Oh My Zsh on! | Auto-suggestions: press right\nhello\n"
|
||||
result = _clean_shell_noise(output)
|
||||
assert result == "hello\n"
|
||||
|
||||
def test_zsh_full_noise_sandwich(self):
|
||||
"""Both leading and trailing zsh noise stripped."""
|
||||
output = (
|
||||
"Restored session: Mon Mar 2\n"
|
||||
"command not found: docker\n"
|
||||
"Oh My Zsh on!\n"
|
||||
"actual output\n"
|
||||
"Saving session...completed.\n"
|
||||
)
|
||||
result = _clean_shell_noise(output)
|
||||
assert result == "actual output\n"
|
||||
|
||||
def test_last_login_stripped(self):
|
||||
output = "Last login: Mon Mar 2 22:00:00 on ttys001\nhello\n"
|
||||
result = _clean_shell_noise(output)
|
||||
assert result == "hello\n"
|
||||
|
||||
|
||||
# ── _extract_fenced_output unit tests ────────────────────────────────────
|
||||
|
||||
class TestExtractFencedOutput:
|
||||
def test_normal_fenced_output(self):
|
||||
raw = f"noise\n{_OUTPUT_FENCE}hello world\n{_OUTPUT_FENCE}more noise\n"
|
||||
assert _extract_fenced_output(raw) == "hello world\n"
|
||||
|
||||
def test_no_trailing_newline(self):
|
||||
"""printf output with no trailing newline is preserved."""
|
||||
raw = f"noise{_OUTPUT_FENCE}exact{_OUTPUT_FENCE}noise"
|
||||
assert _extract_fenced_output(raw) == "exact"
|
||||
|
||||
def test_no_fences_falls_back(self):
|
||||
"""Without fences, falls back to pattern-based cleaning."""
|
||||
raw = "bash: no job control in this shell\nhello\n"
|
||||
result = _extract_fenced_output(raw)
|
||||
assert result == "hello\n"
|
||||
|
||||
def test_only_start_fence(self):
|
||||
"""Only start fence (e.g. user command called exit)."""
|
||||
raw = f"noise{_OUTPUT_FENCE}hello\nSaving session...\n"
|
||||
result = _extract_fenced_output(raw)
|
||||
assert result == "hello\n"
|
||||
|
||||
def test_user_outputs_fence_string(self):
|
||||
"""If user command outputs the fence marker, it is preserved."""
|
||||
raw = f"noise{_OUTPUT_FENCE}{_OUTPUT_FENCE}real\n{_OUTPUT_FENCE}noise"
|
||||
result = _extract_fenced_output(raw)
|
||||
# first fence -> last fence captures the middle including user's fence
|
||||
assert _OUTPUT_FENCE in result
|
||||
assert "real\n" in result
|
||||
|
||||
def test_empty_command_output(self):
|
||||
raw = f"noise{_OUTPUT_FENCE}{_OUTPUT_FENCE}noise"
|
||||
assert _extract_fenced_output(raw) == ""
|
||||
|
||||
def test_multiline_output(self):
|
||||
raw = f"noise\n{_OUTPUT_FENCE}line1\nline2\nline3\n{_OUTPUT_FENCE}noise\n"
|
||||
assert _extract_fenced_output(raw) == "line1\nline2\nline3\n"
|
||||
|
||||
|
||||
# ── LocalEnvironment.execute() ───────────────────────────────────────────
|
||||
|
||||
class TestLocalEnvironmentExecute:
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Tests for the local persistent shell backend."""
|
||||
|
||||
import glob as glob_mod
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.environments.local import LocalEnvironment
|
||||
from tools.environments.persistent_shell import PersistentShellMixin
|
||||
|
||||
|
||||
class TestLocalConfig:
|
||||
def test_local_persistent_default_false(self, monkeypatch):
|
||||
monkeypatch.delenv("TERMINAL_LOCAL_PERSISTENT", raising=False)
|
||||
from tools.terminal_tool import _get_env_config
|
||||
assert _get_env_config()["local_persistent"] is False
|
||||
|
||||
def test_local_persistent_true(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_LOCAL_PERSISTENT", "true")
|
||||
from tools.terminal_tool import _get_env_config
|
||||
assert _get_env_config()["local_persistent"] is True
|
||||
|
||||
def test_local_persistent_yes(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_LOCAL_PERSISTENT", "yes")
|
||||
from tools.terminal_tool import _get_env_config
|
||||
assert _get_env_config()["local_persistent"] is True
|
||||
|
||||
|
||||
class TestMergeOutput:
|
||||
def test_stdout_only(self):
|
||||
assert PersistentShellMixin._merge_output("out", "") == "out"
|
||||
|
||||
def test_stderr_only(self):
|
||||
assert PersistentShellMixin._merge_output("", "err") == "err"
|
||||
|
||||
def test_both(self):
|
||||
assert PersistentShellMixin._merge_output("out", "err") == "out\nerr"
|
||||
|
||||
def test_empty(self):
|
||||
assert PersistentShellMixin._merge_output("", "") == ""
|
||||
|
||||
def test_strips_trailing_newlines(self):
|
||||
assert PersistentShellMixin._merge_output("out\n\n", "err\n") == "out\nerr"
|
||||
|
||||
|
||||
class TestLocalOneShotRegression:
|
||||
def test_echo(self):
|
||||
env = LocalEnvironment(persistent=False)
|
||||
r = env.execute("echo hello")
|
||||
assert r["returncode"] == 0
|
||||
assert "hello" in r["output"]
|
||||
env.cleanup()
|
||||
|
||||
def test_exit_code(self):
|
||||
env = LocalEnvironment(persistent=False)
|
||||
r = env.execute("exit 42")
|
||||
assert r["returncode"] == 42
|
||||
env.cleanup()
|
||||
|
||||
def test_state_does_not_persist(self):
|
||||
env = LocalEnvironment(persistent=False)
|
||||
env.execute("export HERMES_ONESHOT_LOCAL=yes")
|
||||
r = env.execute("echo $HERMES_ONESHOT_LOCAL")
|
||||
assert r["output"].strip() == ""
|
||||
env.cleanup()
|
||||
|
||||
def test_oneshot_heredoc_does_not_leak_fence_wrapper(self):
|
||||
"""Heredoc closing line must not be merged with the fence wrapper tail."""
|
||||
env = LocalEnvironment(persistent=False)
|
||||
cmd = "cat <<'H_EOF'\nheredoc body line\nH_EOF"
|
||||
r = env.execute(cmd)
|
||||
env.cleanup()
|
||||
assert r["returncode"] == 0
|
||||
assert "heredoc body line" in r["output"]
|
||||
assert "__hermes_rc" not in r["output"]
|
||||
assert "printf '" not in r["output"]
|
||||
assert "exit $" not in r["output"]
|
||||
|
||||
|
||||
class TestLocalPersistent:
|
||||
@pytest.fixture
|
||||
def env(self):
|
||||
e = LocalEnvironment(persistent=True)
|
||||
yield e
|
||||
e.cleanup()
|
||||
|
||||
def test_echo(self, env):
|
||||
r = env.execute("echo hello-persistent")
|
||||
assert r["returncode"] == 0
|
||||
assert "hello-persistent" in r["output"]
|
||||
|
||||
def test_env_var_persists(self, env):
|
||||
env.execute("export HERMES_LOCAL_PERSIST_TEST=works")
|
||||
r = env.execute("echo $HERMES_LOCAL_PERSIST_TEST")
|
||||
assert r["output"].strip() == "works"
|
||||
|
||||
def test_cwd_persists(self, env):
|
||||
env.execute("cd /tmp")
|
||||
r = env.execute("pwd")
|
||||
assert r["output"].strip() == "/tmp"
|
||||
|
||||
def test_exit_code(self, env):
|
||||
r = env.execute("(exit 42)")
|
||||
assert r["returncode"] == 42
|
||||
|
||||
def test_stderr(self, env):
|
||||
r = env.execute("echo oops >&2")
|
||||
assert r["returncode"] == 0
|
||||
assert "oops" in r["output"]
|
||||
|
||||
def test_multiline_output(self, env):
|
||||
r = env.execute("echo a; echo b; echo c")
|
||||
lines = r["output"].strip().splitlines()
|
||||
assert lines == ["a", "b", "c"]
|
||||
|
||||
def test_timeout_then_recovery(self, env):
|
||||
r = env.execute("sleep 999", timeout=2)
|
||||
assert r["returncode"] in (124, 130)
|
||||
r = env.execute("echo alive")
|
||||
assert r["returncode"] == 0
|
||||
assert "alive" in r["output"]
|
||||
|
||||
def test_large_output(self, env):
|
||||
r = env.execute("seq 1 1000")
|
||||
assert r["returncode"] == 0
|
||||
lines = r["output"].strip().splitlines()
|
||||
assert len(lines) == 1000
|
||||
assert lines[0] == "1"
|
||||
assert lines[-1] == "1000"
|
||||
|
||||
def test_shell_variable_persists(self, env):
|
||||
env.execute("MY_LOCAL_VAR=hello123")
|
||||
r = env.execute("echo $MY_LOCAL_VAR")
|
||||
assert r["output"].strip() == "hello123"
|
||||
|
||||
def test_cleanup_removes_temp_files(self, env):
|
||||
env.execute("echo warmup")
|
||||
prefix = env._temp_prefix
|
||||
assert len(glob_mod.glob(f"{prefix}-*")) > 0
|
||||
env.cleanup()
|
||||
remaining = glob_mod.glob(f"{prefix}-*")
|
||||
assert remaining == []
|
||||
|
||||
def test_state_does_not_leak_between_instances(self):
|
||||
env1 = LocalEnvironment(persistent=True)
|
||||
env2 = LocalEnvironment(persistent=True)
|
||||
try:
|
||||
env1.execute("export LEAK_TEST=from_env1")
|
||||
r = env2.execute("echo $LEAK_TEST")
|
||||
assert r["output"].strip() == ""
|
||||
finally:
|
||||
env1.cleanup()
|
||||
env2.cleanup()
|
||||
|
||||
def test_special_characters_in_command(self, env):
|
||||
r = env.execute("echo 'hello world'")
|
||||
assert r["output"].strip() == "hello world"
|
||||
|
||||
def test_pipe_command(self, env):
|
||||
r = env.execute("echo hello | tr 'h' 'H'")
|
||||
assert r["output"].strip() == "Hello"
|
||||
|
||||
def test_multiple_commands_semicolon(self, env):
|
||||
r = env.execute("X=42; echo $X")
|
||||
assert r["output"].strip() == "42"
|
||||
@@ -110,7 +110,7 @@ class _FakeResponse:
|
||||
def test_managed_modal_execute_polls_until_completed(monkeypatch):
|
||||
_install_fake_tools_package()
|
||||
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||
modal_common = sys.modules["tools.environments.modal_common"]
|
||||
modal_common = sys.modules["tools.environments.modal_utils"]
|
||||
|
||||
calls = []
|
||||
poll_count = {"value": 0}
|
||||
@@ -173,7 +173,7 @@ def test_managed_modal_create_sends_a_stable_idempotency_key(monkeypatch):
|
||||
def test_managed_modal_execute_cancels_on_interrupt(monkeypatch):
|
||||
interrupt_event = _install_fake_tools_package()
|
||||
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||
modal_common = sys.modules["tools.environments.modal_common"]
|
||||
modal_common = sys.modules["tools.environments.modal_utils"]
|
||||
|
||||
calls = []
|
||||
|
||||
@@ -215,7 +215,7 @@ def test_managed_modal_execute_cancels_on_interrupt(monkeypatch):
|
||||
def test_managed_modal_execute_returns_descriptive_error_on_missing_exec(monkeypatch):
|
||||
_install_fake_tools_package()
|
||||
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||
modal_common = sys.modules["tools.environments.modal_common"]
|
||||
modal_common = sys.modules["tools.environments.modal_utils"]
|
||||
|
||||
def fake_request(method, url, headers=None, json=None, timeout=None):
|
||||
if method == "POST" and url.endswith("/v1/sandboxes"):
|
||||
@@ -293,7 +293,7 @@ def test_managed_modal_rejects_host_credential_passthrough():
|
||||
def test_managed_modal_execute_times_out_and_cancels(monkeypatch):
|
||||
_install_fake_tools_package()
|
||||
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||
modal_common = sys.modules["tools.environments.modal_common"]
|
||||
modal_common = sys.modules["tools.environments.modal_utils"]
|
||||
|
||||
calls = []
|
||||
monotonic_values = iter([0.0, 12.5])
|
||||
|
||||
@@ -231,20 +231,20 @@ class TestEnsurepipFix:
|
||||
"""Verify the pip fix is applied in the ModalEnvironment init."""
|
||||
|
||||
def test_modal_environment_creates_image_with_setup_commands(self):
|
||||
"""ModalEnvironment.__init__ should create a modal.Image with pip fix."""
|
||||
"""_resolve_modal_image should create a modal.Image with pip fix."""
|
||||
try:
|
||||
from tools.environments.modal import ModalEnvironment
|
||||
from tools.environments.modal import _resolve_modal_image
|
||||
except ImportError:
|
||||
pytest.skip("tools.environments.modal not importable")
|
||||
|
||||
import inspect
|
||||
source = inspect.getsource(ModalEnvironment.__init__)
|
||||
source = inspect.getsource(_resolve_modal_image)
|
||||
assert "ensurepip" in source, (
|
||||
"ModalEnvironment should include ensurepip fix "
|
||||
"_resolve_modal_image should include ensurepip fix "
|
||||
"for Modal's legacy image builder"
|
||||
)
|
||||
assert "setup_dockerfile_commands" in source, (
|
||||
"ModalEnvironment should use setup_dockerfile_commands "
|
||||
"_resolve_modal_image should use setup_dockerfile_commands "
|
||||
"to fix pip before Modal's bootstrap"
|
||||
)
|
||||
|
||||
|
||||
@@ -85,11 +85,47 @@ def _install_modal_test_modules(
|
||||
def _prepare_command(self, command: str):
|
||||
return command, None
|
||||
|
||||
sys.modules["tools.environments.base"] = types.SimpleNamespace(BaseEnvironment=_DummyBaseEnvironment)
|
||||
def init_session(self):
|
||||
pass
|
||||
|
||||
# Stub _ThreadedProcessHandle: modal.py imports it but only uses it at
|
||||
# runtime inside _run_bash; the snapshot-isolation tests never call _run_bash,
|
||||
# so a class placeholder is sufficient.
|
||||
class _DummyThreadedProcessHandle:
|
||||
def __init__(self, exec_fn, cancel_fn=None):
|
||||
pass
|
||||
|
||||
def _load_json_store(path):
|
||||
if path.exists():
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save_json_store(path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, indent=2))
|
||||
|
||||
def _file_mtime_key(host_path):
|
||||
try:
|
||||
st = Path(host_path).stat()
|
||||
return (st.st_mtime, st.st_size)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
sys.modules["tools.environments.base"] = types.SimpleNamespace(
|
||||
BaseEnvironment=_DummyBaseEnvironment,
|
||||
_ThreadedProcessHandle=_DummyThreadedProcessHandle,
|
||||
_load_json_store=_load_json_store,
|
||||
_save_json_store=_save_json_store,
|
||||
_file_mtime_key=_file_mtime_key,
|
||||
)
|
||||
sys.modules["tools.interrupt"] = types.SimpleNamespace(is_interrupted=lambda: False)
|
||||
sys.modules["tools.credential_files"] = types.SimpleNamespace(
|
||||
get_credential_file_mounts=lambda: [],
|
||||
iter_skills_files=lambda: [],
|
||||
iter_cache_files=lambda: [],
|
||||
)
|
||||
|
||||
from_id_calls: list[str] = []
|
||||
|
||||
@@ -43,7 +43,7 @@ class TestBuildSSHCommand:
|
||||
lambda *a, **k: MagicMock(stdout=iter([]),
|
||||
stderr=iter([]),
|
||||
stdin=MagicMock()))
|
||||
monkeypatch.setattr("tools.environments.ssh.time.sleep", lambda _: None)
|
||||
monkeypatch.setattr("tools.environments.base.time.sleep", lambda _: None)
|
||||
|
||||
def test_base_flags(self):
|
||||
env = SSHEnvironment(host="h", user="u")
|
||||
|
||||
21
tests/tools/test_terminal_none_command_guard.py
Normal file
21
tests/tools/test_terminal_none_command_guard.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Regression tests for invalid/None terminal command handling."""
|
||||
|
||||
import json
|
||||
|
||||
from tools.terminal_tool import _transform_sudo_command, terminal_tool
|
||||
|
||||
|
||||
def test_transform_sudo_command_none_returns_cleanly():
|
||||
transformed, sudo_stdin = _transform_sudo_command(None)
|
||||
|
||||
assert transformed is None
|
||||
assert sudo_stdin is None
|
||||
|
||||
|
||||
def test_terminal_tool_none_command_returns_clean_error():
|
||||
result = json.loads(terminal_tool(None)) # type: ignore[arg-type]
|
||||
|
||||
assert result["exit_code"] == -1
|
||||
assert result["status"] == "error"
|
||||
assert "expected string" in result["error"].lower()
|
||||
assert "nonetype" in result["error"].lower()
|
||||
90
tests/tools/test_terminal_tool.py
Normal file
90
tests/tools/test_terminal_tool.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Regression tests for sudo detection and sudo password handling."""
|
||||
|
||||
import tools.terminal_tool as terminal_tool
|
||||
|
||||
|
||||
def setup_function():
|
||||
terminal_tool._cached_sudo_password = ""
|
||||
|
||||
|
||||
def teardown_function():
|
||||
terminal_tool._cached_sudo_password = ""
|
||||
|
||||
|
||||
def test_searching_for_sudo_does_not_trigger_rewrite(monkeypatch):
|
||||
monkeypatch.delenv("SUDO_PASSWORD", raising=False)
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
|
||||
command = "rg --line-number --no-heading --with-filename 'sudo' . | head -n 20"
|
||||
transformed, sudo_stdin = terminal_tool._transform_sudo_command(command)
|
||||
|
||||
assert transformed == command
|
||||
assert sudo_stdin is None
|
||||
|
||||
|
||||
def test_printf_literal_sudo_does_not_trigger_rewrite(monkeypatch):
|
||||
monkeypatch.delenv("SUDO_PASSWORD", raising=False)
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
|
||||
command = "printf '%s\\n' sudo"
|
||||
transformed, sudo_stdin = terminal_tool._transform_sudo_command(command)
|
||||
|
||||
assert transformed == command
|
||||
assert sudo_stdin is None
|
||||
|
||||
|
||||
def test_non_command_argument_named_sudo_does_not_trigger_rewrite(monkeypatch):
|
||||
monkeypatch.delenv("SUDO_PASSWORD", raising=False)
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
|
||||
command = "grep -n sudo README.md"
|
||||
transformed, sudo_stdin = terminal_tool._transform_sudo_command(command)
|
||||
|
||||
assert transformed == command
|
||||
assert sudo_stdin is None
|
||||
|
||||
|
||||
def test_actual_sudo_command_uses_configured_password(monkeypatch):
|
||||
monkeypatch.setenv("SUDO_PASSWORD", "testpass")
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
|
||||
transformed, sudo_stdin = terminal_tool._transform_sudo_command("sudo apt install -y ripgrep")
|
||||
|
||||
assert transformed == "sudo -S -p '' apt install -y ripgrep"
|
||||
assert sudo_stdin == "testpass\n"
|
||||
|
||||
|
||||
def test_actual_sudo_after_leading_env_assignment_is_rewritten(monkeypatch):
|
||||
monkeypatch.setenv("SUDO_PASSWORD", "testpass")
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
|
||||
transformed, sudo_stdin = terminal_tool._transform_sudo_command("DEBUG=1 sudo whoami")
|
||||
|
||||
assert transformed == "DEBUG=1 sudo -S -p '' whoami"
|
||||
assert sudo_stdin == "testpass\n"
|
||||
|
||||
|
||||
def test_explicit_empty_sudo_password_tries_empty_without_prompt(monkeypatch):
|
||||
monkeypatch.setenv("SUDO_PASSWORD", "")
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
|
||||
def _fail_prompt(*_args, **_kwargs):
|
||||
raise AssertionError("interactive sudo prompt should not run for explicit empty password")
|
||||
|
||||
monkeypatch.setattr(terminal_tool, "_prompt_for_sudo_password", _fail_prompt)
|
||||
|
||||
transformed, sudo_stdin = terminal_tool._transform_sudo_command("sudo true")
|
||||
|
||||
assert transformed == "sudo -S -p '' true"
|
||||
assert sudo_stdin == "\n"
|
||||
|
||||
|
||||
def test_cached_sudo_password_is_used_when_env_is_unset(monkeypatch):
|
||||
monkeypatch.delenv("SUDO_PASSWORD", raising=False)
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
terminal_tool._cached_sudo_password = "cached-pass"
|
||||
|
||||
transformed, sudo_stdin = terminal_tool._transform_sudo_command("echo ok && sudo whoami")
|
||||
|
||||
assert transformed == "echo ok && sudo -S -p '' whoami"
|
||||
assert sudo_stdin == "cached-pass\n"
|
||||
144
tests/tools/test_threaded_process_handle.py
Normal file
144
tests/tools/test_threaded_process_handle.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Tests for _ThreadedProcessHandle — the adapter for SDK backends."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from tools.environments.base import _ThreadedProcessHandle
|
||||
|
||||
|
||||
class TestBasicExecution:
|
||||
def test_successful_execution(self):
|
||||
def exec_fn():
|
||||
return ("hello world", 0)
|
||||
|
||||
handle = _ThreadedProcessHandle(exec_fn)
|
||||
handle.wait(timeout=5)
|
||||
|
||||
assert handle.returncode == 0
|
||||
output = handle.stdout.read()
|
||||
assert "hello world" in output
|
||||
|
||||
def test_nonzero_exit_code(self):
|
||||
def exec_fn():
|
||||
return ("error occurred", 42)
|
||||
|
||||
handle = _ThreadedProcessHandle(exec_fn)
|
||||
handle.wait(timeout=5)
|
||||
|
||||
assert handle.returncode == 42
|
||||
output = handle.stdout.read()
|
||||
assert "error occurred" in output
|
||||
|
||||
def test_exception_in_exec_fn(self):
|
||||
def exec_fn():
|
||||
raise RuntimeError("boom")
|
||||
|
||||
handle = _ThreadedProcessHandle(exec_fn)
|
||||
handle.wait(timeout=5)
|
||||
|
||||
assert handle.returncode == 1
|
||||
|
||||
def test_empty_output(self):
|
||||
def exec_fn():
|
||||
return ("", 0)
|
||||
|
||||
handle = _ThreadedProcessHandle(exec_fn)
|
||||
handle.wait(timeout=5)
|
||||
|
||||
assert handle.returncode == 0
|
||||
output = handle.stdout.read()
|
||||
assert output == ""
|
||||
|
||||
|
||||
class TestPolling:
|
||||
def test_poll_returns_none_while_running(self):
|
||||
event = threading.Event()
|
||||
|
||||
def exec_fn():
|
||||
event.wait(timeout=5)
|
||||
return ("done", 0)
|
||||
|
||||
handle = _ThreadedProcessHandle(exec_fn)
|
||||
assert handle.poll() is None
|
||||
|
||||
event.set()
|
||||
handle.wait(timeout=5)
|
||||
assert handle.poll() == 0
|
||||
|
||||
def test_poll_returns_returncode_when_done(self):
|
||||
def exec_fn():
|
||||
return ("ok", 0)
|
||||
|
||||
handle = _ThreadedProcessHandle(exec_fn)
|
||||
handle.wait(timeout=5)
|
||||
assert handle.poll() == 0
|
||||
|
||||
|
||||
class TestCancelFn:
|
||||
def test_cancel_fn_called_on_kill(self):
|
||||
called = threading.Event()
|
||||
|
||||
def cancel():
|
||||
called.set()
|
||||
|
||||
def exec_fn():
|
||||
time.sleep(10)
|
||||
return ("", 0)
|
||||
|
||||
handle = _ThreadedProcessHandle(exec_fn, cancel_fn=cancel)
|
||||
handle.kill()
|
||||
assert called.is_set()
|
||||
|
||||
def test_cancel_fn_none_is_safe(self):
|
||||
def exec_fn():
|
||||
return ("ok", 0)
|
||||
|
||||
handle = _ThreadedProcessHandle(exec_fn, cancel_fn=None)
|
||||
handle.kill() # should not raise
|
||||
handle.wait(timeout=5)
|
||||
assert handle.returncode == 0
|
||||
|
||||
def test_cancel_fn_exception_swallowed(self):
|
||||
def cancel():
|
||||
raise RuntimeError("cancel failed")
|
||||
|
||||
def exec_fn():
|
||||
return ("ok", 0)
|
||||
|
||||
handle = _ThreadedProcessHandle(exec_fn, cancel_fn=cancel)
|
||||
handle.kill() # should not raise despite cancel raising
|
||||
handle.wait(timeout=5)
|
||||
|
||||
|
||||
class TestStdoutPipe:
|
||||
def test_stdout_is_readable(self):
|
||||
def exec_fn():
|
||||
return ("line1\nline2\nline3\n", 0)
|
||||
|
||||
handle = _ThreadedProcessHandle(exec_fn)
|
||||
handle.wait(timeout=5)
|
||||
|
||||
lines = handle.stdout.readlines()
|
||||
assert len(lines) == 3
|
||||
assert lines[0] == "line1\n"
|
||||
|
||||
def test_stdout_iterable(self):
|
||||
def exec_fn():
|
||||
return ("a\nb\nc\n", 0)
|
||||
|
||||
handle = _ThreadedProcessHandle(exec_fn)
|
||||
handle.wait(timeout=5)
|
||||
|
||||
collected = list(handle.stdout)
|
||||
assert len(collected) == 3
|
||||
|
||||
def test_unicode_output(self):
|
||||
def exec_fn():
|
||||
return ("hello 世界 🌍\n", 0)
|
||||
|
||||
handle = _ThreadedProcessHandle(exec_fn)
|
||||
handle.wait(timeout=5)
|
||||
|
||||
output = handle.stdout.read()
|
||||
assert "世界" in output
|
||||
assert "🌍" in output
|
||||
Reference in New Issue
Block a user